346 Commits

Author SHA1 Message Date
Asger Geel Weirsøe
a81bc1250c Big visual overhaul docker compsoe file etc
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
2026-03-23 14:11:30 +01:00
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
258025ac4e feat(#239): add angular i18n shell namespace bridge
All checks were successful
CI / test-and-quality (push) Successful in 3m43s
CI / test-and-quality (pull_request) Successful in 3m20s
2026-03-02 00:13:12 +00:00
f28a390f95 feat(i18n): add shared key manifest and drift check script
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m33s
2026-03-02 00:12:17 +00:00
a1bb1ccbed Merge pull request '[MVP][READY] #223 Telefon-klient guard: ingen lydafspilning på secondary device' (#242) from dev/issue-223-secondary-device-audio-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 2m35s
2026-03-02 01:08:30 +01:00
ee025e8deb Guard legacy player client against secondary-device audio playback
All checks were successful
CI / test-and-quality (push) Successful in 2m58s
CI / test-and-quality (pull_request) Successful in 3m0s
2026-03-02 00:00:40 +00:00
b977016ef4 Merge pull request '[MVP][READY] #175-C Angular host/player integration + hardcoded kerneflow-tekster cleanup (#227)' (#238) from feat/issue-227-angular-host-player-i18n-cleanup into main
All checks were successful
CI / test-and-quality (push) Successful in 2m38s
2026-03-02 00:50:44 +01:00
1b899a30a2 fix(#227): remove hardcoded unknown-error fallback in host/player flow
All checks were successful
CI / test-and-quality (push) Successful in 3m12s
CI / test-and-quality (pull_request) Successful in 3m14s
2026-03-01 23:42:47 +00:00
187b26e561 Merge pull request '[MVP][READY] #225 Backend i18n baseline (resolver + fallback)' (#237) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m45s
2026-03-02 00:36:16 +01:00
0b4ddaf43f Merge pull request '[MVP][READY] #223 Telefon-klient guard: stop aktiv lyd på secondary device' (#236) from feature/issue-223-secondary-device-audio-guard-followup into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-02 00:36:13 +01:00
7a3d649e11 fix(i18n): normalize underscore locale tags before fallback (#225)
All checks were successful
CI / test-and-quality (push) Successful in 3m55s
CI / test-and-quality (pull_request) Successful in 3m2s
2026-03-01 23:29:49 +00:00
f50f6a08ae fix(player): silence active media on secondary-device guard install
All checks were successful
CI / test-and-quality (push) Successful in 3m39s
CI / test-and-quality (pull_request) Successful in 3m54s
2026-03-01 23:29:15 +00:00
000a486db1 Merge pull request '[MVP][READY] #223 Telefon-klient guard: ingen lydafspilning på secondary device' (#235) from feature/issue-223-player-audio-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 2m47s
2026-03-02 00:21:38 +01:00
845e94b726 fix(player): ref-count secondary-device audio guard lifecycle
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m16s
CI / test-and-quality (push) Successful in 3m19s
2026-03-01 23:13:34 +00:00
5fe8f92ee4 Merge pull request '[MVP][READY] #220 Angular host/player shared i18n key-map bootstrap (da+en)' (#233) from feat/issue-220-angular-shared-i18n-keymap-bootstrap into main
All checks were successful
CI / test-and-quality (push) Successful in 3m45s
2026-03-02 00:00:04 +01:00
e2f184d1bc Merge pull request '[MVP][READY] #175-B: Shared key-map + locale-kontrakt mellem backend/frontend' (#231) from feat/issue-226-shared-keymap-locale-contract into main
All checks were successful
CI / test-and-quality (push) Successful in 3m11s
2026-03-01 23:55:55 +01:00
ab41798220 Merge pull request '[MVP][READY] #223 Telefon-klient guard: ingen lydafspilning på secondary device' (#234) from feature/issue-223-player-audio-guard into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 23:53:22 +01:00
c7ff3d96de docs(i18n): normalize flow table to host/player/system families
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m21s
2026-03-01 22:51:13 +00:00
3398aead7f frontend: consume shared backend->frontend error map at runtime
All checks were successful
CI / test-and-quality (push) Successful in 3m57s
CI / test-and-quality (pull_request) Successful in 4m1s
2026-03-01 22:49:49 +00:00
97945ede92 fix(issue-226): map host_only_action in shared backend→frontend key map 2026-03-01 22:49:49 +00:00
3655bad847 Merge pull request '[MVP][READY] #225 Backend i18n baseline (resolver + fallback)' (#232) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 3m59s
2026-03-01 23:44:22 +01:00
fdef33f44a docs(issue-223): add audio guard acceptance artifact
All checks were successful
CI / test-and-quality (push) Successful in 3m2s
CI / test-and-quality (pull_request) Successful in 2m57s
2026-03-01 22:43:32 +00:00
bc78f79f78 docs(i18n): align issue-220 families and contract mapping
All checks were successful
CI / test-and-quality (push) Successful in 3m54s
CI / test-and-quality (pull_request) Successful in 3m57s
2026-03-01 22:42:22 +00:00
784622058a docs(i18n): add Angular host/player key-map bootstrap for MVP flow (#220)
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m48s
2026-03-01 22:38:54 +00:00
257732e2ab feat(issue-225): extend backend i18n error contract to flow endpoints
All checks were successful
CI / test-and-quality (push) Successful in 3m40s
CI / test-and-quality (pull_request) Successful in 3m43s
2026-03-01 22:32:33 +00:00
64fe273691 Merge pull request '[MVP][READY] #226 Shared key-map + locale-kontrakt mellem backend/frontend' (#230) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m33s
2026-03-01 23:23:23 +01:00
cd6fb06343 feat(issue-226): add shared backend-frontend key-map and locale contract
All checks were successful
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m17s
2026-03-01 22:14:08 +00:00
508d462bb6 test(lobby): cover backend locale resolver normalization and default fallback
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Failing after 3m4s
2026-03-01 22:12:43 +00:00
e435a41660 Merge pull request '[MVP] Angular-first host+player i18n integration without React (issue #222)' (#229) from feat/issue-222-angular-first-host-player-i18n into main
All checks were successful
CI / test-and-quality (push) Successful in 2m37s
2026-03-01 23:06:12 +01:00
b9bfe55f93 Merge pull request '[MVP][READY] #224 Trunk-sekvens for #175: A/B/C små mergeklare bidder' (#228) from feat/issue-224-trunk-sequence-175 into main
Some checks are pending
CI / test-and-quality (push) Has started running
2026-03-01 23:03:38 +01:00
8e21ca8e5e docs(issue-224): clarify docs-only verification
All checks were successful
CI / test-and-quality (push) Successful in 3m35s
CI / test-and-quality (pull_request) Successful in 2m46s
2026-03-01 21:56:38 +00:00
ddf8e874e2 feat(issue-222): wire angular host/player i18n to backend shell locale
All checks were successful
CI / test-and-quality (push) Successful in 3m37s
CI / test-and-quality (pull_request) Successful in 3m38s
2026-03-01 21:54:32 +00:00
21a25a063c docs(issue-224): define A/B/C trunk sequence for #175
All checks were successful
CI / test-and-quality (push) Successful in 3m45s
CI / test-and-quality (pull_request) Successful in 3m50s
2026-03-01 21:51:54 +00:00
4e300e4631 feat(player): guard against audio playback on secondary device 2026-03-01 21:51:54 +00:00
5fe9939057 Merge pull request '[Need-to-have] #175 Shared i18n contract docs + bilingual MVP flow smoke' (#218) from dev/issue-175-shared-i18n-mainline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m27s
2026-03-01 22:34:32 +01:00
59cabcb56c feat(i18n): add shared-contract architecture + bilingual MVP flow smoke
All checks were successful
CI / test-and-quality (push) Successful in 3m0s
CI / test-and-quality (pull_request) Successful in 2m58s
2026-03-01 21:25:56 +00:00
d0c97e1d9c Merge pull request '[API] Issue #205: Django i18n foundation validation hardening' (#217) from feat/issue-205-django-i18n-foundation into main
All checks were successful
CI / test-and-quality (push) Successful in 2m24s
2026-03-01 22:18:31 +01:00
bb8109baf6 test(i18n): harden resolver logging and fallback coverage
All checks were successful
CI / test-and-quality (push) Successful in 3m0s
CI / test-and-quality (pull_request) Successful in 3m0s
2026-03-01 21:09:37 +00:00
32770f54b4 Merge pull request 'feat(cutover): asset versioning + rollback playbook hardening (#188)' (#216) from feat/issue-188-cutover-hardening into main
All checks were successful
CI / test-and-quality (push) Successful in 2m38s
2026-03-01 22:01:45 +01:00
a4c0d0603d feat(cutover): harden SPA asset cache busting and rollback playbook (#188)
All checks were successful
CI / test-and-quality (push) Successful in 2m55s
CI / test-and-quality (pull_request) Successful in 2m56s
2026-03-01 20:52:04 +00:00
bb823575db Merge pull request '[READY][i18n][P19] Issue #207 smoke/e2e artifact: da+en locale switch + primary-only audio policy' (#215) from dev/issue-207-i18n-audio-smoke into main
All checks were successful
CI / test-and-quality (push) Successful in 2m30s
2026-03-01 21:46:03 +01:00
3bc3ff8cc1 Merge pull request 'docs(issue-201): USE_SPA_UI rollout checklist + staging smoke-gate updates' (#214) from dev/issue-201-spa-cutover-rollout-gate into main
All checks were successful
CI / test-and-quality (push) Successful in 3m6s
2026-03-01 21:38:05 +01:00
c626b19eda test(i18n): add issue-207 smoke evidence for locale fallback and audio policy
All checks were successful
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m13s
2026-03-01 20:34:35 +00:00
bde56a2346 docs(issue-201): define USE_SPA_UI rollout gate and rollback checkpoints
All checks were successful
CI / test-and-quality (push) Successful in 3m33s
CI / test-and-quality (pull_request) Successful in 3m38s
2026-03-01 20:33:33 +00:00
9498391366 Merge pull request 'fix(spa): preserve scoreboard phase in Angular state sync (#200)' (#213) from feat/issue-200-angular-host-handoff into main
All checks were successful
CI / test-and-quality (push) Successful in 2m34s
2026-03-01 21:28:27 +01:00
011bbde840 fix(spa): keep scoreboard phase in derived gameplay state
All checks were successful
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m55s
2026-03-01 20:10:50 +00:00
d3963367e4 Merge pull request '[READY][SPA][P14] Issue #200: host/player phase-sync artifact' (#212) from feat/issue-200-spa-phase-sync-artifact into main
All checks were successful
CI / test-and-quality (push) Successful in 2m32s
2026-03-01 21:04:44 +01:00
abe0d91080 docs(issue-200): align artifact test list with head
All checks were successful
CI / test-and-quality (push) Successful in 2m57s
CI / test-and-quality (pull_request) Successful in 2m58s
2026-03-01 19:48:26 +00:00
63ac0d38e1 docs(spa): add issue-200 host-player phase sync artifact
All checks were successful
CI / test-and-quality (push) Successful in 2m43s
CI / test-and-quality (pull_request) Successful in 2m46s
2026-03-01 19:45:13 +00:00
fab0244361 Merge pull request '[READY][i18n][P18] Angular host+player i18n binding med simpel telefon-UX og nul client-audio' (#211) from dev/issue-206-angular-i18n-phone-ux-no-audio into main
All checks were successful
CI / test-and-quality (push) Successful in 2m29s
2026-03-01 20:38:36 +01:00
f3bd071322 fix(frontend): propagate locale changes reactively to mounted shells
All checks were successful
CI / test-and-quality (push) Successful in 3m4s
CI / test-and-quality (pull_request) Successful in 3m10s
2026-03-01 19:33:19 +00:00
1675e041d6 Merge pull request '[READY][i18n][P16] Shared keyspace-kontrakt (Django+Angular) med en-default + da/en matrix' (#210) from feat/issue-204-shared-i18n-keyspace-contract into main
All checks were successful
CI / test-and-quality (push) Successful in 2m16s
2026-03-01 20:30:23 +01:00
dd3b48067a feat(i18n): bind angular host/player copy to shared locale catalog
All checks were successful
CI / test-and-quality (push) Successful in 2m26s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-01 19:27:22 +00:00
b55b379134 feat(i18n): enforce shared keyspace contract across django and spa
All checks were successful
CI / test-and-quality (push) Successful in 2m17s
CI / test-and-quality (pull_request) Successful in 2m23s
2026-03-01 19:24:12 +00:00
377722eb9a Merge pull request '[READY][i18n][P17] Django i18n foundation: locale pipeline + resolver for shared keys (da/en)' (#209) from feat/issue-205-django-i18n-foundation into main
All checks were successful
CI / test-and-quality (push) Successful in 2m11s
2026-03-01 20:07:44 +01:00
f9efb3c5e4 Merge pull request '[READY][i18n][P17] Django i18n foundation: locale pipeline + resolver for shared keys (da/en)' (#208) from feat/issue-200-angular-host-handoff-phase-sync into main
All checks were successful
CI / test-and-quality (push) Successful in 2m5s
2026-03-01 20:02:48 +01:00
9e47a3a139 feat(i18n): add da/en locale pipeline and shared backend key resolver
All checks were successful
CI / test-and-quality (push) Successful in 2m21s
CI / test-and-quality (pull_request) Successful in 2m21s
2026-03-01 18:57:45 +00:00
fcfb3b21b1 feat(spa): sync host/player hash phase routes during gameplay
All checks were successful
CI / test-and-quality (push) Successful in 2m26s
CI / test-and-quality (pull_request) Successful in 2m35s
2026-03-01 18:54:19 +00:00
778b8e2817 Merge pull request '[SPA][P13] Angular API-kontrakt smoke for host/player endpoints (#199)' (#203) from feat/issue-199-angular-api-contract-smoke into main
All checks were successful
CI / test-and-quality (push) Successful in 2m4s
2026-03-01 19:23:14 +01:00
6cff552572 test(spa): expand angular API contract smoke for host/player endpoints (#199)
All checks were successful
CI / test-and-quality (push) Successful in 2m38s
CI / test-and-quality (pull_request) Successful in 2m38s
2026-03-01 18:13:55 +00:00
53e1be1471 Merge pull request '[SPA] Issue #180: next-round sync + final leaderboard flow evidence' (#197) from feat/issue-180-next-round-final-leaderboard into main
All checks were successful
CI / test-and-quality (push) Successful in 2m18s
2026-03-01 19:03:43 +01:00
a6738e2297 Merge pull request '[SPA] Issue #191: route/session guard bootstrap wiring for host+player' (#202) from issue-199-angular-api-contract-smoke into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 19:03:42 +01:00
988a8e5302 test(spa): add angular api contract smoke for session/join/start
All checks were successful
CI / test-and-quality (push) Successful in 2m31s
CI / test-and-quality (pull_request) Successful in 2m26s
2026-03-01 17:45:26 +00:00
cb9ef8e627 fix(spa): clear state sync timer when returning to join
All checks were successful
CI / test-and-quality (push) Successful in 2m16s
CI / test-and-quality (pull_request) Successful in 2m17s
2026-03-01 17:42:45 +00:00
fd1fbbf5e7 feat(spa): keep player in sync across next-round and document issue-180 flow 2026-03-01 17:42:45 +00:00
177574ae19 Merge pull request '[SPA] Issue #187: reconnect/loading/error states for player flow' (#198) from feat/issue-187-player-reconnect-states into main
All checks were successful
CI / test-and-quality (push) Successful in 1m49s
2026-03-01 18:12:50 +01:00
d26d2b1a09 feat(player): add reconnect loading and fallback join state (#187)
All checks were successful
CI / test-and-quality (push) Successful in 2m5s
CI / test-and-quality (pull_request) Successful in 2m8s
2026-03-01 16:55:33 +00:00
c4850f2e0e Merge pull request '[SPA][P12] Harden Angular host/player route session guards (#191)' (#195) from feat/191-angular-route-session-guards into main
All checks were successful
CI / test-and-quality (push) Successful in 1m51s
2026-03-01 17:48:25 +01:00
60ce650653 Merge pull request '[READY][SPA][P9] Angular API-contract guard: typed client + response mappers for host/player flow' (#196) from feat/issue-186-angular-api-contract-guard into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 17:47:04 +01:00
fb782432ea feat(spa): guard angular host/player api contracts
All checks were successful
CI / test-and-quality (push) Successful in 2m22s
CI / test-and-quality (pull_request) Successful in 1m56s
2026-03-01 16:40:34 +00:00
71c90109e4 feat(spa): enforce player session context in angular route guards
All checks were successful
CI / test-and-quality (push) Successful in 2m21s
CI / test-and-quality (pull_request) Successful in 2m21s
2026-03-01 16:40:12 +00:00
7f42fa12c9 Merge pull request '[READY][SPA][P8] #180 Next-round + final leaderboard flow in Angular SPA' (#194) from feat/issue-180-spa-next-round-final-leaderboard into main
All checks were successful
CI / test-and-quality (push) Successful in 1m52s
2026-03-01 17:34:54 +01:00
9a69110c7d feat(spa): guard host/player API contract with typed client calls
All checks were successful
CI / test-and-quality (push) Successful in 2m13s
CI / test-and-quality (pull_request) Successful in 2m9s
2026-03-01 16:20:10 +00:00
82711dd537 Merge pull request '[SPA][P12] Foundation hardening: Angular route/session guards for host+player entry' (#193) from feat/issue-191-route-session-guards into main
All checks were successful
CI / test-and-quality (push) Successful in 1m53s
2026-03-01 17:06:40 +01:00
8ed88c9762 Merge pull request '[SPA][P10] Issue #187: Player reconnect/loading/error states' (#192) from feat/issue-187-player-reconnect-states into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 17:06:36 +01:00
8ba737be7f feat(spa): add host/player route session guards
All checks were successful
CI / test-and-quality (push) Successful in 2m30s
CI / test-and-quality (pull_request) Successful in 2m6s
2026-03-01 16:01:26 +00:00
f3ea19fcd7 feat(player): add reconnect/offline states in angular gameplay flow
All checks were successful
CI / test-and-quality (push) Successful in 2m18s
CI / test-and-quality (pull_request) Successful in 2m29s
2026-03-01 16:00:53 +00:00
386ac5b7c1 Merge pull request '[SPA][P9] Typed client + response mappers for host/player flow' (#190) from feat/issue-186-typed-contract-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 1m53s
2026-03-01 16:36:16 +01:00
0e7bb1b041 Merge pull request '[SPA][P8] Host final leaderboard summary + reset flow' (#189) from pr-185 into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 16:35:36 +01:00
de5007943e feat(spa): add typed API response mappers and contract guards
All checks were successful
CI / test-and-quality (push) Successful in 2m29s
CI / test-and-quality (pull_request) Successful in 2m20s
2026-03-01 15:32:26 +00:00
634bd617e7 feat(spa): render final leaderboard summary in host shell
All checks were successful
CI / test-and-quality (push) Successful in 2m10s
CI / test-and-quality (pull_request) Successful in 2m26s
2026-03-01 15:31:23 +00:00
7cc1e4c17f Merge pull request '[SPA][P8] #180 Next-round + final leaderboard flow i Angular SPA' (#185) from dev/issue-180-spa-next-round-final-leaderboard-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m50s
2026-03-01 16:26:27 +01:00
a20dcebe0a Merge pull request '[SPA][P6] Cutover prep: USE_SPA_UI smoke artefact update' (#184) from feat/issue-162-use-spa-ui-cutover-prep into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 16:26:18 +01:00
55e646651e fix(spa): remove duplicated player shell controller fields
All checks were successful
CI / test-and-quality (push) Successful in 2m6s
CI / test-and-quality (pull_request) Successful in 2m5s
2026-03-01 15:22:56 +00:00
3fc92c9ba0 feat(spa): add next-round and final leaderboard flow in Angular shells 2026-03-01 15:22:35 +00:00
0fb10f08c9 docs(spa): update cutover smoke artefacts for USE_SPA_UI
All checks were successful
CI / test-and-quality (push) Successful in 2m5s
CI / test-and-quality (pull_request) Successful in 2m3s
2026-03-01 15:19:23 +00:00
fbfb948e99 Merge pull request '[Need-to-have] #175 Shared i18n across frontend/backend for lobby flow' (#183) from dev/issue-175-shared-i18n-lobby into main
All checks were successful
CI / test-and-quality (push) Successful in 1m53s
2026-03-01 16:14:32 +01:00
25688cde79 Merge pull request '[SPA][P4] #169 Lobby join + start round wired via vertical slice in shell' (#182) from dev/issue-169-spa-lobby-start-round-wire-spa-flow into main
All checks were successful
CI / test-and-quality (push) Successful in 2m0s
2026-03-01 16:11:00 +01:00
3253f4d343 feat(i18n): share lobby message catalog across frontend/backend
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m8s
CI / test-and-quality (push) Successful in 2m15s
2026-03-01 15:07:47 +00:00
c6aaef9d94 feat(spa): wire lobby join/start round through vertical slice
All checks were successful
CI / test-and-quality (push) Successful in 2m24s
CI / test-and-quality (pull_request) Successful in 2m20s
2026-03-01 15:05:09 +00:00
a5c9e4f255 Merge pull request '[SPA][P7] #172 Gameplay MVP-del 2: Lie -> guess -> reveal -> scoreboard wired flow' (#181) from dev/issue-172-spa-gameplay-flow into main
All checks were successful
CI / test-and-quality (push) Successful in 2m1s
2026-03-01 15:59:34 +01:00
84c88e5627 Merge pull request '[SPA][P5] #161 Gameplay phase state-machine skeleton (lie/guess/reveal/scoreboard)' (#177) from dev/issue-161-spa-gameplay-phase-state-machine into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 15:59:13 +01:00
de4302622b test(angular): strengthen gameplay wiring coverage for host/player flows
All checks were successful
CI / test-and-quality (push) Successful in 2m20s
CI / test-and-quality (pull_request) Successful in 2m1s
2026-03-01 14:46:21 +00:00
70c9b71f99 merge(main): update PR #177 branch for mergeability
All checks were successful
CI / test-and-quality (push) Successful in 2m23s
CI / test-and-quality (pull_request) Successful in 2m24s
2026-03-01 14:44:30 +00:00
89870f44ac test(angular): cover host/player gameplay transitions and retry paths
All checks were successful
CI / test-and-quality (push) Successful in 2m18s
CI / test-and-quality (pull_request) Successful in 2m19s
2026-03-01 14:41:17 +00:00
2f6a21de9c feat(spa): wire lie-guess-reveal-scoreboard gameplay flow (#172)
All checks were successful
CI / test-and-quality (push) Successful in 2m10s
CI / test-and-quality (pull_request) Successful in 2m10s
2026-03-01 14:35:00 +00:00
176218c360 fix(frontend): restore default session context persistence and empty-code guards
All checks were successful
CI / test-and-quality (push) Successful in 2m28s
CI / test-and-quality (pull_request) Successful in 2m29s
2026-03-01 14:03:28 +00:00
9e54aa0ab2 Merge pull request '[SPA][P2] Angular API-client lag fix for health + session read (#168)' (#178) from dev/issue-168-angular-api-client-lagfix into main
All checks were successful
CI / test-and-quality (push) Successful in 1m56s
2026-03-01 15:02:04 +01:00
58f7f02af3 fix(spa): normalize angular api client base URL for django endpoints
All checks were successful
CI / test-and-quality (push) Successful in 2m11s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 13:52:32 +00:00
c9c2ec23a2 Merge pull request '[SPA][P4] #169 Gameplay MVP del 1: lobby join + start round flow (v2)' (#176) from issue-169-spa-lobby-join-start-round into main
All checks were successful
CI / test-and-quality (push) Successful in 1m58s
2026-03-01 14:44:42 +01:00
749997a8fb fix(spa): guard empty session code before hydrate/start
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m7s
CI / test-and-quality (push) Successful in 2m9s
2026-03-01 13:32:17 +00:00
85e970b90c fix(frontend): restore default session context store in vertical slice
All checks were successful
CI / test-and-quality (push) Successful in 2m0s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-01 13:24:46 +00:00
b52896d137 test(spa): cover lobby->start-round flow without reload (#169)
All checks were successful
CI / test-and-quality (push) Successful in 2m4s
CI / test-and-quality (pull_request) Successful in 2m4s
2026-03-01 13:17:38 +00:00
b0aca04420 fix(frontend): restore session context store integration in vertical slice
All checks were successful
CI / test-and-quality (push) Successful in 2m8s
CI / test-and-quality (pull_request) Successful in 1m55s
2026-03-01 13:08:51 +00:00
24a319fd8f fix(frontend): restore session context behavior in vertical slice 2026-03-01 13:08:51 +00:00
093a928e6a feat(spa): add gameplay phase state-machine skeleton 2026-03-01 13:08:51 +00:00
538368de99 fix(frontend): restore session context behavior in vertical slice
All checks were successful
CI / test-and-quality (push) Successful in 2m8s
CI / test-and-quality (pull_request) Successful in 2m9s
2026-03-01 13:05:34 +00:00
cab5c47759 feat(spa): wire join/start round in Angular API client for lobby flow 2026-03-01 13:05:26 +00:00
68325944c1 Merge pull request '[SPA] Angular API client for health + session read (#168)' (#170) from dev/issue-168-angular-api-client into main
All checks were successful
CI / test-and-quality (push) Successful in 1m56s
2026-03-01 13:30:50 +01:00
d1e1ef0fde Merge pull request '[SPA][P3] Session context store for SPA flow (#159)' (#171) from dev/issue-159-spa-session-context-store into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 13:30:22 +01:00
07a8c9568d feat(frontend): wire SPA flow to session context store
All checks were successful
CI / test-and-quality (push) Successful in 2m25s
CI / test-and-quality (pull_request) Successful in 2m7s
2026-03-01 12:21:50 +00:00
4a6acd79c1 feat(frontend): add robust session context store for SPA 2026-03-01 12:21:46 +00:00
b6617fc356 feat(spa): add Angular API client for health and session read
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m9s
CI / test-and-quality (push) Successful in 1m47s
2026-03-01 12:21:18 +00:00
b647db2048 Merge pull request '[SPA] Non-blocking loading skeletons for host+player critical views (issue #150)' (#167) from dev/issue-150-spa-loading-skeletons into main
All checks were successful
CI / test-and-quality (push) Successful in 2m1s
2026-03-01 13:14:45 +01:00
29ef754389 Merge pull request '[SPA] Angular app-shell + host/player routing skeleton (#157)' (#164) from dev/issue-157-angular-shell-v2 into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 13:14:29 +01:00
702ab6b9ee Merge main into PR #164 and resolve SPA shell conflicts
All checks were successful
CI / test-and-quality (push) Successful in 2m18s
CI / test-and-quality (pull_request) Successful in 1m58s
2026-03-01 12:02:40 +00:00
7294ad409c feat(spa): add non-blocking host/player loading skeletons
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m15s
CI / test-and-quality (push) Successful in 2m16s
2026-03-01 12:01:41 +00:00
2f142aeb24 fix(lobby): normalize host SPA deeplink path segments
All checks were successful
CI / test-and-quality (push) Successful in 2m7s
CI / test-and-quality (pull_request) Successful in 2m6s
2026-03-01 11:25:54 +00:00
fa6c5e30c9 Merge pull request '[SPA] MVP vertical slice: Lobby -> Join -> Start round (#160)' (#165) from dev/issue-160-spa-vertical-slice-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m48s
2026-03-01 12:22:34 +01:00
cd3c604ba6 Merge pull request '[SPA] Cutover feature-flag USE_SPA_UI med sikker fallback (#152)' (#166) from dev/issue-152-spa-cutover-flag into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 12:22:14 +01:00
84438b2880 Fix host SPA deeplink route propagation
All checks were successful
CI / test-and-quality (push) Successful in 2m6s
CI / test-and-quality (pull_request) Successful in 2m7s
2026-03-01 11:17:46 +00:00
1aa296c45c feat(spa): add USE_SPA_UI cutover flag with legacy fallback
All checks were successful
CI / test-and-quality (push) Successful in 2m17s
CI / test-and-quality (pull_request) Successful in 2m5s
2026-03-01 11:14:38 +00:00
ea8954e702 feat(spa): add lobby-join-start vertical slice controller
All checks were successful
CI / test-and-quality (push) Successful in 2m6s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 11:13:48 +00:00
ea82f920b1 test(lobby): cover SPA shell rendering for host/player
All checks were successful
CI / test-and-quality (push) Successful in 2m5s
CI / test-and-quality (pull_request) Successful in 2m9s
2026-03-01 11:06:59 +00:00
5bdbdbd837 feat(lobby): gate Angular SPA shell behind feature flag 2026-03-01 11:06:55 +00:00
7180cc9b0d feat(spa): scaffold Angular app shell with host/player routes 2026-03-01 11:06:33 +00:00
61eb08ad73 Merge pull request '[SPA] API-client baseline for health + session read (#158)' (#163) from dev/issue-158-spa-api-client-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 1m46s
Merged by integrator-runner
2026-03-01 12:05:30 +01:00
37b86d7065 test(spa): add integration coverage for API client error mapping
All checks were successful
CI / test-and-quality (push) Successful in 1m54s
CI / test-and-quality (pull_request) Successful in 1m54s
2026-03-01 11:02:00 +00:00
2e25d32ba1 feat(spa): add baseline API client for health and session read 2026-03-01 11:01:58 +00:00
825f8c599b Merge pull request '[SPA] Shared contract for lobby/game phase view-model' (#155) from dev/issue-149-phase-view-model into main
All checks were successful
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:55:25 +01:00
87ba42c68a Merge pull request '[SPA] Error boundary + recover actions on top-level app shell' (#156) from dev/issue-151-app-shell-error-boundary into main
All checks were successful
CI / test-and-quality (push) Successful in 1m38s
2026-03-01 11:50:18 +01:00
2882a7737b feat(spa): add top-level app-shell error boundary recover actions (#151)
All checks were successful
CI / test-and-quality (push) Successful in 1m56s
CI / test-and-quality (pull_request) Successful in 1m56s
2026-03-01 10:46:48 +00:00
a9868ae450 feat(lobby): add shared phase view-model contract
All checks were successful
CI / test-and-quality (push) Successful in 2m12s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 10:41:16 +00:00
d6c7a36730 Merge pull request '[SPA] Host shell route-guards + deep-link fallback (#147)' (#154) from dev/issue-147-host-shell-route-guards into main
All checks were successful
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:34:46 +01:00
de99e456c7 merge(main): resolve host_screen deep-link route guard conflict
All checks were successful
CI / test-and-quality (pull_request) Successful in 1m58s
CI / test-and-quality (push) Successful in 1m58s
2026-03-01 10:29:07 +00:00
79c4734fe6 Merge pull request '[SPA] Player reconnect UX-state (lost connection banner + retry)' (#153) from dev/issue-148-player-reconnect-ux into main
All checks were successful
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:24:15 +01:00
c8c27346a8 fix(host-ui): accept deep-link routes and normalize shell path
All checks were successful
CI / test-and-quality (push) Successful in 2m14s
CI / test-and-quality (pull_request) Successful in 1m55s
2026-03-01 10:20:32 +00:00
994e2930d5 chore(ci): retrigger checks after stale review pending gate
All checks were successful
CI / test-and-quality (push) Successful in 2m27s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-01 10:17:31 +00:00
3e0cb9cee7 feat(lobby): add host SPA deep-link fallback and route guards
All checks were successful
CI / test-and-quality (push) Successful in 2m12s
CI / test-and-quality (pull_request) Successful in 2m12s
2026-03-01 10:13:26 +00:00
64bff4efb3 feat(player): show reconnect banner with retry action
Some checks failed
CI / test-and-quality (push) Failing after 2m3s
CI / test-and-quality (pull_request) Failing after 2m16s
2026-03-01 10:11:29 +00:00
c58b1e8102 Merge pull request '[Execution] Add staging gameplay smoke artifact template (#144)' (#146) from dev/staging-gameplay-smoke-artifact-144 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m38s
2026-03-01 10:33:48 +01:00
7aae8f3798 chore(ci): retrigger pipeline for PR #146
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m38s
CI / test-and-quality (push) Successful in 2m39s
2026-03-01 09:11:41 +00:00
0c0d27cc52 docs: add staging gameplay smoke evidence artifact template (#144)
Some checks failed
CI / test-and-quality (push) Failing after 24s
CI / test-and-quality (pull_request) Successful in 1m59s
2026-03-01 07:08:44 +00:00
164416e4a9 Merge pull request 'Issue #144: staging gameplay smoke artifact output' (#145) from dev/issue-144-smoke-artifact into main
Some checks failed
CI / test-and-quality (push) Failing after 25s
2026-03-01 07:51:09 +01:00
b782f73f49 Add staging gameplay smoke artifact output
All checks were successful
CI / test-and-quality (pull_request) Successful in 2m2s
CI / test-and-quality (push) Successful in 2m8s
2026-03-01 06:39:51 +00:00
9d1e41ef3b Merge pull request 'Fix #129: normalize session code input across host/player flows' (#143) from feature/issue-129-normalize-session-code into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 21:37:49 +01:00
046212d29a Normalize session code input in join and lookup flows
All checks were successful
CI / test-and-quality (push) Successful in 1m42s
CI / test-and-quality (pull_request) Successful in 1m42s
2026-02-28 20:22:58 +00:00
6fd57d1714 Merge pull request 'fix(devops): harden staging deploy health check race' (#142) from fix/141-staging-healthcheck-retry into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 18:40:44 +01:00
c4ea5ca208 fix(staging): retry health check after restart
All checks were successful
CI / test-and-quality (push) Successful in 1m30s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 17:36:14 +00:00
ab08303fc3 Merge pull request 'fix(smoke): load staging env before migration/gameplay checks' (#140) from fix/smoke-env-load-130 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 17:50:33 +01:00
8b6f115759 fix(smoke): load staging env before migrate/smoke checks (refs #130 #90)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 16:35:25 +00:00
c75189deb9 Merge pull request 'fix(staging): keep /opt/wpp-staging/app writable for wpp runtime (fix #138)' (#139) from feature/fix-138-staging-app-ownership into main
All checks were successful
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 17:29:29 +01:00
30c22d2f0c fix(staging): enforce writable app ownership during deploy
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m38s
2026-02-28 16:06:58 +00:00
30e3f1c77f Merge pull request 'fix(smoke): schema-drift guard + token-aware staging smoke flow (refs #130 #90)' (#137) from fix/staging-promote-after-migrate-130 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 16:48:31 +01:00
abb656d50b fix(smoke): guard staging schema and include player session tokens (refs #130 #90)
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 15:36:29 +00:00
b1e89b135a Merge pull request 'fix(staging): avoid schema/code drift after failed deploy' (#136) from fix/staging-deploy-schema-drift-130 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 16:25:47 +01:00
e9104cdc44 fix(staging): prevent app/schema drift on failed deploy (refs #130 #90)
All checks were successful
CI / test-and-quality (push) Successful in 1m30s
CI / test-and-quality (pull_request) Successful in 1m32s
2026-02-28 15:04:06 +00:00
65ee2fc0db Merge pull request 'fix(staging): remove tracked sqlite artifact from deploy archives (fixes #131)' (#135) from fix/staging-sqlite-artifact-131 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 15:55:12 +01:00
12fc12f955 fix(staging): remove tracked sqlite artifact from deploy archives (refs #131 #130)
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-28 14:33:31 +00:00
e8f13646f9 Merge pull request 'feat(staging): enforce MySQL-only staging deploy (fixes #133)' (#134) from feat/staging-mysql-133 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 15:14:56 +01:00
a36221ae4b staging deploy: load env before manage.py checks
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 14:02:39 +00:00
fce18c1ee3 staging: enforce MySQL and add staging env template (#133)
All checks were successful
CI / test-and-quality (push) Successful in 1m33s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 11:31:39 +00:00
183 changed files with 37722 additions and 938 deletions

7
.claude/settings.json Normal file
View File

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

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.venv
venv
__pycache__
*.py[cod]
*.egg-info
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
frontend/node_modules
frontend/angular/node_modules
frontend/angular/dist
db.sqlite3
staticfiles
media

View File

@@ -27,7 +27,34 @@ jobs:
pip install ruff
- name: Lint
run: ruff check lobby
run: ruff check .
- name: Tests
run: python manage.py test lobby -v 1
- name: Django checks
run: |
python manage.py check
python scripts/check_i18n_drift.py
python manage.py test lobby fupogfakta -v 1
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install shared frontend dependencies
run: npm ci --prefix frontend
- name: Shared frontend checks
run: |
npm --prefix frontend test
npm --prefix frontend run build
- name: Install SPA dependencies
run: |
npm ci --prefix frontend/angular
node -e "require('./frontend/angular/node_modules/rollup/dist/native.js')" \
|| npm install --prefix frontend/angular
- name: SPA Angular checks
run: |
npm --prefix frontend/angular test
npm --prefix frontend/angular run build

3
.gitignore vendored
View File

@@ -17,12 +17,15 @@ venv/
db.sqlite3
staticfiles/
media/
artifacts/
# Env/secrets
.env
.env.*
!.env.test.example
!.env.staging.example
!.env.prod.example
!.env.dev.example
# Editors/OS
.vscode/

30
AGENTS.md Normal file
View File

@@ -0,0 +1,30 @@
# Repository Guidelines
## Project Structure & Module Organization
`partyhub/` is the Django project entrypoint (`settings.py`, `urls.py`, `asgi.py`). Backend apps live at the repo root: `lobby/` handles session and player flows, `fupogfakta/` owns game rules and scoring, `realtime/` holds websocket/broadcast code, and `core_admin/` plus `voice/` cover admin and future audio integration. Shared locale data lives in `shared/i18n/`, helper scripts in `scripts/`, deployment assets in `infra/`, and release or smoke evidence in `docs/`.
Frontend code is split in two layers: `frontend/src/` contains the framework-agnostic TypeScript API client and SPA state helpers, while `frontend/angular/src/` contains the Angular 19 shell for host and player screens.
## Build, Test, and Development Commands
Install backend dependencies with `.venv/bin/pip install -r requirements.txt`.
- `.venv/bin/python manage.py runserver` starts the Django dev server.
- `.venv/bin/python manage.py migrate` applies schema changes.
- `.venv/bin/python manage.py check` runs Django configuration checks.
- `.venv/bin/python manage.py test lobby` runs the backend suite currently enforced in CI.
- `npm --prefix frontend test` runs Vitest for the shared TypeScript client.
- `npm --prefix frontend run build` performs the TypeScript compile check.
- `npm --prefix frontend/angular start` serves the Angular shell locally.
- `npm --prefix frontend/angular test` runs Angular-side Vitest smoke tests.
- `.venv/bin/python scripts/check_i18n_drift.py` validates shared locale keys.
## Coding Style & Naming Conventions
Use 4-space indentation in Python and follow Django conventions: snake_case for functions, PascalCase for models, explicit `on_delete` on `ForeignKey`s, and committed migrations in each apps `migrations/` package. Keep business rules server-authoritative.
Use 2-space indentation in TypeScript and Angular. Match the existing style: single quotes, semicolons, PascalCase classes, camelCase functions, and kebab-case filenames such as `gameplay-phase-machine.ts`. Keep API types in `frontend/src/api/types.ts` aligned with backend JSON payloads.
## Testing Guidelines
Backend tests live in `<app>/tests.py`; frontend tests live in `frontend/tests/*.test.ts` and `frontend/angular/src/**/*.spec.ts`. No numeric coverage gate is committed, so add targeted tests for every gameplay, i18n, or payload-contract change. Before opening a PR, run `manage.py check`, `manage.py test lobby`, and `npm --prefix frontend/angular test` at minimum.
## Commit & Pull Request Guidelines
Recent history follows short, imperative subjects with optional scopes, for example `fix(gameplay): ...`, `test(lobby): ...`, and `chore: ...`. Keep commits small and reference issue numbers when relevant. Open PRs from `feature/<name>` branches with a clear problem statement, linked issue, test evidence, and screenshots for host/player UI changes. If you touch `USE_SPA_UI`, staging flow, or i18n artifacts, include the related smoke or parity document in `docs/`.

View File

@@ -1,5 +1,10 @@
# Changelog
## [Unreleased]
### Docs
- Added `docs/ISSUE-279-I18N-MVP-CLOSEOUT.md` with the issue #279 i18n MVP close-out note, including migration impact, reusable release-note text, and a release-readiness checklist refreshed against `main@1bc4c27` after PR #282/#283 landed on 2026-03-13 UTC.
- Clarified that the close-out note supersedes earlier PR snapshot assumptions and now treats PR #282 (`6ad5430`) and PR #283 (`1bc4c27`) as already merged on `main`.
## [0.1.0] - 2026-02-27
### Added
- Projekt scaffold for Weirsøe Party Protocol (Django 6.0.2)

119
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

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential default-libmysqlclient-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip \
&& pip install -r /app/requirements.txt
COPY . /app
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

71
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] `fupogfakta` (Spil 1)
- [x] `lobby` (room/session/player join flow)
- [x] `realtime` (channels events, game state broadcast)
- [x] `voice` (fælles voice-acting interface)
- [x] `realtime` (app-skelet oprettet — consumers/routing IKKE implementeret endnu)
- [x] `voice` (fælles voice-acting interface — stub)
- [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner)
- [x] Konfig for MySQL test/prod
@@ -53,14 +53,15 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [x] `ScoreEvent` (auditérbar pointslog)
### Fase 3 — Spilflow `Fup og Fakta`
- [x] Lobby: host opretter session, spillere joiner via kode
- [x] Runde starter med kategori
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
- [x] System blander korrekt svar + løgne
- [x] Guessfase: alle gætter inden Z sek
- [x] Pointudregning (konfigurerbar pr. runde)
- [x] Scoreboard + næste spørgsmål/runde
- [x] Slutresultat
- [x] Lobby: host opretter session, spillere joiner via kode (REST)
- [x] Runde starter med kategori (REST)
- [x] Spørgsmål vises -> alle skriver løgn inden X sek (REST)
- [x] System blander korrekt svar + løgne (persisted i JSONField, anti-cheat dedup)
- [x] Guessfase: alle gætter inden Z sek (REST)
- [x] Pointudregning (konfigurerbar pr. runde, ScoreEvent audit trail)
- [x] Scoreboard + næste spørgsmål/runde (REST)
- [x] Slutresultat (REST)
- [x] **WebSocket push af phase-events til host + spillere** (GameConsumer + broadcast.py, InMemoryChannelLayer i tests)
### Fase 4 — Voice-acting (platformkrav)
- [ ] Definér TTS provider-interface
@@ -103,10 +104,11 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [ ] Migrations + static + health checks
### Backlog — Need-to-have / Nice-to-have
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
- [x] (Need-to-have) Persistér mixed svarrækkefølge pr. round question — DONE (JSONField + migration 0003 + test)
- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
- [ ] (Need-to-have) Fjern ubrugte scaffold-imports i core_admin/*, realtime/*, voice/*, fupogfakta/views.py (kør `ruff check --fix`) så CI quality gate er grøn
- [x] (Need-to-have) [Issue #251] Release-often lane: SPA MVP opdelt i 3 merge-klare micro-PR batches (plan + acceptance criteria dokumenteret i `docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md`).
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
- [ ] (Need-to-have) Session-kode brute-force beskyttelse
- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip)

View File

@@ -1,3 +1 @@
from django.contrib import admin
# Register your models here.
"""Admin registrations for the core_admin app."""

View File

@@ -1,3 +1 @@
from django.db import models
# Create your models here.
"""Database models for the core_admin app."""

View File

@@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here.
"""Test module placeholder for the core_admin app."""

View File

@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.
"""HTTP views for the core_admin app."""

Binary file not shown.

82
docker-compose.yml Normal file
View File

@@ -0,0 +1,82 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
command: sh /app/scripts/docker_dev_entrypoint.sh
env_file:
- infra/env/.env.dev.example
environment:
DB_HOST: db
DB_PORT: "3306"
CHANNEL_REDIS_HOST: redis
CHANNEL_REDIS_PORT: "6379"
USE_SPA_UI: ${USE_SPA_UI:-false}
WPP_SPA_ASSET_BASE: ${WPP_SPA_ASSET_BASE:-http://localhost:4200/browser}
WPP_SPA_ASSET_VERSION: ${WPP_SPA_ASSET_VERSION:-dev}
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test:
- CMD
- python
- -c
- import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status == 200 else 1)
interval: 5s
timeout: 5s
retries: 20
start_period: 15s
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- .:/app
stdin_open: true
tty: true
db:
image: mysql:8.4
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_DATABASE: wpp_dev
MYSQL_USER: wpp_dev
MYSQL_PASSWORD: wpp_dev
MYSQL_ROOT_PASSWORD: wpp_root
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
ports:
- "${DB_FORWARD_PORT:-3307}:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports:
- "${REDIS_FORWARD_PORT:-6380}:6379"
volumes:
- redis_data:/data
spa-assets:
profiles: ["spa"]
image: node:22-alpine
working_dir: /workspace/frontend/angular
command: sh -c "npm ci && npm run build && node /workspace/scripts/serve_static_dir.mjs dist/wpp-angular-shell 4200 http://app:8000"
ports:
- "${SPA_PORT:-4200}:4200"
volumes:
- .:/workspace
- spa_node_modules:/workspace/frontend/angular/node_modules
volumes:
mysql_data:
redis_data:
spa_node_modules:

89
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,89 @@
# Development Setup
## MVP Runtime Path
The current MVP runtime path is the legacy Django host/player UI with `USE_SPA_UI=false`.
## Docker Compose
The fastest MVP path is the legacy UI with MySQL and Redis behind Django:
```bash
docker compose up --build
```
App URLs:
- `http://localhost:8000/admin/login/`
- `http://localhost:8000/lobby/ui/host`
- `http://localhost:8000/lobby/ui/player`
Compose uses `infra/env/.env.dev.example` and overrides `DB_HOST`/`CHANNEL_REDIS_HOST` inside containers so the same file also works for host-side commands.
If port `8000` is already in use, run with `APP_PORT=18000 docker compose up --build` and use `http://localhost:18000/...` instead.
The app container now waits for the database and Redis endpoints before running migrations, so transient Docker DNS startup races do not kill the local stack.
## Bootstrap
Create deterministic demo credentials and sample questions with:
```bash
docker compose exec app python manage.py bootstrap_mvp
```
Default output:
- host username: `demo-host`
- host password: `demo-pass`
- category slug: `general`
- questions: `3`
You can override the host/category names with `--username`, `--password`, `--category-slug`, and `--category-name`.
For a quick seeded regression flow, run:
```bash
docker compose exec app python manage.py smoke_staging --artifact /tmp/wpp-smoke.json
```
That creates `smoke-host` / `smoke-pass`, ensures one active smoke question exists, and exercises one full round. Use `bootstrap_mvp` for the reusable local try-out account.
## Local MVP Smoke
For a one-command local MVP proof, run:
```bash
./scripts/run_local_mvp_smoke.sh
```
That starts the compose stack, waits for `/healthz`, runs `bootstrap_mvp`, executes `smoke_staging`, and writes a JSON artifact under `artifacts/local/`.
If port `8000` is busy on your machine, use `APP_PORT=18000 ./scripts/run_local_mvp_smoke.sh`.
By default the stack stays up after the smoke so you can continue in the browser. Use `KEEP_STACK_RUNNING=0` if you want the script to shut the stack down on exit.
## Release Gate
Run the full local MVP release gate with:
```bash
./scripts/verify_mvp_release.sh
```
That runs repo lint, shared i18n drift checks, Django checks/tests, both frontend test/build pipelines, and a `docker compose config` sanity pass.
## Optional SPA Shell
To serve the Angular shell as the UI path instead of the legacy templates:
```bash
USE_SPA_UI=true docker compose --profile spa up --build
```
Use these entry points:
- `http://localhost:4200/` for the SPA landing page with host login, host session creation, and player join
- `http://localhost:4200/host?session=ABC123` for the host shell after a session exists
- `http://localhost:4200/player?session=ABC123` for the player shell after join
- `http://localhost:8000/lobby/ui/host` and `http://localhost:8000/lobby/ui/player` if you want Django to render the SPA shell with `USE_SPA_UI=true`
The raw SPA container serves the compiled Angular app at `/` and also proxies `/accounts/*`, `/lobby/*`, and other Django endpoints back to `http://localhost:8000`.
`WPP_SPA_ASSET_BASE` still points at `http://localhost:4200/browser` because Django-rendered SPA pages load their static bundles from the compiled Angular `browser/` directory.

49
docs/I18N_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,49 @@
# Shared i18n architecture (frontend + backend)
## Scope
Issue #175 requires one shared i18n contract for MVP host/player flows across frontend and backend.
## Source of truth
- Catalog: `shared/i18n/lobby.json`
- Locales:
- supported: `en`, `da`
- default/fallback: `en`
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
- Domain-first namespaces:
- `frontend.ui.common.*`
- `frontend.ui.host.*`
- `frontend.ui.player.*`
- `frontend.errors.*`
- `backend.errors.*`
- `backend.error_codes.*`
- UI lookup keys in Angular are shortened aliases mapped under `frontend.ui`, e.g.:
- `host.start_round`
- `player.submit_guess`
- `common.session_code`
- Backend API errors return stable code + localized message:
- `error_code` = machine-stable key from `backend.error_codes`
- `error` = localized message from `backend.errors`
- `locale` = resolved request locale
## Fallback model (robust)
1. Resolve requested locale (`Accept-Language` on backend, user/browser preference on frontend).
2. If locale unsupported -> use default `en`.
3. If key missing -> return key and log warning.
4. If locale translation missing for key -> fallback to `en` translation.
## Audio-routing policy
- Catalog capability: `frontend.capabilities.client_has_no_audio_output = true`
- Host/player clients expose this as a read-only capability flag.
- Policy: phone clients must not play audio directly; only primary/host output is allowed.
## Verification
- Backend tests: `lobby/tests.py` i18n coverage for locale selection + fallback + error-code/message matrix.
- Frontend smoke/e2e-level unit coverage:
- `frontend/angular/src/app/lobby-i18n.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/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,35 @@
# ISSUE-175 Artifact — shared i18n contract + MVP host/player flow
## Delivered
- Shared i18n contract is centralized in `shared/i18n/lobby.json` (da/en, default `en`, robust fallback).
- Frontend host/player MVP copy is read via shared keys (no hardcoded Danish strings in Angular MVP flow components).
- Backend error messages resolve from shared keyspace with locale-aware API payload (`error_code`, `error`, `locale`).
- Audio-routing policy is explicit and shared: `frontend.capabilities.client_has_no_audio_output=true`.
- Architecture + key naming documented in `docs/I18N_ARCHITECTURE.md`.
## Smoke / e2e evidence
### Backend locale + fallback
Command:
```bash
. .venv/bin/activate && python manage.py test \
lobby.tests.LobbyFlowTests.test_join_error_localizes_to_danish_with_accept_language_header \
lobby.tests.LobbyFlowTests.test_join_error_falls_back_to_english_for_unsupported_locale \
lobby.tests.I18nResolverTests
```
Result:
- PASS (7 tests)
- Confirms `da` localization and fallback to `en` for unsupported locale.
### Frontend host/player + language switch + audio policy
Command:
```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
```
Result:
- PASS (22 tests)
- Includes smoke for host/player copy in **both en and da** and verifies primary-only audio policy flag (`clientHasNoAudioOutput=true`).

View File

@@ -0,0 +1,28 @@
# Issue #180 Evidence — SPA next-round + final leaderboard
## Flow log (Angular SPA)
1. Host reaches `reveal` phase and runs `loadScoreboard()` (`GET /lobby/sessions/:code/scoreboard`).
2. Host can start next round directly in SPA via `startNextRound()` (`POST /lobby/sessions/:code/rounds/next`) and then session hydrate (`GET /lobby/sessions/:code`) without page reload.
3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering winner + sorted final leaderboard (plus raw payload for debug) in the same shell.
4. Player SPA renders final leaderboard from refreshed finished-session payload (sorted by score desc, nickname tiebreak) without leaving SPA route.
5. Error/retry paths are implemented and covered:
- scoreboard failure -> `scoreboardError` + retry button
- next-round failure -> `nextRoundError` + retry button
- finish-game/final-leaderboard failure -> `finishError` + retry button
## Test evidence
### `frontend/angular` (Vitest)
- `src/app/features/host/host-shell.component.spec.ts`
- `runs next-round transition without reload and clears scoreboard payload`
- `captures finish-game failure for retry and stores final leaderboard on success`
- `src/app/features/player/player-shell.component.spec.ts`
- `builds final leaderboard in finished status without legacy page hop`
Result:
- Test Files: 2 passed
- Tests: 9 passed
### `frontend` shared SPA tests (regression)
Result:
- Test Files: 5 passed
- Tests: 24 passed

View File

@@ -0,0 +1,47 @@
# Issue #188 Artifact — SPA cutover hardening (asset versioning + rollback)
## Scope
Acceptance for `[READY][SPA][P11] Cutover hardening`:
1. Dokumenteret strategi for cache-busting/versionering af SPA assets i Django staticfiles/reverse proxy setup.
2. Konfigurerbar rollback-procedure for `USE_SPA_UI` (trin-for-trin, target <10 min).
3. Smoke-artefakt for både SPA on/off i samme release-vindue.
4. Ingen gameplay-ændringer.
## 1) Asset versioning/cache-busting strategi
Implementeret i SPA shell render-path:
- Konfiguration i `partyhub/settings.py`:
- `WPP_SPA_ASSET_BASE` (eksisterende)
- `WPP_SPA_ASSET_VERSION` (ny)
- `lobby/ui_views.py` injicerer `spa_asset_version` til template-context.
- `lobby/templates/lobby/spa_shell.html` appender `?v={{ spa_asset_version }}` på:
- `styles.css`
- `main.js`
Effekt:
- Ny release-version (fx SHA/tag) kan tvinge cache-miss i browser/proxy uden ændring af route.
- Rollback kan pege på tidligere stabil version-token med samme mekanisme.
## 2) Rollback playbook (`USE_SPA_UI`) — target <10 min
Dokumenteret i `docs/spa-cutover-flag.md`:
1. Sæt `USE_SPA_UI=false`.
2. Sæt `WPP_SPA_ASSET_VERSION` til sidste stabile release-token.
3. Deploy/reload app-processer.
4. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`).
5. Kør hurtig smoke sanity.
6. Log trigger/timestamp/resultat i smoke artifact.
## 3) Smoke artifact for SPA OFF/ON i samme release-vindue
Dokumenteret i:
- `docs/UI_SMOKE.md` (sektion: "Samme release-vindue: SPA OFF + ON verifikation")
- `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` (template udvidet med release-window check + `WPP_SPA_ASSET_VERSION`)
Krav:
- OFF-pass (legacy) og ON-pass (SPA) køres i samme deploy/release-vindue.
- Begge passes logges i samme artifact med UTC timestamps og version-token.
## Non-goals bekræftet
- Ingen gameplay-regler ændret.
- Ingen API-kontrakter ændret.
- Ingen UX-redesign; kun drift/cutover-hardening.

View File

@@ -0,0 +1,57 @@
# Issue #200 Artifact — SPA host→player phase sync (no reload)
## Scope
Acceptance for `[READY][SPA][P14] Gameplay MVP-del 5`:
1. Host handlinger (`start round` / `næste fase`) propagere korrekt til player-ruter i SPA.
2. Happy-path artefakt for én fuld faseovergang uden page reload.
3. Sync-fejl giver kontrolleret fallback/error state.
Afgrænsning overholdt: ingen nye spilregler og ingen redesign af backend event-model.
## Happy-path faseovergang (uden page reload)
Reference-flow i Angular shell:
1. **Host starter runde/fase**
- Host action kalder backend endpoint (`POST /lobby/sessions/:code/start` eller fase-endpoint)
- Host shell hydrerer session igen (`GET /lobby/sessions/:code`)
- Host route synkes via hash-rewrite i samme SPA shell:
- `#/host/<phase>/<CODE>`
- Implementeret i `HostShellComponent.syncRouteFromSession()`
2. **Player modtager ny fase via state sync**
- Player shell kører periodisk session-refresh (3s), uden hard reload
- Når `session.status` ændrer sig, synkes hash-route i samme SPA shell:
- `#/player/<phase>/<CODE>`
- Implementeret i `PlayerShellComponent.syncRouteFromSession()`
3. **Ingen page reload**
- Routing sker med `window.history.replaceState(...)`
- URL opdateres i eksisterende SPA instans (ingen template-hop, ingen full refresh)
## Kontrolleret fallback ved sync-fejl
Når player-sync fejler (netværk/fetch/session-refresh):
- UI går i kontrolleret connection-state:
- `reconnecting` ved online netværksfejl
- `offline` når browser rapporterer offline
- Fejl vises med retry/back-to-join handlinger
- Reconnect forsøges igen via timer (`scheduleReconnect`) uden at crashe shell
## Verifikation (tests)
Kørt i `frontend/angular` med Vitest:
- `src/app/features/host/host-shell.component.spec.ts`
- Verificerer host-faseflow og hash-route sync uden reload
- `src/app/features/player/player-shell.component.spec.ts`
- Verificerer periodisk player state sync + hash-route sync
- Verificerer reconnect/offline fallback ved sync-fejl
- `src/app/api-contract-smoke.spec.ts`
- `src/app/session-route-context.spec.ts`
Kommando:
```bash
npm test -- --reporter=dot
```
Resultat: **4/4 testfiler grønne, 22/22 tests bestået**.

View File

@@ -0,0 +1,36 @@
# Issue #205 — Django i18n foundation (da/en)
## Implemented acceptance checks
- **Django i18n setup for `en` + `da` with `en` fallback**
- `LANGUAGE_CODE` set to `en`.
- `LANGUAGES = [('en', 'English'), ('da', 'Danish')]`.
- `LocaleMiddleware` enabled in middleware chain.
- **Shared-key resolver/adapter (no ad hoc backend mapping)**
- Backend error responses now resolve from shared catalog keys in `shared/i18n/lobby.json`.
- `lobby.i18n.api_error()` accepts a shared key and resolves locale-specific text.
- **Representative API flow documented with key/locale behavior**
- `POST /lobby/join` with empty code returns:
- `error_code: "session_code_required"`
- localized `error`
- resolved `locale`
- **Missing key handling deterministic and loggable**
- `resolve_error_message()` returns key string when key/translation is missing.
- Warning is logged (`lobby.i18n` logger) for missing key/translation.
## Example response behavior
### Request
`POST /lobby/join` with empty code and header `Accept-Language: da`
### Response (400)
```json
{
"error": "Sessionskode er påkrævet",
"error_code": "session_code_required",
"locale": "da"
}
```
If locale is unsupported (e.g. `fr`), response uses `locale: "en"` and English message.

View File

@@ -0,0 +1,35 @@
# Issue #207 Artifact — da/en locale switch + audio routing (primary-only)
## Scope
Acceptance for `[READY][i18n][P19]`:
1. Verify one MVP host+player flow in both `en` and `da` locale context.
2. Verify controlled fallback to `en` when a locale key is missing.
3. Verify audio-routing policy is primary-only (client/player has no audio output).
No new feature behavior added — this is verification-only evidence.
## Smoke/e2e evidence (Angular shell test run)
Executed from `frontend/angular`:
```bash
npm test -- --reporter=dot src/app/lobby-i18n.spec.ts src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts
```
Evidence covered by the run:
- `lobby-i18n.spec.ts`
- subscriber locale switch updates (`en -> da -> en`)
- controlled fallback where `da` key is removed during test and `en` copy is returned
- `clientHasNoAudioOutput === true` (primary-only audio routing)
- `host-shell.component.spec.ts`
- host actions and route sync in SPA (no reload)
- `player-shell.component.spec.ts`
- player session refresh/sync path and retry behavior
## Policy evidence
- Shared catalog capability: `shared/i18n/lobby.json`
- `frontend.capabilities.client_has_no_audio_output = true`
- Angular host/player shells bind this capability:
- `HostShellComponent.clientHasNoAudioOutput`
- `PlayerShellComponent.clientHasNoAudioOutput`
Conclusion: locale switching works in `da`/`en`, fallback resolves to `en` when locale key is intentionally missing, and client audio remains disabled by policy (`primary-only`).

View File

@@ -0,0 +1,20 @@
# Issue #223 — Telefon-klient audio guard (artifact)
## Scope leveret
- Telefon-/player-klient installerer en eksplicit audio guard ved mount (`installSecondaryDeviceAudioGuard`).
- Guard overskriver `HTMLMediaElement.prototype.play` til en no-op på secondary device (client policy: `client_has_no_audio_output=true`).
- Guard fjernes igen ved unmount (`ngOnDestroy`) så øvrige flows/enheder ikke påvirkes.
- Ingen audio-controls er eksponeret i player-shell UI.
## Acceptance mapping
1. **Telefon-klient trigger ikke audio playback i kerneflow**
- Verificeret af test: `player-shell.component.spec.ts` (`installs secondary-device audio guard while player shell is mounted`).
2. **Primær enhed påvirkes ikke negativt**
- Guard er scoped til player-shell lifecycle og restore'r original `play` ved destroy.
3. **Enkel test/verifikation dokumenteret**
- Dokumenteret her + testkørsel nedenfor.
## Testkørsel
- Kommando:
- `cd frontend/angular && npm test -- src/app/features/player/player-shell.component.spec.ts`
- Resultat: bestået (inkl. audio-guard test).

View File

@@ -0,0 +1,55 @@
# Issue #224 — Trunk-sekvens for #175 (A/B/C)
Formål: gøre #175 scheduler-klar som tre små, uafhængige og mergeklare bidder.
## Sekvens
### A) Backend i18n baseline
- Tracking issue: #225
- Scope:
- Backend resolver til locale (da/en)
- Fallback til `en` ved unsupported locale
- Stabil fejlkontrakt i payload (`error_code`, `error`, `locale`)
- Mergebarhed: Kan merges uden frontend-ændringer.
- Acceptance:
- Backend tests dækker `da` + fallback `en`
- Kontraktfelter er stabile i response
### B) Shared key-map + locale-kontrakt
- Tracking issue: #226
- Scope:
- Én shared key-map for lobby/kerneflow
- Locale-kontrakt (tilladte locales, default locale, fallback-regler)
- Dokumentation af naming + ownership
- Mergebarhed: Kan merges uden host/player UI-migrering.
- Acceptance:
- Shared kontrakt findes ét sted
- Begge sider kan importere den
- Docs opdateret med da/en eksempler
### C) Angular host/player integration + hardcoded-text cleanup
- Tracking issue: #227
- Scope:
- Angular host/player kerneflow bruger shared keys
- Hardcoded tekster fjernes i aftalte kernekomponenter
- Sprogskift verificeres i kritiske states
- Mergebarhed: Kan merges selvstændigt når frontend-tests er grønne.
- Acceptance:
- Korrekt i18n-copy i da/en i kerneflow
- Ingen hardcoded kerneflow-tekster tilbage
- Frontend tests/smoke grønne
## PR-grænser (per bid)
- 1 PR pr. bid (A/B/C) mod `main`
- Mål: ~200300 net LOC per PR (ekskl. generated artefakter)
- Undgå cross-layer scope creep
- Review-tid <30 min
## Overordnet acceptance for #224
- A/B/C-sekvens er tydelig med links
- Hver bid er mergebar isoleret
- Scheduler kan assigne direkte uden ekstra afklaring
## Verification (docs-only)
- Verificeret at dokumentet kun beskriver trunk-sekvensen for issue #224 og linker til #225/#226/#227.
- Ingen runtime-kode ændret; der er derfor ikke kørt kode/tests i denne PR.

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,30 @@
# ISSUE-226 — Shared key-map + locale-kontrakt (backend/frontend)
## Source of truth
- Single shared artifact: `shared/i18n/lobby.json`
- Ownership is documented under `contract.ownership` in the same artifact.
## Locale contract
Defined under `contract.locale`:
- default locale: `en`
- supported locales: `en`, `da`
- fallback rule: use default locale when requested locale is unsupported or a key translation is missing.
## Shared backend→frontend key-map
Defined under `contract.backend_to_frontend_error_keys`.
Examples:
- `session_not_found -> session_not_found`
- `session_not_joinable -> join_failed`
- `round_start_invalid_phase -> start_round_failed`
This allows backend error codes to remain stable while frontend copy keys stay UX-oriented.
## da/en example values
From `shared/i18n/lobby.json`:
- `frontend.errors.session_code_required.en = "Session code is required."`
- `frontend.errors.session_code_required.da = "Sessionskoden er påkrævet."`
## Verification
- Backend: `python manage.py test lobby.tests.I18nResolverTests`
- Frontend: `npm test -- --run tests/lobby-i18n.contract.test.ts`

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

@@ -10,11 +10,13 @@ Sikre at release-tags altid repræsenterer faktisk deployet software.
## Release-flow
1. Bekræft architect-gate (`issue #17`) er release-approved.
2. Bekræft tester ikke er aktiv.
3. Deploy kandidat til staging (`infra/staging/deploy_staging.sh`).
4. Verificér `/healthz` + smoke-resultat.
5. Tilføj changelog-entry i `CHANGELOG.md`.
6. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
2. Kør den lokale MVP gate: `./scripts/verify_mvp_release.sh`.
3. Bekræft tester ikke er aktiv.
4. Kør helst `infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]`.
5. Hvis wrapper ikke bruges: deploy med `infra/staging/deploy_staging.sh` og kør derefter `infra/staging/run_mvp_smoke.sh`.
6. Verificér `/healthz` + smoke-resultat.
7. Tilføj changelog-entry i `CHANGELOG.md`.
8. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
## Minimum release-notes template
```markdown

View File

@@ -0,0 +1,69 @@
# SPA visual + realtime smoke artifact
## Purpose
This is the Batch 6 manual evidence lane for the presenter-host and player-phone overhaul. Use it when `USE_SPA_UI=true` and you need reviewable proof that the Angular host/player shells behave correctly across realtime reconnects, role-based visibility, and multi-device presentation.
The automated companion lane for this checklist is:
```bash
npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts
```
## When to capture it
- Staging or local smoke after a host/player visual or realtime change.
- Before asking for SPA cutover confidence beyond unit-level component coverage.
- When reconnect recovery or developer-state safety changed and reviewers need concrete device evidence.
## Evidence template
```markdown
### SPA visual + realtime smoke evidence
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
- Environment: <local/staging>
- Commit/Head SHA: <sha>
- `USE_SPA_UI`: `true`
- Locale: <en/da>
- Devices: projected host + <N> player phones/tabs
#### Setup
- Host route: `/lobby/ui/host`
- Player route: `/lobby/ui/player`
- Session code: <code>
- Participants joined: <list or count>
- Developer-state left OFF by default before evidence capture: <yes/no>
#### Checks (PASS/FAIL)
1. Presenter-only question visibility
- Host lie/presenter scene shows the active prompt: <pass/fail>
- Player phones stay prompt-hidden until the allowed phase payload reveals it: <pass/fail>
2. Reconnect recovery
- Disconnect one player device or throttle network during an active lie/guess input: <pass/fail>
- Reconnect badge/card appears without clearing the local draft/selection: <pass/fail>
- Recovered websocket push resumes before the 3s polling fallback becomes the steady-state transport: <pass/fail>
3. Multi-device reveal + scoreboard
- At least 3 player devices reach reveal and scoreboard together: <pass/fail>
- Host projected scene remains presenter-grade through reveal and final standings: <pass/fail>
- Shared player identity tokens/colors/icons stay consistent between the projected host roster and player-phone developer-state snapshots: <pass/fail>
4. Developer-state safety
- Host developer-state screenshot or recording captured separately from the default presenter screen: <pass/fail>
- Player developer-state screenshot or recording captured separately from the default phone UI: <pass/fail>
- Host/player developer-state captures show the current `phase_display.theme` and `phase_display.ornament` tokens plus each player `identity.token` and `identity.icon` so contract-driven scene art/copy and roster styling can be traced back to the payload: <pass/fail>
- At least one lie/guess/reveal capture shows an authored question ornament slug from admin/bootstrap content instead of only the deterministic fallback set: <pass/fail>
5. Optional host voice cue check
- Host-only voice playback still routes on the primary device when enabled: <pass/fail/not-run>
#### Artifact pointers
- Automated smoke command: `npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts`
- Screenshot/video refs:
- host projected scene: <ref>
- reconnect recovery: <ref>
- reveal/scoreboard multi-device: <ref>
- host/player developer-state: <ref>
- Result: <PASS/FAIL>
- If FAIL: blocker link + shortest repro
```
## Minimum acceptable artifact
- One projected host screenshot during lie, reveal, or scoreboard.
- One player-device capture showing reconnect recovery or input preservation.
- One reveal or scoreboard capture with 3+ player devices.
- Separate host and player developer-state captures so diagnostics stay out of the default presentation.

View File

@@ -0,0 +1,65 @@
# Staging gameplay smoke artifact (Issue #144)
Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke i #16/#90-sporet uden scope-udvidelse.
## Guardrails (MVP)
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
- Kun verifikation af eksisterende flow; ingen nye features/polish.
- Primær MVP release-gate bruger legacy UI med `USE_SPA_UI=false`.
## Hvornår bruges artifacten
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
- Resultatet logges i issue/PR-kommentar med denne skabelon.
## Evidence template (kopiér i PR/issue-kommentar)
```markdown
### Staging gameplay smoke evidence
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
- Environment: staging
- Commit/Head SHA: <sha>
- Linked scope: #16 #17 #90 #129 #144
#### Setup
- Host authenticated in Django admin: <yes/no>
- Active category/questions present: <yes/no>
- Participants: host + <N> players
- `USE_SPA_UI`: `false`
- `WPP_SPA_ASSET_VERSION`: <release-token/sha>
- UI routes used: `/lobby/ui/host` + `/lobby/ui/player`
#### Checks (PASS/FAIL)
1. Legacy route sanity
- Host/player legacy templates svarer korrekt: <pass/fail>
2. Lobby -> join -> start
- Mixed-case + whitespace session code accepted: <pass/fail>
3. One full round to scoreboard
- submit lie -> mix -> submit guess -> calculate score -> show scoreboard: <pass/fail>
4. Next-round + game-end sanity
- next round transitions: <pass/fail>
- final leaderboard visible: <pass/fail>
#### MVP smoke-gate decision
- Gate status: <GREEN/RED>
- Gate criteria met:
- [ ] Legacy route sanity = PASS
- [ ] Full gameplay round = PASS
- [ ] Next-round/final leaderboard sanity = PASS
- [ ] Ingen nye blocker-regressioner i host/player flow
#### Rollback checkpoint
- Rollback required: <yes/no>
- Trigger reason (if yes): <kort trigger>
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no/not-needed>
#### Evidence pointers
- Command(s): `./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]` or `./infra/staging/run_mvp_smoke.sh [artifact-path]`
- UI notes/screenshots/log refs: <short refs>
- Result: <PASS/FAIL>
- If FAIL: blocker issue link + shortest repro
```
## Anti-stall minimum
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.
## SPA cutover note
Hvis der køres separat SPA-cutover, dokumenteres det i et særskilt artifact med henvisning til `docs/spa-cutover-flag.md`. Brug i så fald `ALLOW_SPA_CUTOVER=1` eksplicit ved staging smoke.

View File

@@ -1,18 +1,35 @@
# UI smoke (MVP)
## Forudsætning
- Host er logget ind i Django.
- Mindst én aktiv kategori med spørgsmål findes.
## MVP path
- Current MVP path: `USE_SPA_UI=false`
- Canonical routes: `/lobby/ui/host` + `/lobby/ui/player`
- SPA shell verification is follow-up cutover work; keep it out of the primary MVP smoke.
## Preconditions
- Host can log in through Django.
- At least one active category with questions exists.
- Recommended local bootstrap: `python manage.py bootstrap_mvp`
- Fastest local setup: `./scripts/run_local_mvp_smoke.sh` and then keep the stack running for browser follow-up.
## Flow
1. Åbn host-siden på /lobby/ui/host og tryk Opret session.
2. Åbn player-siden i 3 faner/enheder på /lobby/ui/player.
3. Join alle spillere med sessionkode og nickname.
4. Host: vælg kategori, Start runde, Vis spørgsmål.
5. Spillere: brug round_question_id og submit løgn.
6. Host: Mix svar.
7. Spillere: submit gæt.
8. Host: Beregn score og Vis scoreboard.
9. Host: Næste runde eller Afslut spil.
1. Confirm `USE_SPA_UI=false`.
2. Open `/lobby/ui/host` and create a session.
3. Open `/lobby/ui/player` in 3 tabs or devices.
4. Join all players with the session code and nicknames.
5. Host selects a category, starts the round, and shows the question.
6. Players submit lies.
7. Host mixes answers.
8. Players submit guesses.
9. Host calculates scores and opens the scoreboard.
10. Host starts the next round or finishes the game.
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.
## Pass criteria
- One full round reaches scoreboard without raw API calls.
- Error banners are absent in the host/player core flow.
- Session detail reflects the same phase on both screens.
- Finish-game path shows the final leaderboard.
## Cutover note
If SPA shell validation is needed, use `docs/spa-cutover-flag.md` and `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md`. Those checks are not the primary MVP smoke gate.
For the presenter/player visual lane specifically, capture the manual evidence in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`.
For local SPA-only checks with the compose `spa` profile, start at `http://localhost:4200/`.

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

34
docs/i18n-drift-check.md Normal file
View File

@@ -0,0 +1,34 @@
# i18n key manifest + drift check
Issue: #240
This repo keeps shared lobby keyspaces in two files:
- Contract source: `shared/i18n/lobby.json`
- Key manifest: `shared/i18n/key-manifest.json`
The manifest is intentionally small and explicit. It lists:
- Supported locales (`locales`)
- Frontend error key set (`frontend_error_keys`)
- Backend error code set (`backend_error_codes`)
- Backend translation key set (`backend_error_keys`)
- Optional contract-only aliases (`allowed_contract_only_backend_codes`)
## Local check
Run the read-only drift checker from repo root:
```bash
python3 scripts/check_i18n_drift.py
```
The script returns non-zero when it detects drift, including:
- key set mismatch between manifest and shared catalog
- missing backend→frontend mapping coverage
- mapping to unknown frontend keys
- mappings for unknown backend codes
- missing/empty locale translations (`en`/`da`)
No CI gating changes are included in this task; this is a local guardrail.

69
docs/i18n-keymap.md Normal file
View File

@@ -0,0 +1,69 @@
# i18n key-map bootstrap (Angular host/player MVP)
Issue: #220
Scope: Lobby → Join → Start round → Round → Reveal → Scoreboard
Locales: `en`, `da`
This document is the gameplay key-namespace map for Angular host/player MVP.
It maps existing text keys only (no feature expansion) and stays aligned with `shared/i18n/lobby.json`.
## Key families
- `host``frontend.ui.host.*` host-facing gameplay actions and status text.
- `player``frontend.ui.player.*` player-facing gameplay actions and status text.
- `system` — shared UI labels used across host/player views (implemented under `frontend.ui.common.*` in `shared/i18n/lobby.json`).
- `errors` — user-facing error keys shown by frontend (`frontend.errors.*`) plus backend code → frontend key bridge via `backend.error_codes.*` / `contract.backend_to_frontend_error_keys.*`.
## Gameplay flow key map
| Flow step | Family | Key | en | da |
|---|---|---|---|---|
| Lobby | `host` | `host.title` | Host gameplay flow | Vært gameplay-flow |
| Lobby | `player` | `player.title` | Player gameplay flow | Spiller gameplay-flow |
| Lobby | `system` (`frontend.ui.common`) | `common.session_code` | Session code | Sessionskode |
| Lobby | `player` | `player.nickname` | Nickname | Kaldenavn |
| Join | `player` | `player.join` | Join | Join |
| Start round | `host` | `host.start_round` | Start round | Start runde |
| Round | `host` | `host.show_question` | Show question | Vis spørgsmål |
| Round | `player` | `player.lie_label` | Lie | Løgn |
| Round | `player` | `player.submit_lie` | Submit lie | Send løgn |
| Round | `player` | `player.submit_guess` | Submit guess | Send gæt |
| Reveal | `host` | `host.mix_answers` | Mix answers → guess | Bland svar → gæt |
| Reveal | `host` | `host.calculate_scores` | Calculate scores → reveal | Udregn score → afslør |
| Scoreboard | `host` | `host.load_scoreboard` | Load scoreboard | Hent scoreboard |
| Scoreboard | `host` | `host.final_leaderboard` | Final leaderboard | Finale leaderboard |
| Scoreboard | `player` | `player.final_leaderboard` | Final leaderboard | Finale leaderboard |
| Scoreboard | `system` (`frontend.ui.common`) | `common.points_short` | pts | point |
## Frontend error keys used in flow scope
| Error family | Key | en | da |
|---|---|---|---|
| Join | `frontend.errors.session_code_required` | Session code is required. | Sessionskoden er påkrævet. |
| Join | `frontend.errors.session_not_found` | Session code is invalid or the session no longer exists. | Sessionskoden er ugyldig, eller sessionen findes ikke længere. |
| Join | `frontend.errors.nickname_invalid` | Nickname must be between 2 and 40 characters. | Kaldenavn skal være mellem 2 og 40 tegn. |
| Join | `frontend.errors.nickname_taken` | Nickname is already taken. | Kaldenavnet er allerede taget. |
| Join | `frontend.errors.join_failed` | Join failed. Check code or nickname and try again. | Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen. |
| Start round | `frontend.errors.start_round_failed` | Could not start round. Refresh the lobby and try again. | Kunne ikke starte runden. Opdater lobbyen og prøv igen. |
| Any | `frontend.errors.unknown` | Action failed. Refresh status and try again. | Handlingen fejlede. Opdater status og prøv igen. |
## Backend→frontend mapping for gameplay errors
Mapped in `contract.backend_to_frontend_error_keys` (source: `shared/i18n/lobby.json`):
> Note: `host_only_action` is **not** part of the current shared contract mapping and is intentionally not listed here.
- `session_code_required``session_code_required`
- `nickname_invalid``nickname_invalid`
- `session_not_found``session_not_found`
- `session_not_joinable``join_failed`
- `nickname_taken``nickname_taken`
- `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`
## Notes
- This is a bootstrap key-map doc for MVP mergeability.
- The key/value source of truth remains `shared/i18n/lobby.json`.

View File

@@ -0,0 +1,28 @@
# Issue #180 SPA gameplay flow evidence
## Flow log (host + player, no page reload)
1. Host opens SPA host shell and loads scoreboard (`GET /lobby/sessions/{code}/scoreboard`).
2. Host starts next round (`POST /lobby/sessions/{code}/rounds/next`).
3. Host shell refreshes session state in-place (`GET /lobby/sessions/{code}`) and clears old scoreboard/final leaderboard payloads.
4. Player shell performs periodic session refresh while online (3s cadence) and transitions from `scoreboard` to `lobby` without page reload.
5. Host finishes game (`POST /lobby/sessions/{code}/finish`) and renders final leaderboard directly in SPA shell.
6. Player shell reads `finished` state and renders final leaderboard in SPA (sorted by score).
7. Error/retry paths available:
- Host: next-round and finish-game retry buttons with explicit error feedback.
- Player: reconnect + submit retry feedback.
## Test output snapshot
Command:
```bash
cd frontend/angular
npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts
```
Result:
- `host-shell.component.spec.ts`: 6 passed
- `player-shell.component.spec.ts`: 7 passed
- Total: 13 passed, 0 failed

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

@@ -0,0 +1,193 @@
# Host + Player Visual Overhaul Plan
**Date:** 2026-03-18
**Updated:** 2026-03-23
**Status:** Active
## Goal
- make the SPA host shell usable as the projected primary game screen
- make the SPA player shell a minimal mobile action surface instead of a secondary dashboard
- make phase changes feel shared across devices, without manual refresh in the normal path
- keep explicit developer-state available for host and player without contaminating the default presentation
## Product Decisions From Playtesting
- the authenticated host may still create the session and choose the game, but the first player to join becomes the lobby captain on a phone
- the lobby captain is the default pre-game operator on the player side:
- can start the game
- can confirm readiness
- can choose or rotate a player icon from a curated set when that is supported by the cartridge
- non-captain player devices should stay intentionally simple before game start:
- icon selection if the game supports it
- otherwise a passive "wait for the game to start" state
- once the game has started, player devices should only show the current player action:
- text input
- buttons
- draw area
- hidden private information when a cartridge needs it
- player devices should not show roster, session metadata, or broad game-state detail in the default UX
- pushed updates need to land across clients in the same phase-change window; perfect instant sync is not required, but manual update should not be part of the happy path
## Current State
- role-aware session-detail payloads already exist for host/player/public viewers
- websocket transport and reconnect fallback already exist
- host/player developer-state toggles already exist
- presenter/player visual styling, question ornaments, and host voice playback already exist
- the remaining gaps are mostly product-shape gaps:
- lobby captain flow is not yet the default
- the default player shell still exposes too much context
- synchronized multi-device updates are not yet strong enough for confidence
- authored player identity assets and presenter-copy content are still incomplete
## Non-Goals
- solving every future cartridge in this lane
- custom user-uploaded avatars
- full spectator mode
- native mobile apps
- replacing the existing REST contract wholesale
## Known Issues To Solve
- a player phone cannot yet reliably act as the pre-game "start game" controller
- some clients still feel stale until a manual update path is used
- the default player UI still behaves too much like a diagnostic shell
- the plan file itself previously drifted into websocket-only scope; this document is now the authoritative visual-overhaul plan again
## Verification
- `.venv/bin/python manage.py check`
- `.venv/bin/python manage.py test realtime.tests lobby.tests.SessionDetailPhaseViewModelTests fupogfakta.tests.FupOgFaktaExtractionSliceTests`
- `npm --prefix frontend test -- angular-api-client.test.ts`
- `npm --prefix frontend/angular test -- src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts src/app/realtime-visual-smoke.spec.ts`
- `npm --prefix frontend/angular run build`
- local or staging multi-device smoke captured with `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`
## Batch 1 — Lobby Captain Flow
- add an explicit lobby-captain concept to the session contract
- the first player to join becomes the default captain
- expose captain capability to the player shell:
- start game
- ready/continue controls where appropriate
- pre-game icon selection when supported
- keep a clear fallback/override story for dev and staging:
- authenticated host can still inspect or recover the flow
- developer-state shows who the current captain is and why
- update lobby and gameplay rules so "who can start" is deterministic and testable
**Acceptance:** in the normal couch + TV flow, the first joined player can start the game from a phone without needing the projected host screen as an operator console.
## Batch 2 — Realtime Coherence
- treat websocket push as the primary phase-change transport
- make every significant phase transition emit enough information for all connected clients to converge without user action
- add or tighten revision/phase markers so stale refreshes do not leave some devices behind
- keep polling only as fallback and recovery
- if realtime recovery takes longer than a short threshold, show a clear reconnect notice instead of silently leaving stale UI on screen
- extend tests to cover one host plus three player clients moving through the same phase transition window
**Acceptance:** phase changes propagate across host and player clients together closely enough that manual update is not required in the happy path.
## Batch 3 — Simplified Player Phone UX
- reduce the default player shell to one main action surface per phase
- pre-game states:
- captain device: start game + identity/icon choice if supported
- non-captain devices: icon choice or passive wait state
- in-game states:
- input-only or choice-only when action is required
- simple waiting state when no action is required
- private hidden information panel when a cartridge needs it
- remove roster/session/debug context from the default player presentation
- keep developer-state behind the existing explicit toggle/query override
- preserve draft text, selections, and focus during background sync and reconnect
**Acceptance:** a player can glance at the phone and immediately know what to do, without seeing unnecessary room/session detail.
## Batch 4 — Presenter Host Experience
- keep the host screen presenter-first:
- lobby scene
- question/lie scene
- guess scene
- reveal scene
- scoreboard scene
- final result scene
- show the question prominently on the projected host even when players only get input controls
- display player icons/colors consistently across all presenter beats
- keep operational controls in a secondary backstage layer instead of the default projected surface
- integrate voice cues and uploaded audio into the presenter rhythm
- make the default lobby scene support the new player-captain model:
- projected host shows readiness and roster
- captain phone owns the start action
**Acceptance:** the host screen can stay on the TV as the main visual surface without asking the projected operator to click through the game.
## Batch 5 — Content, Assets, and Cartridge Hooks
- add content/admin support for curated player icon sets or avatar-like identity options
- keep authored question ornaments and extend them where useful
- add optional phase-specific presenter copy that can be authored instead of only inferred from shared i18n keys
- define one generic private-info contract for cartridges that need hidden player instructions or roles
- keep the fallback story explicit:
- deterministic icons and copy still work when authored assets are absent
**Acceptance:** the visual lane is not blocked on hardcoded placeholder assets, and future cartridges have a place to attach hidden player info without bloating the default phone UX.
## Batch 6 — Smoke Coverage and Sign-Off
- extend automated smoke for:
- first-player captain start
- websocket phase propagation across host + 3 player clients
- no-input-loss during reconnect
- presenter-only question visibility
- hidden player info routing where applicable
- developer-state visibility and safety gates
- extend the manual artifact checklist so it proves:
- projected host during lobby, reveal, and scoreboard
- captain phone start flow
- at least 3 player devices
- reconnect recovery without manual refresh
- separate host/player developer-state captures
**Acceptance:** the overhaul is demonstrable in a realistic living-room flow, not just in isolated component tests.
## Definition of Done
This lane is complete when:
- the first joined player can start the game from a phone in the normal flow
- non-captain phones stay intentionally minimal before game start
- player phones show only the current action or private hidden info by default
- websocket state sync is the primary update path and manual update is not required in the happy path
- the projected host screen is usable as a real presenter surface
- backend contracts remain role-correct
- host/player visuals share one coherent visual system
- regression tests cover realtime, visibility, and input preservation
- host and player both provide an explicit developer-state that is useful in dev/staging and hidden by default
- the multi-device artifact in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md` has been captured for the current flow
## Recommended Order
1. Batch 1: lobby captain flow.
2. Batch 2: realtime coherence.
3. Batch 3: simplified player phone UX.
4. Batch 4: presenter host flow adjustments for the captain model.
5. Batch 5: authored assets and cartridge hooks.
6. Batch 6: smoke coverage and sign-off.
## Ralph Loop Exit Rule
- this plan should only be marked `Completed` when the Definition of Done is satisfied
- narrower websocket or sub-feature slices should be tracked as check-ins under this plan or in separate plan files, not by rewriting this file into a different plan
## Explicitly Deferred
- custom uploaded user avatars
- advanced moderation/admin tooling
- spectator mode
- native mobile clients
- multi-cartridge theming beyond the shared shell and contract hooks above

View File

@@ -0,0 +1,137 @@
# MVP Plan — Deployable and Testable Repository
**Date:** 2026-03-18
**Status:** In progress
## Goal
Get the repository into a state where one person can deploy it to staging or a local VM, run one documented setup flow, and verify one full playable round through the intended UI without ad hoc fixes.
For this plan, **deployable and testable** means:
- backend boots with documented env vars
- frontend assets build successfully
- one UI path is designated as the MVP path
- a development `docker compose` setup exists for the app and its required services
- CI covers the checks needed to trust a release candidate
- a smoke flow proves host + 3 players can complete one full round
## Current Baseline
Verified on 2026-03-18:
- `manage.py check` passes
- `manage.py test lobby fupogfakta` passes
- `npm --prefix frontend test` passes
- `npm --prefix frontend run build` passes
- `npm --prefix frontend/angular test` passes
- `npm --prefix frontend/angular run build` **fails**
Main blockers observed:
1. Angular production build fails on strict template nullability in host/player reveal panels.
2. `shared/i18n/lobby.json` contains duplicate keys and produces build warnings.
3. The repo still has two UI modes (`USE_SPA_UI` ON/OFF), so the MVP “real path” is ambiguous.
4. CI does not yet enforce the full MVP gate.
## Progress Snapshot
Implemented on 2026-03-18:
- legacy UI (`USE_SPA_UI=false`) is the explicit MVP path
- Angular build and test pipeline passes
- duplicate shared i18n keys were removed and drift is checked
- development `docker compose` exists for Django + MySQL + Redis
- local deterministic bootstrap is available via `python manage.py bootstrap_mvp`
- local release verification is available via `./scripts/verify_mvp_release.sh`
- staging deploy + smoke wrappers exist via `infra/staging/deploy_and_smoke_staging.sh`
Remaining MVP sign-off item:
- run the real staging deploy + smoke flow and record the resulting artifact
## Plan
### Batch 1 — Make the chosen UI path buildable
- Decide the MVP runtime path:
- Current choice: legacy UI (`USE_SPA_UI=false`)
- SPA remains a follow-up cutover path after the MVP release gate is stable
- Fix Angular template errors in `frontend/angular/src/app/features/host/host-shell.component.ts` and `frontend/angular/src/app/features/player/player-shell.component.ts`.
- Remove duplicate i18n keys in `shared/i18n/lobby.json`.
- Require these checks to pass locally:
- `npm --prefix frontend/angular test`
- `npm --prefix frontend/angular run build`
### Batch 2 — Make local/staging setup deterministic
- Add one documented bootstrap path for:
- backend venv install
- database migrate
- host user creation
- sample category/question seed
- Create a development `docker compose` file that can start the services needed for local work:
- Django app
- MySQL or the chosen dev database
- Redis for Channels
- optional frontend dev server if the team wants one-command startup
- Ensure the compose setup matches the documented env var contract and supports the chosen MVP UI path.
- Keep SQLite acceptable for local try-out; keep MySQL/Redis for staging.
- Document the exact env vars and `USE_SPA_UI` setting required for the MVP path.
### Batch 3 — Make smoke verification release-grade
- Treat one canonical smoke as required:
- create session
- 3 players join
- start round
- submit lies
- submit guesses
- reveal
- scoreboard
- next round or finish
- Keep `python manage.py smoke_staging --artifact <path>` as the canonical backend smoke entrypoint.
- Provide one staging wrapper command that chains deploy + MVP smoke for release candidates.
- Add one short manual UI smoke checklist for the chosen MVP path only.
### Batch 4 — Align CI with MVP readiness
- Expand CI to run:
- `python manage.py check`
- `python manage.py test lobby fupogfakta`
- `npm --prefix frontend test`
- `npm --prefix frontend run build`
- `npm --prefix frontend/angular test`
- `npm --prefix frontend/angular run build`
- Update lint scope so it no longer only checks `lobby/`.
- Provide one local wrapper command for the same MVP gate before staging deploy.
### Batch 5 — Freeze the MVP boundary
- Declare which items are required for MVP and which are explicitly deferred.
- Defer non-blockers:
- broader game-driver redesign
- extra cartridges
- polished host presentation UX
- voice integration
- post-game awards or richer reactions
## Definition of Done
The repo is MVP-deployable and testable when all of the following are true:
- one UI path is explicitly marked as the MVP path
- Angular production build passes for that path
- a development `docker compose` setup can bring up the required local dependencies
- staging deploy docs match the code and env flags
- CI enforces the same checks used for release sign-off
- one full smoke round passes through host/player UI and is captured as evidence
## Recommended Order
1. Fix Angular build blockers and i18n duplication.
2. Choose SPA ON or legacy as the single MVP runtime path.
3. Add the development `docker compose` setup.
4. Write the bootstrap/setup doc and seed flow.
5. Expand CI to the full MVP gate.
6. Re-run staging smoke and record the artifact.

101
docs/spa-cutover-flag.md Normal file
View File

@@ -0,0 +1,101 @@
# SPA cutover feature flag (`USE_SPA_UI`)
## Formål
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
Aktuel MVP release-gate bruger `USE_SPA_UI=false`. Denne note beskriver separat cutover-arbejde, ikke den primære MVP deploy-path.
## Miljø-toggle (uden kodeændring)
Sæt env var pr. miljø:
- `USE_SPA_UI=true` -> `/lobby/ui/host` og `/lobby/ui/player` returnerer SPA shell
- `USE_SPA_UI=false` (default) -> legacy template-flow bruges uændret
Backward compatibility under cutover:
- Hvis `USE_SPA_UI` ikke er sat, bruges `WPP_SPA_ENABLED` som fallback.
## Static asset versioning/cache-busting (hardening)
Formål: sikre at browser/proxy/CDN hurtigt henter ny SPA bundle i release-vinduet uden at kræve hard refresh.
- `WPP_SPA_ASSET_BASE` peger fortsat på build-output (`/static/frontend/angular/browser`).
- `WPP_SPA_ASSET_VERSION` injiceres i SPA shell URLs som query-param (`?v=<version>`).
- Anbefalet værdi for `WPP_SPA_ASSET_VERSION`: release-tag eller kort commit SHA.
- Ved rollback sættes `WPP_SPA_ASSET_VERSION` til den tidligere kendte stabile release-værdi.
Eksempel (staging/prod env):
```env
USE_SPA_UI=true
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=rel-2026-03-01-bb82357
```
## Staging rollout-checkliste (`USE_SPA_UI`)
1. **Baseline (flag OFF)**
- Bekræft at staging kører med `USE_SPA_UI=false`.
- Kør gameplay smoke på legacy routes (`/lobby/ui/host` + `/lobby/ui/player`).
2. **Smoke-gate før aktivering (skal være grøn)**
- Cutover route sanity = PASS for både OFF og ON checks.
- Full gameplay round (join/start/round/scoreboard) = PASS.
- Next-round/final leaderboard sanity = PASS.
- Ingen nye blocker-regressioner i host/player kerneflow.
3. **Kontrolleret aktivering i staging**
- Sæt `USE_SPA_UI=true` i staging miljøet.
- Kør smoke-flow igen med SPA-route-verifikation.
4. **Post-cutover dokumentation**
- Log evidens med commit/head SHA, UTC timestamp og gate-status.
## Rollback playbook (`USE_SPA_UI`) — mål: <10 min
Rollback til legacy (`USE_SPA_UI=false`) udføres straks hvis et checkpoint fejler:
- Forkert route/shell for valgt flag i cutover route sanity.
- Gameplay smoke kan ikke gennemføres til scoreboard/final leaderboard.
- Kritisk regression i host/player flow under smoke.
Trin-for-trin:
1. Sæt `USE_SPA_UI=false` i deploy-env.
2. Sæt `WPP_SPA_ASSET_VERSION` til sidste stabile release-token.
3. Deploy/reload app-processer.
4. Verificér legacy routes: `/lobby/ui/host` + `/lobby/ui/player`.
5. Kør hurtig smoke sanity (join/start/scoreboard path).
6. Log UTC tid, trigger, release-token og resultat i smoke artifact.
Target: rollback + sanity-verifikation inden for 10 minutter.
## React fallback trigger-kriterier (kun delivery-blocking)
Formål: React fallback må kun bruges som kortvarig leverings-sikring, når release ellers er blokeret.
### Hvornår fallback er tilladt
Alle punkter skal være opfyldt:
1. **Delivery-blocking fejl i Angular SPA**
- Host/player kerneflow kan ikke leveres i release-vinduet (fx login/join/start/round/scoreboard stopper).
2. **Ingen hurtig Angular-fix inden for release-vinduet**
- Teamet har vurderet at patch + verificering ikke kan nås sikkert i tide.
3. **Rollback alene løser ikke leveringsbehovet**
- `USE_SPA_UI=false` (legacy) er enten utilstrækkelig for den konkrete leverance eller allerede verificeret som ikke tilstrækkelig.
4. **Beslutning er eksplicit logget**
- Trigger, impact, UTC-tid, ansvarlig, issue/incident-reference og plan for tilbagevenden til Angular er dokumenteret i release/smoke artifact.
### Scope-limits for fallback
- Fallback omfatter kun **delivery-blocking host/player-paths**.
- Ingen nye features, UX-forbedringer eller ikke-kritiske ændringer må bundtes ind i fallback.
- Fallback er **midlertidig** og gælder kun for aktiv incident/release-vindue.
- Når blocker er fjernet, skal miljøet tilbage på standard cutover-spor (Angular + `USE_SPA_UI` styring).
### Ikke tilladt
- Proaktiv fallback "for en sikkerheds skyld" uden aktiv blocker.
- Brug af fallback til at omgå normale kvalitetsgates eller testkrav.
- Langvarig drift i fallback-mode uden dokumenteret blocker og opfølgningsplan.
## Verifikation
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
- 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`
- 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`.

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# Frontend API client baseline
Dette er baseline-klientlaget for SPA-sporet.
## Kører checks lokalt
```bash
cd frontend
npm install
npm test
npm run build
```

3
frontend/angular/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.angular/

View File

@@ -0,0 +1,36 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"projects": {
"wpp-angular-shell": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/wpp-angular-shell",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"],
"outputHashing": "none"
}
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "wpp-angular-shell:build"
}
}
}
}
},
"cli": {
"analytics": false
}
}

8577
frontend/angular/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "wpp-angular-shell",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "ng serve",
"build": "ng build",
"test": "vitest run"
},
"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": {
"@angular/build": "^19.2.0",
"@angular/cli": "^19.2.0",
"@angular/compiler-cli": "^19.2.0",
"typescript": "~5.7.2",
"vitest": "^2.1.9"
}
}

View File

@@ -0,0 +1,347 @@
import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../../../src/api/angular-client';
describe('SPA Angular API contract smoke (host/player foundation)', () => {
it('smoke-checks canonical host/player endpoint contracts', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, is_connected: 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: 'lobby',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
if (url === '/lobby/sessions/ABCD12?session_token=session-token-1') {
return {
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 2 },
viewer_role: 'player',
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: null,
shown_at: '2026-03-01T18:00:00Z',
answers: []
},
reveal: null,
voice_cues: {
default_locale: 'en',
intro: null,
phase: null,
question_prompt: null,
question_reveal: null
},
phase_view_model: {
status: 'lie',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: true,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: true,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
if (url === '/lobby/sessions/ABCD12/scoreboard') {
return {
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
]
} 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/join') {
expect(body).toEqual({ code: 'ABCD12', nickname: 'Maja' });
return {
player: { id: 9, nickname: 'Maja', session_token: 'session-token-1', score: 0 },
session: { code: 'ABCD12', status: 'lobby' }
} as T;
}
if (url === '/lobby/sessions/create') {
expect(body).toEqual({});
return {
session: { code: 'H0ST42', status: 'lobby', host_id: 1, current_round: 1 }
} as T;
}
if (url === '/lobby/sessions/ABCD12/rounds/start') {
expect(body).toEqual({ category_slug: 'history' });
return {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/show') {
expect(body).toEqual({});
return {
round_question: {
id: 77,
prompt: 'Q?',
round_number: 1,
shown_at: '2026-03-01T18:00:00Z',
lie_deadline_at: '2026-03-01T18:00:30Z'
},
config: { lie_seconds: 30 }
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/answers/mix') {
expect(body).toEqual({});
return {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, round_number: 1 },
answers: [{ text: 'A' }, { text: 'B' }]
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
expect(body).toEqual({});
return {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 2,
reveal: {
round_question_id: 77,
correct_answer: 'A',
lies: [],
guesses: []
},
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
]
} as T;
}
if (url === '/lobby/sessions/ABCD12/rounds/next') {
expect(body).toEqual({});
return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T;
}
if (url === '/lobby/sessions/ABCD12/finish') {
expect(body).toEqual({});
return {
session: { code: 'ABCD12', status: 'finished', current_round: 2 },
winner: { id: 9, nickname: 'Maja', score: 250 },
leaderboard: [
{ id: 9, nickname: 'Maja', score: 250 },
{ id: 10, nickname: 'Bo', score: 150 }
]
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/lies/submit') {
expect(body).toEqual({ player_id: 9, session_token: 'session-token-1', text: 'my lie' });
return {
lie: {
id: 501,
player_id: 9,
round_question_id: 77,
text: 'my lie',
created_at: '2026-03-01T18:00:05Z'
},
window: { lie_deadline_at: '2026-03-01T18:00:30Z' }
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') {
expect(body).toEqual({ player_id: 9, session_token: 'session-token-1', selected_text: 'B' });
return {
guess: {
id: 601,
player_id: 9,
round_question_id: 77,
selected_text: 'B',
is_correct: false,
fooled_player_id: null,
created_at: '2026-03-01T18:00:15Z'
},
window: { guess_deadline_at: '2026-03-01T18:01:00Z' }
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const client = createAngularApiClient({ get, post } as AngularHttpClientLike);
const session = await client.getSession(' abcd12 ');
const playerSession = await client.getSession(' abcd12 ', { session_token: 'session-token-1' });
expect(session.ok).toBe(true);
expect(playerSession.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(false);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(false);
expect(session.data.reveal?.correct_answer).toBe('A');
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
}
if (playerSession.ok) {
expect(playerSession.data.viewer_role).toBe('player');
expect(playerSession.data.round_question?.prompt).toBeNull();
expect(playerSession.data.voice_cues?.question_prompt).toBeNull();
}
expect((await client.createSession()).ok).toBe(true);
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
expect((await client.startRound(' abcd12 ', { category_slug: 'history' })).ok).toBe(true);
expect((await client.showQuestion(' abcd12 ')).ok).toBe(true);
expect((await client.mixAnswers(' abcd12 ', 77)).ok).toBe(true);
expect((await client.calculateScores(' abcd12 ', 77)).ok).toBe(true);
expect((await client.getScoreboard(' abcd12 ')).ok).toBe(true);
expect((await client.startNextRound(' abcd12 ')).ok).toBe(true);
expect((await client.finishGame(' abcd12 ')).ok).toBe(true);
expect(
(
await client.submitLie(' abcd12 ', 77, {
player_id: 9,
session_token: 'session-token-1',
text: 'my lie'
})
).ok
).toBe(true);
expect(
(
await client.submitGuess(' abcd12 ', 77, {
player_id: 9,
session_token: 'session-token-1',
selected_text: 'B'
})
).ok
).toBe(true);
expect(get).toHaveBeenNthCalledWith(1, '/lobby/sessions/ABCD12', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12?session_token=session-token-1', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/create',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/join',
{ code: 'ABCD12', nickname: 'Maja' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
3,
'/lobby/sessions/ABCD12/rounds/start',
{ category_slug: 'history' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(4, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
5,
'/lobby/sessions/ABCD12/questions/77/answers/mix',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
6,
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(8, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
9,
'/lobby/sessions/ABCD12/questions/77/lies/submit',
{ player_id: 9, session_token: 'session-token-1', text: 'my lie' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
10,
'/lobby/sessions/ABCD12/questions/77/guesses/submit',
{ player_id: 9, session_token: 'session-token-1', selected_text: 'B' },
{ withCredentials: true }
);
});
});

View File

@@ -0,0 +1,86 @@
.shell {
margin: 0 auto;
max-width: 84rem;
min-height: 100vh;
padding: 1.25rem;
}
.shell__header {
display: grid;
gap: 1rem;
padding: 1rem 1.1rem;
border: 1px solid var(--wpp-border);
border-radius: var(--wpp-radius-xl);
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.5), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(245, 249, 250, 0.94));
box-shadow: var(--wpp-shadow-soft);
backdrop-filter: blur(10px);
}
.shell__brand {
display: grid;
gap: 0.3rem;
}
.shell__brand h1 {
margin: 0;
font-family: var(--wpp-font-display);
font-size: clamp(1.8rem, 3vw, 2.6rem);
line-height: 0.95;
}
.shell__brand p {
margin: 0;
color: var(--wpp-ink-muted);
}
.shell__nav-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.9rem;
align-items: center;
}
.shell__header nav {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.shell__header nav a {
display: inline-flex;
align-items: center;
padding: 0.58rem 0.86rem;
border-radius: var(--wpp-radius-pill);
color: var(--wpp-accent);
text-decoration: none;
font-weight: 800;
background: var(--wpp-accent-soft);
}
.shell__content {
margin-top: 1.1rem;
}
.locale-picker {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-size: 0.95rem;
color: var(--wpp-ink);
}
.locale-picker select {
border: 1px solid var(--wpp-border);
border-radius: var(--wpp-radius-pill);
padding: 0.46rem 0.8rem;
background: rgba(255, 255, 255, 0.78);
}
@media (max-width: 720px) {
.shell {
padding: 0.85rem;
}
}

View File

@@ -0,0 +1,27 @@
<main class="shell">
<header class="shell__header">
<div class="shell__brand">
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
<h1>{{ copy('app.title') }}</h1>
<p>{{ copy('app.home_intro') }}</p>
</div>
<div class="shell__nav-row">
<nav>
<a routerLink="/">{{ copy('app.home_nav') }}</a>
<a routerLink="/host">{{ copy('app.host_nav') }}</a>
<a routerLink="/player">{{ copy('app.player_nav') }}</a>
</nav>
<label class="locale-picker">
{{ copy('app.language_label') }}
<select [ngModel]="locale" (ngModelChange)="setLocale($event)">
<option value="en">English</option>
<option value="da">Dansk</option>
</select>
</label>
</div>
</header>
<section class="shell__content" [attr.data-wpp-locale]="locale">
<router-outlet></router-outlet>
</section>
</main>

View File

@@ -0,0 +1,33 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink, RouterOutlet } from '@angular/router';
import { resolvePreferredLocale, setPreferredLocale, t } from './lobby-i18n';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, FormsModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent {
private readonly router = inject(Router);
locale = resolvePreferredLocale();
constructor() {
const shellRoute = document.body.dataset['wppShellRoute'];
if (shellRoute?.startsWith('/host') || shellRoute?.startsWith('/player')) {
void this.router.navigateByUrl(shellRoute);
}
}
copy(key: string): string {
return t(key, this.locale);
}
setLocale(locale: string): void {
this.locale = setPreferredLocale(locale);
}
}

View File

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

View File

@@ -0,0 +1,53 @@
import { Routes } from '@angular/router';
import {
hostRouteContextResolver,
hostRouteGuard,
playerRouteContextResolver,
playerRouteGuard,
} from './session-route-context';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./features/home/home-shell.component').then((m) => m.HomeShellComponent),
},
{
path: 'host',
resolve: { routeContext: hostRouteContextResolver },
canActivate: [hostRouteGuard],
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'host/:phase',
resolve: { routeContext: hostRouteContextResolver },
canActivate: [hostRouteGuard],
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'host/:phase/:context',
resolve: { routeContext: hostRouteContextResolver },
canActivate: [hostRouteGuard],
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'player',
resolve: { routeContext: playerRouteContextResolver },
canActivate: [playerRouteGuard],
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{
path: 'player/:phase',
resolve: { routeContext: playerRouteContextResolver },
canActivate: [playerRouteGuard],
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{
path: 'player/:phase/:context',
resolve: { routeContext: playerRouteContextResolver },
canActivate: [playerRouteGuard],
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{ path: '**', redirectTo: '' },
];

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveDeveloperState, toggleDeveloperState } from './developer-state';
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
};
}
describe('developer-state helpers', () => {
it('reads persisted developer state when no query override is present', () => {
const storage = storageMock({ 'wpp.host.developer-mode': 'true' });
expect(
resolveDeveloperState('wpp.host.developer-mode', {
storage,
location: { search: '', hash: '#/host/lobby/ABCD12' },
}),
).toBe(true);
});
it('lets query params override persisted state and persists the override', () => {
const storage = storageMock({ 'wpp.host.developer-mode': 'false' });
expect(
resolveDeveloperState('wpp.host.developer-mode', {
storage,
location: { search: '?dev=1', hash: '#/host/lobby/ABCD12' },
}),
).toBe(true);
expect(storage.setItem).toHaveBeenCalledWith('wpp.host.developer-mode', 'true');
});
it('reads hash query overrides for hash-routed SPA paths', () => {
const storage = storageMock();
expect(
resolveDeveloperState('wpp.player.developer-mode', {
storage,
location: { search: '', hash: '#/player/guess/ABCD12?session=ABCD12&dev=1' },
}),
).toBe(true);
});
it('toggles and persists the next developer state value', () => {
const storage = storageMock();
expect(toggleDeveloperState('wpp.player.developer-mode', false, storage)).toBe(true);
expect(storage.setItem).toHaveBeenCalledWith('wpp.player.developer-mode', 'true');
});
});

View File

@@ -0,0 +1,68 @@
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
type LocationLike = Pick<Location, 'search' | 'hash'>;
function readFlag(value: string | null): boolean | null {
if (value === null) {
return null;
}
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false;
}
return null;
}
function readHashQuery(hash: string): URLSearchParams {
const queryIndex = hash.indexOf('?');
if (queryIndex === -1) {
return new URLSearchParams();
}
return new URLSearchParams(hash.slice(queryIndex + 1));
}
function resolveFlagFromLocation(locationLike: LocationLike | null | undefined): boolean | null {
if (!locationLike) {
return null;
}
const searchValue = readFlag(new URLSearchParams(locationLike.search || '').get('dev'));
if (searchValue !== null) {
return searchValue;
}
return readFlag(readHashQuery(locationLike.hash || '').get('dev'));
}
export function resolveDeveloperState(
storageKey: string,
options: {
storage?: StorageLike | null;
location?: LocationLike | null;
} = {},
): boolean {
const storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
const locationLike = options.location ?? (typeof window !== 'undefined' ? window.location : null);
const locationValue = resolveFlagFromLocation(locationLike);
if (locationValue !== null) {
storage?.setItem(storageKey, String(locationValue));
return locationValue;
}
return storage?.getItem(storageKey) === 'true';
}
export function toggleDeveloperState(
storageKey: string,
currentValue: boolean,
storage?: StorageLike | null,
): boolean {
const nextValue = !currentValue;
const targetStorage = storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
targetStorage?.setItem(storageKey, String(nextValue));
return nextValue;
}

View File

@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AngularApiClient } from '../../../../../src/api/angular-client';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { HomeShellComponent } from './home-shell.component';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
removeItem: vi.fn((key: string) => {
data.delete(key);
}),
};
}
function apiMock(overrides: Partial<AngularApiClient> = {}): AngularApiClient {
return {
health: vi.fn(),
createSession: vi.fn(),
getSession: vi.fn(),
joinSession: vi.fn(),
startRound: vi.fn(),
showQuestion: vi.fn(),
mixAnswers: vi.fn(),
calculateScores: vi.fn(),
getScoreboard: vi.fn(),
startNextRound: vi.fn(),
finishGame: vi.fn(),
submitLie: vi.fn(),
submitGuess: vi.fn(),
...overrides,
} as unknown as AngularApiClient;
}
describe('HomeShellComponent', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('creates a host session and routes to the host shell', async () => {
const sessionStorage = storageMock();
vi.stubGlobal('window', {
sessionStorage,
localStorage: storageMock(),
location: { assign: vi.fn() },
});
const router = { navigate: vi.fn().mockResolvedValue(true) };
const api = apiMock({
createSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: { session: { code: 'ABCD12', status: 'lobby', host_id: 4, current_round: 1 } },
}),
});
const component = new HomeShellComponent().withTestingDependencies({
router,
api,
sessionContextStore: createSessionContextStore(storageMock() as Storage),
location: { assign: vi.fn() },
});
await component.createSession();
expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'ABCD12');
expect(router.navigate).toHaveBeenCalledWith(['/host'], { queryParams: { session: 'ABCD12' } });
expect(component.hostError).toBe('');
});
it('joins a player session and persists player context before routing', async () => {
const localStorage = storageMock();
vi.stubGlobal('window', {
sessionStorage: storageMock(),
localStorage,
location: { assign: vi.fn() },
});
const router = { navigate: vi.fn().mockResolvedValue(true) };
const api = apiMock({
joinSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: {
player: { id: 9, nickname: 'Luna', session_token: 'tok-9', score: 0 },
session: { code: 'ABCD12', status: 'lobby' },
},
}),
});
const store = createSessionContextStore(localStorage as Storage);
const component = new HomeShellComponent().withTestingDependencies({
router,
api,
sessionContextStore: store,
location: { assign: vi.fn() },
});
component.sessionCode = ' abcd12 ';
component.nickname = ' Luna ';
await component.joinSession();
expect(store.get()).toEqual({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-9' });
expect(router.navigate).toHaveBeenCalledWith(['/player'], { queryParams: { session: 'ABCD12' } });
expect(component.playerError).toBe('');
});
});

View File

@@ -0,0 +1,219 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import type { AngularApiClient } from '../../../../../src/api/angular-client';
import { createSessionContextStore, type SessionContextStore } from '../../../../../src/spa/session-context-store';
import { subscribeToLocaleChanges, resolvePreferredLocale, t } from '../../lobby-i18n';
import { WPP_API_CLIENT } from '../../wpp-api-client';
type RouterLike = Pick<Router, 'navigate'>;
type LocationLike = Pick<Location, 'assign'>;
type HomeShellDependencies = {
router: RouterLike;
api: AngularApiClient;
sessionContextStore: SessionContextStore;
location: LocationLike | null;
};
function resolveLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
}
function fallbackRouter(): RouterLike {
return { navigate: async () => false };
}
function fallbackApi(): AngularApiClient {
return {
health: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
createSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
getSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
joinSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
startRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
showQuestion: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
mixAnswers: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
calculateScores: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
getScoreboard: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
startNextRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
finishGame: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
submitLie: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
submitGuess: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
};
}
function tryInject<T>(factory: () => T, fallback: T): T {
try {
return factory();
} catch {
return fallback;
}
}
@Component({
selector: 'app-home-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<section class="wpp-page landing">
<div class="wpp-hero-card hero">
<div class="wpp-stack">
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
<h2 class="wpp-title">{{ copy('app.home_title') }}</h2>
<p class="wpp-subtitle hero__intro">{{ copy('app.home_intro') }}</p>
</div>
</div>
<div class="wpp-grid wpp-grid--two">
<article class="wpp-card wpp-stack">
<h3 class="wpp-section-title">{{ copy('app.host_card_title') }}</h3>
<p class="wpp-section-copy">{{ copy('app.host_card_body') }}</p>
<p class="wpp-inline-note">{{ copy('app.host_login_hint') }}</p>
<div class="wpp-action-row">
<button type="button" class="wpp-button wpp-button--secondary" (click)="loginAsHost()">
{{ copy('app.host_login') }}
</button>
<button type="button" class="wpp-button" (click)="createSession()" [disabled]="hostBusy">
{{ hostBusy ? copy('app.creating_session') : copy('app.create_session') }}
</button>
</div>
<p *ngIf="hostError" class="wpp-error">{{ hostError }}</p>
</article>
<article class="wpp-card wpp-stack">
<h3 class="wpp-section-title">{{ copy('app.player_card_title') }}</h3>
<p class="wpp-section-copy">{{ copy('app.player_card_body') }}</p>
<label class="wpp-field">
<span class="wpp-field-label">{{ copy('common.session_code') }}</span>
<input [(ngModel)]="sessionCode" autocomplete="one-time-code" />
</label>
<label class="wpp-field">
<span class="wpp-field-label">{{ copy('player.nickname') }}</span>
<input [(ngModel)]="nickname" autocomplete="nickname" />
</label>
<div class="wpp-action-row">
<button type="button" class="wpp-button" (click)="joinSession()" [disabled]="playerBusy">
{{ playerBusy ? copy('app.joining_session') : copy('app.join_session') }}
</button>
</div>
<p *ngIf="playerError" class="wpp-error">{{ playerError }}</p>
</article>
</div>
</section>
`,
styles: [`
.landing { gap: 1.4rem; }
.hero { max-width: 54rem; }
.hero__intro { max-width: 42rem; }
`],
})
export class HomeShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale();
sessionCode = '';
nickname = '';
hostBusy = false;
playerBusy = false;
hostError = '';
playerError = '';
private router: RouterLike;
private api: AngularApiClient;
private sessionContextStore: SessionContextStore;
private location: LocationLike | null;
private unsubscribeLocale: (() => void) | null = null;
constructor() {
this.router = tryInject(() => inject(Router), fallbackRouter());
this.api = tryInject(() => inject(WPP_API_CLIENT), fallbackApi());
this.sessionContextStore = createSessionContextStore(resolveLocalStorage());
this.location = typeof window !== 'undefined' ? window.location : null;
}
withTestingDependencies(deps: Partial<HomeShellDependencies>): this {
if (deps.router) {
this.router = deps.router;
}
if (deps.api) {
this.api = deps.api;
}
if (deps.sessionContextStore) {
this.sessionContextStore = deps.sessionContextStore;
}
if ('location' in deps) {
this.location = deps.location ?? null;
}
return this;
}
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
}
ngOnDestroy(): void {
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
}
copy(key: string): string {
return t(key, this.locale);
}
loginAsHost(): void {
this.location?.assign(`/accounts/login/?next=${encodeURIComponent('/')}`);
}
async createSession(): Promise<void> {
this.hostBusy = true;
this.hostError = '';
const result = await this.api.createSession();
if (!result.ok) {
this.hostBusy = false;
this.hostError = `${this.copy('app.create_session_failed')}: ${result.error.message}`;
return;
}
const code = result.data.session.code;
if (typeof window !== 'undefined') {
window.sessionStorage.setItem('wpp.host-session-code', code);
}
this.hostBusy = false;
await this.router.navigate(['/host'], { queryParams: { session: code } });
}
async joinSession(): Promise<void> {
this.playerBusy = true;
this.playerError = '';
const normalizedCode = this.sessionCode.trim().toUpperCase();
this.sessionCode = normalizedCode;
const result = await this.api.joinSession({
code: normalizedCode,
nickname: this.nickname,
});
if (!result.ok) {
this.playerBusy = false;
this.playerError = `${this.copy('app.join_session_failed')}: ${result.error.message}`;
return;
}
this.sessionContextStore.set({
sessionCode: result.data.session.code,
playerId: result.data.player.id,
token: result.data.player.session_token,
});
this.playerBusy = false;
await this.router.navigate(['/player'], { queryParams: { session: result.data.session.code } });
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { deriveGameplayPhase, transitionGameplayPhase } from '../../../src/spa/gameplay-phase-machine';
describe('gameplay phase machine sync guards', () => {
it('keeps explicit scoreboard status as scoreboard phase', () => {
const phase = deriveGameplayPhase({
session: {
code: 'ABCD12',
status: 'scoreboard',
host_id: 1,
current_round: 1,
players_count: 2,
},
round_question: null,
players: [],
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: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false,
},
},
});
expect(phase).toBe('scoreboard');
});
it('maps finished status to scoreboard phase fallback', () => {
const phase = deriveGameplayPhase({
session: {
code: 'ABCD12',
status: 'finished',
host_id: 1,
current_round: 1,
players_count: 2,
},
round_question: null,
players: [],
phase_view_model: {
status: 'finished',
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: false,
can_finish_game: false,
},
player: {
can_join: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: true,
},
},
});
expect(phase).toBe('scoreboard');
});
it('transitions reveal -> scoreboard on SCOREBOARD_READY', () => {
expect(transitionGameplayPhase('reveal', 'SCOREBOARD_READY')).toEqual({
phase: 'scoreboard',
changed: true,
});
});
});

View File

@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n';
function stubShellGlobals(initialLocale: string) {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(initialLocale), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: `${initialLocale}-US`, onLine: true });
}
describe('i18n MVP flow smoke (host/player + audio policy)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it.each([
{
locale: 'en',
hostRefresh: 'Refresh',
hostStartRound: 'Start round',
playerSubmitGuess: 'Submit guess',
},
{
locale: 'da',
hostRefresh: 'Opdatér',
hostStartRound: 'Start runde',
playerSubmitGuess: 'Send gæt',
},
])('resolves one host/player locale run for $locale', ({ locale, hostRefresh, hostStartRound, playerSubmitGuess }) => {
stubShellGlobals(locale);
const host = new HostShellComponent();
const player = new PlayerShellComponent();
host.ngOnInit();
player.ngOnInit();
setPreferredLocale(locale);
expect(host.copy('common.refresh')).toBe(hostRefresh);
expect(host.copy('game.host.start_round')).toBe(hostStartRound);
expect(player.copy('game.player.submit_guess')).toBe(playerSubmitGuess);
player.ngOnDestroy();
host.ngOnDestroy();
});
it('keeps audio routing primary-only by guarding player playback without muting the host path', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('primary host playback'));
const mediaPrototype = { play: originalPlay };
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLMediaElement: { prototype: mediaPrototype },
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
vi.stubGlobal('document', { querySelectorAll: vi.fn().mockReturnValue([]) });
const host = new HostShellComponent();
host.ngOnInit();
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
const player = new PlayerShellComponent();
player.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
player.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
host.ngOnDestroy();
});
});

View File

@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
};
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
};
}
describe('lobby i18n locale propagation', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
});
it('notifies subscribers immediately and on locale changes', async () => {
const localStorage = storageMock({ 'wpp.locale': 'en' });
vi.stubGlobal('window', {
location: { search: '' },
localStorage,
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
const updates: string[] = [];
const unsubscribe = i18n.subscribeToLocaleChanges((locale) => updates.push(locale));
expect(updates).toEqual(['en']);
i18n.setPreferredLocale('da');
expect(updates).toEqual(['en', 'da']);
unsubscribe();
i18n.setPreferredLocale('en');
expect(updates).toEqual(['en', 'da']);
});
it('prefers backend-provided shell locale over browser defaults', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock(),
});
vi.stubGlobal('document', {
body: { dataset: { wppLocale: 'da' } },
querySelector: vi.fn(() => null),
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
expect(i18n.resolvePreferredLocale()).toBe('da');
});
it('falls back to default en translation when da key is intentionally missing', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock({ 'wpp.locale': 'da' }),
});
vi.stubGlobal('navigator', { language: 'da-DK' });
const i18n = await import('./lobby-i18n');
const catalogModule = await import('../../../../shared/i18n/lobby.json');
const catalog = catalogModule.default as {
frontend: {
ui: {
common: {
refresh: {
en?: string;
da?: string;
};
};
};
};
};
const originalDa = catalog.frontend.ui.common.refresh.da;
catalog.frontend.ui.common.refresh.da = undefined;
try {
expect(i18n.t('common.refresh', 'da')).toBe(catalog.frontend.ui.common.refresh.en);
} finally {
catalog.frontend.ui.common.refresh.da = originalDa;
}
});
it('resolves baseline shell/game keys from shared namespaces', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock({ 'wpp.locale': 'da' }),
});
vi.stubGlobal('navigator', { language: 'da-DK' });
const i18n = await import('./lobby-i18n');
const baselineKeys = [
'lobby.shell.title',
'lobby.shell.home_nav',
'lobby.shell.host_nav',
'lobby.shell.player_nav',
'lobby.shell.language_label',
'lobby.shell.home_title',
'common.refresh',
'common.session_code',
'game.host.title',
'game.host.start_round',
'game.player.title',
'game.player.submit_guess',
] as const;
for (const key of baselineKeys) {
const value = i18n.t(key, 'da');
expect(value).toBeTypeOf('string');
expect(value.length).toBeGreaterThan(0);
expect(value).not.toBe(key);
}
});
it('exposes primary-only audio routing policy to clients', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock({ 'wpp.locale': 'en' }),
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
expect(i18n.clientHasNoAudioOutput).toBe(true);
});
});

View File

@@ -0,0 +1,74 @@
import {
DEFAULT_LOCALE,
LOBBY_I18N_CATALOG,
normalizeLocale,
type SupportedLocale,
translateCatalogPath,
} from '../../../shared/i18n/lobby-loader';
let activeLocale: SupportedLocale | null = null;
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
export { normalizeLocale };
export function resolvePreferredLocale(): SupportedLocale {
if (activeLocale) {
return activeLocale;
}
if (typeof window === 'undefined') {
activeLocale = DEFAULT_LOCALE;
return activeLocale;
}
const rootLocale =
typeof document !== 'undefined' ? document.querySelector<HTMLElement>('app-root')?.dataset?.['wppLocale'] : null;
const shellLocale = typeof document !== 'undefined' ? document.body?.dataset?.['wppLocale'] : null;
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
activeLocale = normalizeLocale(rootLocale || shellLocale || queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
return activeLocale;
}
export function setPreferredLocale(locale: string): SupportedLocale {
const normalized = normalizeLocale(locale);
activeLocale = normalized;
if (typeof window !== 'undefined') {
window.localStorage?.setItem?.('wpp.locale', normalized);
}
for (const subscriber of localeSubscribers) {
subscriber(normalized);
}
return normalized;
}
export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) => void): () => void {
localeSubscribers.add(callback);
callback(resolvePreferredLocale());
return () => {
localeSubscribers.delete(callback);
};
}
function resolveCatalogPath(key: string): string {
if (key.startsWith('lobby.shell.')) {
return key.replace(/^lobby\.shell\./, 'app.');
}
if (key.startsWith('game.host.')) {
return key.replace(/^game\.host\./, 'host.');
}
if (key.startsWith('game.player.')) {
return key.replace(/^game\.player\./, 'player.');
}
return key;
}
export function t(key: string, locale: string): string {
return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, resolveCatalogPath(key), locale);
}
export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.frontend.capabilities.client_has_no_audio_output);

View File

@@ -0,0 +1,363 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SessionDetailResponse } from '../../../src/api/types';
import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n';
type ViewerRole = 'host' | 'player';
type SessionSeedPlayer = Pick<SessionDetailResponse['players'][number], 'id' | 'nickname' | 'score'> & {
is_connected?: boolean;
identity?: SessionDetailResponse['players'][number]['identity'];
};
type SessionSeed = {
viewerRole: ViewerRole;
status: string;
currentPhase?: string;
prompt?: string | null;
answers?: string[];
phaseDisplay?: SessionDetailResponse['phase_display'];
players?: SessionSeedPlayer[];
playerPermissions?: Partial<SessionDetailResponse['phase_view_model']['player']>;
roundQuestionId?: number | null;
};
class SharedRealtimeSocketMock {
static instances: SharedRealtimeSocketMock[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
SharedRealtimeSocketMock.instances.push(this);
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitOpen(): void {
this.onopen?.();
}
}
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
function buildSessionDetail(seed: SessionSeed): SessionDetailResponse {
const phase = seed.currentPhase ?? seed.status;
const players = (seed.players ?? [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 9, nickname: 'Luna', score: 120, is_connected: true },
{ id: 10, nickname: 'Mads', score: 80, is_connected: true },
]).map((player) => ({
...player,
is_connected: player.is_connected ?? true,
}));
const roundQuestionId = seed.roundQuestionId ?? 41;
return {
session: {
code: 'ABCD12',
status: seed.status,
host_id: seed.viewerRole === 'host' ? 1 : null,
current_round: 1,
players_count: players.length,
},
viewer_role: seed.viewerRole,
players,
round_question:
roundQuestionId === null
? null
: {
id: roundQuestionId,
round_number: 1,
prompt: seed.prompt === undefined ? 'Which planet is closest to the sun?' : seed.prompt,
shown_at: '2026-03-23T11:24:02Z',
answers: (seed.answers ?? []).map((text) => ({ text })),
},
reveal: null,
voice_cues: null,
phase_display: seed.phaseDisplay ?? null,
phase_view_model: {
status: seed.status,
current_phase: phase,
round_number: 1,
players_count: players.length,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: phase !== 'lobby',
scoreboard_ready: phase === 'reveal' || phase === 'scoreboard',
},
host: {
can_start_round: phase === 'lobby',
can_show_question: phase === 'lie',
can_mix_answers: phase === 'lie' || phase === 'guess',
can_calculate_scores: phase === 'guess',
can_reveal_scoreboard: phase === 'reveal',
can_start_next_round: phase === 'scoreboard',
can_finish_game: phase === 'scoreboard',
},
player: {
can_join: seed.playerPermissions?.can_join ?? phase === 'lobby',
can_submit_lie: seed.playerPermissions?.can_submit_lie ?? phase === 'lie',
can_submit_guess: seed.playerPermissions?.can_submit_guess ?? phase === 'guess',
can_view_final_result: seed.playerPermissions?.can_view_final_result ?? phase === 'finished',
},
},
};
}
function stubShellGlobals(): void {
vi.stubGlobal('window', {
location: { hash: '', search: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
}
function latestSocket(search: string): SharedRealtimeSocketMock {
const socket = [...SharedRealtimeSocketMock.instances].reverse().find((candidate) => candidate.url.includes(search));
expect(socket).toBeDefined();
return socket as SharedRealtimeSocketMock;
}
describe('realtime visual smoke (host/player resilience + visibility)', () => {
afterEach(() => {
SharedRealtimeSocketMock.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('keeps lie prompts presenter-only while host and player hydrate the same session', async () => {
stubShellGlobals();
setPreferredLocale('en');
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/lobby/sessions/ABCD12') {
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'host',
status: 'lie',
prompt: 'Which planet is closest to the sun?',
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
],
phaseDisplay: {
theme: 'host-spotlight',
ornament: 'harbor-flare',
title_key: 'host.presenter_scene_title',
body_key: 'host.presenter_scene_body_lie',
cue_label_key: 'host.presenter_scene_cue_mix_label',
cue_body_key: 'host.presenter_scene_cue_mix_body',
},
playerPermissions: { can_join: false, can_submit_lie: true },
}),
),
);
}
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'player',
status: 'lie',
prompt: null,
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
],
phaseDisplay: {
theme: 'player-ink',
ornament: 'harbor-flare',
title_key: 'player.submit_lie',
body_key: 'player.phase_summary_lie',
cue_label_key: 'player.active_scene_cue_lie_label',
cue_body_key: 'player.active_scene_cue_lie_body',
},
playerPermissions: { can_join: false, can_submit_lie: true },
}),
),
);
}
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
const host = new HostShellComponent();
host.sessionCode = 'ABCD12';
await host.refreshSession();
latestSocket('?role=host').emitOpen();
const player = new PlayerShellComponent();
player.sessionCode = 'ABCD12';
player.playerId = 9;
player.sessionToken = 'tok-9';
await player.refreshSession();
latestSocket('session_token=tok-9').emitOpen();
expect(host.showLiePresenterScene).toBe(true);
expect(host.presenterSceneHeadline).toBe('Which planet is closest to the sun?');
expect(host.presenterSceneTheme).toBe('host-spotlight');
expect(host.presenterSceneOrnament).toBe('harbor-flare');
expect(host.syncTransport).toBe('websocket');
expect(host.presenterPlayers[1].badge).toBe('L2');
expect(host.presenterPlayers[1].tone).toBe('lagoon');
expect(host.presenterPlayers[1].icon).toBe('wave');
expect(player.showLieControls).toBe(true);
expect(player.currentPrompt).toBe('');
expect(player.activeSceneHeadline).toBe(player.copy('player.round_prompt_waiting'));
expect(player.activeSceneTheme).toBe('player-ink');
expect(player.activeSceneOrnament).toBe('harbor-flare');
expect(player.syncTransport).toBe('websocket');
expect(player.playerIdentityToken(9, 'Luna', 1)).toBe('L2');
expect(player.playerTone(9, 'Luna', 1)).toBe('lagoon');
expect(player.playerIcon(9, 'Luna', 1)).toBe('wave');
expect(player.activeSceneOrnament).toBe(host.presenterSceneOrnament);
player.ngOnDestroy();
host.ngOnDestroy();
});
it('recovers shared host/player realtime sync before polling fallback fires', async () => {
vi.useFakeTimers();
stubShellGlobals();
setPreferredLocale('en');
let hostFetchCount = 0;
let playerFetchCount = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/lobby/sessions/ABCD12') {
hostFetchCount += 1;
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'host',
status: 'guess',
prompt: 'Which planet is closest to the sun?',
answers: ['Mercury', 'Venus', 'Mars'],
playerPermissions: { can_join: false, can_submit_guess: true },
}),
),
);
}
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
playerFetchCount += 1;
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'player',
status: playerFetchCount === 1 ? 'guess' : 'reveal',
currentPhase: 'guess',
prompt: 'Which planet is closest to the sun?',
answers: ['Mercury', 'Venus', 'Mars', 'Earth'],
playerPermissions: { can_join: false, can_submit_guess: true },
}),
),
);
}
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
const host = new HostShellComponent();
host.sessionCode = 'ABCD12';
await host.refreshSession();
const firstHostSocket = latestSocket('?role=host');
firstHostSocket.emitOpen();
const player = new PlayerShellComponent();
player.sessionCode = 'ABCD12';
player.playerId = 9;
player.sessionToken = 'tok-9';
await player.refreshSession();
player.selectedGuess = 'Venus';
const firstPlayerSocket = latestSocket('session_token=tok-9');
firstPlayerSocket.emitOpen();
firstHostSocket.emitClose({ code: 1006, wasClean: false });
firstPlayerSocket.emitClose({ code: 1006, wasClean: false });
expect(host.syncTransport).toBe('polling');
expect(player.syncTransport).toBe('polling');
expect(player.showSyncStatusCard).toBe(true);
await vi.advanceTimersByTimeAsync(1500);
const recoveredHostSocket = latestSocket('?role=host');
const recoveredPlayerSocket = latestSocket('session_token=tok-9');
expect(recoveredHostSocket).not.toBe(firstHostSocket);
expect(recoveredPlayerSocket).not.toBe(firstPlayerSocket);
recoveredHostSocket.emitOpen();
recoveredPlayerSocket.emitOpen();
expect(host.syncTransport).toBe('websocket');
expect(player.syncTransport).toBe('websocket');
expect(player.showSyncStatusCard).toBe(false);
await vi.advanceTimersByTimeAsync(3000);
expect(hostFetchCount).toBe(1);
expect(playerFetchCount).toBe(1);
expect(player.selectedGuess).toBe('Venus');
recoveredHostSocket.emitMessage({ type: 'phase.guess_snapshot' });
recoveredPlayerSocket.emitMessage({ type: 'phase.guess_snapshot' });
await vi.waitFor(() => {
expect(hostFetchCount).toBe(2);
expect(playerFetchCount).toBe(2);
});
expect(host.lastRealtimeEventType).toBe('phase.guess_snapshot');
expect(player.lastRealtimeEventType).toBe('phase.guess_snapshot');
expect(player.gameplayPhase).toBe('guess');
expect(player.selectedGuess).toBe('Venus');
player.ngOnDestroy();
host.ngOnDestroy();
});
});

View File

@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSessionRealtimeClient, resolveSessionRealtimeUrl } from './session-realtime';
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
emitOpen(): void {
this.onopen?.();
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitError(event: unknown = new Event('error')): void {
this.onerror?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
}
describe('session realtime client', () => {
afterEach(() => {
FakeWebSocket.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
it('builds host and player websocket URLs from the current location', () => {
expect(
resolveSessionRealtimeUrl(
{ protocol: 'http:', host: 'localhost:4200' },
{ sessionCode: 'abcd12', role: { mode: 'host' } },
),
).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
expect(
resolveSessionRealtimeUrl(
{ protocol: 'https:', host: 'party.example' },
{ sessionCode: 'abcd12', role: { mode: 'player', sessionToken: 'tok-1' } },
),
).toBe('wss://party.example/ws/game/ABCD12/?session_token=tok-1');
});
it('publishes websocket events and reconnects after unexpected disconnects', async () => {
vi.useFakeTimers();
const events: string[] = [];
const states: string[] = [];
const client = createSessionRealtimeClient({
onEvent: (event) => {
events.push(String(event.type));
},
onStatusChange: (status) => {
states.push(status.connectionState);
},
webSocketFactory: (url) => new FakeWebSocket(url),
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
});
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'host' } });
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
FakeWebSocket.instances[0]?.emitOpen();
FakeWebSocket.instances[0]?.emitMessage({ type: 'phase.guess_started' });
FakeWebSocket.instances[0]?.emitClose();
expect(events).toEqual(['phase.guess_started']);
expect(states).toEqual(['connecting', 'connected', 'connected', 'reconnecting']);
await vi.advanceTimersByTimeAsync(1500);
expect(FakeWebSocket.instances).toHaveLength(2);
FakeWebSocket.instances[1]?.emitOpen();
expect(client.getStatus().connectionState).toBe('connected');
});
it('reconfigures the socket when the player session token changes', () => {
const client = createSessionRealtimeClient({
onEvent: vi.fn(),
webSocketFactory: (url) => new FakeWebSocket(url),
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
});
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-1' } });
const firstSocket = FakeWebSocket.instances[0];
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-2' } });
expect(firstSocket?.close).toHaveBeenCalledWith(1000, 'reconfigure');
expect(FakeWebSocket.instances[1]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?session_token=tok-2');
});
});

View File

@@ -0,0 +1,264 @@
export type SessionRealtimeRole =
| { mode: 'host' }
| { mode: 'player'; sessionToken: string };
export type SessionRealtimeEvent = {
type?: string;
[key: string]: unknown;
};
export type SessionRealtimeConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting';
export type SessionRealtimeStatus = {
connectionState: SessionRealtimeConnectionState;
lastEventAt: number | null;
lastEventType: string | null;
reconnectAttempt: number;
};
type TimeoutHandle = ReturnType<typeof setTimeout>;
type WebSocketLike = {
close: (code?: number, reason?: string) => void;
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null;
onerror: ((event: unknown) => void) | null;
onmessage: ((event: { data: string }) => void) | null;
onopen: (() => void) | null;
};
type SessionRealtimeTarget = {
role: SessionRealtimeRole;
sessionCode: string;
};
type SessionRealtimeOptions = {
clearTimeoutImpl?: (handle: TimeoutHandle) => void;
onEvent: (event: SessionRealtimeEvent) => void;
onStatusChange?: (status: SessionRealtimeStatus) => void;
setTimeoutImpl?: (callback: () => void, delayMs: number) => TimeoutHandle;
webSocketFactory?: ((url: string) => WebSocketLike) | null;
windowLike?: Pick<Window, 'location'>;
};
const DEFAULT_RECONNECT_DELAY_MS = 1500;
const MAX_RECONNECT_DELAY_MS = 5000;
function resolveWindowLike(windowLike?: Pick<Window, 'location'>): Pick<Window, 'location'> | null {
if (windowLike) {
return windowLike;
}
if (typeof window === 'undefined') {
return null;
}
return window;
}
function normalizeSessionCode(value: string): string {
return value.trim().toUpperCase();
}
export function resolveSessionRealtimeUrl(
location: Pick<Location, 'host' | 'protocol'>,
target: SessionRealtimeTarget,
): string {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = new URL(`${protocol}//${location.host}/ws/game/${encodeURIComponent(normalizeSessionCode(target.sessionCode))}/`);
if (target.role.mode === 'host') {
url.searchParams.set('role', 'host');
} else {
url.searchParams.set('session_token', target.role.sessionToken);
}
return url.toString();
}
function sameTarget(left: SessionRealtimeTarget | null, right: SessionRealtimeTarget | null): boolean {
if (!left || !right) {
return left === right;
}
if (normalizeSessionCode(left.sessionCode) !== normalizeSessionCode(right.sessionCode)) {
return false;
}
if (left.role.mode !== right.role.mode) {
return false;
}
if (left.role.mode === 'host' && right.role.mode === 'host') {
return true;
}
if (left.role.mode === 'player' && right.role.mode === 'player') {
return left.role.sessionToken === right.role.sessionToken;
}
return false;
}
export function createSessionRealtimeClient(options: SessionRealtimeOptions) {
const setTimeoutImpl = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle));
const webSocketFactory =
options.webSocketFactory ??
(typeof WebSocket === 'function' ? ((url: string) => new WebSocket(url) as unknown as WebSocketLike) : null);
let reconnectTimer: TimeoutHandle | null = null;
let reconnectAttempt = 0;
let socket: WebSocketLike | null = null;
let target: SessionRealtimeTarget | null = null;
let status: SessionRealtimeStatus = {
connectionState: 'idle',
lastEventAt: null,
lastEventType: null,
reconnectAttempt: 0,
};
function publishStatus(next: Partial<SessionRealtimeStatus>): void {
status = {
...status,
...next,
reconnectAttempt,
};
options.onStatusChange?.(status);
}
function clearReconnectTimer(): void {
if (!reconnectTimer) {
return;
}
clearTimeoutImpl(reconnectTimer);
reconnectTimer = null;
}
function clearSocket(closeCode?: number, reason?: string): void {
if (!socket) {
return;
}
const currentSocket = socket;
socket = null;
currentSocket.onopen = null;
currentSocket.onmessage = null;
currentSocket.onerror = null;
currentSocket.onclose = null;
currentSocket.close(closeCode, reason);
}
function scheduleReconnect(): void {
if (!target || reconnectTimer) {
return;
}
publishStatus({ connectionState: 'reconnecting' });
const delayMs = Math.min(DEFAULT_RECONNECT_DELAY_MS * Math.max(1, reconnectAttempt + 1), MAX_RECONNECT_DELAY_MS);
reconnectTimer = setTimeoutImpl(() => {
reconnectTimer = null;
reconnectAttempt += 1;
connect();
}, delayMs);
}
function connect(): void {
if (!target) {
publishStatus({ connectionState: 'idle' });
return;
}
const windowLike = resolveWindowLike(options.windowLike);
if (
!windowLike ||
typeof windowLike.location?.protocol !== 'string' ||
typeof windowLike.location?.host !== 'string' ||
!windowLike.location.host ||
!webSocketFactory
) {
publishStatus({ connectionState: 'idle' });
return;
}
clearReconnectTimer();
clearSocket(1000, 'reconnect');
publishStatus({ connectionState: reconnectAttempt > 0 ? 'reconnecting' : 'connecting' });
const nextSocket = webSocketFactory(resolveSessionRealtimeUrl(windowLike.location, target));
socket = nextSocket;
nextSocket.onopen = () => {
if (socket !== nextSocket) {
return;
}
reconnectAttempt = 0;
publishStatus({ connectionState: 'connected' });
};
nextSocket.onmessage = (event) => {
if (socket !== nextSocket) {
return;
}
try {
const payload = JSON.parse(event.data) as SessionRealtimeEvent;
publishStatus({
lastEventAt: Date.now(),
lastEventType: typeof payload.type === 'string' ? payload.type : null,
});
options.onEvent(payload);
} catch {
// Ignore malformed websocket frames; the HTTP refresh path remains authoritative.
}
};
nextSocket.onerror = () => {
if (socket !== nextSocket) {
return;
}
publishStatus({ connectionState: 'reconnecting' });
};
nextSocket.onclose = () => {
if (socket !== nextSocket) {
return;
}
socket = null;
scheduleReconnect();
};
}
return {
disconnect(): void {
target = null;
reconnectAttempt = 0;
clearReconnectTimer();
clearSocket(1000, 'disconnect');
publishStatus({ connectionState: 'idle' });
},
getStatus(): SessionRealtimeStatus {
return status;
},
updateTarget(nextTarget: SessionRealtimeTarget | null): void {
if (sameTarget(target, nextTarget)) {
if (!nextTarget) {
this.disconnect();
}
return;
}
target = nextTarget;
reconnectAttempt = 0;
clearReconnectTimer();
clearSocket(1000, 'reconfigure');
if (!target) {
publishStatus({ connectionState: 'idle' });
return;
}
connect();
},
};
}

View File

@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { hostRouteContextResolver, playerRouteContextResolver, resolveSessionCode } from './session-route-context';
type RouteLike = {
paramMap: { get: (key: string) => string | null };
queryParamMap: { get: (key: string) => string | null };
};
function route(params: Record<string, string | null>, query: Record<string, string | null> = {}): RouteLike {
return {
paramMap: { get: (key: string) => params[key] ?? null },
queryParamMap: { get: (key: string) => query[key] ?? null },
};
}
function storageMock(initial: Record<string, string> = {}): Storage {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
removeItem: vi.fn((key: string) => {
data.delete(key);
}),
clear: vi.fn(() => {
data.clear();
}),
key: vi.fn((index: number) => Array.from(data.keys())[index] ?? null),
get length() {
return data.size;
},
} as unknown as Storage;
}
function setWindow(localStorage: Storage, sessionStorage: Storage): void {
vi.stubGlobal('window', { localStorage, sessionStorage });
}
describe('session route context', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('resolves player code from persisted session context when route has no code', () => {
setWindow(
storageMock({ 'wpp.session-context': JSON.stringify({ sessionCode: 'ab12', playerId: 7, token: 'tok' }) }),
storageMock()
);
expect(resolveSessionCode(route({}, {}) as never, 'player')).toBe('AB12');
});
it('resolves host code from session query string', () => {
setWindow(storageMock(), storageMock());
expect(resolveSessionCode(route({}, { session: 'qwe9' }) as never, 'host')).toBe('QWE9');
});
it('player resolver emits player id/token when context matches route session', () => {
setWindow(
storageMock({ 'wpp.session-context': JSON.stringify({ sessionCode: 'AB12', playerId: 5, token: 'tok-5' }) }),
storageMock()
);
expect(playerRouteContextResolver(route({ context: 'ab12' }) as never, {} as never)).toEqual({
sessionCode: 'AB12',
playerId: 5,
token: 'tok-5',
locale: 'en',
});
});
it('host resolver stores normalized host session code for refresh bootstrap', () => {
const sessionStorage = storageMock();
setWindow(storageMock(), sessionStorage);
expect(hostRouteContextResolver(route({ context: 'ab12' }) as never, {} as never)).toEqual({
sessionCode: 'AB12',
playerId: null,
token: null,
locale: 'en',
});
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

@@ -0,0 +1,156 @@
import { inject } from '@angular/core';
import { type ActivatedRouteSnapshot, type CanActivateFn, type ResolveFn, Router, type UrlTree } from '@angular/router';
import { createSessionContextStore } from '../../../src/spa/session-context-store';
import { normalizeLocale, resolvePreferredLocale, setPreferredLocale } from './lobby-i18n';
export interface RouteSessionContext {
sessionCode: string | null;
playerId: number | null;
token: string | null;
locale: string;
}
const HOST_STORAGE_KEY = 'wpp.host-session-code';
function normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
function isCodeLike(value: string | null | undefined): value is string {
return !!value && /^[A-Za-z0-9]{4,12}$/.test(value.trim());
}
function hasPlayerSessionContext(sessionCode: string): boolean {
const context = createSessionContextStore(window.localStorage).get();
if (!context) {
return false;
}
return (
isCodeLike(context.sessionCode) &&
normalizeCode(context.sessionCode) === normalizeCode(sessionCode) &&
Number.isInteger(context.playerId) &&
context.playerId > 0 &&
!!context.token.trim()
);
}
export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): string | null {
const contextParam = route.paramMap.get('context');
const queryCode = route.queryParamMap.get('session');
if (isCodeLike(contextParam)) {
return normalizeCode(contextParam);
}
if (isCodeLike(queryCode)) {
return normalizeCode(queryCode);
}
if (mode === 'player') {
const persisted = createSessionContextStore(window.localStorage).get()?.sessionCode;
if (isCodeLike(persisted)) {
return normalizeCode(persisted);
}
return null;
}
const stored = window.sessionStorage.getItem(HOST_STORAGE_KEY);
if (isCodeLike(stored)) {
return normalizeCode(stored);
}
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> {
const response = await fetch(`/lobby/sessions/${encodeURIComponent(code)}`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
return response.ok;
}
async function requireSessionContext(route: ActivatedRouteSnapshot, mode: 'host' | 'player'): Promise<boolean> {
const phase = route.paramMap.get('phase');
const code = resolveSessionCode(route, mode);
if (!phase) {
if (mode === 'host' && code) {
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
}
return true;
}
if (!code) {
return false;
}
if (mode === 'player' && !hasPlayerSessionContext(code)) {
return false;
}
const ok = await sessionExists(code);
if (!ok) {
return false;
}
if (mode === 'host') {
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
}
return true;
}
async function guard(mode: 'host' | 'player', route: ActivatedRouteSnapshot): Promise<boolean | UrlTree> {
const router = inject(Router);
const allowed = await requireSessionContext(route, mode);
if (allowed) {
return true;
}
return router.createUrlTree([`/${mode}`]);
}
export const hostRouteGuard: CanActivateFn = (route) => guard('host', route);
export const playerRouteGuard: CanActivateFn = (route) => guard('player', route);
export const hostRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
const code = resolveSessionCode(route, 'host');
const locale = resolveRouteLocale(route);
if (code) {
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
}
return { sessionCode: code, playerId: null, token: null, locale };
};
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
const code = resolveSessionCode(route, 'player');
const locale = resolveRouteLocale(route);
const context = createSessionContextStore(window.localStorage).get();
if (!code || !context || normalizeCode(context.sessionCode) !== code) {
return { sessionCode: code, playerId: null, token: null, locale };
}
return {
sessionCode: code,
playerId: Number.isInteger(context.playerId) && context.playerId > 0 ? context.playerId : null,
token: context.token.trim() || null,
locale,
};
};

View File

@@ -0,0 +1,67 @@
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(200, { session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 1 }, viewer_role: 'player', players: [], round_question: { id: 77, round_number: 1, prompt: null, shown_at: '2026-03-18T12:00:00Z', answers: [] }, phase_view_model: { status: 'lie', round_number: 1, players_count: 1, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: false, max_players_allowed: true }, host: { can_start_round: false, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: true, can_submit_guess: false, can_view_final_result: false } } }))
.mockResolvedValueOnce(jsonResponse(201, { player: { id: 1, nickname: 'Luna', session_token: 'tok', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }))
.mockResolvedValueOnce(jsonResponse(201, { session: { code: 'ZXCV12', status: 'lobby', host_id: 1, current_round: 1 } }));
const client = createWppApiClient(fetchMock);
const session = await client.getSession(' abcd12 ');
const playerSession = await client.getSession(' abcd12 ', { session_token: 'tok-1' });
const joined = await client.joinSession({ code: ' abcd12 ', nickname: ' Luna ' });
const created = await client.createSession();
expect(session.ok).toBe(true);
expect(playerSession.ok).toBe(true);
expect(joined.ok).toBe(true);
expect(created.ok).toBe(true);
if (playerSession.ok) {
expect(playerSession.data.viewer_role).toBe('player');
expect(playerSession.data.round_question?.prompt).toBeNull();
}
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12?session_token=tok-1',
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/lobby/sessions/join',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'/lobby/sessions/create',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({}),
})
);
});
});

View File

@@ -0,0 +1,107 @@
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 {
function readCookie(name: string): string {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
return '';
}
const prefix = `${name}=`;
for (const part of document.cookie.split(';')) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return '';
}
async function ensureCsrfToken(): Promise<string> {
const existing = readCookie('csrftoken');
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
return existing;
}
try {
await fetchImpl('/lobby/csrf', {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
} catch {
return '';
}
return readCookie('csrftoken');
}
async function parsePayload(response: Response): Promise<unknown> {
if (response.redirected && response.url.includes('/accounts/login')) {
throw {
status: 401,
message: 'Login required',
error: { redirect: response.url },
};
}
return response.json().catch(() => ({}));
}
return {
async get<T>(url: string): Promise<T> {
const response = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const payload = await parsePayload(response);
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 csrfToken = await ensureCsrfToken();
const response = await fetchImpl(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
},
body: JSON.stringify(body),
credentials: 'same-origin',
});
const payload = await parsePayload(response);
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

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WPP Angular Shell</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig).catch((error) => {
console.error(error);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
import '@angular/compiler';

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@@ -0,0 +1,22 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.spec.ts'],
setupFiles: ['src/test-setup.ts'],
},
});

1588
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "wpp-frontend-api-client-baseline",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "vitest run",
"build": "tsc --noEmit"
},
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
}

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

@@ -0,0 +1,279 @@
import {
mapCalculateScoresResponse,
mapCreateSessionResponse,
mapFinishGameResponse,
mapHealthResponse,
mapJoinSessionResponse,
mapMixAnswersResponse,
mapScoreboardResponse,
mapSessionDetailResponse,
mapShowQuestionResponse,
mapStartNextRoundResponse,
mapStartRoundResponse,
mapSubmitGuessResponse,
mapSubmitLieResponse
} from './mappers';
import type {
ApiFailure,
ApiResult,
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailRequestOptions,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
StartRoundRequest,
StartRoundResponse,
SubmitGuessRequest,
SubmitGuessResponse,
SubmitLieRequest,
SubmitLieResponse
} from './types';
export interface AngularHttpError {
status?: number;
message?: string;
error?: unknown;
}
export interface AngularHttpClientLike {
get<T>(url: string, options?: { withCredentials?: boolean }): Promise<T>;
post<T>(url: string, body: unknown, options?: { withCredentials?: boolean }): Promise<T>;
}
export interface AngularApiClient {
health(): Promise<ApiResult<HealthResponse>>;
createSession(): Promise<ApiResult<CreateSessionResponse>>;
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>;
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
submitGuess(
code: string,
roundQuestionId: number,
payload: SubmitGuessRequest
): Promise<ApiResult<SubmitGuessResponse>>;
}
function toFailure(error: unknown): ApiFailure {
const candidate = (error ?? {}) as AngularHttpError;
const status = typeof candidate.status === 'number' ? candidate.status : 0;
const payload = candidate.error;
if (status === 0) {
return {
kind: 'network',
status: 0,
message: candidate.message ?? 'Network error while contacting API'
};
}
return {
kind: 'http',
status,
message: candidate.message ?? `HTTP ${status}`,
...(payload === undefined ? {} : { payload })
};
}
function normalizeCode(code: string): string {
return code.trim().toUpperCase();
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
}
function buildUrl(baseUrl: string, path: string): string {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
const sessionToken = options?.session_token?.trim();
if (!sessionToken) {
return path;
}
const params = new URLSearchParams({ session_token: sessionToken });
return `${path}?${params.toString()}`;
}
async function wrap<T>(call: () => Promise<unknown>, mapper: (payload: unknown) => T): Promise<ApiResult<T>> {
let payload: unknown;
try {
payload = await call();
} catch (error: unknown) {
return {
ok: false,
status: typeof (error as AngularHttpError)?.status === 'number' ? (error as AngularHttpError).status! : 0,
error: toFailure(error)
};
}
try {
return { ok: true, status: 200, data: mapper(payload) };
} catch (error: unknown) {
return {
ok: false,
status: 200,
error: {
kind: 'parse',
status: 200,
message: error instanceof Error ? error.message : 'Invalid API response contract',
payload
}
};
}
}
export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient {
return {
health: () =>
wrap(() => http.get<HealthResponse>(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse),
createSession: () =>
wrap(
() => http.post<CreateSessionResponse>(buildUrl(baseUrl, '/lobby/sessions/create'), {}, { withCredentials: true }),
mapCreateSessionResponse
),
getSession: (code: string, options?: SessionDetailRequestOptions) =>
wrap(
() => http.get<SessionDetailResponse>(buildUrl(baseUrl, buildSessionDetailPath(code, options)), { withCredentials: true }),
mapSessionDetailResponse
),
joinSession: (payload: JoinSessionRequest) =>
wrap(
() =>
http.post<JoinSessionResponse>(
buildUrl(baseUrl, '/lobby/sessions/join'),
{
code: normalizeCode(payload.code),
nickname: payload.nickname.trim()
},
{ withCredentials: true }
),
mapJoinSessionResponse
),
startRound: (code: string, payload: StartRoundRequest) =>
wrap(
() =>
http.post<StartRoundResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`),
payload,
{ withCredentials: true }
),
mapStartRoundResponse
),
showQuestion: (code: string) =>
wrap(
() =>
http.post<ShowQuestionResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/show`),
{},
{ withCredentials: true }
),
mapShowQuestionResponse
),
mixAnswers: (code: string, roundQuestionId: number) =>
wrap(
() =>
http.post<MixAnswersResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/answers/mix`
),
{},
{ withCredentials: true }
),
mapMixAnswersResponse
),
calculateScores: (code: string, roundQuestionId: number) =>
wrap(
() =>
http.post<CalculateScoresResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/scores/calculate`
),
{},
{ withCredentials: true }
),
mapCalculateScoresResponse
),
getScoreboard: (code: string) =>
wrap(
() =>
http.get<ScoreboardResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/scoreboard`),
{ withCredentials: true }
),
mapScoreboardResponse
),
startNextRound: (code: string) =>
wrap(
() =>
http.post<StartNextRoundResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`),
{},
{ withCredentials: true }
),
mapStartNextRoundResponse
),
finishGame: (code: string) =>
wrap(
() =>
http.post<FinishGameResponse>(
buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/finish`),
{},
{ withCredentials: true }
),
mapFinishGameResponse
),
submitLie: (code: string, roundQuestionId: number, payload: SubmitLieRequest) =>
wrap(
() =>
http.post<SubmitLieResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/lies/submit`
),
{
player_id: payload.player_id,
session_token: payload.session_token,
text: payload.text
},
{ withCredentials: true }
),
mapSubmitLieResponse
),
submitGuess: (code: string, roundQuestionId: number, payload: SubmitGuessRequest) =>
wrap(
() =>
http.post<SubmitGuessResponse>(
buildUrl(
baseUrl,
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/guesses/submit`
),
{
player_id: payload.player_id,
session_token: payload.session_token,
selected_text: payload.selected_text
},
{ withCredentials: true }
),
mapSubmitGuessResponse
)
};
}

271
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,271 @@
import {
mapCalculateScoresResponse,
mapCreateSessionResponse,
mapFinishGameResponse,
mapHealthResponse,
mapJoinSessionResponse,
mapMixAnswersResponse,
mapScoreboardResponse,
mapSessionDetailResponse,
mapShowQuestionResponse,
mapStartNextRoundResponse,
mapStartRoundResponse,
mapSubmitGuessResponse,
mapSubmitLieResponse
} from './mappers';
import type {
ApiResult,
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailRequestOptions,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
StartRoundRequest,
StartRoundResponse,
SubmitGuessRequest,
SubmitGuessResponse,
SubmitLieRequest,
SubmitLieResponse
} from './types';
export interface ApiClient {
health(): Promise<ApiResult<HealthResponse>>;
createSession(): Promise<ApiResult<CreateSessionResponse>>;
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>;
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise<ApiResult<SubmitGuessResponse>>;
}
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
function readCookie(name: string): string {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
return '';
}
const prefix = `${name}=`;
for (const part of document.cookie.split(';')) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return '';
}
async function ensureCsrfToken(): Promise<string> {
const existing = readCookie('csrftoken');
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
return existing;
}
try {
await fetchImpl(`${baseUrl}/lobby/csrf`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin'
});
} catch {
return '';
}
return readCookie('csrftoken');
}
async function request<T>(
path: string,
method: 'GET' | 'POST',
mapper: (payload: unknown) => T,
payload?: unknown
): Promise<ApiResult<T>> {
const csrfToken = method === 'POST' ? await ensureCsrfToken() : '';
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {})
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
credentials: 'same-origin'
});
} catch {
return {
ok: false,
status: 0,
error: { kind: 'network', status: 0, message: 'Network error while contacting API' }
};
}
if (response.redirected && response.url.includes('/accounts/login')) {
return {
ok: false,
status: 401,
error: {
kind: 'http',
status: 401,
message: 'Login required',
payload: { redirect: response.url }
}
};
}
let responsePayload: unknown;
try {
responsePayload = await response.json();
} catch {
return {
ok: false,
status: response.status,
error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' }
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
error: {
kind: 'http',
status: response.status,
message: `HTTP ${response.status}`,
payload: responsePayload
}
};
}
try {
return { ok: true, status: response.status, data: mapper(responsePayload) };
} catch (error) {
return {
ok: false,
status: response.status,
error: {
kind: 'parse',
status: response.status,
message: error instanceof Error ? error.message : 'Invalid API response contract',
payload: responsePayload
}
};
}
}
const normalizeCode = (value: string): string => value.trim().toUpperCase();
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
const sessionToken = options?.session_token?.trim();
if (!sessionToken) {
return path;
}
const params = new URLSearchParams({ session_token: sessionToken });
return `${path}?${params.toString()}`;
}
return {
health: () => request<HealthResponse>('/healthz', 'GET', mapHealthResponse),
createSession: () =>
request<CreateSessionResponse>(
'/lobby/sessions/create',
'POST',
mapCreateSessionResponse,
{}
),
getSession: (code: string, options?: SessionDetailRequestOptions) =>
request<SessionDetailResponse>(
buildSessionDetailPath(code, options),
'GET',
mapSessionDetailResponse
),
joinSession: (payload: JoinSessionRequest) =>
request<JoinSessionResponse>(
'/lobby/sessions/join',
'POST',
mapJoinSessionResponse,
{
code: normalizeCode(payload.code),
nickname: payload.nickname.trim()
}
),
startRound: (code: string, payload: StartRoundRequest) =>
request<StartRoundResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/start`,
'POST',
mapStartRoundResponse,
payload
),
showQuestion: (code: string) =>
request<ShowQuestionResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/show`,
'POST',
mapShowQuestionResponse,
{}
),
mixAnswers: (code: string, roundQuestionId: number) =>
request<MixAnswersResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/answers/mix`,
'POST',
mapMixAnswersResponse,
{}
),
calculateScores: (code: string, roundQuestionId: number) =>
request<CalculateScoresResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/scores/calculate`,
'POST',
mapCalculateScoresResponse,
{}
),
getScoreboard: (code: string) =>
request<ScoreboardResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/scoreboard`,
'GET',
mapScoreboardResponse
),
startNextRound: (code: string) =>
request<StartNextRoundResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`,
'POST',
mapStartNextRoundResponse,
{}
),
finishGame: (code: string) =>
request<FinishGameResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/finish`,
'POST',
mapFinishGameResponse,
{}
),
submitLie: (code: string, roundQuestionId: number, payload: SubmitLieRequest) =>
request<SubmitLieResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/lies/submit`,
'POST',
mapSubmitLieResponse,
payload
),
submitGuess: (code: string, roundQuestionId: number, payload: SubmitGuessRequest) =>
request<SubmitGuessResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/questions/${roundQuestionId}/guesses/submit`,
'POST',
mapSubmitGuessResponse,
payload
)
};
}

553
frontend/src/api/mappers.ts Normal file
View File

@@ -0,0 +1,553 @@
import type {
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
StartRoundResponse,
SubmitGuessResponse,
SubmitLieResponse,
VoiceCue,
} from './types';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function asRecord(value: unknown, path: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`Invalid API contract: expected object at ${path}`);
}
return value;
}
function readString(record: Record<string, unknown>, key: string, path: string): string {
const value = record[key];
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.${key}`);
}
return value;
}
function readNumber(record: Record<string, unknown>, key: string, path: string): number {
const value = record[key];
if (!isNumber(value)) {
throw new Error(`Invalid API contract: expected number at ${path}.${key}`);
}
return value;
}
function readNullableString(record: Record<string, unknown>, key: string, path: string): string | null {
const value = record[key];
if (value === undefined || value === null) {
return null;
}
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string|null at ${path}.${key}`);
}
return value;
}
function readBoolean(record: Record<string, unknown>, key: string, path: string): boolean {
const value = record[key];
if (!isBoolean(value)) {
throw new Error(`Invalid API contract: expected boolean at ${path}.${key}`);
}
return value;
}
function readNullableNumber(record: Record<string, unknown>, key: string, path: string): number | null {
const value = record[key];
if (value === undefined || value === null) {
return null;
}
if (!isNumber(value)) {
throw new Error(`Invalid API contract: expected number|null at ${path}.${key}`);
}
return value;
}
function mapVoiceCue(payload: unknown, path: string): VoiceCue | null {
if (payload === null || payload === undefined) {
return null;
}
const record = asRecord(payload, path);
const translations = record.translations;
if (!isRecord(translations)) {
throw new Error(`Invalid API contract: expected object at ${path}.translations`);
}
const audioUrls = record.audio_urls;
if (audioUrls !== undefined && audioUrls !== null && !isRecord(audioUrls)) {
throw new Error(`Invalid API contract: expected object at ${path}.audio_urls`);
}
const textByLocale: Record<string, string> = {};
for (const [locale, value] of Object.entries(translations)) {
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.translations.${locale}`);
}
textByLocale[locale] = value;
}
const audioByLocale: Record<string, string> = {};
for (const [locale, value] of Object.entries(audioUrls ?? {})) {
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.audio_urls.${locale}`);
}
audioByLocale[locale] = value;
}
return {
cue: readString(record, 'cue', path),
translations: textByLocale,
audio_urls: audioByLocale,
source: readString(record, 'source', path),
};
}
function mapSessionPlayerIdentity(payload: unknown, path: string): { token: string; tone: string; icon?: string } | undefined {
if (payload === undefined || payload === null) {
return undefined;
}
const record = asRecord(payload, path);
const icon = record.icon;
if (icon !== undefined && icon !== null && !isString(icon)) {
throw new Error(`Invalid API contract: expected string at ${path}.icon`);
}
return {
token: readString(record, 'token', path),
tone: readString(record, 'tone', path),
...(icon === undefined || icon === null ? {} : { icon }),
};
}
export function mapHealthResponse(payload: unknown): HealthResponse {
const root = asRecord(payload, 'health');
return {
ok: readBoolean(root, 'ok', 'health'),
service: readString(root, 'service', 'health')
};
}
export function mapCreateSessionResponse(payload: unknown): CreateSessionResponse {
const root = asRecord(payload, 'create_session');
const session = asRecord(root.session, 'create_session.session');
return {
session: {
code: readString(session, 'code', 'create_session.session'),
status: readString(session, 'status', 'create_session.session'),
host_id: readNullableNumber(session, 'host_id', 'create_session.session'),
current_round: readNumber(session, 'current_round', 'create_session.session')
}
};
}
function mapSessionDetail(payload: unknown): SessionDetailResponse {
const root = asRecord(payload, 'session_detail');
const session = asRecord(root.session, 'session_detail.session');
const players = root.players;
if (!Array.isArray(players)) {
throw new Error('Invalid API contract: expected array at session_detail.players');
}
const roundQuestionRaw = root.round_question;
let roundQuestion: SessionDetailResponse['round_question'] = null;
if (roundQuestionRaw !== null) {
const roundQuestionRecord = asRecord(roundQuestionRaw, 'session_detail.round_question');
const answersRaw = roundQuestionRecord.answers;
if (!Array.isArray(answersRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.round_question.answers');
}
roundQuestion = {
id: readNumber(roundQuestionRecord, 'id', 'session_detail.round_question'),
round_number: readNumber(roundQuestionRecord, 'round_number', 'session_detail.round_question'),
prompt: readNullableString(roundQuestionRecord, 'prompt', 'session_detail.round_question'),
shown_at: readString(roundQuestionRecord, 'shown_at', 'session_detail.round_question'),
answers: answersRaw.map((answer, index) => {
const answerRecord = asRecord(answer, `session_detail.round_question.answers[${index}]`);
return { text: readString(answerRecord, 'text', `session_detail.round_question.answers[${index}]`) };
})
};
}
const phase = asRecord(root.phase_view_model, 'session_detail.phase_view_model');
const constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints');
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
const player = asRecord(phase.player, 'session_detail.phase_view_model.player');
const revealRaw = root.reveal;
let reveal: SessionDetailResponse['reveal'] = null;
if (revealRaw !== null && revealRaw !== undefined) {
const revealRecord = asRecord(revealRaw, 'session_detail.reveal');
const liesRaw = revealRecord.lies;
const guessesRaw = revealRecord.guesses;
if (!Array.isArray(liesRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.reveal.lies');
}
if (!Array.isArray(guessesRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses');
}
reveal = {
round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'),
round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'),
prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'),
correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'),
lies: liesRaw.map((lie, index) => {
const record = asRecord(lie, `session_detail.reveal.lies[${index}]`);
return {
player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`),
nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`),
text: readString(record, 'text', `session_detail.reveal.lies[${index}]`),
created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`)
};
}),
guesses: guessesRaw.map((guess, index) => {
const path = `session_detail.reveal.guesses[${index}]`;
const record = asRecord(guess, path);
const fooledPlayerId = readNullableNumber(record, 'fooled_player_id', path);
const fooledPlayerNickname = record.fooled_player_nickname;
if (fooledPlayerId === null) {
if (fooledPlayerNickname !== undefined) {
throw new Error(`Invalid API contract: expected ${path}.fooled_player_nickname to be omitted when fooled_player_id is null`);
}
} else if (!isString(fooledPlayerNickname)) {
throw new Error(`Invalid API contract: expected string at ${path}.fooled_player_nickname when fooled_player_id is set`);
}
return {
player_id: readNumber(record, 'player_id', path),
nickname: readString(record, 'nickname', path),
selected_text: readString(record, 'selected_text', path),
is_correct: readBoolean(record, 'is_correct', path),
fooled_player_id: fooledPlayerId,
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }),
created_at: readString(record, 'created_at', path)
};
})
};
}
const voiceCuesRaw = root.voice_cues;
let voiceCues: SessionDetailResponse['voice_cues'] = null;
if (voiceCuesRaw !== null && voiceCuesRaw !== undefined) {
const record = asRecord(voiceCuesRaw, 'session_detail.voice_cues');
voiceCues = {
default_locale: readString(record, 'default_locale', 'session_detail.voice_cues'),
intro: mapVoiceCue(record.intro, 'session_detail.voice_cues.intro'),
phase: mapVoiceCue(record.phase, 'session_detail.voice_cues.phase'),
question_prompt: mapVoiceCue(record.question_prompt, 'session_detail.voice_cues.question_prompt'),
question_reveal: mapVoiceCue(record.question_reveal, 'session_detail.voice_cues.question_reveal'),
};
}
const phaseDisplayRaw = root.phase_display;
let phaseDisplay: SessionDetailResponse['phase_display'] = null;
if (phaseDisplayRaw !== null && phaseDisplayRaw !== undefined) {
const record = asRecord(phaseDisplayRaw, 'session_detail.phase_display');
const ornament = readNullableString(record, 'ornament', 'session_detail.phase_display');
phaseDisplay = {
theme: readString(record, 'theme', 'session_detail.phase_display'),
...(ornament === null ? {} : { ornament }),
title_key: readString(record, 'title_key', 'session_detail.phase_display'),
body_key: readString(record, 'body_key', 'session_detail.phase_display'),
cue_label_key: readString(record, 'cue_label_key', 'session_detail.phase_display'),
cue_body_key: readString(record, 'cue_body_key', 'session_detail.phase_display'),
};
}
return {
session: {
code: readString(session, 'code', 'session_detail.session'),
status: readString(session, 'status', 'session_detail.session'),
host_id: (() => {
const hostId = session.host_id;
if (hostId === null) {
return null;
}
if (!isNumber(hostId)) {
throw new Error('Invalid API contract: expected number|null at session_detail.session.host_id');
}
return hostId;
})(),
current_round: readNumber(session, 'current_round', 'session_detail.session'),
players_count: readNumber(session, 'players_count', 'session_detail.session')
},
viewer_role:
root.viewer_role === 'host' || root.viewer_role === 'player' || root.viewer_role === 'public'
? root.viewer_role
: undefined,
players: players.map((item, index) => {
const record = asRecord(item, `session_detail.players[${index}]`);
return {
id: readNumber(record, 'id', `session_detail.players[${index}]`),
nickname: readString(record, 'nickname', `session_detail.players[${index}]`),
score: readNumber(record, 'score', `session_detail.players[${index}]`),
is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`),
identity: mapSessionPlayerIdentity(record.identity, `session_detail.players[${index}].identity`),
};
}),
round_question: roundQuestion,
reveal,
voice_cues: voiceCues,
phase_display: phaseDisplay,
phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'),
current_phase: typeof phase.current_phase === 'string' ? phase.current_phase : undefined,
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
constraints: {
min_players_to_start: readNumber(constraints, 'min_players_to_start', 'session_detail.phase_view_model.constraints'),
max_players_mvp: readNumber(constraints, 'max_players_mvp', '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')
},
readiness:
phase.readiness && typeof phase.readiness === 'object'
? {
question_ready:
typeof (phase.readiness as Record<string, unknown>).question_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).question_ready as boolean)
: undefined,
scoreboard_ready:
typeof (phase.readiness as Record<string, unknown>).scoreboard_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).scoreboard_ready as boolean)
: undefined,
}
: undefined,
host: {
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
can_mix_answers: readBoolean(host, 'can_mix_answers', 'session_detail.phase_view_model.host'),
can_calculate_scores: readBoolean(host, 'can_calculate_scores', 'session_detail.phase_view_model.host'),
can_reveal_scoreboard: readBoolean(host, 'can_reveal_scoreboard', 'session_detail.phase_view_model.host'),
can_start_next_round: readBoolean(host, 'can_start_next_round', 'session_detail.phase_view_model.host'),
can_finish_game: readBoolean(host, 'can_finish_game', 'session_detail.phase_view_model.host')
},
player: {
can_join: readBoolean(player, 'can_join', 'session_detail.phase_view_model.player'),
can_submit_lie: readBoolean(player, 'can_submit_lie', 'session_detail.phase_view_model.player'),
can_submit_guess: readBoolean(player, 'can_submit_guess', 'session_detail.phase_view_model.player'),
can_view_final_result: readBoolean(player, 'can_view_final_result', 'session_detail.phase_view_model.player')
}
}
};
}
export function mapSessionDetailResponse(payload: unknown): SessionDetailResponse {
return mapSessionDetail(payload);
}
export function mapJoinSessionResponse(payload: unknown): JoinSessionResponse {
const root = asRecord(payload, 'join_session');
const player = asRecord(root.player, 'join_session.player');
const session = asRecord(root.session, 'join_session.session');
return {
player: {
id: readNumber(player, 'id', 'join_session.player'),
nickname: readString(player, 'nickname', 'join_session.player'),
session_token: readString(player, 'session_token', 'join_session.player'),
score: readNumber(player, 'score', 'join_session.player')
},
session: {
code: readString(session, 'code', 'join_session.session'),
status: readString(session, 'status', 'join_session.session')
}
};
}
export function mapStartRoundResponse(payload: unknown): StartRoundResponse {
const root = asRecord(payload, 'start_round');
const session = asRecord(root.session, 'start_round.session');
const round = asRecord(root.round, 'start_round.round');
const category = asRecord(round.category, 'start_round.round.category');
return {
session: {
code: readString(session, 'code', 'start_round.session'),
status: readString(session, 'status', 'start_round.session'),
current_round: readNumber(session, 'current_round', 'start_round.session')
},
round: {
number: readNumber(round, 'number', 'start_round.round'),
category: {
slug: readString(category, 'slug', 'start_round.round.category'),
name: readString(category, 'name', 'start_round.round.category')
}
}
};
}
function mapLeaderboardEntry(payload: unknown, path: string): { id: number; nickname: string; score: number } {
const record = asRecord(payload, path);
return {
id: readNumber(record, 'id', path),
nickname: readString(record, 'nickname', path),
score: readNumber(record, 'score', path)
};
}
function mapSessionState(payload: unknown, path: string): { code: string; status: string; current_round: number } {
const session = asRecord(payload, path);
return {
code: readString(session, 'code', path),
status: readString(session, 'status', path),
current_round: readNumber(session, 'current_round', path)
};
}
export function mapShowQuestionResponse(payload: unknown): ShowQuestionResponse {
const root = asRecord(payload, 'show_question');
const roundQuestion = asRecord(root.round_question, 'show_question.round_question');
const config = asRecord(root.config, 'show_question.config');
return {
round_question: {
id: readNumber(roundQuestion, 'id', 'show_question.round_question'),
prompt: readString(roundQuestion, 'prompt', 'show_question.round_question'),
round_number: readNumber(roundQuestion, 'round_number', 'show_question.round_question'),
shown_at: readString(roundQuestion, 'shown_at', 'show_question.round_question'),
lie_deadline_at: readString(roundQuestion, 'lie_deadline_at', 'show_question.round_question')
},
config: {
lie_seconds: readNumber(config, 'lie_seconds', 'show_question.config')
}
};
}
export function mapMixAnswersResponse(payload: unknown): MixAnswersResponse {
const root = asRecord(payload, 'mix_answers');
const roundQuestion = asRecord(root.round_question, 'mix_answers.round_question');
const answersRaw = root.answers;
if (!Array.isArray(answersRaw)) {
throw new Error('Invalid API contract: expected array at mix_answers.answers');
}
return {
session: mapSessionState(root.session, 'mix_answers.session'),
round_question: {
id: readNumber(roundQuestion, 'id', 'mix_answers.round_question'),
round_number: readNumber(roundQuestion, 'round_number', 'mix_answers.round_question')
},
answers: answersRaw.map((answer, index) => {
const record = asRecord(answer, `mix_answers.answers[${index}]`);
return { text: readString(record, 'text', `mix_answers.answers[${index}]`) };
})
};
}
export function mapCalculateScoresResponse(payload: unknown): CalculateScoresResponse {
const root = asRecord(payload, 'calculate_scores');
const roundQuestion = asRecord(root.round_question, 'calculate_scores.round_question');
const leaderboardRaw = root.leaderboard;
if (!Array.isArray(leaderboardRaw)) {
throw new Error('Invalid API contract: expected array at calculate_scores.leaderboard');
}
return {
session: mapSessionState(root.session, 'calculate_scores.session'),
round_question: {
id: readNumber(roundQuestion, 'id', 'calculate_scores.round_question'),
round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question')
},
events_created: readNumber(root, 'events_created', 'calculate_scores'),
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`))
};
}
export function mapScoreboardResponse(payload: unknown): ScoreboardResponse {
const root = asRecord(payload, 'scoreboard');
const leaderboardRaw = root.leaderboard;
if (!Array.isArray(leaderboardRaw)) {
throw new Error('Invalid API contract: expected array at scoreboard.leaderboard');
}
return {
session: mapSessionState(root.session, 'scoreboard.session'),
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `scoreboard.leaderboard[${index}]`))
};
}
export function mapStartNextRoundResponse(payload: unknown): StartNextRoundResponse {
const root = asRecord(payload, 'start_next_round');
return { session: mapSessionState(root.session, 'start_next_round.session') };
}
export function mapFinishGameResponse(payload: unknown): FinishGameResponse {
const root = asRecord(payload, 'finish_game');
const leaderboardRaw = root.leaderboard;
if (!Array.isArray(leaderboardRaw)) {
throw new Error('Invalid API contract: expected array at finish_game.leaderboard');
}
const winnerRaw = root.winner;
return {
session: mapSessionState(root.session, 'finish_game.session'),
winner: winnerRaw === null ? null : mapLeaderboardEntry(winnerRaw, 'finish_game.winner'),
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `finish_game.leaderboard[${index}]`))
};
}
export function mapSubmitLieResponse(payload: unknown): SubmitLieResponse {
const root = asRecord(payload, 'submit_lie');
const lie = asRecord(root.lie, 'submit_lie.lie');
const window = asRecord(root.window, 'submit_lie.window');
return {
lie: {
id: readNumber(lie, 'id', 'submit_lie.lie'),
player_id: readNumber(lie, 'player_id', 'submit_lie.lie'),
round_question_id: readNumber(lie, 'round_question_id', 'submit_lie.lie'),
text: readString(lie, 'text', 'submit_lie.lie'),
created_at: readString(lie, 'created_at', 'submit_lie.lie')
},
window: {
lie_deadline_at: readString(window, 'lie_deadline_at', 'submit_lie.window')
}
};
}
export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse {
const root = asRecord(payload, 'submit_guess');
const guess = asRecord(root.guess, 'submit_guess.guess');
const window = asRecord(root.window, 'submit_guess.window');
const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess');
return {
guess: {
id: readNumber(guess, 'id', 'submit_guess.guess'),
player_id: readNumber(guess, 'player_id', 'submit_guess.guess'),
round_question_id: readNumber(guess, 'round_question_id', 'submit_guess.guess'),
selected_text: readString(guess, 'selected_text', 'submit_guess.guess'),
is_correct: readBoolean(guess, 'is_correct', 'submit_guess.guess'),
fooled_player_id: fooledPlayerId,
created_at: readString(guess, 'created_at', 'submit_guess.guess')
},
window: {
guess_deadline_at: readString(window, 'guess_deadline_at', 'submit_guess.window')
}
};
}

299
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,299 @@
export interface HealthResponse {
ok: boolean;
service: string;
}
export interface CreateSessionResponse {
session: {
code: string;
status: string;
host_id: number | null;
current_round: number;
};
}
export interface SessionSummary {
code: string;
status: string;
host_id: number | null;
current_round: number;
players_count: number;
}
export interface SessionPlayer {
id: number;
nickname: string;
score: number;
is_connected: boolean;
identity?: {
token: string;
tone: string;
icon?: string;
};
}
export interface SessionAnswer {
text: string;
}
export interface SessionRoundQuestion {
id: number;
round_number: number;
prompt: string | null;
shown_at: string;
answers: SessionAnswer[];
}
export interface PhaseViewModel {
status: string;
current_phase?: string;
round_number: number;
players_count: number;
constraints: {
min_players_to_start: number;
max_players_mvp: number;
min_players_reached: boolean;
max_players_allowed: boolean;
};
readiness?: {
question_ready?: boolean;
scoreboard_ready?: boolean;
};
host: {
can_start_round: boolean;
can_show_question: boolean;
can_mix_answers: boolean;
can_calculate_scores: boolean;
can_reveal_scoreboard: boolean;
can_start_next_round: boolean;
can_finish_game: boolean;
};
player: {
can_join: boolean;
can_submit_lie: boolean;
can_submit_guess: boolean;
can_view_final_result: boolean;
};
}
export interface RevealLie {
player_id: number;
nickname: string;
text: string;
created_at: string;
}
export interface RevealGuess {
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at: string;
}
export interface RevealPayload {
round_question_id: number;
round_number: number;
prompt: string;
correct_answer: string;
lies: RevealLie[];
guesses: RevealGuess[];
}
export interface VoiceCue {
cue: string;
translations: Record<string, string>;
audio_urls: Record<string, string>;
source: string;
}
export interface SessionVoiceCues {
default_locale: string;
intro: VoiceCue | null;
phase: VoiceCue | null;
question_prompt: VoiceCue | null;
question_reveal: VoiceCue | null;
}
export interface SessionPhaseDisplay {
theme: string;
ornament?: string;
title_key: string;
body_key: string;
cue_label_key: string;
cue_body_key: string;
}
export interface SessionDetailResponse {
session: SessionSummary;
viewer_role?: 'host' | 'player' | 'public';
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
voice_cues?: SessionVoiceCues | null;
phase_display?: SessionPhaseDisplay | null;
phase_view_model: PhaseViewModel;
}
export interface SessionDetailRequestOptions {
session_token?: string;
}
export interface JoinSessionRequest {
code: string;
nickname: string;
}
export interface JoinSessionResponse {
player: {
id: number;
nickname: string;
session_token: string;
score: number;
};
session: {
code: string;
status: string;
};
}
export interface StartRoundRequest {
category_slug: string;
}
export interface StartRoundResponse {
session: {
code: string;
status: string;
current_round: number;
};
round: {
number: number;
category: {
slug: string;
name: string;
};
};
}
export interface ShowQuestionResponse {
round_question: {
id: number;
prompt: string;
round_number: number;
shown_at: string;
lie_deadline_at: string;
};
config: {
lie_seconds: number;
};
}
export interface MixAnswersResponse {
session: {
code: string;
status: string;
current_round: number;
};
round_question: {
id: number;
round_number: number;
};
answers: Array<{ text: string }>;
}
export interface CalculateScoresResponse {
session: {
code: string;
status: string;
current_round: number;
};
round_question: {
id: number;
round_number: number;
};
events_created: number;
leaderboard: Array<{ id: number; nickname: string; score: number }>;
}
export interface ScoreboardResponse {
session: {
code: string;
status: string;
current_round: number;
};
leaderboard: Array<{ id: number; nickname: string; score: number }>;
}
export interface StartNextRoundResponse {
session: {
code: string;
status: string;
current_round: number;
};
}
export interface FinishGameResponse {
session: {
code: string;
status: string;
current_round: number;
};
winner: { id: number; nickname: string; score: number } | null;
leaderboard: Array<{ id: number; nickname: string; score: number }>;
}
export interface SubmitLieRequest {
player_id: number;
session_token: string;
text: string;
}
export interface SubmitLieResponse {
lie: {
id: number;
player_id: number;
round_question_id: number;
text: string;
created_at: string;
};
window: {
lie_deadline_at: string;
};
}
export interface SubmitGuessRequest {
player_id: number;
session_token: string;
selected_text: string;
}
export interface SubmitGuessResponse {
guess: {
id: number;
player_id: number;
round_question_id: number;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
created_at: string;
};
window: {
guess_deadline_at: string;
};
}
export type ApiErrorKind = 'network' | 'http' | 'parse';
export interface ApiFailure {
kind: ApiErrorKind;
message: string;
status: number;
payload?: unknown;
}
export type ApiResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; error: ApiFailure };

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