185 Commits

Author SHA1 Message Date
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
All checks were successful
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
21e390d200 test: tighten pr320 lobby ownership guard
All checks were successful
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
df9b6d192c chore: refresh i18n parity artifact
All checks were successful
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
702f130de2 test(lobby): lock issue-310 transition ownership boundary
All checks were successful
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
92f2cda83a test(lobby): lock scoreboard next-round bootstrap target
All checks were successful
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
d080f05661 fix(ci): retain lobby payload ownership export
All checks were successful
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
e246bd648f fix(gameplay): scope next-round selection to target round
Some checks failed
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
06e4ccac61 fix(lobby): restore scoreboard payload import
Some checks failed
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
3c9214178e fix(ci): remove stale scoreboard payload import
Some checks failed
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
feddd910eb fix: restore reveal payload import in submit_guess
Some checks failed
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
dd615796f4 refactor(payloads): delegate session detail gameplay payload
Some checks failed
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
d2cdf16322 test(lobby): lock repaired stale next-round replay
All checks were successful
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
101c3f9c26 fix(gameplay): repair stale next-round question drift
All checks were successful
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
65eb5685f7 test(lobby): lock scoreboard ownership boundary
All checks were successful
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
8a70645fda test(lobby): lock session detail payload delegation
All checks were successful
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
2cd8d940f9 test(lobby): lock session detail ownership boundary
All checks were successful
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
72bc5997ff test(gameplay): keep lobby delegation checks in lobby suite
All checks were successful
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
c9e64bc8a8 test(gameplay): lock lobby delegation for host transitions (#310)
All checks were successful
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
1c7f1e7c53 fix(ci): satisfy PR #320 lobby lint contract
All checks were successful
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
03850b5ed5 refactor(gameplay): extract start/show transitions from lobby views
Some checks failed
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
16c9cf6b57 refactor(gameplay): extract round start payload builders
All checks were successful
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
c45f04f9f1 refactor(gameplay): extract round question payload builder
All checks were successful
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
319038555a refactor(gameplay): move phase view model into cartridge
All checks were successful
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
e318711148 test(gameplay): lock refreshed next-round deadline contract
All checks were successful
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
a9c6e4fd79 test(lobby): lock host transition ownership boundary
All checks were successful
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
7eb3507934 fix(gameplay): refresh stale next-round bootstrap config
All checks were successful
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
dfa197b33b refactor(gameplay): keep host transition payloads in cartridge
All checks were successful
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
fefc5ecd56 test(lobby): lock refreshed deadline for reused bootstrap round
All checks were successful
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
94f940e5d8 refactor(gameplay): delegate host transition events from service
All checks were successful
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
a102a72a77 fix(gameplay): refresh reused bootstrap lie timer
All checks were successful
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
d272e35a79 refactor(gameplay): keep host transition events in payload layer
All checks were successful
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
8a07433f11 refactor(gameplay): move transition event composition into service
All checks were successful
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
9baade0105 test(gameplay): lock lobby replay side-effect delegation
All checks were successful
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
35e2d09ee3 test(gameplay): lock lobby host-transition delegation
All checks were successful
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
a916da12a7 refactor: move scoreboard promotion out of lobby view
All checks were successful
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
7f20cb3bf9 refactor(gameplay): move scoreboard phase events into cartridge payloads
All checks were successful
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
f736f4f74e refactor(gameplay): move scoreboard transitions into cartridge service
All checks were successful
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
8247787404 refactor(gameplay): move transition payload builders to cartridge
All checks were successful
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
6722be43d4 merge(main): resolve PR #320 scoreboard transition conflict
All checks were successful
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
212549373b fix(gameplay): gate next-round replay on scoreboard exit marker
All checks were successful
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
47659ed673 test(gameplay): guard extracted lobby helper wiring
All checks were successful
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
All checks were successful
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
44e480931b fix(gameplay): gate next-round replay on prior scoreboard exit
Some checks failed
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
8c0a561a64 Merge pull request 'refactor(fupogfakta): extract first lobby gameplay slice (#312)' (#319) from dev/issue-312-extraction-map into main
All checks were successful
CI / test-and-quality (push) Successful in 2m55s
2026-03-17 08:01:26 +01:00
1839b30e0a merge(main): resolve PR #320 gameplay conflicts
Some checks failed
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
7de843e44b fix(lobby): use extracted fupogfakta helpers
All checks were successful
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
542d326615 fix(gameplay): gate next-round replay on prior transition
All checks were successful
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
e39605d782 merge(main): resolve PR #319 lobby extraction conflict
Some checks failed
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
d36d256daf fix(gameplay): make scoreboard host exits idempotent
All checks were successful
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
2ee235c6c0 refactor(fupogfakta): extract first lobby gameplay slice (#312)
All checks were successful
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
592c265331 docs(architecture): map lobby vs fupogfakta extraction boundary refs #311 #312 2026-03-16 18:57:29 +00:00
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
Some checks failed
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
d9c4cda966 fix(frontend): prefer canonical phase for client action gating
All checks were successful
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
2437f0e8bd Merge pull request 'test(gameplay): add canonical loop smoke evidence (#302)' (#304) from dev/issue-302-canonical-loop-evidence into main
All checks were successful
CI / test-and-quality (push) Successful in 2m43s
2026-03-16 17:31:23 +01:00
3b4b844126 chore(ci): retrigger canonical loop evidence checks
All checks were successful
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
c8c17654a4 Merge pull request 'fix(gameplay): harden scoreboard -> next round bootstrap invariants (#300)' (#305) from dev/issue-300-round-bootstrap-invariants-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 2m39s
2026-03-16 16:44:22 +01:00
fd6e3e86e8 ci: repair rollup optional dep on npm ci
Some checks failed
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
7c0332f95f fix(gameplay): harden scoreboard to round bootstrap invariants (#300)
All checks were successful
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
9970257f32 test(gameplay): add canonical loop smoke evidence (#302)
Some checks failed
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
112a85a22d Merge pull request 'fix(gameplay): gate client actions from canonical phase state (#301)' (#303) from dev/issue-301-client-action-gating into main
All checks were successful
CI / test-and-quality (push) Successful in 2m36s
2026-03-16 15:53:44 +01:00
33b428955b test(frontend): install angular spec runtime in root suite
All checks were successful
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
55fc758389 test(gameplay): stabilize canonical host gating specs
All checks were successful
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
f0142f33b6 test(issue-301): align host gating specs with canonical phases
All checks were successful
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
3acaf3e370 test(frontend): include angular specs in vitest suite
Some checks failed
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
1cb36a5943 merge(main): resolve PR #303 conflicts
Some checks failed
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
fc68e30cf4 fix(frontend): restore phase-gating build
All checks were successful
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
57ca237565 fix(issue-301): gate client actions from canonical phase flags
All checks were successful
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
076faf2ff1 feat: gate client actions by canonical phase state 2026-03-16 10:15:35 +00:00
f58e852246 Merge pull request 'feat(lobby): canonical backend round flow for issue #287' (#298) from issue-287-canonical-round-flow-backend into main
All checks were successful
CI / test-and-quality (push) Successful in 2m36s
2026-03-16 07:25:52 +01:00
242aeaacd6 fix(lobby): avoid orphaned round configs on round start
All checks were successful
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
624bcd602b fix(lobby): gate reveal promotion on resolved rounds
All checks were successful
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
bfa4ab859c fix(lobby): promote zero-score reveals to scoreboard
Some checks failed
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
3706bc3b1c fix(lobby): guard auto score calculation 2026-03-16 02:42:19 +00:00
a6e09e2bea fix(lobby): remove dead reveal state flag
All checks were successful
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
5bb035deec fix(lobby): tighten canonical host round flow for issue 287
Some checks failed
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
ab08dc2b6d feat(lobby): align canonical round flow for issue 287
Some checks failed
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
a2c60749f8 feat(lobby): canonicalize round phase ownership 2026-03-16 00:44:11 +00:00
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
All checks were successful
CI / test-and-quality (push) Successful in 2m26s
2026-03-16 00:47:53 +01:00
c43975a1c8 fix(frontend): enforce canonical reveal fooled-player refs
All checks were successful
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
2cc2a08ccb test(lobby): lock omitted reveal fooled-player nickname contract
All checks were successful
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
0d91531b90 test(frontend): lock omitted reveal nickname contract
All checks were successful
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
e566e0967d test(frontend): harden reveal fooled-player normalization
All checks were successful
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
0b0e3c325c fix(frontend): normalize omitted reveal fooled-player ids
All checks were successful
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
f44dd92543 test(frontend): normalize reveal guess fooled-player nullability
All checks were successful
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
c363ec92da merge(main): resolve PR #297 conflicts
All checks were successful
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
2472b70d45 test(lobby): align lie submission assertions with i18n payload
All checks were successful
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
7a6eb0b88e fix(frontend): restore canonical reveal payload typecheck
Some checks failed
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
1cbec3b70e Merge pull request '[Gameplay] Canonical reveal payload for round question incl. who-fooled-whom' (#295) from dev/issue-289-canonical-reveal into main
All checks were successful
CI / test-and-quality (push) Successful in 2m25s
2026-03-15 16:46:24 +01:00
49257af0b0 fix(frontend): align session detail contract in tests
All checks were successful
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
e8883e803b fix: preserve reveal before scoreboard
All checks were successful
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
076ca4ebbb test(gameplay): lock canonical reveal payload across scoreboard
All checks were successful
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
207c934b48 test(lobby): cover legacy scoreboard host gating
All checks were successful
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
All checks were successful
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
Some checks failed
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
f0e87eb988 feat: expose canonical reveal payload in SPA refs #289 parent #287
Some checks failed
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
a80b1ee354 test(gameplay): align guess error contract assertions 2026-03-15 11:54:39 +00:00
3f20f25902 fix: expose canonical reveal payload in scoreboard detail 2026-03-15 11:46:30 +00:00
1a6869643f Merge pull request 'fix(gameplay): explicit scoreboard phase after reveal (#288)' (#291) from dev/issue-288-scoreboard-phase into main
All checks were successful
CI / test-and-quality (push) Successful in 2m21s
2026-03-15 11:48:58 +01:00
5c9d29a3a7 fix(realtime): restore websocket phase event type
All checks were successful
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
62174135b8 fix(ci): remove duplicate realtime import
All checks were successful
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
17234de5d1 Merge main into PR #291 and resolve scoreboard phase conflicts
Some checks failed
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
be38fe6ac2 fix(realtime): tolerate missing scoreboard channel layer
All checks were successful
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
8fa39adc2b fix(gameplay): restore scoreboard phase error contract
Some checks failed
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
97b366d1e9 fix(gameplay): make scoreboard reads idempotent
All checks were successful
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
558f8fe245 fix(gameplay): restore reveal before scoreboard
All checks were successful
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
dc0c203f7f fix(gameplay): align scoreboard API contract
All checks were successful
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
173cc8f2d9 fix(gameplay): align scoreboard phase contract 2026-03-13 19:34:05 +00:00
638c9452d8 fix(spa): register scoreboard host shell route
All checks were successful
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
a0277fd8be fix(gameplay): add explicit scoreboard phase (#288)
All checks were successful
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
8503e18e57 Merge pull request 'docs(#279): add i18n MVP close-out note' (#286) from dev/issue-279-i18n-mvp-closeout-note into main
All checks were successful
CI / test-and-quality (push) Successful in 2m12s
2026-03-13 12:44:17 +01:00
3747081eb4 docs(#279): clarify merged snapshot in close-out note
All checks were successful
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
4a12cee6ee docs(i18n): refresh issue 279 close-out status
All checks were successful
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
1bc4c27273 Merge pull request 'feat(#275): harden django i18n locale negotiation and fallback' (#283) from feat/issue-275-django-i18n-hardening into main
All checks were successful
CI / test-and-quality (push) Successful in 2m22s
2026-03-13 12:00:03 +01:00
6ad5430302 Merge pull request 'docs(#277): add shared i18n parity artifact' (#282) from feat/issue-277-i18n-parity-report into main
Some checks failed
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
All checks were successful
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
ceb71aff6e docs(issue-279): restate close-out note as reviewed snapshot
All checks were successful
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
864984273a fix(ci): drop unused lobby i18n import
All checks were successful
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
b2e66389c3 docs(issue-279): refresh i18n close-out snapshot
All checks were successful
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
8ff552aeae merge(main): resolve PR #283 lobby/views.py conflict
Some checks failed
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
b968ea4430 test(i18n): guard issue-277 artifact determinism
All checks were successful
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
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
All checks were successful
CI / test-and-quality (push) Successful in 2m12s
2026-03-13 10:57:14 +01:00
575f4782b5 docs(issue-279): add i18n mvp close-out note
All checks were successful
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
e5b8081c10 test: add issue 278 locale and audio smoke gate
All checks were successful
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
5a580964c4 fix(i18n): make parity artifact reproducible
All checks were successful
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
db7be0dfc6 test(i18n): cover locale fallback and backend error payloads
All checks were successful
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
80520bad51 feat(i18n): unify django api error resolution 2026-03-13 09:16:23 +00:00
e0aba3fdf6 docs(i18n): add MVP keyspace parity artifact for issue 277
All checks were successful
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
c0c3ecd90c docs(issue-277): record PR delivery metadata
Some checks failed
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
b8a9fbf6d1 docs(issue-277): add shared i18n parity artifact
Some checks failed
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
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
All checks were successful
CI / test-and-quality (push) Successful in 2m7s
2026-03-13 09:39:39 +01:00
58874c0d78 feat: simplify angular host/player mvp controls
All checks were successful
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
fb657cb76c Merge pull request 'docs: design doc for fup og fakta game engine + platform architecture' (#280) from feature/planning-and-websocket into main
All checks were successful
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
All checks were successful
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
Some checks failed
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
f1699841e6 Merge pull request 'docs(#252): document React fallback trigger criteria' (#274) from feat/issue-252-react-fallback-criteria into main
All checks were successful
CI / test-and-quality (push) Successful in 2m39s
2026-03-02 06:14:59 +01:00
7841fb7651 docs(issue-252): require issue/incident reference in fallback decision log
All checks were successful
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
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
All checks were successful
CI / test-and-quality (push) Successful in 2m28s
2026-03-02 06:03:22 +01:00
ad841dfe9f chore(ci): retrigger required push check for PR #272
All checks were successful
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
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
All checks were successful
CI / test-and-quality (push) Successful in 2m27s
2026-03-02 05:14:25 +01:00
022ba24fd0 feat(i18n): wire legacy lobby shells to shared locale catalog
All checks were successful
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
b63b0ccf7e docs: define React fallback triggers for delivery-blocking only (#252)
Some checks failed
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
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
All checks were successful
CI / test-and-quality (push) Successful in 2m32s
2026-03-02 04:58:11 +01:00
e4841afbaa test(issue-268): lock phone audio guard against playback regressions
All checks were successful
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
ee2a202f34 Merge pull request 'docs(#257): add acceptance artifact for shared i18n keyspace loader' (#269) from dev/issue-257-acceptance-artifact into main
All checks were successful
CI / test-and-quality (push) Successful in 2m31s
2026-03-02 04:46:44 +01:00
f73b99b637 Merge pull request 'test(#225): lock backend i18n error payload contract keys' (#270) from dev/issue-225-backend-i18n-baseline-v2 into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-02 04:46:38 +01:00
6d99741305 test(i18n): lock backend error payload contract keys for issue 225
All checks were successful
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
cf58ba8067 docs(issue-257): add shared i18n loader acceptance artifact
All checks were successful
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
f7ed3d9407 Merge pull request 'docs(#225): refresh backend i18n baseline acceptance artifact' (#267) from feature/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m32s
2026-03-02 04:30:37 +01:00
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
All checks were successful
CI / test-and-quality (push) Successful in 2m58s
2026-03-02 04:25:24 +01:00
63fce7760a docs(issue-225): refresh backend i18n baseline verification evidence
All checks were successful
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
8899bf547c docs(#257): add acceptance artifact for shared i18n loader
All checks were successful
CI / test-and-quality (push) Successful in 3m26s
CI / test-and-quality (pull_request) Successful in 3m29s
2026-03-02 03:19:15 +00:00
0dad635311 Merge pull request 'test(#257): normalize underscore locale tags in shared frontend loader' (#265) from dev/issue-257-shared-i18n-locale-underscore-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 2m27s
2026-03-02 04:08:43 +01:00
7c7a6b6a08 Merge pull request 'fix(#225): honor Accept-Language q-values in backend locale resolver' (#264) from dev/issue-225-backend-i18n-resolver-qweight into main
All checks were successful
CI / test-and-quality (push) Successful in 2m42s
2026-03-02 04:05:38 +01:00
f87e0b60cf test(i18n): normalize underscore locale tags in shared frontend loader (#257)
All checks were successful
CI / test-and-quality (push) Successful in 3m13s
CI / test-and-quality (pull_request) Successful in 2m55s
2026-03-02 03:02:04 +00:00
aa2d636e90 fix(i18n): honor Accept-Language q-values in locale resolver (#225)
All checks were successful
CI / test-and-quality (push) Successful in 3m6s
CI / test-and-quality (pull_request) Successful in 3m17s
2026-03-02 03:01:10 +00:00
9219648231 Merge pull request 'test(#257): harden shared i18n loader parity/locale guards' (#263) from dev/issue-257-shared-i18n-keyspace-guards into main
All checks were successful
CI / test-and-quality (push) Successful in 2m31s
2026-03-02 03:55:18 +01:00
377fb712e1 Merge pull request '[Issue #251] Batch A plan hardening: execution checks + rollback + parallelization' (#262) from dev/issue-251-batch-a-shell-router into main
All checks were successful
CI / test-and-quality (push) Successful in 2m31s
2026-03-02 03:51:44 +01:00
bb90295d26 test(i18n): harden shared loader locale normalization coverage (#257)
All checks were successful
CI / test-and-quality (push) Successful in 3m21s
CI / test-and-quality (pull_request) Successful in 3m2s
2026-03-02 02:46:44 +00:00
f9e1999e74 docs(issue-251): make 3-batch SPA lane execution-ready
All checks were successful
CI / test-and-quality (push) Successful in 3m33s
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-02 02:45:32 +00:00
0c515ed2b7 Merge pull request '[MVP] Issue #260: phone/client no-audio guard regression coverage' (#261) from dev/issue-260-phone-no-audio-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 2m32s
2026-03-02 03:34:55 +01:00
0bb15f749b test(player): lock primary-device audio policy for issue 260
All checks were successful
CI / test-and-quality (push) Successful in 2m56s
CI / test-and-quality (pull_request) Successful in 2m57s
2026-03-02 02:30:11 +00:00
361f78b1c8 Merge pull request 'feat(#257): shared i18n keyspace loader + da/en parity guard (Angular-first)' (#259) from dev/issue-257-shared-i18n-loader into main
All checks were successful
CI / test-and-quality (push) Successful in 2m27s
2026-03-02 03:20:27 +01:00
4d46611910 Merge pull request 'docs(#225): add backend i18n baseline verification artifact' (#258) from dev/issue-225-backend-i18n-baseline into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-02 03:20:04 +01:00
ed72f9a824 feat(i18n): share frontend lobby loader and add da/en parity check (#257)
All checks were successful
CI / test-and-quality (push) Successful in 3m21s
CI / test-and-quality (pull_request) Successful in 3m2s
2026-03-02 02:15:36 +00:00
3474d68c57 docs(issue-225): add backend i18n baseline artifact
All checks were successful
CI / test-and-quality (push) Successful in 3m4s
CI / test-and-quality (pull_request) Successful in 3m16s
2026-03-02 02:13:38 +00:00
0bc4e6f066 Merge pull request 'Issue #250: MVP guardrail for phone-client audio playback policy' (#256) from dev/issue-250-primary-device-audio into main
All checks were successful
CI / test-and-quality (push) Successful in 2m31s
2026-03-02 03:09:12 +01:00
b18b05cc70 chore(issue-250): refresh PR #256 head after artifact reconciliation
All checks were successful
CI / test-and-quality (push) Successful in 2m57s
CI / test-and-quality (pull_request) Successful in 2m57s
2026-03-02 02:02:49 +00:00
ed57efb1b3 test(player): harden audio policy i18n assertions
All checks were successful
CI / test-and-quality (push) Successful in 3m17s
CI / test-and-quality (pull_request) Successful in 3m9s
2026-03-02 01:58:40 +00:00
1faadbea4d Merge pull request '[Docs][Issue #251] Release-often lane: SPA MVP split into 3 merge-ready micro-PR batches' (#255) from dev/issue-251-release-often-batches into main
All checks were successful
CI / test-and-quality (push) Successful in 3m21s
2026-03-02 02:58:22 +01:00
dc6af7547c Issue #250: enforce primary-device-only audio policy guardrail
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m20s
2026-03-02 01:53:40 +00:00
1f98f01283 docs(#251): define release-often SPA MVP 3-batch micro-PR plan
All checks were successful
CI / test-and-quality (push) Successful in 3m19s
CI / test-and-quality (pull_request) Successful in 3m32s
2026-03-02 01:52:37 +00:00
a278934960 Merge pull request '[MVP][backend] #248 Shared i18n keyspace + Django i18n bootstrap (da/en)' (#254) from dev/issue-248-django-i18n-bootstrap into main
All checks were successful
CI / test-and-quality (push) Successful in 2m38s
2026-03-02 02:38:03 +01:00
c1391e8dc5 Merge pull request '[MVP][frontend] #249 Angular-first SPA foundation: host/player shell + API client skeleton' (#253) from dev/issue-249-angular-spa-foundation into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-02 02:38:02 +01:00
8d3df1f850 feat(frontend): add angular app-shell API client skeleton
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m29s
CI / test-and-quality (push) Successful in 3m3s
2026-03-02 01:31:06 +00:00
6838cc0efc feat(#248): bootstrap django i18n from shared locale contract
All checks were successful
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 2m58s
2026-03-02 01:30:45 +00:00
9deae85a56 Merge pull request '[MVP][frontend] Issue #241: host/player route i18n integration + secondary no-audio guard' (#247) from feat/issue-241-route-i18n-audio into main
All checks were successful
CI / test-and-quality (push) Successful in 2m31s
2026-03-02 02:23:19 +01:00
4b2b21fe57 Fix route locale resolver to only apply explicit lang param
All checks were successful
CI / test-and-quality (push) Successful in 3m1s
CI / test-and-quality (pull_request) Successful in 3m1s
2026-03-02 01:05:00 +00:00
5538a91800 feat(frontend): wire route locale context for host/player shells (#241)
All checks were successful
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m56s
2026-03-02 01:02:04 +00:00
79b694c590 Merge pull request '[MVP][READY] #225 Backend i18n baseline (resolver + fallback) follow-up' (#245) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m47s
2026-03-02 01:45:04 +01:00
87c1a0ee6c PR #246: merge
All checks were successful
CI / test-and-quality (push) Successful in 3m23s
2026-03-02 01:39:57 +01:00
c34a52e83e Fix Accept-Language q parsing in locale resolver
All checks were successful
CI / test-and-quality (push) Successful in 3m35s
CI / test-and-quality (pull_request) Successful in 3m38s
2026-03-02 00:38:34 +00:00
edf9460ceb fix(player): harden secondary-device audio playback guard
All checks were successful
CI / test-and-quality (push) Successful in 3m20s
CI / test-and-quality (pull_request) Successful in 3m16s
2026-03-02 00:34:22 +00:00
a0a1424e90 fix(issue-225): honor Accept-Language fallback chain in locale resolver
All checks were successful
CI / test-and-quality (push) Successful in 3m2s
CI / test-and-quality (pull_request) Successful in 3m15s
2026-03-02 00:31:42 +00:00
60e58f6214 Merge pull request '[MVP][READY] #175-B Angular i18n shell (shared keys + da/en bootstrap) (#239)' (#244) from dev/issue-239-angular-i18n-shell into main
All checks were successful
CI / test-and-quality (push) Successful in 2m34s
Auto-merge by integrator: required checks green + official approval.
2026-03-02 01:23:21 +01:00
a658ef5f80 Merge pull request '[MVP][READY][nice] Shared i18n key manifest + drift-check script (#240)' (#243) from dev/issue-240-shared-i18n-manifest-drift-check into main
All checks were successful
CI / test-and-quality (push) Successful in 3m1s
2026-03-02 01:16:44 +01:00
81 changed files with 10244 additions and 798 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash"
]
}
}

View File

@@ -38,7 +38,10 @@ jobs:
node-version: "22" node-version: "22"
- name: Install SPA dependencies - name: Install SPA dependencies
run: npm ci --prefix frontend/angular 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 smoke tests - name: SPA Angular smoke tests
run: npm --prefix frontend/angular test run: npm --prefix frontend/angular test

View File

@@ -1,5 +1,10 @@
# Changelog # 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 ## [0.1.0] - 2026-02-27
### Added ### Added
- Projekt scaffold for Weirsøe Party Protocol (Django 6.0.2) - Projekt scaffold for Weirsøe Party Protocol (Django 6.0.2)

119
CLAUDE.md Normal file
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

71
PROMPT.md Normal file
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)

26
TODO.md
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] `core_admin` (global administration)
- [x] `fupogfakta` (Spil 1) - [x] `fupogfakta` (Spil 1)
- [x] `lobby` (room/session/player join flow) - [x] `lobby` (room/session/player join flow)
- [x] `realtime` (channels events, game state broadcast) - [x] `realtime` (app-skelet oprettet — consumers/routing IKKE implementeret endnu)
- [x] `voice` (fælles voice-acting interface) - [x] `voice` (fælles voice-acting interface — stub)
- [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner) - [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner)
- [x] Konfig for MySQL test/prod - [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) - [x] `ScoreEvent` (auditérbar pointslog)
### Fase 3 — Spilflow `Fup og Fakta` ### Fase 3 — Spilflow `Fup og Fakta`
- [x] Lobby: host opretter session, spillere joiner via kode - [x] Lobby: host opretter session, spillere joiner via kode (REST)
- [x] Runde starter med kategori - [x] Runde starter med kategori (REST)
- [x] Spørgsmål vises -> alle skriver løgn inden X sek - [x] Spørgsmål vises -> alle skriver løgn inden X sek (REST)
- [x] System blander korrekt svar + løgne - [x] System blander korrekt svar + løgne (persisted i JSONField, anti-cheat dedup)
- [x] Guessfase: alle gætter inden Z sek - [x] Guessfase: alle gætter inden Z sek (REST)
- [x] Pointudregning (konfigurerbar pr. runde) - [x] Pointudregning (konfigurerbar pr. runde, ScoreEvent audit trail)
- [x] Scoreboard + næste spørgsmål/runde - [x] Scoreboard + næste spørgsmål/runde (REST)
- [x] Slutresultat - [x] Slutresultat (REST)
- [x] **WebSocket push af phase-events til host + spillere** (GameConsumer + broadcast.py, InMemoryChannelLayer i tests)
### Fase 4 — Voice-acting (platformkrav) ### Fase 4 — Voice-acting (platformkrav)
- [ ] Definér TTS provider-interface - [ ] Definér TTS provider-interface
@@ -103,10 +104,11 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [ ] Migrations + static + health checks - [ ] Migrations + static + health checks
### Backlog — Need-to-have / Nice-to-have ### 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) - [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) - [ ] (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) Rate limiting på join/submit endpoints
- [ ] (Need-to-have) Session-kode brute-force beskyttelse - [ ] (Need-to-have) Session-kode brute-force beskyttelse
- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip) - [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip)

View File

@@ -9,7 +9,7 @@ Issue #175 requires one shared i18n contract for MVP host/player flows across fr
- supported: `en`, `da` - supported: `en`, `da`
- default/fallback: `en` - default/fallback: `en`
Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.py`) read from this catalog. Django (`lobby/i18n.py`) reads directly from the catalog. Frontend runtimes are Angular-first and use a shared loader (`frontend/shared/i18n/lobby-loader.ts`) so Angular shell and SPA fallback consume the same keyspace + locale normalization.
## Key naming convention ## Key naming convention
- Domain-first namespaces: - Domain-first namespaces:
@@ -46,3 +46,4 @@ Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts` - `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
- `frontend/angular/src/app/features/host/host-shell.component.spec.ts` - `frontend/angular/src/app/features/host/host-shell.component.spec.ts`
- `frontend/angular/src/app/features/player/player-shell.component.spec.ts` - `frontend/angular/src/app/features/player/player-shell.component.spec.ts`
- `frontend/tests/lobby-loader.parity.test.ts` (minimal da/en key parity guard for shared keyspace)

View File

@@ -0,0 +1,44 @@
# ISSUE-225 Artifact — Backend i18n baseline (resolver + fallback)
Issue: **#225** (`[MVP][READY] #175-A: Backend i18n baseline (resolver + fallback)`)
## Scope verified
- Backend locale resolver supports `da` + `en` and normalizes language tags.
- Unsupported locale requests fall back to default locale (`en`).
- Error payload contract is stable for API errors:
- `error_code`
- `error`
- `locale`
## Implementation references
- Locale resolution + fallback chain:
- `lobby/i18n.py`
- `resolve_locale()`
- `resolve_error_message()`
- `api_error()`
- Shared locale contract source:
- `shared/i18n/lobby.json` (`locales.default=en`, supported includes `en`, `da`)
## Acceptance checks run
Command:
```bash
.venv/bin/python manage.py test \
lobby.tests.I18nResolverTests \
lobby.tests.LobbyFlowTests \
lobby.tests.StartRoundTests
```
Result (2026-03-02):
- `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
- Existing merged follow-ups in `main` include Accept-Language parsing fixes for q-values and locale tag normalization.
- This artifact documents the final baseline state and verification evidence for #225.

View File

@@ -0,0 +1,16 @@
# Issue #250 Artifact — MVP guardrail (telefon-klient uden lydafspilning)
## Scope
Implementeret guardrail for `primary-device only` audio policy i SPA player-flow.
## Acceptance mapping
1. **Telefon-klient flow indeholder ingen audio-play init-path**
- Test: `player-shell.component.spec.ts``does not trigger original media play during player-shell init path`.
2. **Primær enhed policy er dokumenteret og testbar**
- Policy i contract: `shared/i18n/lobby.json``frontend.capabilities.client_has_no_audio_output=true`.
- Testbar via eksisterende guard-tests + init-path test i player-shell specs.
3. **Krav er refereret i SPA-plan/cutover-noter**
- Dokumenteret i `docs/spa-cutover-flag.md` under *MVP audio policy guardrail (telefon-klient)*.
## UX/i18n note
- Player shell viser advarselstekst via i18n key: `frontend.ui.player.audio_policy_notice`.

View File

@@ -0,0 +1,124 @@
# Issue #251 — Release-often lane for SPA MVP (3 micro-PR batches)
## Formål
Bryde SPA MVP-arbejdet op i **3 merge-klare micro-PRs** med tydelige acceptance criteria,
så vi kan levere værdi oftere, reducere review-risiko og holde `main` grøn.
## Scope (issue-bound)
Denne plan dækker kun planlægning/acceptance for den kommende SPA MVP-lane.
Implementering af de konkrete features sker i efterfølgende PRs.
## Hard acceptance criteria for issue #251
- [x] Der findes en dokumenteret plan med præcis **3 batches**.
- [x] Hver batch har:
- [x] mål og afgrænsning
- [x] konkrete leverancer (kodeområder)
- [x] test/checks før merge
- [x] rollback-note
- [x] "ikke med i denne batch" for at undgå scope creep
- [x] Batch-rækkefølgen er dependencies-sikker (batch B bygger på batch A, batch C på batch B).
- [x] Hver batch kan merges/releaseres uafhængigt uden at blokere drift på `main`.
- [x] Planen linker til konkrete dev-opgaver for lane-kørsel.
## Batch-plan (merge-klare micro-PRs)
### Batch A — SPA shell + routing baseline
**Mål:** Et stabilt SPA-skelet med route-struktur og guard-basics.
**Leverancer (kodeområder)**
- `frontend/angular/src/app/app.routes.ts` (host/player entry routes + fallback)
- `frontend/angular/src/app/session-route-context.ts` (baseline route guards)
- `frontend/angular/src/app/app.component.*` (shell-nav + route outlet wiring)
- `lobby/templates/lobby/spa_shell.html` (kompatibel shell-entry ved SPA cutover)
**Done-kriterier**
- Host- og player-entry routes kan åbnes uden runtime-fejl i samme SPA-shell.
- Route guards afviser ugyldige parametre deterministisk (ingen hard crash).
- `USE_SPA_UI=false` fortsætter med legacy-flow uden regression.
**Checks før merge**
- `cd frontend/angular && npm test -- --run src/app/app.routes.spec.ts src/app/session-route-context.spec.ts`
- `cd frontend/angular && npm run build`
- Manual smoke: `/lobby/ui/host` + `/lobby/ui/player` (både `USE_SPA_UI=false` og `true`).
**Rollback-note**
- Sæt `USE_SPA_UI=false` og redeploy; verificér legacy routes svarer 200.
**Ikke med i batch A**
- Fuld gameplay-state synkronisering.
- Audio/polish og i18n finpudsning ud over baseline wiring.
---
### Batch B — Session-state + host/player sync MVP
**Mål:** Korrekt synkronisering af session-state mellem host og spillerklient.
**Leverancer (kodeområder)**
- `frontend/angular/src/app/features/host/host-shell.component.ts`
- `frontend/angular/src/app/features/player/player-shell.component.ts`
- `frontend/src/api/angular-client.ts` (MVP-kald for status/phase-overgange)
**Done-kriterier**
- Host handlinger (`start`, `show`, `mix`, `score`, `next`, `finish`) afspejles hos player uden side-reload.
- Session-phase transitions er deterministiske i happy-path (`lobby -> question -> score -> next/finish`).
- Guardrails reducerer race-condition regressions ved hurtige phase-skift.
**Checks før merge**
- `cd frontend/angular && npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts`
- `cd frontend && npm test -- --run tests/angular-api-client.test.ts`
- Manual smoke: host action -> player phase sync indenfor forventet latenstid.
**Rollback-note**
- Slå `USE_SPA_UI=false`, redeploy, og kør hurtig gameplay-smoke i legacy flow.
**Ikke med i batch B**
- Avanceret UX-polish/animation.
- Udvidet observability udenfor MVP-kritiske logs.
---
### Batch C — Lobby/join/start minimal flow + release readiness
**Mål:** Gøre SPA MVP release-klar med fokus på stabilitet og driftssikkerhed omkring det minimale flow.
**Leverancer (kodeområder)**
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts` + relevante shell-tests
- `docs/UI_SMOKE.md` + `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md`
- `CHANGELOG.md` release-input for SPA MVP lane
**Done-kriterier**
- End-to-end minimal flow (`lobby -> join -> start`) er dokumenteret PASS i SPA.
- Fejl-/empty-/loading states for flowets kritiske skærme er verificeret.
- Driftsteam kan udføre cutover + rollback uden tvetydighed.
**Checks før merge**
- `cd frontend/angular && npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts`
- `python manage.py test lobby.tests.LobbyFlowTests`
- Opdateret staging-smoke artifact med UTC tidsstempler og gate-resultat.
**Rollback-note**
- Brug eksisterende playbook i `docs/spa-cutover-flag.md` (`USE_SPA_UI=false` + asset-version rollback).
**Ikke med i batch C**
- Post-MVP featureudvidelser.
- Større refactors uden direkte release-værdi.
## Rækkefølge og parallel-kørsel
- **Dependency-rækkefølge:** A -> B -> C.
- **Kan køres parallelt uden konflikt:**
- Test-/doc-forberedelse til C kan startes parallelt med B (ingen blokering af runtime-kode), men merges først efter B.
- Drift-smoke templates kan opdateres tidligt, så længe de ikke ændrer runtime-adfærd.
- **Må ikke køre parallelt:**
- Runtime routing/guard ændringer i A og session-sync logik i B på samme filer uden feature-flag koordinering.
## Konkret lane-opgavebinding (dev-opgaver)
- Batch A PR: `feat/issue-251-batch-1-spa-shell-routing`
- Batch B PR: `feat/issue-251-batch-2-session-sync`
- Batch C PR: `feat/issue-251-batch-3-lobby-join-start-release-readiness`
Hver PR skal linke tilbage til issue #251 og inkludere test-evidence + rollback-check.
## Merge-gate for alle 3 batches
- Små PRs (mål: reviewbar størrelse, helst < ~300 netto-linjer når muligt).
- Grøn CI/checks før review-request.
- Tydelig PR-beskrivelse med: scope, test evidence, out-of-scope.
- Ingen skjulte sideeffekter på tværs af apps/domæner.

View File

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

View File

@@ -0,0 +1,32 @@
# ISSUE-257 Artifact — Shared i18n keyspace + frontend loader (da/en, Angular-first)
Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader`)
## Acceptance checklist
- [x] **Delt key-strategi dokumenteret (frontend/backend)**
- Arkitektur/deling beskrevet i `docs/I18N_ARCHITECTURE.md`.
- Shared contract + keyspace source of truth: `shared/i18n/lobby.json`.
- [x] **Frontend loader kan indlæse da+en med samme keyspace**
- Shared loader: `frontend/shared/i18n/lobby-loader.ts`.
- Angular-first integration via `frontend/angular/src/app/lobby-i18n.ts` (samme loader/samme keyspace).
- Locale-normalisering inkluderer underscore/hyphen variants (`da_DK``da`).
- [x] **Minimal check for key-paritet da/en**
- `collectLocaleParityIssues(...)` i shared loader.
- Testet i `frontend/tests/lobby-loader.parity.test.ts`.
- [x] **Ingen API-kontraktbrud**
- Contract-test: `frontend/tests/lobby-i18n.contract.test.ts`.
- Drift-check mellem manifest og katalog: `scripts/check_i18n_drift.py`.
## Kørte checks
```bash
python3 scripts/check_i18n_drift.py
cd frontend && npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts
cd frontend/angular && npm test -- --run src/app/lobby-i18n.spec.ts src/app/i18n-mvp-flow-smoke.spec.ts
```
Resultat: alle checks grønne.

View File

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

View File

@@ -0,0 +1,18 @@
# Issue #260 Artifact — Phone/client no-audio guard (primary-device only playback)
## Scope
Added regression coverage for MVP audio policy to ensure phone/client flows never claim playback ownership, while primary-device playback stays unaffected.
## Acceptance mapping
1. **client/phone triggers no playback**
- Existing test coverage retained in `player-shell.component.spec.ts`:
- `does not trigger original media play during player-shell init path`
- `installs secondary-device audio guard while player shell is mounted`
2. **primary device playback unaffected**
- New negative test in `player-shell.component.spec.ts`:
- `keeps primary-device playback untouched when no-audio capability is disabled`
3. **one negative test for phone audio**
- Existing negative path preserved:
- `does not trigger original media play during player-shell init path`
4. **no backend contract changes**
- Frontend test/docs-only scope; no backend contract files changed.

View File

@@ -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
```

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.

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.

View File

@@ -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`.

View File

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

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

View File

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

View File

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

View File

@@ -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"
}
}

View File

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

View File

@@ -59,9 +59,41 @@ Trin-for-trin:
Target: rollback + sanity-verifikation inden for 10 minutter. 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 ## Verifikation
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off` - Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled` - Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
- Flag ON (host deep-link): `UiScreenTests.test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled` - Flag ON (host deep-link): `UiScreenTests.test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled`
- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled` - Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled`
- Smoke-checkliste for cutover paths: `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` + `docs/UI_SMOKE.md` - Smoke-checkliste for cutover paths: `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` + `docs/UI_SMOKE.md`
## MVP audio policy guardrail (telefon-klient)
- Telefon-/player-klienten må ikke starte lydafspilning lokalt i MVP (`primary-device only`).
- Policy er bundet til capability-flaget `frontend.capabilities.client_has_no_audio_output=true` i `shared/i18n/lobby.json`.
- Brugeradvarsel i player UI leveres via i18n key: `frontend.ui.player.audio_policy_notice`.
- Acceptance-spec er dækket i Angular tests (`player-shell.component.spec.ts`), inkl. at init-path ikke kalder original media `play`.

View File

@@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
shown_at: '2026-03-01T18:00:00Z', shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }] 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: { phase_view_model: {
status: 'lobby', status: 'lobby',
round_number: 1, round_number: 1,
@@ -31,17 +49,17 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
}, },
host: { host: {
can_start_round: true, can_start_round: true,
can_show_question: true, can_show_question: false,
can_mix_answers: true, can_mix_answers: false,
can_calculate_scores: true, can_calculate_scores: false,
can_reveal_scoreboard: true, can_reveal_scoreboard: false,
can_start_next_round: true, can_start_next_round: false,
can_finish_game: true can_finish_game: false
}, },
player: { player: {
can_join: true, can_join: true,
can_submit_lie: true, can_submit_lie: false,
can_submit_guess: true, can_submit_guess: false,
can_view_final_result: false can_view_final_result: false
} }
} }
@@ -50,7 +68,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
if (url === '/lobby/sessions/ABCD12/scoreboard') { if (url === '/lobby/sessions/ABCD12/scoreboard') {
return { return {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
leaderboard: [ leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 }, { id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 } { id: 10, nickname: 'Bo', score: 150 }
@@ -107,6 +125,12 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 }, round_question: { id: 77, round_number: 1 },
events_created: 2, events_created: 2,
reveal: {
round_question_id: 77,
correct_answer: 'A',
lies: [],
guesses: []
},
leaderboard: [ leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 }, { id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 } { id: 10, nickname: 'Bo', score: 150 }
@@ -170,8 +194,10 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
expect(session.ok).toBe(true); expect(session.ok).toBe(true);
if (session.ok) { if (session.ok) {
expect(session.data.session.code).toBe('ABCD12'); 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.host.can_start_next_round).toBe(false);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true); 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');
} }
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true); expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);

View File

@@ -1,7 +1,15 @@
import { ApplicationConfig } from '@angular/core'; import { ApplicationConfig } from '@angular/core';
import { provideRouter, withHashLocation } from '@angular/router'; import { provideRouter, withHashLocation } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { createWppApiClient, WPP_API_CLIENT } from './wpp-api-client';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withHashLocation())], providers: [
provideRouter(routes, withHashLocation()),
{
provide: WPP_API_CLIENT,
useFactory: () => createWppApiClient(),
},
],
}; };

View File

@@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>; type FetchMock = ReturnType<typeof vi.fn>;
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
function jsonResponse(status: number, body: unknown) { function jsonResponse(status: number, body: unknown) {
return { return {
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@@ -12,7 +14,31 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response; } 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)));
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
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 roundQuestionId = options?.roundQuestionId ?? 41; const roundQuestionId = options?.roundQuestionId ?? 41;
return { return {
@@ -37,8 +63,26 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
{ id: 1, nickname: 'Host', score: 0, is_connected: true }, { id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true }, { id: 2, nickname: 'Mads', score: 120, 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_view_model: { phase_view_model: {
status, status,
current_phase: options?.currentPhase ?? status,
round_number: 1, round_number: 1,
players_count: 2, players_count: 2,
constraints: { constraints: {
@@ -47,14 +91,18 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
min_players_reached: true, min_players_reached: true,
max_players_allowed: true, max_players_allowed: true,
}, },
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: { host: {
can_start_round: status === 'lobby', can_start_round: (options?.currentPhase ?? status) === 'lobby',
can_show_question: status === 'lie', can_show_question: (options?.currentPhase ?? status) === 'lie',
can_mix_answers: status === 'lie', can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess',
can_calculate_scores: status === 'guess', can_calculate_scores: (options?.currentPhase ?? status) === 'guess',
can_reveal_scoreboard: status === 'reveal', can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal',
can_start_next_round: status === 'scoreboard', can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard',
can_finish_game: status === 'scoreboard', can_finish_game: (options?.currentPhase ?? status) === 'scoreboard',
}, },
player: { player: {
can_join: status === 'lobby', can_join: status === 'lobby',
@@ -101,54 +149,76 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.loading).toBe(false); expect(component.loading).toBe(false);
}); });
it('captures scoreboard error for retry path', async () => { it('hydrates canonical reveal payload in reveal phase', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' })); 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); vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent(); const component = new HostShellComponent();
component.sessionCode = 'ABCD12'; component.sessionCode = 'ABCD12';
await component.loadScoreboard(); await component.refreshSession();
expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' })); expect(component.session?.reveal?.correct_answer).toBe('Mercury');
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable'); expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' });
expect(component.loading).toBe(false); 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 () => { it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
const fetchMock: FetchMock = vi let refreshCount = 0;
.fn() const fetchMock = createFetchRouteMock((input, init) => {
.mockResolvedValueOnce( const url = String(input);
jsonResponse(200, { const method = init?.method ?? 'GET';
round_question: {
id: 77, if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
round_number: 1, return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
prompt: 'Q?', }
shown_at: '2026-01-01T00:00:00Z', if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
lie_deadline_at: '2026-01-01T00:00:45Z', return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
}, }
config: { lie_seconds: 45 }, if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
}) return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
) }
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 }))) if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
.mockResolvedValueOnce( refreshCount += 1;
jsonResponse(200, { if (refreshCount === 1) {
session: { code: 'ABCD12', status: 'guess', current_round: 1 }, return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
round_question: { id: 77, round_number: 1 }, }
answers: [{ text: 'A' }], if (refreshCount === 2) {
}) return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
) }
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
.mockResolvedValueOnce( }
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, throw new Error(`Unhandled fetch in test: ${method} ${url}`);
round_question: { id: 77, round_number: 1 }, });
events_created: 2,
leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }],
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
@@ -156,19 +226,37 @@ describe('HostShellComponent gameplay wiring', () => {
component.sessionCode = ' abcd12 '; component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 '; component.roundQuestionId = ' 77 ';
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
await component.showQuestion(); 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(); await component.mixAnswers();
expect(component.session?.session.status).toBe('guess');
await component.calculateScores(); await component.calculateScores();
expect(component.session?.session.status).toBe('reveal');
expect(component.error).toBe(''); expect(component.error).toBe('');
expect(component.loading).toBe(false); expect(component.loading).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(6);
}); });
it('runs next-round transition without reload and clears scoreboard payload', async () => { it('runs next-round transition without reload and clears scoreboard payload', async () => {
const fetchMock: FetchMock = vi const fetchMock = createFetchRouteMock((input, init) => {
.fn() const url = String(input);
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } })) const method = init?.method ?? 'GET';
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
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); vi.stubGlobal('fetch', fetchMock);
@@ -177,6 +265,7 @@ describe('HostShellComponent gameplay wiring', () => {
component.scoreboardPayload = '{"leaderboard":[]}'; component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound(); await component.startNextRound();
@@ -186,8 +275,8 @@ describe('HostShellComponent gameplay wiring', () => {
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
); );
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
expect(component.session?.session.status).toBe('lobby'); expect(component.session?.session.status).toBe('lie');
expect(component.scoreboardPayload).toBe(''); expect(component.roundQuestionId).toBe('99');
expect(component.finalLeaderboardPayload).toBe(''); expect(component.finalLeaderboardPayload).toBe('');
expect(component.finalLeaderboard).toEqual([]); expect(component.finalLeaderboard).toEqual([]);
expect(component.nextRoundError).toBe(''); expect(component.nextRoundError).toBe('');
@@ -213,6 +302,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent(); const component = new HostShellComponent();
component.sessionCode = 'ABCD12'; component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.finishGame(); await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
@@ -236,6 +326,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent(); const component = new HostShellComponent();
component.sessionCode = ' '; component.sessionCode = ' ';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound(); await component.startNextRound();
await component.finishGame(); await component.finishGame();
@@ -245,6 +336,77 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.finishError).toContain('Session code is required'); 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 () => { 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 }))); const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
@@ -262,5 +424,32 @@ describe('HostShellComponent gameplay wiring', () => {
await component.refreshSession(); await component.refreshSession();
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); 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);
}); });
}); });

View File

@@ -3,17 +3,14 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types';
import { deriveGameplayPhase, isHostGameplayActionAllowed } from '../../../../../src/spa/gameplay-phase-machine';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail { type SessionDetail = SessionDetailResponse;
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
players: Array<{ id: number; nickname: string; score: number }>;
}
type LeaderboardEntry = ScoreboardResponse['leaderboard'][number]; type LeaderboardEntry = FinishGameResponse['leaderboard'][number];
type LeaderboardResponse = FinishGameResponse; type LeaderboardResponse = FinishGameResponse;
@Component({ @Component({
@@ -25,23 +22,22 @@ type LeaderboardResponse = FinishGameResponse;
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput"> <div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label> <label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label> <label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button> <button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button> <button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button> <button (click)="showQuestion()" [disabled]="loading || !canShowQuestion">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button> <button (click)="mixAnswers()" [disabled]="loading || !canMixAnswers">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button> <button (click)="calculateScores()" [disabled]="loading || !canCalculateScores">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.load_scoreboard') }}</button> <button (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading">{{ copy('host.start_next_round') }}</button> <button (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading">{{ copy('host.finish_game') }}</button> <button (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.finish_game') }}</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.retry_scoreboard') }}</button> <button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.retry_scoreboard') }}</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy('host.retry_next_round') }}</button> <button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.retry_next_round') }}</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading">{{ copy('host.retry_finish') }}</button> <button *ngIf="finishError" (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.retry_finish') }}</button>
</div> </div>
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p> <p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
<p *ngIf="error" class="error">{{ error }}</p> <p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="scoreboardError" class="error">{{ scoreboardError }}</p>
<p *ngIf="nextRoundError" class="error">{{ nextRoundError }}</p> <p *ngIf="nextRoundError" class="error">{{ nextRoundError }}</p>
<p *ngIf="finishError" class="error">{{ finishError }}</p> <p *ngIf="finishError" class="error">{{ finishError }}</p>
@@ -52,7 +48,28 @@ type LeaderboardResponse = FinishGameResponse;
<ul> <ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li> <li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul> </ul>
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre> <div class="panel" *ngIf="showRevealPanel">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div>
<div *ngIf="finalLeaderboard.length"> <div *ngIf="finalLeaderboard.length">
<h3>{{ copy('host.final_leaderboard') }}</h3> <h3>{{ copy('host.final_leaderboard') }}</h3>
<p *ngIf="finalWinner"><strong>{{ copy('host.winner') }}:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})</p> <p *ngIf="finalWinner"><strong>{{ copy('host.winner') }}:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})</p>
@@ -114,6 +131,42 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = null; this.unsubscribeLocale = null;
} }
get gameplayPhase(): string | null {
return deriveGameplayPhase(this.session as any);
}
get canStartRound(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'startRound');
}
get canShowQuestion(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'showQuestion');
}
get canMixAnswers(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'mixAnswers');
}
get canCalculateScores(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'calculateScores');
}
get canLoadScoreboard(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard');
}
get canStartNextRound(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'startNextRound');
}
get canFinishGame(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'finishGame');
}
get showRevealPanel(): boolean {
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
}
copy(key: string): string { copy(key: string): string {
return t(key, this.locale); return t(key, this.locale);
} }
@@ -174,6 +227,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async startRound(): Promise<void> { async startRound(): Promise<void> {
if (!this.canStartRound) {
return;
}
await this.runAction(async () => { await this.runAction(async () => {
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim()); const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
if (!state.session || state.errorMessage) { if (!state.session || state.errorMessage) {
@@ -183,13 +240,16 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.sessionCode = this.session.session.code; this.sessionCode = this.session.session.code;
this.persistSessionCode(this.sessionCode); this.persistSessionCode(this.sessionCode);
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
this.scoreboardPayload = '';
this.resetFinalLeaderboard(); this.resetFinalLeaderboard();
this.syncRouteFromSession(); this.syncRouteFromSession();
}); });
} }
async showQuestion(): Promise<void> { async showQuestion(): Promise<void> {
if (!this.canShowQuestion) {
return;
}
await this.runAction(async () => { await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode); const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {}); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {});
@@ -198,6 +258,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async mixAnswers(): Promise<void> { async mixAnswers(): Promise<void> {
if (!this.canMixAnswers) {
return;
}
await this.runAction(async () => { await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode); const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim(); const roundQuestionId = this.roundQuestionId.trim();
@@ -207,6 +271,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async calculateScores(): Promise<void> { async calculateScores(): Promise<void> {
if (!this.canCalculateScores) {
return;
}
await this.runAction(async () => { await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode); const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim(); const roundQuestionId = this.roundQuestionId.trim();
@@ -216,12 +284,16 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async loadScoreboard(): Promise<void> { async loadScoreboard(): Promise<void> {
if (!this.canLoadScoreboard) {
return;
}
this.loading = true; this.loading = true;
this.scoreboardError = ''; this.scoreboardError = '';
this.error = ''; this.error = '';
try { try {
const code = this.normalizeCode(this.sessionCode); const code = this.normalizeCode(this.sessionCode);
const payload = await this.request<unknown>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET'); const payload = await this.request<ScoreboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
this.scoreboardPayload = JSON.stringify(payload, null, 2); this.scoreboardPayload = JSON.stringify(payload, null, 2);
await this.refreshSession(); await this.refreshSession();
} catch (error) { } catch (error) {
@@ -232,6 +304,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async startNextRound(): Promise<void> { async startNextRound(): Promise<void> {
if (!this.canStartNextRound) {
return;
}
this.loading = true; this.loading = true;
this.nextRoundError = ''; this.nextRoundError = '';
this.error = ''; this.error = '';
@@ -241,7 +317,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
throw new Error(this.copy('host.session_code_required')); throw new Error(this.copy('host.session_code_required'));
} }
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {}); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
this.scoreboardPayload = '';
this.resetFinalLeaderboard(); this.resetFinalLeaderboard();
await this.refreshSession(); await this.refreshSession();
} catch (error) { } catch (error) {
@@ -252,6 +327,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async finishGame(): Promise<void> { async finishGame(): Promise<void> {
if (!this.canFinishGame) {
return;
}
this.loading = true; this.loading = true;
this.finishError = ''; this.finishError = '';
this.error = ''; this.error = '';
@@ -278,6 +357,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
private resetFinalLeaderboard(): void { private resetFinalLeaderboard(): void {
this.scoreboardPayload = '';
this.finalLeaderboardPayload = ''; this.finalLeaderboardPayload = '';
this.finalLeaderboard = []; this.finalLeaderboard = [];
this.finalWinner = null; this.finalWinner = null;
@@ -288,7 +368,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
return; return;
} }
const phase = this.session.session.status || 'lobby'; const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode); const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) { if (!code) {
return; return;

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import lobbyCatalog from '../../../../../../shared/i18n/lobby.json';
import { PlayerShellComponent } from './player-shell.component'; import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>; type FetchMock = ReturnType<typeof vi.fn>;
@@ -12,7 +13,29 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response; } 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[];
players?: Array<{ id: number; nickname: string; score: number }>;
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 answers = options?.answers ?? [];
const roundQuestionId = options?.roundQuestionId ?? 11; const roundQuestionId = options?.roundQuestionId ?? 11;
@@ -38,8 +61,26 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
...player, ...player,
is_connected: true, 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_view_model: { phase_view_model: {
status, status,
current_phase: options?.currentPhase ?? status,
round_number: 1, round_number: 1,
players_count: (options?.players ?? []).length, players_count: (options?.players ?? []).length,
constraints: { constraints: {
@@ -48,6 +89,10 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
min_players_reached: true, min_players_reached: true,
max_players_allowed: true, max_players_allowed: true,
}, },
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: { host: {
can_start_round: false, can_start_round: false,
can_show_question: false, can_show_question: false,
@@ -58,10 +103,10 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
can_finish_game: false, can_finish_game: false,
}, },
player: { player: {
can_join: status === 'lobby', can_join: (options?.currentPhase ?? status) === 'lobby',
can_submit_lie: status === 'lie', can_submit_lie: (options?.currentPhase ?? status) === 'lie',
can_submit_guess: status === 'guess', can_submit_guess: (options?.currentPhase ?? status) === 'guess',
can_view_final_result: status === 'finished', can_view_final_result: (options?.currentPhase ?? status) === 'finished',
}, },
}, },
}; };
@@ -108,9 +153,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1'; component.sessionToken = 'token-1';
component.lieText = 'my lie'; component.lieText = 'my lie';
component.session = { component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 }, ...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [] }, round_question: { id: 11, prompt: 'Q?', answers: [] },
players: [],
}; };
await component.submitLie(); await component.submitLie();
@@ -157,6 +201,63 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
}); });
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('surfaces guess submit error and retries with selected answer payload', async () => { it('surfaces guess submit error and retries with selected answer payload', async () => {
const fetchMock: FetchMock = vi const fetchMock: FetchMock = vi
.fn() .fn()
@@ -172,9 +273,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1'; component.sessionToken = 'token-1';
component.selectedGuess = 'B'; component.selectedGuess = 'B';
component.session = { 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' }] }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
}; };
await component.submitGuess(); await component.submitGuess();
@@ -198,6 +298,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3); 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 () => { it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -320,6 +443,34 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(values.get('wpp.session-context')).toBeUndefined(); 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 () => { it('syncs player hash-route with latest phase during periodic state sync', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -400,7 +551,14 @@ describe('PlayerShellComponent gameplay wiring', () => {
const component = new PlayerShellComponent(); const component = new PlayerShellComponent();
component.ngOnInit(); component.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined(); const pause = vi.fn();
const audioElement = { muted: false, defaultMuted: false, volume: 1, pause };
await expect(mediaPrototype.play.call(audioElement)).resolves.toBeUndefined();
expect(audioElement.muted).toBe(true);
expect(audioElement.defaultMuted).toBe(true);
expect(audioElement.volume).toBe(0);
expect(pause).toHaveBeenCalledTimes(1);
component.ngOnDestroy(); component.ngOnDestroy();
@@ -436,4 +594,102 @@ describe('PlayerShellComponent gameplay wiring', () => {
secondComponent.ngOnDestroy(); secondComponent.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play'); await expect(mediaPrototype.play()).rejects.toThrow('original play');
}); });
it('does not trigger original media play during player-shell init path', () => {
const originalPlay = vi.fn().mockResolvedValue(undefined);
const mediaPrototype = { play: originalPlay };
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() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
expect(originalPlay).not.toHaveBeenCalled();
component.ngOnDestroy();
});
it('keeps primary-device playback untouched when no-audio capability is disabled', async () => {
const originalPlay = vi.fn().mockResolvedValue(undefined);
const mediaPrototype = { play: originalPlay };
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() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
(component as any).clientHasNoAudioOutput = false;
component.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
expect(mediaPrototype.play).toBe(originalPlay);
expect((mediaPrototype as any).__wppSecondaryDeviceAudioGuard__).toBeUndefined();
component.ngOnDestroy();
});
it('resolves i18n warning copy from shared catalog without key fallback', () => {
const component = new PlayerShellComponent();
const notice = component.copy('player.audio_policy_notice');
const expected = lobbyCatalog.frontend.ui.player.audio_policy_notice[component.locale];
expect(notice).toBe(expected);
expect(notice).not.toBe('player.audio_policy_notice');
});
it('gates template warning notice on the no-audio-output capability flag', () => {
const templateSource = String((PlayerShellComponent as any).ɵcmp?.template);
expect(templateSource).toContain('clientHasNoAudioOutput');
const component = new PlayerShellComponent();
expect(component.copy('player.audio_policy_notice')).not.toBe('player.audio_policy_notice');
expect(component.clientHasNoAudioOutput).toBe(true);
(component as any).clientHasNoAudioOutput = false;
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);
});
}); });

View File

@@ -3,31 +3,35 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import type { SessionDetailResponse } from '../../../../../src/api/types';
import {
deriveGameplayPhase,
isPlayerGameplayActionAllowed,
} from '../../../../../src/spa/gameplay-phase-machine';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
interface SessionDetail { type SessionDetail = SessionDetailResponse;
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
players: Array<{ id: number; nickname: string; score: number }>;
}
type ConnectionState = 'online' | 'reconnecting' | 'offline'; type ConnectionState = 'online' | 'reconnecting' | 'offline';
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
type GuardableMediaElement = {
muted?: boolean;
defaultMuted?: boolean;
volume?: number;
pause?: () => void;
};
type MediaPrototypeWithGuardState = { type MediaPrototypeWithGuardState = {
play?: () => Promise<void>; play?: (this: GuardableMediaElement) => Promise<void>;
__wppSecondaryDeviceAudioGuard__?: { __wppSecondaryDeviceAudioGuard__?: {
originalPlay: () => Promise<void>; originalPlay: (this: GuardableMediaElement) => Promise<void>;
installs: number; installs: number;
}; };
}; };
type GuardableMediaElement = {
muted?: boolean;
pause?: () => void;
};
function resolveLocalStorage(): Storage | undefined { function resolveLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -42,12 +46,13 @@ function resolveLocalStorage(): Storage | undefined {
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
template: ` template: `
<h2>{{ copy('player.title') }}</h2> <h2>{{ copy('player.title') }}</h2>
<p *ngIf="clientHasNoAudioOutput" class="hint">{{ copy('player.audio_policy_notice') }}</p>
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput"> <div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label> <label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label>{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label> <label *ngIf="showJoinControls">{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button> <button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button> <button *ngIf="showJoinControls" (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
</div> </div>
<p *ngIf="connectionState === 'reconnecting'" class="error"> <p *ngIf="connectionState === 'reconnecting'" class="error">
@@ -67,26 +72,53 @@ function resolveLocalStorage(): Storage | undefined {
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }}</p> <p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p> <p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label> <ng-container *ngIf="showLieControls">
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">{{ copy('player.submit_lie') }}</button> <label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || !canSubmitLie" /></label>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button> <button (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.retry_lie_submit') }}</button>
</ng-container>
<div class="answers" *ngIf="session.round_question?.answers?.length"> <ng-container *ngIf="showGuessControls">
<button <div class="answers" *ngIf="session.round_question?.answers?.length">
type="button" <button
*ngFor="let answer of session.round_question?.answers" type="button"
(click)="selectedGuess = answer.text" *ngFor="let answer of session.round_question?.answers"
[class.active]="selectedGuess === answer.text" (click)="selectedGuess = answer.text"
[disabled]="loading || session.session.status !== 'guess'" [class.active]="selectedGuess === answer.text"
> [disabled]="loading || !canSubmitGuess"
{{ answer.text }} >
</button> {{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || !canSubmitGuess || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading || !canSubmitGuess">{{ copy('player.retry_guess_submit') }}</button>
</ng-container>
<div class="panel" *ngIf="showRevealPanel">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div> </div>
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button> <div *ngIf="showFinalLeaderboard && finalLeaderboard.length">
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<h3>{{ copy('player.final_leaderboard') }}</h3> <h3>{{ copy('player.final_leaderboard') }}</h3>
<ol> <ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li> <li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
@@ -177,6 +209,22 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.restoreAudioGuard = null; this.restoreAudioGuard = null;
} }
get gameplayPhase(): string | null {
return deriveGameplayPhase(this.session as any);
}
get canSubmitLie(): boolean {
return isPlayerGameplayActionAllowed(this.session as any, 'submitLie');
}
get canSubmitGuess(): boolean {
return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess');
}
get showRevealPanel(): boolean {
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
}
private readonly handleOnline = (): void => { private readonly handleOnline = (): void => {
this.connectionState = 'reconnecting'; this.connectionState = 'reconnecting';
void this.retryReconnect(); void this.retryReconnect();
@@ -221,7 +269,15 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
guardState.installs += 1; guardState.installs += 1;
} else { } else {
const originalPlay = mediaPrototype.play; const originalPlay = mediaPrototype.play;
mediaPrototype.play = () => Promise.resolve(); mediaPrototype.play = function mediaGuardedPlay(this: GuardableMediaElement): Promise<void> {
this.muted = true;
this.defaultMuted = true;
if (typeof this.volume === 'number') {
this.volume = 0;
}
this.pause?.();
return Promise.resolve();
};
mediaPrototype.__wppSecondaryDeviceAudioGuard__ = { mediaPrototype.__wppSecondaryDeviceAudioGuard__ = {
originalPlay, originalPlay,
installs: 1, installs: 1,
@@ -242,13 +298,14 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}; };
} }
private silenceExistingMediaElements(): void { private silenceExistingMediaElements(): void {
if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') { if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') {
return; return;
} }
const activeElements = document.querySelectorAll('audio,video') as const activeElements = document.querySelectorAll('audio,video') as
| NodeListOf<GuardableMediaElement> | NodeListOf<HTMLMediaElement>
| GuardableMediaElement[] | GuardableMediaElement[]
| undefined; | undefined;
@@ -258,6 +315,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
activeElements.forEach((element) => { activeElements.forEach((element) => {
element.muted = true; element.muted = true;
element.defaultMuted = true;
if (typeof element.volume === 'number') {
element.volume = 0;
}
element.pause?.(); element.pause?.();
}); });
} }
@@ -283,6 +344,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}, 3000); }, 3000);
} }
get showJoinControls(): boolean {
if (!this.session) {
return true;
}
return Boolean(this.session?.phase_view_model?.player?.can_join && !this.playerId && !this.sessionToken);
}
get showLieControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_lie);
}
get showGuessControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_guess);
}
get showFinalLeaderboard(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_view_final_result);
}
get loadingMessage(): string { get loadingMessage(): string {
switch (this.loadingTransition) { switch (this.loadingTransition) {
case 'join': case 'join':
@@ -393,7 +473,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
return; return;
} }
const phase = this.session.session.status || 'lobby'; const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode); const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) { if (!code) {
return; return;
@@ -483,7 +563,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
async submitLie(): Promise<void> { async submitLie(): Promise<void> {
if (!this.session?.round_question?.id) { if (!this.session?.round_question?.id || !this.canSubmitLie) {
return; return;
} }
this.loading = true; this.loading = true;
@@ -511,7 +591,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
async submitGuess(): Promise<void> { async submitGuess(): Promise<void> {
if (!this.session?.round_question?.id || !this.selectedGuess) { if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) {
return; return;
} }
this.loading = true; this.loading = true;

View File

@@ -4,41 +4,58 @@ import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component'; import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n'; 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)', () => { describe('i18n MVP flow smoke (host/player + audio policy)', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('resolves host/player copy in en and da from shared catalog', () => { it.each([
vi.stubGlobal('window', { {
location: { hash: '', search: '' }, locale: 'en',
history: { state: null, replaceState: vi.fn() }, hostRefresh: 'Refresh',
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, hostStartRound: 'Start round',
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, playerSubmitGuess: 'Submit guess',
addEventListener: vi.fn(), },
removeEventListener: vi.fn(), {
}); locale: 'da',
vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); 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 host = new HostShellComponent();
const player = new PlayerShellComponent(); const player = new PlayerShellComponent();
host.ngOnInit(); host.ngOnInit();
player.ngOnInit(); player.ngOnInit();
setPreferredLocale(locale);
expect(host.copy('game.host.start_round')).toBe('Start round'); expect(host.copy('common.refresh')).toBe(hostRefresh);
expect(player.copy('game.player.submit_guess')).toBe('Submit guess'); expect(host.copy('game.host.start_round')).toBe(hostStartRound);
expect(player.copy('game.player.submit_guess')).toBe(playerSubmitGuess);
setPreferredLocale('da');
expect(host.copy('game.host.start_round')).toBe('Start runde');
expect(player.copy('game.player.submit_guess')).toBe('Send gæt');
player.ngOnDestroy(); player.ngOnDestroy();
host.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', { vi.stubGlobal('window', {
location: { hash: '', search: '' }, location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() }, 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() }, sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(), addEventListener: vi.fn(),
removeEventListener: vi.fn(), removeEventListener: vi.fn(),
HTMLMediaElement: { prototype: mediaPrototype },
}); });
vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
vi.stubGlobal('document', { querySelectorAll: vi.fn().mockReturnValue([]) });
const host = new HostShellComponent(); const host = new HostShellComponent();
const player = new PlayerShellComponent(); host.ngOnInit();
expect(host.clientHasNoAudioOutput).toBe(true); await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
expect(player.clientHasNoAudioOutput).toBe(true);
const player = new PlayerShellComponent();
player.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
player.ngOnDestroy(); player.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
host.ngOnDestroy(); host.ngOnDestroy();
}); });
}); });

View File

@@ -1,26 +1,15 @@
import lobbyCatalog from '../../../../shared/i18n/lobby.json'; import {
DEFAULT_LOCALE,
type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number]; LOBBY_I18N_CATALOG,
normalizeLocale,
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale; type SupportedLocale,
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[]; translateCatalogPath,
} from '../../../shared/i18n/lobby-loader';
let activeLocale: SupportedLocale | null = null; let activeLocale: SupportedLocale | null = null;
const localeSubscribers = new Set<(locale: SupportedLocale) => void>(); const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
export function normalizeLocale(rawLocale?: string | null): SupportedLocale { export { normalizeLocale };
const locale = (rawLocale ?? '').trim().toLowerCase();
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const shortLocale = locale.split('-')[0] ?? '';
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
return shortLocale as SupportedLocale;
}
return DEFAULT_LOCALE;
}
export function resolvePreferredLocale(): SupportedLocale { export function resolvePreferredLocale(): SupportedLocale {
if (activeLocale) { if (activeLocale) {
@@ -79,24 +68,7 @@ function resolveCatalogPath(key: string): string {
} }
export function t(key: string, locale: string): string { export function t(key: string, locale: string): string {
const normalizedLocale = normalizeLocale(locale); return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, resolveCatalogPath(key), locale);
const fallbackLocale = DEFAULT_LOCALE;
const segments = resolveCatalogPath(key).split('.');
let cursor: unknown = lobbyCatalog.frontend.ui;
for (const segment of segments) {
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
return key;
}
cursor = (cursor as Record<string, unknown>)[segment];
}
if (!cursor || typeof cursor !== 'object') {
return key;
}
const translations = cursor as Record<string, string>;
return translations[normalizedLocale] ?? translations[fallbackLocale] ?? key;
} }
export const clientHasNoAudioOutput = Boolean(lobbyCatalog.frontend.capabilities.client_has_no_audio_output); export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.frontend.capabilities.client_has_no_audio_output);

View File

@@ -69,6 +69,7 @@ describe('session route context', () => {
sessionCode: 'AB12', sessionCode: 'AB12',
playerId: 5, playerId: 5,
token: 'tok-5', token: 'tok-5',
locale: 'en',
}); });
}); });
@@ -80,7 +81,25 @@ describe('session route context', () => {
sessionCode: 'AB12', sessionCode: 'AB12',
playerId: null, playerId: null,
token: null, token: null,
locale: 'en',
}); });
expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'AB12'); expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'AB12');
}); });
it('resolvers normalize and expose locale from lang query param', () => {
setWindow(storageMock(), storageMock());
expect(hostRouteContextResolver(route({}, { lang: 'da-DK' }) as never, {} as never).locale).toBe('da');
expect(playerRouteContextResolver(route({}, { lang: 'EN' }) as never, {} as never).locale).toBe('en');
});
it('does not reset persisted preferred locale when lang query param is absent', () => {
const localStorage = storageMock({ 'wpp.locale': 'da' });
setWindow(localStorage, storageMock());
expect(hostRouteContextResolver(route({}, { lang: 'da' }) as never, {} as never).locale).toBe('da');
expect(hostRouteContextResolver(route({}, {}) as never, {} as never).locale).toBe('da');
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
expect(localStorage.setItem).toHaveBeenCalledWith('wpp.locale', 'da');
});
}); });

View File

@@ -2,11 +2,13 @@ import { inject } from '@angular/core';
import { type ActivatedRouteSnapshot, type CanActivateFn, type ResolveFn, Router, type UrlTree } from '@angular/router'; import { type ActivatedRouteSnapshot, type CanActivateFn, type ResolveFn, Router, type UrlTree } from '@angular/router';
import { createSessionContextStore } from '../../../src/spa/session-context-store'; import { createSessionContextStore } from '../../../src/spa/session-context-store';
import { normalizeLocale, resolvePreferredLocale, setPreferredLocale } from './lobby-i18n';
export interface RouteSessionContext { export interface RouteSessionContext {
sessionCode: string | null; sessionCode: string | null;
playerId: number | null; playerId: number | null;
token: string | null; token: string | null;
locale: string;
} }
const HOST_STORAGE_KEY = 'wpp.host-session-code'; const HOST_STORAGE_KEY = 'wpp.host-session-code';
@@ -61,6 +63,17 @@ export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' |
return null; return null;
} }
function resolveRouteLocale(route: ActivatedRouteSnapshot): string {
const langParam = route.queryParamMap.get('lang');
if (langParam !== null) {
const locale = normalizeLocale(langParam);
setPreferredLocale(locale);
return locale;
}
return resolvePreferredLocale();
}
async function sessionExists(code: string): Promise<boolean> { async function sessionExists(code: string): Promise<boolean> {
const response = await fetch(`/lobby/sessions/${encodeURIComponent(code)}`, { const response = await fetch(`/lobby/sessions/${encodeURIComponent(code)}`, {
method: 'GET', method: 'GET',
@@ -118,23 +131,26 @@ export const playerRouteGuard: CanActivateFn = (route) => guard('player', route)
export const hostRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => { export const hostRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
const code = resolveSessionCode(route, 'host'); const code = resolveSessionCode(route, 'host');
const locale = resolveRouteLocale(route);
if (code) { if (code) {
window.sessionStorage.setItem(HOST_STORAGE_KEY, code); window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
} }
return { sessionCode: code, playerId: null, token: null }; return { sessionCode: code, playerId: null, token: null, locale };
}; };
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => { export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
const code = resolveSessionCode(route, 'player'); const code = resolveSessionCode(route, 'player');
const locale = resolveRouteLocale(route);
const context = createSessionContextStore(window.localStorage).get(); const context = createSessionContextStore(window.localStorage).get();
if (!code || !context || normalizeCode(context.sessionCode) !== code) { if (!code || !context || normalizeCode(context.sessionCode) !== code) {
return { sessionCode: code, playerId: null, token: null }; return { sessionCode: code, playerId: null, token: null, locale };
} }
return { return {
sessionCode: code, sessionCode: code,
playerId: Number.isInteger(context.playerId) && context.playerId > 0 ? context.playerId : null, playerId: Number.isInteger(context.playerId) && context.playerId > 0 ? context.playerId : null,
token: context.token.trim() || null, token: context.token.trim() || null,
locale,
}; };
}; };

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { createWppApiClient } from './wpp-api-client';
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('WPP Angular API client skeleton', () => {
it('normalizes host/player API calls through fetch transport', async () => {
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' } }));
const client = createWppApiClient(fetchMock);
const session = await client.getSession(' abcd12 ');
const joined = await client.joinSession({ code: ' abcd12 ', nickname: ' Luna ' });
expect(session.ok).toBe(true);
expect(joined.ok).toBe(true);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/join',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
})
);
});
});

View File

@@ -0,0 +1,58 @@
import { InjectionToken } from '@angular/core';
import {
createAngularApiClient,
type AngularApiClient,
type AngularHttpClientLike,
} from '../../../src/api/angular-client';
export const WPP_API_CLIENT = new InjectionToken<AngularApiClient>('WPP_API_CLIENT');
export interface FetchLike {
(input: string, init?: RequestInit): Promise<Response>;
}
export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLike {
return {
async get<T>(url: string): Promise<T> {
const response = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw {
status: response.status,
message: (payload as { error?: string }).error ?? `HTTP ${response.status}`,
error: payload,
};
}
return payload as T;
},
async post<T>(url: string, body: unknown): Promise<T> {
const response = await fetchImpl(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw {
status: response.status,
message: (payload as { error?: string }).error ?? `HTTP ${response.status}`,
error: payload,
};
}
return payload as T;
},
};
}
export function createWppApiClient(fetchImpl: FetchLike = fetch.bind(globalThis)): AngularApiClient {
return createAngularApiClient(createFetchHttpClient(fetchImpl));
}

View File

@@ -7,12 +7,125 @@
"": { "": {
"name": "wpp-frontend-api-client-baseline", "name": "wpp-frontend-api-client-baseline",
"version": "0.1.0", "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": { "devDependencies": {
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^2.1.9" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1188,6 +1301,15 @@
"fsevents": "~2.3.2" "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": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1263,6 +1385,12 @@
"node": ">=14.0.0" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1449,6 +1577,12 @@
"engines": { "engines": {
"node": ">=8" "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"
} }
} }
} }

View File

@@ -7,6 +7,17 @@
"test": "vitest run", "test": "vitest run",
"build": "tsc --noEmit" "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": { "devDependencies": {
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@@ -0,0 +1,74 @@
import lobbyCatalog from '../../../shared/i18n/lobby.json';
export type LobbyCatalog = typeof lobbyCatalog;
export type SupportedLocale = LobbyCatalog['locales']['supported'][number];
export const LOBBY_I18N_CATALOG = lobbyCatalog;
export const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
export const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
const locale = (rawLocale ?? '').trim().toLowerCase().replace(/_/g, '-');
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const shortLocale = locale.split('-')[0] ?? '';
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
return shortLocale as SupportedLocale;
}
return DEFAULT_LOCALE;
}
export function translateCatalogPath(
root: Record<string, unknown>,
keyPath: string,
locale: string,
fallback = DEFAULT_LOCALE,
): string {
const normalizedLocale = normalizeLocale(locale);
const segments = keyPath.split('.');
let cursor: unknown = root;
for (const segment of segments) {
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
return keyPath;
}
cursor = (cursor as Record<string, unknown>)[segment];
}
if (!cursor || typeof cursor !== 'object') {
return keyPath;
}
const translations = cursor as Record<string, string>;
return translations[normalizedLocale] ?? translations[fallback] ?? keyPath;
}
export function collectLocaleParityIssues(
node: unknown,
locales: readonly string[] = SUPPORTED_LOCALES,
path = '',
): string[] {
if (!node || typeof node !== 'object') {
return [];
}
const record = node as Record<string, unknown>;
const keys = Object.keys(record);
const isTranslationLeaf = keys.length > 0 && locales.every((locale) => locale in record);
if (isTranslationLeaf) {
const issues: string[] = [];
for (const locale of locales) {
const value = record[locale];
if (typeof value !== 'string' || !value.trim()) {
issues.push(`${path || '<root>'} missing non-empty '${locale}' translation`);
}
}
return issues;
}
return keys.flatMap((key) => collectLocaleParityIssues(record[key], locales, path ? `${path}.${key}` : key));
}

View File

@@ -4,10 +4,10 @@ import {
mapHealthResponse, mapHealthResponse,
mapJoinSessionResponse, mapJoinSessionResponse,
mapMixAnswersResponse, mapMixAnswersResponse,
mapNextRoundResponse,
mapScoreboardResponse, mapScoreboardResponse,
mapSessionDetailResponse, mapSessionDetailResponse,
mapShowQuestionResponse, mapShowQuestionResponse,
mapStartNextRoundResponse,
mapStartRoundResponse, mapStartRoundResponse,
mapSubmitGuessResponse, mapSubmitGuessResponse,
mapSubmitLieResponse mapSubmitLieResponse
@@ -20,10 +20,10 @@ import type {
JoinSessionRequest, JoinSessionRequest,
JoinSessionResponse, JoinSessionResponse,
MixAnswersResponse, MixAnswersResponse,
NextRoundResponse,
ScoreboardResponse, ScoreboardResponse,
SessionDetailResponse, SessionDetailResponse,
ShowQuestionResponse, ShowQuestionResponse,
StartNextRoundResponse,
StartRoundRequest, StartRoundRequest,
StartRoundResponse, StartRoundResponse,
SubmitGuessRequest, SubmitGuessRequest,
@@ -41,7 +41,7 @@ export interface ApiClient {
mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>; mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>;
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>; calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>; getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
startNextRound(code: string): Promise<ApiResult<NextRoundResponse>>; startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>; finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>; submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise<ApiResult<SubmitGuessResponse>>; submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise<ApiResult<SubmitGuessResponse>>;
@@ -167,10 +167,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
mapScoreboardResponse mapScoreboardResponse
), ),
startNextRound: (code: string) => startNextRound: (code: string) =>
request<NextRoundResponse>( request<StartNextRoundResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`,
'POST', 'POST',
mapNextRoundResponse, mapStartNextRoundResponse,
{} {}
), ),
finishGame: (code: string) => finishGame: (code: string) =>

View File

@@ -60,6 +60,17 @@ function readBoolean(record: Record<string, unknown>, key: string, path: string)
return value; 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;
}
export function mapHealthResponse(payload: unknown): HealthResponse { export function mapHealthResponse(payload: unknown): HealthResponse {
const root = asRecord(payload, 'health'); const root = asRecord(payload, 'health');
return { return {
@@ -102,6 +113,58 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
const host = asRecord(phase.host, 'session_detail.phase_view_model.host'); const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
const player = asRecord(phase.player, 'session_detail.phase_view_model.player'); 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)
};
})
};
}
return { return {
session: { session: {
code: readString(session, 'code', 'session_detail.session'), code: readString(session, 'code', 'session_detail.session'),
@@ -129,8 +192,10 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
}; };
}), }),
round_question: roundQuestion, round_question: roundQuestion,
reveal,
phase_view_model: { phase_view_model: {
status: readString(phase, 'status', 'session_detail.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'), round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'), players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
constraints: { constraints: {
@@ -139,6 +204,19 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'), 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') 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: { host: {
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.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'), can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
@@ -337,10 +415,7 @@ export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse {
const root = asRecord(payload, 'submit_guess'); const root = asRecord(payload, 'submit_guess');
const guess = asRecord(root.guess, 'submit_guess.guess'); const guess = asRecord(root.guess, 'submit_guess.guess');
const window = asRecord(root.window, 'submit_guess.window'); const window = asRecord(root.window, 'submit_guess.window');
const fooledPlayerId = guess.fooled_player_id; const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess');
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
throw new Error('Invalid API contract: expected number|null at submit_guess.guess.fooled_player_id');
}
return { return {
guess: { guess: {

View File

@@ -32,6 +32,7 @@ export interface SessionRoundQuestion {
export interface PhaseViewModel { export interface PhaseViewModel {
status: string; status: string;
current_phase?: string;
round_number: number; round_number: number;
players_count: number; players_count: number;
constraints: { constraints: {
@@ -40,6 +41,10 @@ export interface PhaseViewModel {
min_players_reached: boolean; min_players_reached: boolean;
max_players_allowed: boolean; max_players_allowed: boolean;
}; };
readiness?: {
question_ready?: boolean;
scoreboard_ready?: boolean;
};
host: { host: {
can_start_round: boolean; can_start_round: boolean;
can_show_question: boolean; can_show_question: boolean;
@@ -57,10 +62,37 @@ 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 SessionDetailResponse { export interface SessionDetailResponse {
session: SessionSummary; session: SessionSummary;
players: SessionPlayer[]; players: SessionPlayer[];
round_question: SessionRoundQuestion | null; round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
phase_view_model: PhaseViewModel; phase_view_model: PhaseViewModel;
} }

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 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 = export type GameplayPhaseEvent =
| 'LIES_LOCKED' | 'LIES_LOCKED'
@@ -40,8 +49,7 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[]; return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
} }
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null { function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null {
const status = session?.session.status;
if (!status) { if (!status) {
return null; return null;
} }
@@ -56,3 +64,59 @@ export function deriveGameplayPhase(session: SessionDetailResponse | null): Game
return null; 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);
}
}

View File

@@ -1,24 +1,17 @@
import lobbyCatalog from '../../../shared/i18n/lobby.json'; import { DEFAULT_LOCALE, LOBBY_I18N_CATALOG, normalizeLocale } from '../../shared/i18n/lobby-loader';
const frontendErrors = lobbyCatalog.frontend.errors; const frontendErrors = LOBBY_I18N_CATALOG.frontend.errors;
const localeConfig = lobbyCatalog.locales; const backendToFrontendErrorKeys = LOBBY_I18N_CATALOG.contract.backend_to_frontend_error_keys as Record<
const backendToFrontendErrorKeys = lobbyCatalog.contract.backend_to_frontend_error_keys as Record<string, keyof typeof frontendErrors>; string,
keyof typeof frontendErrors
>;
type FrontendErrorKey = keyof typeof frontendErrors; type FrontendErrorKey = keyof typeof frontendErrors;
type SupportedLocale = (typeof localeConfig.supported)[number];
function isFrontendErrorKey(value: string): value is FrontendErrorKey { function isFrontendErrorKey(value: string): value is FrontendErrorKey {
return value in frontendErrors; return value in frontendErrors;
} }
function normalizeLocale(rawLocale?: string): SupportedLocale {
const requested = (rawLocale ?? '').trim().toLowerCase();
if (localeConfig.supported.includes(requested as SupportedLocale)) {
return requested as SupportedLocale;
}
return localeConfig.default;
}
export function lobbyMessage(key: FrontendErrorKey, locale?: string): string { export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
const resolvedLocale = normalizeLocale(locale); const resolvedLocale = normalizeLocale(locale);
const translations = frontendErrors[key] as Record<string, string>; const translations = frontendErrors[key] as Record<string, string>;
@@ -26,8 +19,8 @@ export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
if (translations[resolvedLocale]) { if (translations[resolvedLocale]) {
return translations[resolvedLocale]; return translations[resolvedLocale];
} }
if (translations[localeConfig.default]) { if (translations[DEFAULT_LOCALE]) {
return translations[localeConfig.default]; return translations[DEFAULT_LOCALE];
} }
return key; return key;

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client'; import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
import { mapSessionDetailResponse, mapSubmitGuessResponse } from '../src/api/mappers';
describe('createAngularApiClient', () => { describe('createAngularApiClient', () => {
it('reads health and session detail using Django-compatible endpoints', async () => { it('reads health and session detail using Django-compatible endpoints', async () => {
@@ -206,11 +207,391 @@ 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('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 () => { it('maps host/player gameplay endpoints through typed response mappers', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => { const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12/scoreboard') { if (url === '/lobby/sessions/ABCD12/scoreboard') {
return { return {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
leaderboard: [ leaderboard: [
{ id: 2, nickname: 'Maja', score: 11 }, { id: 2, nickname: 'Maja', score: 11 },
{ id: 3, nickname: 'Bo', score: 7 } { id: 3, nickname: 'Bo', score: 7 }
@@ -245,7 +626,7 @@ describe('createAngularApiClient', () => {
if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
expect(body).toEqual({}); expect(body).toEqual({});
return { return {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
round_question: { id: 77, round_number: 1 }, round_question: { id: 77, round_number: 1 },
events_created: 3, events_created: 3,
leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }] leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }]
@@ -253,7 +634,7 @@ describe('createAngularApiClient', () => {
} }
if (url === '/lobby/sessions/ABCD12/rounds/next') { if (url === '/lobby/sessions/ABCD12/rounds/next') {
expect(body).toEqual({}); 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') { if (url === '/lobby/sessions/ABCD12/finish') {
expect(body).toEqual({}); expect(body).toEqual({});

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { import {
allowedGameplayEvents, allowedGameplayEvents,
deriveGameplayPhase, deriveGameplayPhase,
isHostGameplayActionAllowed,
isPlayerGameplayActionAllowed,
transitionGameplayPhase, transitionGameplayPhase,
type GameplayPhase type GameplayPhase
} from '../src/spa/gameplay-phase-machine'; } 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 }, session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 },
players: [], players: [],
round_question: null, round_question: null,
reveal: null,
phase_view_model: { phase_view_model: {
status: 'lie', status: 'lie',
round_number: 1, 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 }, session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 },
players: [], players: [],
round_question: null, round_question: null,
reveal: null,
phase_view_model: { phase_view_model: {
status: 'finished', status: 'finished',
round_number: 1, round_number: 1,
@@ -103,4 +107,44 @@ describe('gameplay phase machine skeleton', () => {
}) })
).toBe('scoreboard'); ).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);
});
}); });

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import {
collectLocaleParityIssues,
LOBBY_I18N_CATALOG,
normalizeLocale,
SUPPORTED_LOCALES,
translateCatalogPath,
} from '../shared/i18n/lobby-loader';
describe('shared lobby i18n loader parity', () => {
it('keeps da/en translation parity in shared keyspace', () => {
const issues = collectLocaleParityIssues(LOBBY_I18N_CATALOG, SUPPORTED_LOCALES);
expect(issues).toEqual([]);
});
it('normalizes browser-style locale tags to supported keyspace locales', () => {
expect(normalizeLocale('da-DK')).toBe('da');
expect(normalizeLocale('da_DK')).toBe('da');
expect(normalizeLocale('en-US')).toBe('en');
expect(normalizeLocale('en_US')).toBe('en');
expect(normalizeLocale('fr-FR')).toBe('en');
});
it('resolves shared frontend ui keys with fallback-safe behavior', () => {
expect(
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'host.start_round', 'da-DK'),
).toBe('Start runde');
expect(
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'app.language_label', 'en-US'),
).toBe('Language');
expect(
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'host.non_existing_key', 'da'),
).toBe('host.non_existing_key');
});
});

View File

@@ -16,6 +16,7 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 }, session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [], players: [],
round_question: null, round_question: null,
reveal: null,
phase_view_model: { phase_view_model: {
status: 'lobby', status: 'lobby',
round_number: 1, round_number: 1,
@@ -56,7 +57,15 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
session: { code: 'ABCD12', status: 'lie', current_round: 1 }, session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } } 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 }; return { ...base, ...overrides };

View File

@@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'],
setupFiles: ['angular/src/test-setup.ts'],
exclude: ['**/node_modules/**'] exclude: ['**/node_modules/**']
} }
}); });

View File

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

View File

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

View File

@@ -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 = []

View File

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

View File

@@ -42,6 +42,7 @@ class GameSession(models.Model):
LIE = "lie", "Løgnfase" LIE = "lie", "Løgnfase"
GUESS = "guess", "Gættefase" GUESS = "guess", "Gættefase"
REVEAL = "reveal", "Reveal" REVEAL = "reveal", "Reveal"
SCOREBOARD = "scoreboard", "Scoreboard"
FINISHED = "finished", "Afsluttet" FINISHED = "finished", "Afsluttet"
host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions") host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions")
@@ -82,6 +83,7 @@ class RoundConfig(models.Model):
points_bluff = models.IntegerField(default=2) points_bluff = models.IntegerField(default=2)
lie_seconds = models.PositiveIntegerField(default=45) lie_seconds = models.PositiveIntegerField(default=45)
guess_seconds = models.PositiveIntegerField(default=30) guess_seconds = models.PositiveIntegerField(default=30)
started_from_scoreboard = models.BooleanField(default=False)
class Meta: class Meta:
unique_together = (("session", "number"),) unique_together = (("session", "number"),)

275
fupogfakta/payloads.py Normal file
View File

@@ -0,0 +1,275 @@
from datetime import timedelta
from .models import GameSession, Player, RoundConfig, RoundQuestion
def build_player_ref(player: Player | None) -> dict | None:
if player is None:
return None
return {
"player_id": player.id,
"nickname": player.nickname,
}
def build_round_question_payload(round_question: RoundQuestion | None) -> 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,
"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) -> 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,
"correct_answer": round_question.correct_answer,
"lies": lies,
"guesses": guesses,
}
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 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": False,
"can_mix_answers": False,
"can_calculate_scores": False,
"can_reveal_scoreboard": False,
"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,
) -> dict:
return {
"round_question": build_round_question_payload(current_round_question),
"reveal": build_reveal_payload(current_round_question)
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,
"phase_view_model": build_phase_view_model(
session,
players_count=players_count,
has_round_question=bool(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"],
}

479
fupogfakta/services.py Normal file
View File

@@ -0,0 +1,479 @@
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())
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

View File

@@ -1,2 +1,410 @@
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, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import (
build_lie_started_payload,
build_phase_view_model,
build_reveal_payload,
build_round_question_payload,
build_session_detail_gameplay_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_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)
lie_payload = build_lie_started_payload(self.session, self.round_config, round_question)
reveal_payload = build_reveal_payload(round_question)
phase_view_model = build_phase_view_model(
self.session,
players_count=3,
has_round_question=True,
)
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_finish_game"])
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,
)
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"])

View File

@@ -1,46 +1,69 @@
import json
import logging import logging
from functools import lru_cache
from pathlib import Path
from django.http import HttpRequest, JsonResponse from django.http import HttpRequest, JsonResponse
from django.utils.translation import get_language_from_request from django.utils.translation import get_language_from_request
from partyhub.i18n_bootstrap import locale_config, shared_i18n_catalog
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def lobby_i18n_catalog() -> dict: def lobby_i18n_catalog() -> dict:
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json" return shared_i18n_catalog()
with catalog_path.open(encoding="utf-8") as handle:
return json.load(handle)
@lru_cache(maxsize=1)
def i18n_locale_config() -> tuple[str, tuple[str, ...]]: def i18n_locale_config() -> tuple[str, tuple[str, ...]]:
locales = lobby_i18n_catalog().get("locales", {}) return locale_config()
default_locale = str(locales.get("default", "en")).strip().lower() or "en"
supported_locales = tuple(
locale.strip().lower() for locale in locales.get("supported", ["en", "da"]) if str(locale).strip()
) or ("en", "da")
return default_locale, supported_locales
def lobby_i18n_errors() -> dict: def lobby_i18n_errors() -> dict:
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {}) return shared_i18n_catalog().get("backend", {}).get("error_codes", {})
def lobby_i18n_error_messages() -> dict: def lobby_i18n_error_messages() -> dict:
return lobby_i18n_catalog().get("backend", {}).get("errors", {}) 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("=")
if separator and key.strip().lower() == "q":
try:
return float(value.strip())
except ValueError:
return None
return None
def resolve_locale(request: HttpRequest) -> str: def resolve_locale(request: HttpRequest) -> str:
default_locale, supported_locales = i18n_locale_config() default_locale, supported_locales = i18n_locale_config()
raw_accept_language = (request.META.get("HTTP_ACCEPT_LANGUAGE") or "").split(",", 1)[0] accept_language = request.META.get("HTTP_ACCEPT_LANGUAGE") or ""
raw_requested = raw_accept_language.split(";", 1)[0].strip().replace("_", "-").split("-", 1)[0].lower() ranked_candidates: list[tuple[float, int, str]] = []
if raw_requested in supported_locales: for index, candidate in enumerate(accept_language.split(",")):
return raw_requested quality = _quality_value(candidate)
if quality is not None and quality <= 0:
continue
tag = candidate.split(";", 1)[0]
normalized = tag.strip().replace("_", "-").split("-", 1)[0].lower()
if normalized not in supported_locales:
continue
ranked_candidates.append((quality if quality is not None else 1.0, index, normalized))
if ranked_candidates:
ranked_candidates.sort(key=lambda entry: (-entry[0], entry[1]))
return ranked_candidates[0][2]
requested = (get_language_from_request(request) or "").replace("_", "-").split("-", 1)[0].lower() requested = (get_language_from_request(request) or "").replace("_", "-").split("-", 1)[0].lower()
if requested in supported_locales: if requested in supported_locales:
@@ -64,12 +87,13 @@ def resolve_error_message(*, key: str, locale: str) -> str:
return key 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) locale = resolve_locale(request)
key = resolve_error_key(code)
return JsonResponse( return JsonResponse(
{ {
"error": resolve_error_message(key=key, locale=locale), "error": resolve_error_message(key=key, locale=locale),
"error_code": key, "error_code": code,
"locale": locale, "locale": locale,
}, },
status=status, status=status,

View File

@@ -10,7 +10,7 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
class Command(BaseCommand): class Command(BaseCommand):
help = "Run minimal staging smoke flow for lobby gameplay" help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
@@ -18,6 +18,26 @@ class Command(BaseCommand):
help="Optional path to write smoke result artifact as JSON", 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 handle(self, *args, **options): def handle(self, *args, **options):
GameSession.objects.all().delete() GameSession.objects.all().delete()
Player.objects.all().delete() Player.objects.all().delete()
@@ -30,11 +50,14 @@ class Command(BaseCommand):
category.is_active = True category.is_active = True
category.save(update_fields=["is_active"]) category.save(update_fields=["is_active"])
Question.objects.get_or_create( question, _ = Question.objects.get_or_create(
category=category, category=category,
prompt="Smoke prompt?", prompt="Smoke prompt?",
defaults={"correct_answer": "Correct", "is_active": True}, defaults={"correct_answer": "Correct", "is_active": True},
) )
if not question.is_active:
question.is_active = True
question.save(update_fields=["is_active"])
User = get_user_model() User = get_user_model()
host, _ = User.objects.get_or_create(username="smoke-host") host, _ = User.objects.get_or_create(username="smoke-host")
@@ -42,121 +65,254 @@ class Command(BaseCommand):
host.is_staff = True host.is_staff = True
host.save() host.save()
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 = Client() host_client = Client()
host_client.force_login(host) host_client.force_login(host)
create_res = host_client.post("/lobby/sessions/create", content_type="application/json") create_payload = self._expect_status(
if create_res.status_code != 201: host_client.post("/lobby/sessions/create", content_type="application/json"),
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}") 201,
"create_session",
code = create_res.json()["session"]["code"] )
code = create_payload["session"]["code"]
artifact["session_code"] = code
artifact["steps"].append(
{
"step": "create_session",
"session_status": create_payload["session"]["status"],
}
)
players = [] players = []
for nickname in ["P1", "P2", "P3"]: for nickname in ["P1", "P2", "P3"]:
join_res = Client().post( join_payload = self._expect_status(
"/lobby/sessions/join", Client().post(
data=json.dumps({"code": code, "nickname": nickname}), "/lobby/sessions/join",
content_type="application/json", 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", 201,
f"join_session[{nickname}]",
) )
if lie_res.status_code != 201: players.append(join_payload["player"])
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}") artifact["players"] = [player["nickname"] for player in players]
artifact["steps"].append(
mix_res = host_client.post( {
f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix", "step": "join_players",
content_type="application/json", "players_count": len(players),
}
) )
if mix_res.status_code != 200:
raise CommandError(f"mix_answers failed: {mix_res.status_code}")
answers = mix_res.json().get("answers", []) 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(
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: if not answers:
raise CommandError("mix_answers returned empty 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
for player in players: if not answers:
nick = player["nickname"] self._fail("auto_guess_transition", "canonical lie->guess transition returned empty answers")
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
guess_res = Client().post( if not any(answer.get("text") == question.correct_answer for answer in answers):
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit", self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers})
data=json.dumps( if len(answers) < len(players) + 1:
{ self._fail(
"player_id": player["id"], "auto_guess_transition",
"session_token": player["session_token"], "mixed answers shorter than expected bluff set",
"selected_text": selected["text"], {"answers": answers, "players_count": len(players)},
}
),
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( self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition")
f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate", artifact["steps"].append(
content_type="application/json", {
"step": "auto_guess_transition",
"session_status": lie_transition_payload["session"]["status"],
"answers": [answer["text"] for answer in answers],
}
) )
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") answer_texts = {answer["text"] for answer in answers}
if board_res.status_code != 200: correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None)
raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}") if correct_answer is None:
self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers})
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json") guess_plan = {
if finish_res.status_code != 200: players[0]["nickname"]: "Lie from P2",
raise CommandError(f"finish_game failed: {finish_res.status_code}") 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(
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") artifact_path = options.get("artifact")
if artifact_path: 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 = Path(artifact_path)
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8") output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")

View File

@@ -24,14 +24,10 @@
<p id="categoryGuardHint">Kategori er kun redigérbar i lobby-fasen.</p> <p id="categoryGuardHint">Kategori er kun redigérbar i lobby-fasen.</p>
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p> <p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
<button id="showQuestionBtn" onclick="showQuestion()" disabled>3) Vis spørgsmål</button> <p id="roundQuestionStatus">Aktiv round question: afventer session-status.</p>
<input id="roundQuestionId" placeholder="Round question id"> <p id="roundQuestionGuardHint">Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.</p>
<p id="roundQuestionGuardHint">Round question-id kan kun redigeres i lie/guess/reveal-faser.</p> <button id="nextRoundBtn" onclick="nextRound()" disabled>3) Næste runde</button>
<button id="mixAnswersBtn" onclick="mixAnswers()" disabled>4) Mix svar</button> <button id="finishGameBtn" onclick="finishGame()" disabled>4) Afslut spil</button>
<button id="calcScoresBtn" onclick="calcScores()" disabled>5) Beregn score</button>
<button id="showScoreboardBtn" onclick="showScoreboard()" disabled>6) Scoreboard</button>
<button id="nextRoundBtn" onclick="nextRound()" disabled>7) Næste runde</button>
<button id="finishGameBtn" onclick="finishGame()" disabled>8) Afslut spil</button>
<p id="hostActionHint">Angiv sessionkode for at aktivere host-actions.</p> <p id="hostActionHint">Angiv sessionkode for at aktivere host-actions.</p>
<p id="hostErrorHint">Ingen fejl.</p> <p id="hostErrorHint">Ingen fejl.</p>
<button id="sessionDetailBtn" onclick="sessionDetail()">Session-status</button> <button id="sessionDetailBtn" onclick="sessionDetail()">Session-status</button>
@@ -55,8 +51,12 @@
<p id="hostCriticalPlayers">Spillere: afventer</p> <p id="hostCriticalPlayers">Spillere: afventer</p>
<p id="hostCriticalRound">Aktiv round question: afventer</p> <p id="hostCriticalRound">Aktiv round question: afventer</p>
</section> </section>
<pre id="out">Klar.</pre> <pre id="out">Ready.</pre>
{{ lobby_i18n|json_script:"wppHostI18n" }}
<script> <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 currentSessionStatus="";
var autoRefreshEnabled=false; var autoRefreshEnabled=false;
var autoRefreshTimer=null; var autoRefreshTimer=null;
@@ -65,14 +65,15 @@ var lastRefreshAtLabel="";
var lastRefreshFailed=false; var lastRefreshFailed=false;
var sessionDetailInFlight=false; var sessionDetailInFlight=false;
var hostShellRouteHint=""; 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 hostShellFatalError=false;
var hostShellRecoverInFlight=false; var hostShellRecoverInFlight=false;
var hostCriticalHydrated=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 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(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; hostCriticalHydrated=true;
setHostCriticalLoading(false); setHostCriticalLoading(false);
} }
@@ -82,10 +83,9 @@ function setHostShellFatalError(detail){hostShellFatalError=true;var out=documen
function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();} function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();}
function recoverHostShell(mode){if(hostShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}hostShellRecoverInFlight=true;updateHostShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearHostShellFatalError();return result;}).catch(function(err){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();throw err;});} function 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 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(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
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.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
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"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";} function 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 expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";}
function syncHostShellRoute(){var currentRoute=hostShellRouteFromPath();var expectedRoute=expectedHostShellRoute();if(!currentRoute||!expectedRoute){hostShellRouteHint="";return;}if(currentRoute===expectedRoute){hostShellRouteHint="";return;}var nextPath="/lobby/ui/host/"+expectedRoute;window.history.replaceState(null,"",nextPath);hostShellRouteHint="Deep-link route guard: omdirigeret fra /"+currentRoute+" til /"+expectedRoute+" for fase "+phaseLabel(currentSessionStatus)+".";} function 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 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 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 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 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 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 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 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 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 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 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)+".";} 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.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;} 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 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 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 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 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 nextRound(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/next","POST",{});});}
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","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("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);}); window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setHostShellFatalError(detail);});

View File

@@ -69,8 +69,12 @@
<p id="playerCriticalRound">Round question: afventer</p> <p id="playerCriticalRound">Round question: afventer</p>
<p id="playerCriticalJoin">Join-status: afventer</p> <p id="playerCriticalJoin">Join-status: afventer</p>
</section> </section>
<pre id="out">Klar.</pre> <pre id="out">Ready.</pre>
{{ lobby_i18n|json_script:"wppPlayerI18n" }}
<script> <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 availableAnswers=[];
var guessSubmitted=false; var guessSubmitted=false;
var lieSubmitted=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 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 pid(){return document.getElementById("playerId").value.trim();}
function rq(){return document.getElementById("roundQuestionId").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 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 savePlayerContext(){try{localStorage.setItem(PLAYER_CONTEXT_KEY,JSON.stringify({code:code(),nickname:document.getElementById("nickname").value.trim(),player_id:pid(),session_token:document.getElementById("sessionToken").value.trim(),round_question_id:rq(),auto_refresh:playerAutoRefreshEnabled}));}catch(_e){}}
function loadPlayerContext(){try{var raw=localStorage.getItem(PLAYER_CONTEXT_KEY);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}} function 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 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 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 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_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("da-DK",{hour12:false});} 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 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 updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";} function 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.";}

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ def host_screen(request, spa_path=None):
{ {
"categories": categories, "categories": categories,
"lobby_i18n": lobby_i18n_catalog(), "lobby_i18n": lobby_i18n_catalog(),
"shell_locale": resolve_locale(request),
}, },
) )
@@ -48,4 +49,8 @@ def player_screen(request):
if use_spa_ui(): if use_spa_ui():
return _render_spa_shell(request, "/player", "player") return _render_spa_shell(request, "/player", "player")
return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()}) return render(
request,
"lobby/player_screen.html",
{"lobby_i18n": lobby_i18n_catalog(), "shell_locale": resolve_locale(request)},
)

View File

@@ -1,26 +1,38 @@
import json
import random
from datetime import timedelta from datetime import timedelta
import json
import random
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import HttpRequest, JsonResponse from django.http import HttpRequest, JsonResponse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import ( from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
Category, from fupogfakta.payloads import (
GameSession, build_leaderboard as _build_leaderboard,
Guess, build_reveal_payload as _build_reveal_payload,
LieAnswer, build_scoreboard_phase_event as _build_scoreboard_phase_event,
Player, build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
Question,
RoundConfig,
RoundQuestion,
ScoreEvent,
) )
from fupogfakta.services import (
finish_game as _finish_game,
get_current_round_question as _get_current_round_question,
prepare_mixed_answers as _prepare_mixed_answers,
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
resolve_scores as _resolve_scores,
select_round_question as _select_round_question,
show_question as _show_question,
start_next_round as _start_next_round,
start_round as _start_round,
)
from realtime.broadcast import sync_broadcast_phase_event
from .i18n import api_error
from .i18n import api_error, lobby_i18n_errors _GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
_select_round_question,
_build_scoreboard_phase_event,
)
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6 SESSION_CODE_LENGTH = 6
@@ -30,8 +42,10 @@ JOINABLE_STATUSES = {
GameSession.Status.LIE, GameSession.Status.LIE,
GameSession.Status.GUESS, GameSession.Status.GUESS,
GameSession.Status.REVEAL, GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
} }
ERROR_CODES = lobby_i18n_errors()
def _json_body(request: HttpRequest) -> dict: def _json_body(request: HttpRequest) -> dict:
@@ -61,43 +75,17 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code") raise RuntimeError("Could not generate unique session code")
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_reveal = status == GameSession.Status.REVEAL
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3 def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
max_players_allowed = players_count <= 5 transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return transition.session
return {
"status": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": in_lie and not has_round_question,
"can_mix_answers": in_lie or in_guess,
"can_calculate_scores": in_guess,
"can_reveal_scoreboard": in_reveal,
"can_start_next_round": in_reveal,
"can_finish_game": in_reveal,
},
"player": {
"can_join": status in JOINABLE_STATUSES,
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
@require_POST @require_POST
@@ -129,14 +117,14 @@ def join_session(request: HttpRequest) -> JsonResponse:
if not code: if not code:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("session_code_required", "session_code_required"), code="session_code_required",
status=400, status=400,
) )
if len(nickname) < 2 or len(nickname) > 40: if len(nickname) < 2 or len(nickname) > 40:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("nickname_invalid", "nickname_invalid"), code="nickname_invalid",
status=400, status=400,
) )
@@ -145,21 +133,21 @@ def join_session(request: HttpRequest) -> JsonResponse:
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("session_not_found", "session_not_found"), code="session_not_found",
status=404, status=404,
) )
if session.status not in JOINABLE_STATUSES: if session.status not in JOINABLE_STATUSES:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("session_not_joinable", "session_not_joinable"), code="session_not_joinable",
status=400, status=400,
) )
if Player.objects.filter(session=session, nickname__iexact=nickname).exists(): if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("nickname_taken", "nickname_taken"), code="nickname_taken",
status=409, status=409,
) )
@@ -191,7 +179,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("session_not_found", "session_not_found"), code="session_not_found",
status=404, status=404,
) )
@@ -204,27 +192,12 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
) )
) )
current_round_question = ( session = _maybe_promote_reveal_to_scoreboard(session)
RoundQuestion.objects.filter(session=session, round_number=session.current_round) current_round_question = _get_current_round_question(session)
.select_related("question") gameplay_payload = _build_session_detail_gameplay_payload(
.order_by("-id")
.first()
)
round_question_payload = None
if current_round_question:
round_question_payload = {
"id": current_round_question.id,
"round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
}
phase_view_model = _build_phase_view_model(
session, session,
current_round_question=current_round_question,
players_count=len(players), players_count=len(players),
has_round_question=bool(current_round_question),
) )
return JsonResponse( return JsonResponse(
@@ -237,8 +210,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"players_count": len(players), "players_count": len(players),
}, },
"players": players, "players": players,
"round_question": round_question_payload, **gameplay_payload,
"phase_view_model": phase_view_model,
} }
) )
@@ -252,7 +224,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if not category_slug: if not category_slug:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("category_slug_required", "category_slug_required"), code="category_slug_required",
status=400, status=400,
) )
@@ -263,82 +235,35 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("session_not_found", "session_not_found"), code="session_not_found",
status=404, status=404,
) )
if session.host_id != request.user.id: if session.host_id != request.user.id:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("host_only_start_round", "host_only_start_round"), code="host_only_start_round",
status=403, status=403,
) )
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
status=400,
)
try: try:
category = Category.objects.get(slug=category_slug, is_active=True) transition = _start_round(session, category_slug)
except Category.DoesNotExist: except ValueError as exc:
return api_error( error_code = str(exc)
request, error_status = {
key=ERROR_CODES.get("category_not_found", "category_not_found"), "category_not_found": 404,
status=404, "round_already_configured": 409,
) }.get(error_code, 400)
return api_error(request, code=error_code, status=error_status)
if not Question.objects.filter(category=category, is_active=True).exists(): sync_broadcast_phase_event(
return api_error( transition.session.code,
request, transition.phase_event_name,
key=ERROR_CODES.get("category_has_no_questions", "category_has_no_questions"), transition.phase_event_payload,
status=400,
)
with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk)
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
status=400,
)
round_config, created = RoundConfig.objects.get_or_create(
session=session,
number=session.current_round,
defaults={"category": category},
)
if not created:
return api_error(
request,
key=ERROR_CODES.get("round_already_configured", "round_already_configured"),
status=409,
)
session.status = GameSession.Status.LIE
session.save(update_fields=["status"])
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
},
status=201,
) )
return JsonResponse(transition.response_payload, status=201)
@require_POST @require_POST
@login_required @login_required
@@ -350,78 +275,29 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("session_not_found", "session_not_found"), code="session_not_found",
status=404, status=404,
) )
if session.host_id != request.user.id: if session.host_id != request.user.id:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("host_only_show_question", "host_only_show_question"), code="host_only_show_question",
status=403, status=403,
) )
if session.status != GameSession.Status.LIE:
return api_error(
request,
key=ERROR_CODES.get("show_question_invalid_phase", "show_question_invalid_phase"),
status=400,
)
try: try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round) transition = _show_question(session)
except RoundConfig.DoesNotExist: except ValueError as exc:
return api_error( return api_error(request, code=str(exc), status=400)
request,
key=ERROR_CODES.get("round_config_missing", "round_config_missing"),
status=400,
)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): sync_broadcast_phase_event(
return api_error( transition.session.code,
request, transition.phase_event_name,
key=ERROR_CODES.get("question_already_shown", "question_already_shown"), transition.phase_event_payload,
status=409,
)
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter(
category=round_config.category,
is_active=True,
).exclude(pk__in=used_question_ids)
if not available_questions.exists():
return api_error(
request,
key=ERROR_CODES.get("no_available_questions", "no_available_questions"),
status=400,
)
question = random.choice(list(available_questions))
round_question = RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
) )
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) return JsonResponse(transition.response_payload, status=201)
return JsonResponse(
{
"round_question": {
"id": round_question.id,
"prompt": question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
},
status=201,
)
@require_POST @require_POST
@@ -434,29 +310,29 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
lie_text = str(payload.get("text", "")).strip() lie_text = str(payload.get("text", "")).strip()
if not player_id: if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400) return api_error(request, code="player_id_required", status=400)
if not session_token: if not session_token:
return JsonResponse({"error": "session_token is required"}, status=400) return api_error(request, code="session_token_required", status=400)
if not lie_text or len(lie_text) > 255: if not lie_text or len(lie_text) > 255:
return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400) return api_error(request, code="lie_text_invalid", status=400)
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(request, code="session_not_found", status=404)
if session.status != GameSession.Status.LIE: if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Lie submission is only allowed in lie phase"}, status=400) return api_error(request, code="lie_submission_invalid_phase", status=400)
try: try:
player = Player.objects.get(pk=player_id, session=session) player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist: except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404) return api_error(request, code="player_not_found_in_session", status=404)
if player.session_token != session_token: if player.session_token != session_token:
return JsonResponse({"error": "Invalid player session token"}, status=403) return api_error(request, code="invalid_player_session_token", status=403)
try: try:
round_question = RoundQuestion.objects.get( round_question = RoundQuestion.objects.get(
@@ -465,21 +341,46 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
round_number=session.current_round, round_number=session.current_round,
) )
except RoundQuestion.DoesNotExist: except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404) return api_error(request, code="round_question_not_found", status=404)
try: try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist: except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400) return api_error(request, code="round_config_missing", status=400)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
if timezone.now() > lie_deadline_at: if timezone.now() > lie_deadline_at:
return JsonResponse({"error": "Lie submission window has closed"}, status=400) return api_error(request, code="lie_submission_closed", status=400)
try: try:
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text) lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
except IntegrityError: except IntegrityError:
return JsonResponse({"error": "Lie already submitted for this player"}, status=409) 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( return JsonResponse(
{ {
@@ -493,6 +394,18 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
"window": { "window": {
"lie_deadline_at": lie_deadline_at.isoformat(), "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, status=201,
) )
@@ -507,21 +420,21 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("session_not_found", "session_not_found"), code="session_not_found",
status=404, status=404,
) )
if session.host_id != request.user.id: if session.host_id != request.user.id:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("host_only_mix_answers", "host_only_mix_answers"), code="host_only_mix_answers",
status=403, status=403,
) )
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"), code="mix_answers_invalid_phase",
status=400, status=400,
) )
@@ -534,7 +447,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
except RoundQuestion.DoesNotExist: except RoundQuestion.DoesNotExist:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("round_question_not_found", "round_question_not_found"), code="round_question_not_found",
status=404, status=404,
) )
@@ -543,38 +456,37 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error( return api_error(
request, request,
key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"), code="mix_answers_invalid_phase",
status=400, status=400,
) )
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
deduped_answers = list(locked_round_question.mixed_answers or []) try:
if not deduped_answers: deduped_answers = _prepare_mixed_answers(locked_round_question)
lie_texts = list(locked_round_question.lies.values_list("text", flat=True)) except ValueError as exc:
seen = set() return api_error(request, code=str(exc), status=400)
for text in [locked_round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
return api_error(
request,
key=ERROR_CODES.get("not_enough_answers_to_mix", "not_enough_answers_to_mix"),
status=400,
)
random.shuffle(deduped_answers)
locked_round_question.mixed_answers = deduped_answers
locked_round_question.save(update_fields=["mixed_answers"])
if locked_session.status == GameSession.Status.LIE: if locked_session.status == GameSession.Status.LIE:
locked_session.status = GameSession.Status.GUESS locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"]) 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": t} for t in deduped_answers],
"guess_seconds": _guess_seconds,
},
)
return JsonResponse( return JsonResponse(
{ {
"session": { "session": {
@@ -601,29 +513,29 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
selected_text = str(payload.get("selected_text", "")).strip() selected_text = str(payload.get("selected_text", "")).strip()
if not player_id: if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400) return api_error(request, code="player_id_required", status=400)
if not session_token: if not session_token:
return JsonResponse({"error": "session_token is required"}, status=400) return api_error(request, code="session_token_required", status=400)
if not selected_text or len(selected_text) > 255: if not selected_text or len(selected_text) > 255:
return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400) return api_error(request, code="selected_text_invalid", status=400)
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(request, code="session_not_found", status=404)
if session.status != GameSession.Status.GUESS: if session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400) return api_error(request, code="guess_submission_invalid_phase", status=400)
try: try:
player = Player.objects.get(pk=player_id, session=session) player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist: except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404) return api_error(request, code="player_not_found_in_session", status=404)
if player.session_token != session_token: if player.session_token != session_token:
return JsonResponse({"error": "Invalid player session token"}, status=403) return api_error(request, code="invalid_player_session_token", status=403)
try: try:
round_question = RoundQuestion.objects.get( round_question = RoundQuestion.objects.get(
@@ -632,18 +544,18 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
round_number=session.current_round, round_number=session.current_round,
) )
except RoundQuestion.DoesNotExist: except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404) return api_error(request, code="round_question_not_found", status=404)
try: try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist: except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400) return api_error(request, code="round_config_missing", status=400)
guess_deadline_at = round_question.shown_at + timedelta( guess_deadline_at = round_question.shown_at + timedelta(
seconds=round_config.lie_seconds + round_config.guess_seconds seconds=round_config.lie_seconds + round_config.guess_seconds
) )
if timezone.now() > guess_deadline_at: if timezone.now() > guess_deadline_at:
return JsonResponse({"error": "Guess submission window has closed"}, status=400) return api_error(request, code="guess_submission_closed", status=400)
allowed_answers = { allowed_answers = {
round_question.correct_answer.strip().casefold(), round_question.correct_answer.strip().casefold(),
@@ -656,7 +568,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
selected_normalized = selected_text.casefold() selected_normalized = selected_text.casefold()
if selected_normalized not in allowed_answers: if selected_normalized not in allowed_answers:
return JsonResponse({"error": "Selected answer is not part of this round"}, status=400) return api_error(request, code="selected_answer_invalid", status=400)
correct_normalized = round_question.correct_answer.strip().casefold() correct_normalized = round_question.correct_answer.strip().casefold()
fooled_player_id = None fooled_player_id = None
@@ -674,7 +586,68 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
fooled_player_id=fooled_player_id, fooled_player_id=fooled_player_id,
) )
except IntegrityError: except IntegrityError:
return JsonResponse({"error": "Guess already submitted for this player"}, status=409) 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)
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( return JsonResponse(
{ {
@@ -690,6 +663,19 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
"window": { "window": {
"guess_deadline_at": guess_deadline_at.isoformat(), "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, status=201,
) )
@@ -705,30 +691,23 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(request, code="session_not_found", status=404)
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can view scoreboard"}, status=403) return api_error(request, code="host_only_view_scoreboard", status=403)
if session.status != GameSession.Status.REVEAL: transition = _promote_reveal_to_scoreboard(session)
return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400) if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
session = transition.session
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code="scoreboard_invalid_phase", status=400)
leaderboard = list( return JsonResponse(transition.response_payload)
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
)
@require_POST @require_POST
@@ -739,29 +718,24 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(request, code="session_not_found", status=404)
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start next round"}, status=403) return api_error(request, code="host_only_start_next_round", status=403)
with transaction.atomic(): try:
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) transition = _start_next_round(session)
if locked_session.status != GameSession.Status.REVEAL: except ValueError as exc:
return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400) return api_error(request, code=str(exc), status=400)
locked_session.current_round += 1 if transition.should_broadcast:
locked_session.status = GameSession.Status.LOBBY sync_broadcast_phase_event(
locked_session.save(update_fields=["current_round", "status"]) transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return JsonResponse( return JsonResponse(transition.response_payload)
{
"session": {
"code": session.code,
"status": GameSession.Status.LOBBY,
"current_round": locked_session.current_round,
}
}
)
@require_POST @require_POST
@login_required @login_required
@@ -771,38 +745,24 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(request, code="session_not_found", status=404)
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can finish game"}, status=403) return api_error(request, code="host_only_finish_game", status=403)
with transaction.atomic(): try:
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) transition = _finish_game(session)
if locked_session.status != GameSession.Status.REVEAL: except ValueError as exc:
return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400) return api_error(request, code=str(exc), status=400)
locked_session.status = GameSession.Status.FINISHED if transition.should_broadcast:
locked_session.save(update_fields=["status"]) sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
leaderboard = list( return JsonResponse(transition.response_payload)
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
winner = leaderboard[0] if leaderboard else None
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": winner,
"leaderboard": leaderboard,
}
)
@require_POST @require_POST
@@ -813,20 +773,20 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(request, code="session_not_found", status=404)
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can calculate scores"}, status=403) return api_error(request, code="host_only_calculate_scores", status=403)
already_calculated = ScoreEvent.objects.filter( already_calculated = ScoreEvent.objects.filter(
session=session, session=session,
meta__round_question_id=round_question_id, meta__round_question_id=round_question_id,
).exists() ).exists()
if already_calculated: if already_calculated:
return JsonResponse({"error": "Scores already calculated for this round question"}, status=409) return api_error(request, code="scores_already_calculated", status=409)
if session.status != GameSession.Status.GUESS: if session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) return api_error(request, code="calculate_scores_invalid_phase", status=400)
try: try:
round_question = RoundQuestion.objects.get( round_question = RoundQuestion.objects.get(
@@ -835,16 +795,16 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
round_number=session.current_round, round_number=session.current_round,
) )
except RoundQuestion.DoesNotExist: except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404) return api_error(request, code="round_question_not_found", status=404)
try: try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist: except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400) return api_error(request, code="round_config_missing", status=400)
guesses = list(round_question.guesses.select_related("player")) guesses = list(round_question.guesses.select_related("player"))
if not guesses: if not guesses:
return JsonResponse({"error": "No guesses submitted for this round question"}, status=400) return api_error(request, code="no_guesses_submitted", status=400)
bluff_counts = {} bluff_counts = {}
for guess in guesses: for guess in guesses:
@@ -854,7 +814,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
with transaction.atomic(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.GUESS: if locked_session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400) return api_error(request, code="calculate_scores_invalid_phase", status=400)
score_events = [] score_events = []
@@ -898,6 +858,21 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
.values("id", "nickname", "score") .values("id", "nickname", "score")
) )
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( return JsonResponse(
{ {
"session": { "session": {
@@ -909,6 +884,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
"id": round_question.id, "id": round_question.id,
"round_number": round_question.round_number, "round_number": round_question.round_number,
}, },
"reveal": _build_reveal_payload(round_question),
"events_created": len(score_events), "events_created": len(score_events),
"leaderboard": leaderboard, "leaderboard": leaderboard,
} }

View File

@@ -1,11 +1,15 @@
import os import os
from channels.routing import ProtocolTypeRouter
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings')
django_asgi_app = get_asgi_application() django_asgi_app = get_asgi_application()
from realtime.routing import websocket_urlpatterns # noqa: E402 — must come after env setup
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
'http': django_asgi_app, 'http': django_asgi_app,
'websocket': URLRouter(websocket_urlpatterns),
}) })

