From 62090d7e64e4f43ec5691a905275186a7c834be9 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 17:58:42 +0000 Subject: [PATCH] feat(spa): sync host/player hash routes with gameplay phase --- .../app/features/host/host-shell.component.ts | 16 +++- .../features/player/player-shell.component.ts | 25 ++++--- .../src/app/gameplay-route-sync.spec.ts | 29 ++++++++ .../angular/src/app/gameplay-route-sync.ts | 73 +++++++++++++++++++ 4 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 frontend/angular/src/app/gameplay-route-sync.spec.ts create mode 100644 frontend/angular/src/app/gameplay-route-sync.ts diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 899d3d6..742c3c7 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -1,10 +1,13 @@ import { CommonModule } from '@angular/common'; +import type { ActivatedRoute } from '@angular/router'; import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; +import { syncHashRoute } from '../../gameplay-route-sync'; +import type { RouteSessionContext } from '../../session-route-context'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -80,14 +83,15 @@ export class HostShellComponent implements OnInit { private readonly api = createApiClient(); private readonly controller = createVerticalSliceController(this.api); + constructor(private readonly route: Pick = { snapshot: { data: {} } as never }) {} + ngOnInit(): void { if (typeof window === 'undefined') { return; } - const hashRoute = window.location.hash.replace(/^#\/?/, ''); - const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i); - const codeFromRoute = match?.[1] ?? ''; + const routeContext = this.route.snapshot.data['routeContext'] as RouteSessionContext | undefined; + const codeFromRoute = routeContext?.sessionCode ?? ''; const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? ''; const candidate = codeFromRoute || storedCode; @@ -144,6 +148,9 @@ export class HostShellComponent implements OnInit { this.sessionCode = this.session.session.code; this.persistSessionCode(this.sessionCode); this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; + if (!syncHashRoute('host', this.session)) { + throw new Error(`Unsupported host phase status: ${this.session.session.status}`); + } if (this.session.session.status !== 'finished') { this.resetFinalLeaderboard(); } @@ -164,6 +171,9 @@ export class HostShellComponent implements OnInit { this.sessionCode = this.session.session.code; this.persistSessionCode(this.sessionCode); this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; + if (!syncHashRoute('host', this.session)) { + throw new Error(`Unsupported host phase status: ${this.session.session.status}`); + } this.scoreboardPayload = ''; this.resetFinalLeaderboard(); }); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 11b2f40..6a63cc6 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -1,10 +1,13 @@ import { CommonModule } from '@angular/common'; +import type { ActivatedRoute } from '@angular/router'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; +import { syncHashRoute } from '../../gameplay-route-sync'; +import type { RouteSessionContext } from '../../session-route-context'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -108,7 +111,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); private reconnectTimer: ReturnType | null = null; - constructor() { + constructor(private readonly route: Pick = { snapshot: { data: {} } as never }) { if (typeof navigator !== 'undefined' && !navigator.onLine) { this.connectionState = 'offline'; } @@ -120,17 +123,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } ngOnInit(): void { - const hashRoute = window.location.hash.replace(/^#\/?/, ''); - const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); - const codeFromRoute = match?.[1] ?? ''; - + const routeContext = this.route.snapshot.data['routeContext'] as RouteSessionContext | undefined; const persistedContext = this.sessionContextStore.get(); - if (persistedContext) { - this.playerId = persistedContext.playerId; - this.sessionToken = persistedContext.token; - } - const candidate = codeFromRoute || persistedContext?.sessionCode || ''; + this.playerId = routeContext?.playerId ?? persistedContext?.playerId ?? 0; + this.sessionToken = routeContext?.token ?? persistedContext?.token ?? ''; + + const candidate = routeContext?.sessionCode || persistedContext?.sessionCode || ''; if (!candidate) { return; } @@ -291,6 +290,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; + if (!syncHashRoute('player', this.session)) { + throw new Error(`Unsupported player phase status: ${this.session.session.status}`); + } if (this.session.session.status !== 'guess') { this.selectedGuess = ''; } @@ -316,6 +318,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; + if (!syncHashRoute('player', this.session)) { + throw new Error(`Unsupported player phase status: ${this.session.session.status}`); + } const sessionContext = this.sessionContextStore.get(); this.playerId = sessionContext?.playerId ?? 0; diff --git a/frontend/angular/src/app/gameplay-route-sync.spec.ts b/frontend/angular/src/app/gameplay-route-sync.spec.ts new file mode 100644 index 0000000..391692b --- /dev/null +++ b/frontend/angular/src/app/gameplay-route-sync.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { syncHashRoute } from './gameplay-route-sync'; + +describe('gameplay route sync', () => { + it('maps host status to hash phase route with session context', () => { + vi.stubGlobal('window', { + location: { hash: '#/host/lobby/ABCD12' }, + history: { replaceState: vi.fn() }, + }); + + const ok = syncHashRoute('host', { session: { code: 'abcd12', status: 'guess' } }); + + expect(ok).toBe(true); + expect(window.history.replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); + }); + + it('returns false for unsupported status to allow controlled fallback', () => { + vi.stubGlobal('window', { + location: { hash: '#/player/lobby/ABCD12' }, + history: { replaceState: vi.fn() }, + }); + + const ok = syncHashRoute('player', { session: { code: 'ABCD12', status: 'waiting' } }); + + expect(ok).toBe(false); + expect(window.history.replaceState).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/angular/src/app/gameplay-route-sync.ts b/frontend/angular/src/app/gameplay-route-sync.ts new file mode 100644 index 0000000..8b7c256 --- /dev/null +++ b/frontend/angular/src/app/gameplay-route-sync.ts @@ -0,0 +1,73 @@ +import { deriveGameplayPhase } from '../../../src/spa/gameplay-phase-machine'; + +type ShellMode = 'host' | 'player'; + +interface SessionLike { + session: { + code: string; + status: string; + }; +} + +function normalizeCode(value: string): string { + return value.trim().toUpperCase(); +} + +function toRoutePhase(status: string): string | null { + if (status === 'lobby') { + return 'lobby'; + } + + const phase = deriveGameplayPhase({ + session: { code: 'route-sync', status, host_id: null, current_round: 1, players_count: 0 }, + players: [], + round_question: null, + phase_view_model: { + status, + round_number: 1, + players_count: 0, + constraints: { + min_players_to_start: 0, + max_players_mvp: 0, + min_players_reached: false, + max_players_allowed: false, + }, + 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: false, + }, + }, + }); + + return phase; +} + +export function syncHashRoute(mode: ShellMode, session: SessionLike): boolean { + if (typeof window === 'undefined') { + return true; + } + + const code = normalizeCode(session.session.code); + const phase = toRoutePhase(session.session.status); + if (!code || !phase) { + return false; + } + + const nextHash = `#/${mode}/${phase}/${encodeURIComponent(code)}`; + if (window.location.hash !== nextHash) { + window.history.replaceState(null, '', nextHash); + } + + return true; +}