View File

@@ -0,0 +1,30 @@
import json
from functools import lru_cache
from pathlib import Path
_LOCALE_LABELS = {
"en": "English",
"da": "Danish",
}
@lru_cache(maxsize=1)
def shared_i18n_catalog() -> dict:
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json"
with catalog_path.open(encoding="utf-8") as handle:
return json.load(handle)
@lru_cache(maxsize=1)
def locale_config() -> tuple[str, tuple[str, ...]]:
locales = shared_i18n_catalog().get("locales", {})
default_locale = str(locales.get("default", "en")).strip().lower() or "en"
supported_locales = tuple(
locale.strip().lower() for locale in locales.get("supported", ["en", "da"]) if str(locale).strip()
) or ("en", "da")
return default_locale, supported_locales
def django_languages() -> list[tuple[str, str]]:
_default_locale, supported_locales = locale_config()
return [(locale, _LOCALE_LABELS.get(locale, locale.upper())) for locale in supported_locales]

View File

@@ -1,6 +1,8 @@
from pathlib import Path from pathlib import Path
import os import os
from partyhub.i18n_bootstrap import django_languages, locale_config
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -90,11 +92,8 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
] ]
LANGUAGE_CODE = 'en' LANGUAGE_CODE, _SUPPORTED_LOCALES = locale_config()
LANGUAGES = [ LANGUAGES = django_languages()
('en', 'English'),
('da', 'Danish'),
]
LOCALE_PATHS = [BASE_DIR / 'locale'] LOCALE_PATHS = [BASE_DIR / 'locale']
TIME_ZONE = 'Europe/Copenhagen' TIME_ZONE = 'Europe/Copenhagen'
USE_I18N = True USE_I18N = True
@@ -117,8 +116,13 @@ WPP_SPA_ASSET_VERSION = env('WPP_SPA_ASSET_VERSION', 'dev')
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1') CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')
CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379')) CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379'))
import sys # noqa: E402
_testing = 'test' in sys.argv
CHANNEL_LAYERS = { CHANNEL_LAYERS = {
'default': { 'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer',
} if _testing else {
'BACKEND': 'channels_redis.core.RedisChannelLayer', 'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {'hosts': [(CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT)]}, 'CONFIG': {'hosts': [(CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT)]},
} }

View File

@@ -0,0 +1,28 @@
from django.conf import settings
from django.test import TestCase
from partyhub.i18n_bootstrap import django_languages, locale_config, shared_i18n_catalog
class I18nBootstrapTests(TestCase):
def test_shared_catalog_bootstraps_default_and_supported_locales(self):
default_locale, supported_locales = locale_config()
self.assertEqual(default_locale, "en")
self.assertEqual(supported_locales, ("en", "da"))
def test_django_language_settings_bootstrap_from_shared_i18n_catalog(self):
default_locale, supported_locales = locale_config()
self.assertEqual(settings.LANGUAGE_CODE, default_locale)
self.assertEqual(tuple(code for code, _ in settings.LANGUAGES), supported_locales)
self.assertEqual(settings.LANGUAGES, django_languages())
def test_locale_contract_in_catalog_matches_django_language_codes(self):
catalog_locales = shared_i18n_catalog()["locales"]
self.assertEqual(settings.LANGUAGE_CODE, catalog_locales["default"])
self.assertEqual(
[code for code, _ in settings.LANGUAGES],
catalog_locales["supported"],
)

35
realtime/broadcast.py Normal file
View File

@@ -0,0 +1,35 @@
from asgiref.sync import async_to_sync
from channels.exceptions import InvalidChannelLayerError
from channels.layers import get_channel_layer
try:
from redis.exceptions import ConnectionError as RedisConnectionError
except Exception: # pragma: no cover - optional dependency in local/test runtimes
RedisConnectionError = RuntimeError
async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None:
"""Send a phase event to all WebSocket clients connected to a game session."""
try:
channel_layer = get_channel_layer()
if channel_layer is None:
return
group_name = f"game_{session_code.upper()}"
await channel_layer.group_send(
group_name,
{
"type": "phase.event",
"event_type": event_type,
"payload": payload,
},
)
except (InvalidChannelLayerError, RedisConnectionError):
return
def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None:
"""Sync wrapper for calling broadcast_phase_event from synchronous Django views."""
try:
async_to_sync(broadcast_phase_event)(session_code, event_type, payload)
except (InvalidChannelLayerError, RedisConnectionError):
return

63
realtime/consumers.py Normal file
View File

@@ -0,0 +1,63 @@
import json
from urllib.parse import parse_qs
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from fupogfakta.models import Player
class GameConsumer(AsyncJsonWebsocketConsumer):
"""
WebSocket consumer for a game session.
URL: ws/game/<session_code>/
Query params:
- session_token: player session token (players only)
- role=host: skip token check for host in MVP
"""
async def connect(self):
self.session_code = self.scope["url_route"]["kwargs"]["session_code"].upper()
self.group_name = f"game_{self.session_code}"
query_string = self.scope.get("query_string", b"").decode()
params = parse_qs(query_string)
role = params.get("role", [None])[0]
session_token = params.get("session_token", [None])[0]
if role != "host":
if not session_token:
await self.close(code=4001)
return
try:
self.player = await Player.objects.aget(
session_token=session_token,
session__code=self.session_code,
)
except Player.DoesNotExist:
await self.close(code=4003)
return
else:
self.player = None
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
if content.get("type") == "ping":
await self.send_json({"type": "pong"})
# --- Group message handlers ---
async def phase_event(self, event):
"""Forward any phase_event broadcast to the WebSocket client."""
payload = dict(event.get("payload") or {})
payload["type"] = event["event_type"]
await self.send_json(payload)

7
realtime/routing.py Normal file
View File

@@ -0,0 +1,7 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"^ws/game/(?P<session_code>[A-Z0-9]{4,8})/$", consumers.GameConsumer.as_asgi()),
]

View File

@@ -1,3 +1,123 @@
from django.test import TestCase import unittest
from unittest.mock import AsyncMock, Mock, patch
# Create your tests here. from channels.exceptions import InvalidChannelLayerError
from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
try:
from channels.testing import WebsocketCommunicator
except Exception: # pragma: no cover - optional test dependency
WebsocketCommunicator = None
from fupogfakta.models import GameSession, Player
from partyhub.asgi import application
from realtime.broadcast import broadcast_phase_event, sync_broadcast_phase_event
from realtime.consumers import GameConsumer
User = get_user_model()
class BroadcastPhaseEventTests(SimpleTestCase):
@patch("realtime.broadcast.get_channel_layer", return_value=None)
async def test_broadcast_phase_event_noops_without_channel_layer(self, _mock_get_channel_layer):
await broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
@patch("realtime.broadcast.async_to_sync")
def test_sync_broadcast_phase_event_noops_when_channel_layer_is_unavailable(self, mock_async_to_sync):
mock_async_to_sync.return_value.side_effect = InvalidChannelLayerError("missing channel layer")
sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
@patch("realtime.broadcast.async_to_sync")
def test_sync_broadcast_phase_event_still_broadcasts_when_channel_layer_exists(self, mock_async_to_sync):
sender = Mock()
mock_async_to_sync.return_value = sender
sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
sender.assert_called_once_with("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
class GameConsumerPhaseEventTests(SimpleTestCase):
async def test_phase_event_restores_external_type_field(self):
consumer = GameConsumer()
consumer.send_json = AsyncMock()
await consumer.phase_event(
{
"event_type": "phase.test_event",
"payload": {"hello": "world"},
}
)
consumer.send_json.assert_awaited_once_with(
{
"type": "phase.test_event",
"hello": "world",
}
)
@unittest.skipIf(WebsocketCommunicator is None, "channels.testing dependencies unavailable")
class GameConsumerConnectTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="host", password="pw")
self.session = GameSession.objects.create(host=self.user, code="AABBCC")
self.player = Player.objects.create(session=self.session, nickname="Tester")
async def test_player_connect_and_ping(self):
token = self.player.session_token
communicator = WebsocketCommunicator(
application,
f"/ws/game/AABBCC/?session_token={token}",
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to({"type": "ping"})
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "pong")
await communicator.disconnect()
async def test_connect_without_token_rejected(self):
communicator = WebsocketCommunicator(application, "/ws/game/AABBCC/")
connected, code = await communicator.connect()
self.assertFalse(connected)
self.assertEqual(code, 4001)
async def test_connect_invalid_token_rejected(self):
communicator = WebsocketCommunicator(
application,
"/ws/game/AABBCC/?session_token=invalid-token",
)
connected, code = await communicator.connect()
self.assertFalse(connected)
self.assertEqual(code, 4003)
async def test_host_connect_without_token(self):
communicator = WebsocketCommunicator(
application,
"/ws/game/AABBCC/?role=host",
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.disconnect()
async def test_broadcast_reaches_connected_client(self):
token = self.player.session_token
communicator = WebsocketCommunicator(
application,
f"/ws/game/AABBCC/?session_token={token}",
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await broadcast_phase_event("AABBCC", "phase.test_event", {"hello": "world"})
message = await communicator.receive_json_from(timeout=2)
self.assertEqual(message["type"], "phase.test_event")
self.assertEqual(message["hello"], "world")
await communicator.disconnect()

View File

@@ -1,5 +1,6 @@
Django==6.0.2 Django==6.0.2
channels>=4.1,<5 channels>=4.1,<5
channels-redis>=4.2,<5 channels-redis>=4.2,<5
daphne>=4.1,<5
mysqlclient>=2.2,<3 mysqlclient>=2.2,<3
python-dotenv>=1.0,<2 python-dotenv>=1.0,<2

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Build the shared i18n parity artifact for MVP-critical Django/Angular keys."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from collections import defaultdict
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json"
ARTIFACT_PATH = REPO_ROOT / "shared" / "i18n" / "artifacts" / "lobby-mvp-keyspace-parity-report.v1.json"
DJANGO_VIEWS_PATH = REPO_ROOT / "lobby" / "views.py"
FRONTEND_VERTICAL_SLICE_PATH = REPO_ROOT / "frontend" / "src" / "spa" / "vertical-slice.ts"
ANGULAR_I18N_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "lobby-i18n.ts"
ANGULAR_HOST_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "host" / "host-shell.component.ts"
ANGULAR_PLAYER_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "player" / "player-shell.component.ts"
ARTIFACT_NAME = "shared.i18n.lobby.mvp_keyspace_parity_report"
ARTIFACT_VERSION = "v1"
class ParityError(RuntimeError):
pass
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _load_catalog() -> dict[str, Any]:
return json.loads(_read_text(CATALOG_PATH))
def _catalog_hash() -> str:
return hashlib.sha256(CATALOG_PATH.read_bytes()).hexdigest()
def _sorted_unique(values: list[str]) -> list[str]:
return sorted({value for value in values})
def _extract_matches(path: Path, pattern: str) -> list[str]:
return re.findall(pattern, _read_text(path), re.MULTILINE)
def _resolve_frontend_ui_key(key: str) -> str:
if key.startswith("lobby.shell."):
return key.replace("lobby.shell.", "app.", 1)
if key.startswith("game.host."):
return key.replace("game.host.", "host.", 1)
if key.startswith("game.player."):
return key.replace("game.player.", "player.", 1)
return key
def _has_nested_key(root: dict[str, Any], dotted_key: str) -> bool:
current: Any = root
for part in dotted_key.split("."):
if not isinstance(current, dict) or part not in current:
return False
current = current[part]
return isinstance(current, dict)
def build_report() -> dict[str, Any]:
catalog = _load_catalog()
locales = list(catalog["locales"]["supported"])
frontend_errors: dict[str, Any] = catalog["frontend"]["errors"]
frontend_ui: dict[str, Any] = catalog["frontend"]["ui"]
backend_error_codes: dict[str, str] = catalog["backend"]["error_codes"]
backend_errors: dict[str, Any] = catalog["backend"]["errors"]
backend_to_frontend: dict[str, str] = catalog["contract"]["backend_to_frontend_error_keys"]
django_error_codes = _sorted_unique(
_extract_matches(DJANGO_VIEWS_PATH, r'ERROR_CODES\.get\("([a-z0-9_]+)"')
)
frontend_runtime_error_fallbacks = _sorted_unique(
_extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessageFromApiPayload\([^\n]+?'([a-z0-9_]+)'\)")
+ _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessage\('([a-z0-9_]+)'\)")
)
angular_copy_keys = _sorted_unique(
_extract_matches(ANGULAR_HOST_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)")
+ _extract_matches(ANGULAR_PLAYER_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)")
)
angular_catalog_paths = [_resolve_frontend_ui_key(key) for key in angular_copy_keys]
missing_backend_codes = [code for code in django_error_codes if code not in backend_error_codes]
missing_backend_translations = [code for code in django_error_codes if code not in backend_errors]
missing_contract_mappings = [code for code in django_error_codes if code not in backend_to_frontend]
mapped_frontend_error_keys = _sorted_unique(
[backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend]
)
missing_frontend_error_keys = [key for key in mapped_frontend_error_keys if key not in frontend_errors]
missing_frontend_runtime_fallbacks = [key for key in frontend_runtime_error_fallbacks if key not in frontend_errors]
missing_angular_catalog_paths = [path for path in angular_catalog_paths if not _has_nested_key(frontend_ui, path)]
dead_contract_aliases = _sorted_unique([code for code in backend_to_frontend if code not in backend_error_codes])
many_to_one_mappings: dict[str, list[str]] = defaultdict(list)
for code in django_error_codes:
frontend_key = backend_to_frontend.get(code)
if frontend_key:
many_to_one_mappings[frontend_key].append(code)
many_to_one_mappings = {
frontend_key: sorted(codes)
for frontend_key, codes in sorted(many_to_one_mappings.items())
if len(codes) > 1
}
blocking_issues = {
"missing_backend_codes": missing_backend_codes,
"missing_backend_translations": missing_backend_translations,
"missing_contract_mappings": missing_contract_mappings,
"missing_frontend_error_keys": missing_frontend_error_keys,
"missing_frontend_runtime_fallbacks": missing_frontend_runtime_fallbacks,
"missing_angular_catalog_paths": missing_angular_catalog_paths,
}
status = "pass" if not any(blocking_issues.values()) else "fail"
follow_ups: list[dict[str, str]] = []
if dead_contract_aliases:
follow_ups.append(
{
"priority": "need-to-have",
"item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.",
"evidence": ", ".join(dead_contract_aliases),
}
)
if many_to_one_mappings:
follow_ups.append(
{
"priority": "nice-to-have",
"item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.",
"evidence": "; ".join(
f"{frontend_key} <= {', '.join(codes)}" for frontend_key, codes in many_to_one_mappings.items()
),
}
)
return {
"artifact_name": ARTIFACT_NAME,
"artifact_version": ARTIFACT_VERSION,
"naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.",
"source_of_truth": {
"catalog": str(CATALOG_PATH.relative_to(REPO_ROOT)),
"catalog_sha256": _catalog_hash(),
"source_paths": [
str(path.relative_to(REPO_ROOT))
for path in [
DJANGO_VIEWS_PATH,
FRONTEND_VERTICAL_SLICE_PATH,
ANGULAR_I18N_PATH,
ANGULAR_HOST_PATH,
ANGULAR_PLAYER_PATH,
]
],
},
"scope": {
"issue": 277,
"related_epic": 175,
"mvp_locales": locales,
"definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells.",
},
"parity": {
"status": status,
"django_backend_error_codes_used_by_mvp": django_error_codes,
"angular_frontend_error_fallback_keys_used_by_mvp": frontend_runtime_error_fallbacks,
"angular_ui_keys_used_by_mvp": angular_copy_keys,
"angular_ui_catalog_paths": angular_catalog_paths,
"backend_codes_mapped_to_frontend_error_keys": {code: backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend},
"unique_frontend_error_keys_reached_from_django": mapped_frontend_error_keys,
"blocking_issues": blocking_issues,
"follow_ups": follow_ups,
},
}
def write_report() -> None:
report = build_report()
ARTIFACT_PATH.parent.mkdir(parents=True, exist_ok=True)
ARTIFACT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def check_report() -> None:
report = build_report()
if not ARTIFACT_PATH.exists():
raise ParityError(f"artifact missing: {ARTIFACT_PATH}")
existing = json.loads(_read_text(ARTIFACT_PATH))
if existing != report:
raise ParityError("artifact out of date; run scripts/build_i18n_parity_report.py --write")
if report["parity"]["status"] != "pass":
raise ParityError(json.dumps(report["parity"]["blocking_issues"], indent=2))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--write", action="store_true")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
if args.write:
write_report()
if args.check:
check_report()
if not args.write and not args.check:
print(json.dumps(build_report(), indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Guard issue #277 parity artifact against non-deterministic regeneration."""
from __future__ import annotations
import hashlib
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
ARTIFACT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md"
REPORT_SCRIPT = REPO_ROOT / "scripts" / "report_i18n_parity.py"
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> int:
before = sha256(ARTIFACT_PATH)
for run in range(1, 3):
subprocess.run([sys.executable, str(REPORT_SCRIPT)], cwd=REPO_ROOT, check=True)
after = sha256(ARTIFACT_PATH)
if after != before:
raise SystemExit(
f"issue #277 parity artifact is not deterministic after run {run}: {before} != {after}"
)
print(f"issue #277 parity artifact deterministic: {before}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Generate issue #277 shared i18n parity artifact.
Read-only report over the shared lobby i18n catalog, with focus on MVP-critical
backend/frontend parity used by Django and Angular.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json"
OUTPUT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md"
ARTIFACT_ID = "issue-277-shared-i18n-parity-report"
ARTIFACT_VERSION = "1.0"
MVP_FRONTEND_UI_KEYS = [
"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",
]
MVP_FRONTEND_ERROR_KEYS = [
"frontend.errors.session_code_required",
"frontend.errors.session_not_found",
"frontend.errors.nickname_invalid",
"frontend.errors.nickname_taken",
"frontend.errors.join_failed",
"frontend.errors.start_round_failed",
"frontend.errors.unknown",
]
MVP_BACKEND_CODES = [
"backend.error_codes.session_code_required",
"backend.error_codes.nickname_invalid",
"backend.error_codes.session_not_found",
"backend.error_codes.session_not_joinable",
"backend.error_codes.nickname_taken",
"backend.error_codes.category_slug_required",
"backend.error_codes.category_not_found",
"backend.error_codes.round_start_invalid_phase",
"backend.error_codes.round_already_configured",
]
@dataclass(frozen=True)
class MappingRow:
backend_code: str
backend_key: str
frontend_key: str
locales_ok: bool
parity_status: str
note: str
def load_catalog() -> dict:
return json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
def get_path(data: dict, dotted: str):
node = data
for part in dotted.split("."):
node = node[part]
return node
def translation_state(data: dict, dotted: str) -> tuple[bool, list[str]]:
translations = get_path(data, dotted)
missing = [locale for locale in ("en", "da") if not isinstance(translations.get(locale), str) or not translations[locale].strip()]
return (not missing, missing)
def build_mapping_rows(catalog: dict) -> list[MappingRow]:
rows: list[MappingRow] = []
mapping = catalog["contract"]["backend_to_frontend_error_keys"]
backend_errors = catalog["backend"]["errors"]
for dotted_code in MVP_BACKEND_CODES:
code = dotted_code.removeprefix("backend.error_codes.")
backend_key = catalog["backend"]["error_codes"][code]
frontend_key = mapping[code]
backend_locales_ok, _ = translation_state(catalog, f"backend.errors.{backend_key}")
frontend_locales_ok, _ = translation_state(catalog, f"frontend.errors.{frontend_key}")
note = "1:1" if code == frontend_key else "many:1 collapse" if frontend_key == "start_round_failed" else "mapped alias"
rows.append(
MappingRow(
backend_code=code,
backend_key=backend_key,
frontend_key=frontend_key,
locales_ok=backend_locales_ok and frontend_locales_ok,
parity_status="aligned" if frontend_key in backend_errors or code == frontend_key else "mapped",
note=note,
)
)
return rows
def render_report(catalog: dict) -> str:
mapping_rows = build_mapping_rows(catalog)
frontend_ui_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_UI_KEYS)
frontend_error_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_ERROR_KEYS)
backend_code_ok = all(translation_state(catalog, f"backend.errors.{catalog['backend']['error_codes'][key.removeprefix('backend.error_codes.')]}" )[0] for key in MVP_BACKEND_CODES)
mapped_frontend_keys = sorted({row.frontend_key for row in mapping_rows})
collapsed_codes = [row.backend_code for row in mapping_rows if row.frontend_key == "start_round_failed"]
lines: list[str] = []
lines.append("# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)")
lines.append("")
lines.append("Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)")
lines.append("")
lines.append("## Artifact metadata")
lines.append("")
lines.append(f"- `artifact_id`: `{ARTIFACT_ID}`")
lines.append(f"- `artifact_version`: `{ARTIFACT_VERSION}`")
lines.append(f"- `catalog_source`: `{CATALOG_PATH.relative_to(REPO_ROOT)}`")
lines.append(f"- `generator`: `scripts/{Path(__file__).name}`")
lines.append("")
lines.append("## Naming/version rules (email-manager-inspired strategy)")
lines.append("")
lines.append("- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE-<nr>-<slug>-ARTIFACT.md`.")
lines.append("- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.")
lines.append("- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.")
lines.append("- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.")
lines.append("- **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.")
lines.append("")
lines.append("## MVP-critical parity summary")
lines.append("")
lines.append(f"- Frontend UI gameplay keys checked: **{len(MVP_FRONTEND_UI_KEYS)}** → `{'OK' if frontend_ui_ok else 'DRIFT'}`")
lines.append(f"- Frontend error keys checked: **{len(MVP_FRONTEND_ERROR_KEYS)}** → `{'OK' if frontend_error_ok else 'DRIFT'}`")
lines.append(f"- Backend gameplay/error codes checked: **{len(MVP_BACKEND_CODES)}** → `{'OK' if backend_code_ok else 'DRIFT'}`")
lines.append(f"- Distinct frontend error keys reached from backend MVP flow: **{len(mapped_frontend_keys)}** (`{', '.join(mapped_frontend_keys)}`)")
lines.append("")
lines.append("Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**")
lines.append("")
lines.append("## Django ↔ Angular parity matrix (MVP-critical error contract)")
lines.append("")
lines.append("| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |")
lines.append("|---|---|---|---|---|")
for row in mapping_rows:
lines.append(
f"| `{row.backend_code}` | `{row.backend_key}` | `{row.frontend_key}` | `{'OK' if row.locales_ok else 'DRIFT'}` | {row.note} |"
)
lines.append("")
lines.append("## Scope notes")
lines.append("")
lines.append("- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.")
lines.append("- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.")
lines.append("- **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.")
lines.append("")
lines.append("## Verified MVP gameplay UI keyspace present in the shared registry")
lines.append("")
for key in MVP_FRONTEND_UI_KEYS:
lines.append(f"- `{key}`")
lines.append("")
lines.append("## Concrete deviations / follow-up items")
lines.append("")
lines.append(f"1. **Error granularity collapse remains intentional**: backend codes `{', '.join(collapsed_codes)}` 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.")
lines.append("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.")
lines.append("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.")
lines.append("")
lines.append("## Re-run")
lines.append("")
lines.append("```bash")
lines.append("python3 scripts/check_i18n_drift.py")
lines.append("python3 scripts/report_i18n_parity.py")
lines.append("python3 scripts/check_i18n_parity_artifact.py")
lines.append("```")
lines.append("")
return "\n".join(lines)
def main() -> int:
catalog = load_catalog()
OUTPUT_PATH.write_text(render_report(catalog), encoding="utf-8")
print(OUTPUT_PATH.relative_to(REPO_ROOT))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,158 @@
{
"artifact_name": "shared.i18n.lobby.mvp_keyspace_parity_report",
"artifact_version": "v1",
"naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.",
"source_of_truth": {
"catalog": "shared/i18n/lobby.json",
"catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb",
"source_paths": [
"lobby/views.py",
"frontend/src/spa/vertical-slice.ts",
"frontend/angular/src/app/lobby-i18n.ts",
"frontend/angular/src/app/features/host/host-shell.component.ts",
"frontend/angular/src/app/features/player/player-shell.component.ts"
]
},
"scope": {
"issue": 277,
"related_epic": 175,
"mvp_locales": [
"en",
"da"
],
"definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells."
},
"parity": {
"status": "pass",
"django_backend_error_codes_used_by_mvp": [],
"angular_frontend_error_fallback_keys_used_by_mvp": [
"join_failed",
"session_code_required",
"session_fetch_failed",
"start_round_failed"
],
"angular_ui_keys_used_by_mvp": [
"common.back_to_join",
"common.points_short",
"common.prompt",
"common.refresh",
"common.retry",
"common.round",
"common.round_question_id",
"common.session_code",
"common.status",
"common.unknown_error",
"host.audio_locale_hint",
"host.calculate_scores",
"host.category",
"host.final_leaderboard",
"host.finish_game",
"host.finish_game_failed",
"host.load_scoreboard",
"host.mix_answers",
"host.next_round_failed",
"host.retry_finish",
"host.retry_next_round",
"host.retry_scoreboard",
"host.scoreboard_failed",
"host.session_code_required",
"host.session_refresh_failed",
"host.show_question",
"host.start_next_round",
"host.start_round",
"host.title",
"host.winner",
"player.audio_policy_notice",
"player.final_leaderboard",
"player.guess_submit_failed",
"player.join",
"player.join_failed",
"player.lie_label",
"player.lie_submit_failed",
"player.loading_join",
"player.loading_refresh",
"player.loading_submit_guess",
"player.loading_submit_lie",
"player.nickname",
"player.offline_text",
"player.reconnecting_text",
"player.retry_guess_submit",
"player.retry_lie_submit",
"player.retry_now",
"player.session_refresh_failed",
"player.submit_guess",
"player.submit_lie",
"player.title"
],
"angular_ui_catalog_paths": [
"common.back_to_join",
"common.points_short",
"common.prompt",
"common.refresh",
"common.retry",
"common.round",
"common.round_question_id",
"common.session_code",
"common.status",
"common.unknown_error",
"host.audio_locale_hint",
"host.calculate_scores",
"host.category",
"host.final_leaderboard",
"host.finish_game",
"host.finish_game_failed",
"host.load_scoreboard",
"host.mix_answers",
"host.next_round_failed",
"host.retry_finish",
"host.retry_next_round",
"host.retry_scoreboard",
"host.scoreboard_failed",
"host.session_code_required",
"host.session_refresh_failed",
"host.show_question",
"host.start_next_round",
"host.start_round",
"host.title",
"host.winner",
"player.audio_policy_notice",
"player.final_leaderboard",
"player.guess_submit_failed",
"player.join",
"player.join_failed",
"player.lie_label",
"player.lie_submit_failed",
"player.loading_join",
"player.loading_refresh",
"player.loading_submit_guess",
"player.loading_submit_lie",
"player.nickname",
"player.offline_text",
"player.reconnecting_text",
"player.retry_guess_submit",
"player.retry_lie_submit",
"player.retry_now",
"player.session_refresh_failed",
"player.submit_guess",
"player.submit_lie",
"player.title"
],
"backend_codes_mapped_to_frontend_error_keys": {},
"unique_frontend_error_keys_reached_from_django": [],
"blocking_issues": {
"missing_backend_codes": [],
"missing_backend_translations": [],
"missing_contract_mappings": [],
"missing_frontend_error_keys": [],
"missing_frontend_runtime_fallbacks": [],
"missing_angular_catalog_paths": []
},
"follow_ups": [
{
"priority": "need-to-have",
"item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.",
"evidence": "host_only_action"
}
]
}
}

View File

@@ -1,5 +1,8 @@
{ {
"locales": ["en", "da"], "locales": [
"en",
"da"
],
"frontend_error_keys": [ "frontend_error_keys": [
"join_failed", "join_failed",
"nickname_invalid", "nickname_invalid",
@@ -11,50 +14,96 @@
"unknown" "unknown"
], ],
"backend_error_codes": [ "backend_error_codes": [
"calculate_scores_invalid_phase",
"category_has_no_questions", "category_has_no_questions",
"category_not_found", "category_not_found",
"category_slug_required", "category_slug_required",
"finish_game_invalid_phase",
"guess_already_submitted",
"guess_submission_closed",
"guess_submission_invalid_phase",
"host_only_calculate_scores",
"host_only_finish_game",
"host_only_mix_answers", "host_only_mix_answers",
"host_only_show_question", "host_only_show_question",
"host_only_start_next_round",
"host_only_start_round", "host_only_start_round",
"host_only_view_scoreboard",
"invalid_player_session_token",
"lie_already_submitted",
"lie_submission_closed",
"lie_submission_invalid_phase",
"lie_text_invalid",
"mix_answers_invalid_phase", "mix_answers_invalid_phase",
"next_round_invalid_phase",
"nickname_invalid", "nickname_invalid",
"nickname_taken", "nickname_taken",
"no_available_questions", "no_available_questions",
"no_guesses_submitted",
"not_enough_answers_to_mix", "not_enough_answers_to_mix",
"player_id_required",
"player_not_found_in_session",
"question_already_shown", "question_already_shown",
"round_already_configured", "round_already_configured",
"round_config_missing", "round_config_missing",
"round_question_not_found", "round_question_not_found",
"round_start_invalid_phase", "round_start_invalid_phase",
"scoreboard_invalid_phase",
"scores_already_calculated",
"selected_answer_invalid",
"selected_text_invalid",
"session_code_required", "session_code_required",
"session_not_found", "session_not_found",
"session_not_joinable", "session_not_joinable",
"session_token_required",
"show_question_invalid_phase" "show_question_invalid_phase"
], ],
"allowed_contract_only_backend_codes": [ "allowed_contract_only_backend_codes": [
"host_only_action" "host_only_action"
], ],
"backend_error_keys": [ "backend_error_keys": [
"calculate_scores_invalid_phase",
"category_has_no_questions", "category_has_no_questions",
"category_not_found", "category_not_found",
"category_slug_required", "category_slug_required",
"finish_game_invalid_phase",
"guess_already_submitted",
"guess_submission_closed",
"guess_submission_invalid_phase",
"host_only_calculate_scores",
"host_only_finish_game",
"host_only_mix_answers", "host_only_mix_answers",
"host_only_show_question", "host_only_show_question",
"host_only_start_next_round",
"host_only_start_round", "host_only_start_round",
"host_only_view_scoreboard",
"invalid_player_session_token",
"lie_already_submitted",
"lie_submission_closed",
"lie_submission_invalid_phase",
"lie_text_invalid",
"mix_answers_invalid_phase", "mix_answers_invalid_phase",
"next_round_invalid_phase",
"nickname_invalid", "nickname_invalid",
"nickname_taken", "nickname_taken",
"no_available_questions", "no_available_questions",
"no_guesses_submitted",
"not_enough_answers_to_mix", "not_enough_answers_to_mix",
"player_id_required",
"player_not_found_in_session",
"question_already_shown", "question_already_shown",
"round_already_configured", "round_already_configured",
"round_config_missing", "round_config_missing",
"round_question_not_found", "round_question_not_found",
"round_start_invalid_phase", "round_start_invalid_phase",
"scoreboard_invalid_phase",
"scores_already_calculated",
"selected_answer_invalid",
"selected_text_invalid",
"session_code_required", "session_code_required",
"session_not_found", "session_not_found",
"session_not_joinable", "session_not_joinable",
"session_token_required",
"show_question_invalid_phase" "show_question_invalid_phase"
] ]
} }

View File

@@ -264,6 +264,10 @@
"guess_submit_failed": { "guess_submit_failed": {
"en": "Guess submit failed", "en": "Guess submit failed",
"da": "Gætte-fejl" "da": "Gætte-fejl"
},
"audio_policy_notice": {
"en": "Audio playback is disabled on phone clients. Sound is available on the primary host device.",
"da": "Lydafspilning er slået fra på telefon-klienten. Lyd afspilles kun på den primære værtsenhed."
} }
} }
}, },
@@ -273,16 +277,47 @@
}, },
"backend": { "backend": {
"error_codes": { "error_codes": {
"session_code_required": "session_code_required", "calculate_scores_invalid_phase": "calculate_scores_invalid_phase",
"category_has_no_questions": "category_has_no_questions",
"category_not_found": "category_not_found",
"category_slug_required": "category_slug_required",
"finish_game_invalid_phase": "finish_game_invalid_phase",
"guess_already_submitted": "guess_already_submitted",
"guess_submission_invalid_phase": "guess_submission_invalid_phase",
"guess_submission_window_closed": "guess_submission_window_closed",
"host_only_calculate_scores": "host_only_calculate_scores",
"host_only_finish_game": "host_only_finish_game",
"host_only_mix_answers": "host_only_mix_answers",
"host_only_show_question": "host_only_show_question",
"host_only_start_next_round": "host_only_start_next_round",
"host_only_start_round": "host_only_start_round",
"host_only_view_scoreboard": "host_only_view_scoreboard",
"invalid_player_session_token": "invalid_player_session_token",
"lie_already_submitted": "lie_already_submitted",
"lie_submission_invalid_phase": "lie_submission_invalid_phase",
"lie_submission_window_closed": "lie_submission_window_closed",
"lie_text_invalid": "lie_text_invalid",
"mix_answers_invalid_phase": "mix_answers_invalid_phase",
"nickname_invalid": "nickname_invalid", "nickname_invalid": "nickname_invalid",
"nickname_taken": "nickname_taken",
"no_available_questions": "no_available_questions",
"no_guesses_submitted": "no_guesses_submitted",
"not_enough_answers_to_mix": "not_enough_answers_to_mix",
"player_id_required": "player_id_required",
"player_not_found_in_session": "player_not_found_in_session",
"question_already_shown": "question_already_shown",
"round_already_configured": "round_already_configured",
"round_config_missing": "round_config_missing",
"round_question_not_found": "round_question_not_found",
"round_start_invalid_phase": "round_start_invalid_phase",
"scoreboard_invalid_phase": "scoreboard_invalid_phase",
"scores_already_calculated": "scores_already_calculated",
"selected_answer_invalid": "selected_answer_invalid",
"selected_text_invalid": "selected_text_invalid",
"session_code_required": "session_code_required",
"session_not_found": "session_not_found", "session_not_found": "session_not_found",
"session_not_joinable": "session_not_joinable", "session_not_joinable": "session_not_joinable",
"nickname_taken": "nickname_taken", "session_token_required": "session_token_required",
"category_slug_required": "category_slug_required",
"category_not_found": "category_not_found",
"round_start_invalid_phase": "round_start_invalid_phase",
"round_already_configured": "round_already_configured",
"category_has_no_questions": "category_has_no_questions",
"show_question_invalid_phase": "show_question_invalid_phase", "show_question_invalid_phase": "show_question_invalid_phase",
"round_config_missing": "round_config_missing", "round_config_missing": "round_config_missing",
"question_already_shown": "question_already_shown", "question_already_shown": "question_already_shown",
@@ -292,17 +327,184 @@
"not_enough_answers_to_mix": "not_enough_answers_to_mix", "not_enough_answers_to_mix": "not_enough_answers_to_mix",
"host_only_start_round": "host_only_start_round", "host_only_start_round": "host_only_start_round",
"host_only_show_question": "host_only_show_question", "host_only_show_question": "host_only_show_question",
"host_only_mix_answers": "host_only_mix_answers" "host_only_mix_answers": "host_only_mix_answers",
"player_id_required": "player_id_required",
"session_token_required": "session_token_required",
"lie_text_invalid": "lie_text_invalid",
"player_not_found_in_session": "player_not_found_in_session",
"invalid_player_session_token": "invalid_player_session_token",
"lie_submission_closed": "lie_submission_closed",
"lie_already_submitted": "lie_already_submitted",
"host_only_view_scoreboard": "host_only_view_scoreboard",
"scoreboard_invalid_phase": "scoreboard_invalid_phase",
"host_only_start_next_round": "host_only_start_next_round",
"next_round_invalid_phase": "next_round_invalid_phase",
"host_only_finish_game": "host_only_finish_game",
"finish_game_invalid_phase": "finish_game_invalid_phase",
"host_only_calculate_scores": "host_only_calculate_scores",
"scores_already_calculated": "scores_already_calculated",
"calculate_scores_invalid_phase": "calculate_scores_invalid_phase",
"no_guesses_submitted": "no_guesses_submitted",
"guess_submission_closed": "guess_submission_closed",
"selected_answer_invalid": "selected_answer_invalid",
"guess_already_submitted": "guess_already_submitted",
"lie_submission_invalid_phase": "lie_submission_invalid_phase",
"selected_text_invalid": "selected_text_invalid",
"guess_submission_invalid_phase": "guess_submission_invalid_phase"
}, },
"errors": { "errors": {
"session_code_required": { "calculate_scores_invalid_phase": {
"en": "Session code is required", "en": "Scores can only be calculated in guess phase.",
"da": "Sessionskode er påkrævet" "da": "Score kan kun udregnes i gættefasen."
},
"category_has_no_questions": {
"en": "Category has no active questions",
"da": "Kategorien har ingen aktive spørgsmål"
},
"category_not_found": {
"en": "Category not found",
"da": "Kategori blev ikke fundet"
},
"category_slug_required": {
"en": "category_slug is required",
"da": "category_slug er påkrævet"
},
"finish_game_invalid_phase": {
"en": "Game can only be finished from scoreboard phase.",
"da": "Spillet kan kun afsluttes fra scoreboard-fasen."
},
"guess_already_submitted": {
"en": "Guess has already been submitted for this player.",
"da": "Gættet er allerede indsendt for denne spiller."
},
"guess_submission_invalid_phase": {
"en": "Guess submission is only allowed in guess phase.",
"da": "Gæt kan kun sendes i gættefasen."
},
"guess_submission_window_closed": {
"en": "Guess submission window has closed.",
"da": "Vinduet for gætindsendelse er lukket."
},
"host_only_calculate_scores": {
"en": "Only the host can calculate scores.",
"da": "Kun værten kan udregne score."
},
"host_only_finish_game": {
"en": "Only the host can finish the game.",
"da": "Kun værten kan afslutte spillet."
},
"host_only_mix_answers": {
"en": "Only host can mix answers",
"da": "Kun værten kan blande svar"
},
"host_only_show_question": {
"en": "Only host can show question",
"da": "Kun værten kan vise spørgsmålet"
},
"host_only_start_next_round": {
"en": "Only the host can start the next round.",
"da": "Kun værten kan starte næste runde."
},
"host_only_start_round": {
"en": "Only host can start round",
"da": "Kun værten kan starte runden"
},
"host_only_view_scoreboard": {
"en": "Only the host can view the scoreboard.",
"da": "Kun værten kan se scoreboardet."
},
"invalid_player_session_token": {
"en": "Player session token is invalid.",
"da": "Spillerens session-token er ugyldigt."
},
"lie_already_submitted": {
"en": "Lie has already been submitted for this player.",
"da": "Løgnen er allerede indsendt for denne spiller."
},
"lie_submission_invalid_phase": {
"en": "Lie submission is only allowed in lie phase.",
"da": "Løgn kan kun sendes i løgnefasen."
},
"lie_submission_window_closed": {
"en": "Lie submission window has closed.",
"da": "Vinduet for løgnindsendelse er lukket."
},
"lie_text_invalid": {
"en": "Text must be between 1 and 255 characters.",
"da": "Tekst skal være mellem 1 og 255 tegn."
},
"mix_answers_invalid_phase": {
"en": "Answers can only be mixed in lie or guess phase",
"da": "Svar kan kun blandes i løgne- eller gættefasen"
}, },
"nickname_invalid": { "nickname_invalid": {
"en": "Nickname must be between 2 and 40 characters", "en": "Nickname must be between 2 and 40 characters",
"da": "Kaldenavn skal være mellem 2 og 40 tegn" "da": "Kaldenavn skal være mellem 2 og 40 tegn"
}, },
"nickname_taken": {
"en": "Nickname already taken",
"da": "Kaldenavnet er allerede taget"
},
"no_available_questions": {
"en": "No available questions in category",
"da": "Ingen tilgængelige spørgsmål i kategorien"
},
"no_guesses_submitted": {
"en": "No guesses have been submitted for this round question.",
"da": "Der er ikke indsendt gæt for dette rundespørgsmål."
},
"not_enough_answers_to_mix": {
"en": "Not enough answers to mix",
"da": "Ikke nok svar at blande"
},
"player_id_required": {
"en": "Player id is required.",
"da": "Spiller-id er påkrævet."
},
"player_not_found_in_session": {
"en": "Player was not found in this session.",
"da": "Spilleren blev ikke fundet i denne session."
},
"question_already_shown": {
"en": "Question already shown for this round",
"da": "Spørgsmålet er allerede vist for denne runde"
},
"round_already_configured": {
"en": "Round already configured",
"da": "Runden er allerede konfigureret"
},
"round_config_missing": {
"en": "Round config missing",
"da": "Rundekonfiguration mangler"
},
"round_question_not_found": {
"en": "Round question not found",
"da": "Rundespørgsmål blev ikke fundet"
},
"round_start_invalid_phase": {
"en": "Round can only be started from lobby",
"da": "Runden kan kun startes fra lobbyen"
},
"scoreboard_invalid_phase": {
"en": "Scoreboard is only available in scoreboard phase.",
"da": "Scoreboard er kun tilgængeligt i scoreboard-fasen."
},
"scores_already_calculated": {
"en": "Scores have already been calculated for this round question.",
"da": "Score er allerede udregnet for dette rundespørgsmål."
},
"selected_answer_invalid": {
"en": "Selected answer is not part of this round.",
"da": "Det valgte svar er ikke en del af denne runde."
},
"selected_text_invalid": {
"en": "Selected text must be between 1 and 255 characters.",
"da": "Valgt tekst skal være mellem 1 og 255 tegn."
},
"session_code_required": {
"en": "Session code is required",
"da": "Sessionskode er påkrævet"
},
"session_not_found": { "session_not_found": {
"en": "Session not found", "en": "Session not found",
"da": "Session blev ikke fundet" "da": "Session blev ikke fundet"
@@ -311,29 +513,9 @@
"en": "Session is not joinable", "en": "Session is not joinable",
"da": "Sessionen kan ikke joine nu" "da": "Sessionen kan ikke joine nu"
}, },
"nickname_taken": { "session_token_required": {
"en": "Nickname already taken", "en": "Session token is required.",
"da": "Kaldenavnet er allerede taget" "da": "Session-token er påkrævet."
},
"category_slug_required": {
"en": "category_slug is required",
"da": "category_slug er påkrævet"
},
"category_not_found": {
"en": "Category not found",
"da": "Kategori blev ikke fundet"
},
"round_start_invalid_phase": {
"en": "Round can only be started from lobby",
"da": "Runden kan kun startes fra lobbyen"
},
"round_already_configured": {
"en": "Round already configured",
"da": "Runden er allerede konfigureret"
},
"category_has_no_questions": {
"en": "Category has no active questions",
"da": "Kategorien har ingen aktive spørgsmål"
}, },
"show_question_invalid_phase": { "show_question_invalid_phase": {
"en": "Question can only be shown in lie phase", "en": "Question can only be shown in lie phase",
@@ -374,6 +556,98 @@
"host_only_mix_answers": { "host_only_mix_answers": {
"en": "Only host can mix answers", "en": "Only host can mix answers",
"da": "Kun værten kan blande svar" "da": "Kun værten kan blande svar"
},
"player_id_required": {
"en": "player_id is required",
"da": "player_id er påkrævet"
},
"session_token_required": {
"en": "session_token is required",
"da": "session_token er påkrævet"
},
"lie_text_invalid": {
"en": "text must be between 1 and 255 characters",
"da": "text skal være mellem 1 og 255 tegn"
},
"player_not_found_in_session": {
"en": "Player not found in session",
"da": "Spiller blev ikke fundet i sessionen"
},
"invalid_player_session_token": {
"en": "Invalid player session token",
"da": "Ugyldigt spiller-session-token"
},
"lie_submission_closed": {
"en": "Lie submission window has closed",
"da": "Vinduet for løgn-indsendelse er lukket"
},
"lie_already_submitted": {
"en": "Lie already submitted for this player",
"da": "Løgnen er allerede indsendt for denne spiller"
},
"host_only_view_scoreboard": {
"en": "Only host can view scoreboard",
"da": "Kun værten kan se scoreboard"
},
"scoreboard_invalid_phase": {
"en": "Scoreboard is only available in reveal/scoreboard phase",
"da": "Scoreboard er kun tilgængeligt i reveal-/scoreboard-fasen"
},
"host_only_start_next_round": {
"en": "Only host can start next round",
"da": "Kun værten kan starte næste runde"
},
"next_round_invalid_phase": {
"en": "Next round can only start from scoreboard phase",
"da": "Næste runde kan kun starte fra scoreboard-fasen"
},
"host_only_finish_game": {
"en": "Only host can finish game",
"da": "Kun værten kan afslutte spillet"
},
"finish_game_invalid_phase": {
"en": "Game can only be finished from scoreboard phase",
"da": "Spillet kan kun afsluttes fra scoreboard-fasen"
},
"host_only_calculate_scores": {
"en": "Only host can calculate scores",
"da": "Kun værten kan udregne score"
},
"scores_already_calculated": {
"en": "Scores already calculated for this round question",
"da": "Score er allerede udregnet for dette rundespørgsmål"
},
"calculate_scores_invalid_phase": {
"en": "Scores can only be calculated in guess phase",
"da": "Score kan kun udregnes i gættefasen"
},
"no_guesses_submitted": {
"en": "No guesses submitted for this round question",
"da": "Ingen gæt er indsendt for dette rundespørgsmål"
},
"guess_submission_closed": {
"en": "Guess submission window has closed",
"da": "Vinduet for gæt-indsendelse er lukket"
},
"selected_answer_invalid": {
"en": "Selected answer is not part of this round",
"da": "Det valgte svar er ikke en del af denne runde"
},
"guess_already_submitted": {
"en": "Guess already submitted for this player",
"da": "Gættet er allerede indsendt for denne spiller"
},
"lie_submission_invalid_phase": {
"en": "Lie submission is only allowed in lie phase",
"da": "Løgn kan kun indsendes i løgnefasen"
},
"selected_text_invalid": {
"en": "selected_text must be between 1 and 255 characters",
"da": "selected_text skal være mellem 1 og 255 tegn"
},
"guess_submission_invalid_phase": {
"en": "Guess submission is only allowed in guess phase",
"da": "Gæt kan kun indsendes i gættefasen"
} }
} }
}, },
@@ -392,27 +666,78 @@
"fallback": "Use default locale when requested locale is unsupported or key translation is missing." "fallback": "Use default locale when requested locale is unsupported or key translation is missing."
}, },
"backend_to_frontend_error_keys": { "backend_to_frontend_error_keys": {
"session_code_required": "session_code_required", "calculate_scores_invalid_phase": "unknown",
"category_has_no_questions": "start_round_failed",
"category_not_found": "start_round_failed",
"category_slug_required": "start_round_failed",
"finish_game_invalid_phase": "unknown",
"guess_already_submitted": "unknown",
"guess_submission_invalid_phase": "unknown",
"guess_submission_window_closed": "unknown",
"host_only_action": "start_round_failed",
"host_only_calculate_scores": "unknown",
"host_only_finish_game": "unknown",
"host_only_mix_answers": "start_round_failed",
"host_only_show_question": "start_round_failed",
"host_only_start_next_round": "unknown",
"host_only_start_round": "start_round_failed",
"host_only_view_scoreboard": "unknown",
"invalid_player_session_token": "unknown",
"lie_already_submitted": "unknown",
"lie_submission_invalid_phase": "unknown",
"lie_submission_window_closed": "unknown",
"lie_text_invalid": "unknown",
"mix_answers_invalid_phase": "start_round_failed",
"nickname_invalid": "nickname_invalid", "nickname_invalid": "nickname_invalid",
"nickname_taken": "nickname_taken",
"no_available_questions": "start_round_failed",
"no_guesses_submitted": "unknown",
"not_enough_answers_to_mix": "start_round_failed",
"player_id_required": "unknown",
"player_not_found_in_session": "unknown",
"question_already_shown": "start_round_failed",
"round_already_configured": "start_round_failed",
"round_config_missing": "start_round_failed",
"round_question_not_found": "start_round_failed",
"round_start_invalid_phase": "start_round_failed",
"scoreboard_invalid_phase": "unknown",
"scores_already_calculated": "unknown",
"selected_answer_invalid": "unknown",
"selected_text_invalid": "unknown",
"session_code_required": "session_code_required",
"session_not_found": "session_not_found", "session_not_found": "session_not_found",
"session_not_joinable": "join_failed", "session_not_joinable": "join_failed",
"nickname_taken": "nickname_taken", "session_token_required": "unknown",
"category_slug_required": "start_round_failed",
"category_not_found": "start_round_failed",
"round_start_invalid_phase": "start_round_failed",
"round_already_configured": "start_round_failed",
"host_only_start_round": "start_round_failed",
"host_only_show_question": "start_round_failed",
"host_only_mix_answers": "start_round_failed",
"host_only_action": "start_round_failed",
"category_has_no_questions": "start_round_failed",
"show_question_invalid_phase": "start_round_failed", "show_question_invalid_phase": "start_round_failed",
"round_config_missing": "start_round_failed", "round_config_missing": "start_round_failed",
"question_already_shown": "start_round_failed", "question_already_shown": "start_round_failed",
"no_available_questions": "start_round_failed", "no_available_questions": "start_round_failed",
"mix_answers_invalid_phase": "start_round_failed", "mix_answers_invalid_phase": "start_round_failed",
"round_question_not_found": "start_round_failed", "round_question_not_found": "start_round_failed",
"not_enough_answers_to_mix": "start_round_failed" "not_enough_answers_to_mix": "start_round_failed",
"player_id_required": "unknown",
"session_token_required": "unknown",
"lie_text_invalid": "unknown",
"lie_submission_invalid_phase": "unknown",
"player_not_found_in_session": "unknown",
"invalid_player_session_token": "unknown",
"lie_submission_closed": "unknown",
"lie_already_submitted": "unknown",
"selected_text_invalid": "unknown",
"guess_submission_invalid_phase": "unknown",
"guess_submission_closed": "unknown",
"selected_answer_invalid": "unknown",
"guess_already_submitted": "unknown",
"host_only_view_scoreboard": "unknown",
"scoreboard_invalid_phase": "unknown",
"host_only_start_next_round": "unknown",
"next_round_invalid_phase": "unknown",
"host_only_finish_game": "unknown",
"finish_game_invalid_phase": "unknown",
"host_only_calculate_scores": "unknown",
"scores_already_calculated": "unknown",
"calculate_scores_invalid_phase": "unknown",
"no_guesses_submitted": "unknown"
} }
} }
} }