1 Commits

Author SHA1 Message Date
62090d7e64 feat(spa): sync host/player hash routes with gameplay phase
All checks were successful
CI / test-and-quality (push) Successful in 1m53s
2026-03-01 17:59:07 +00:00
6 changed files with 124 additions and 47 deletions

View File

@@ -244,21 +244,4 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.nextRoundError).toContain('Session code is required');
expect(component.finishError).toContain('Session code is required');
});
it('bootstraps refresh from resolver route context on init', () => {
vi.stubGlobal('window', {
sessionStorage: {
getItem: vi.fn().mockReturnValue(null),
setItem: vi.fn(),
},
});
const component = new HostShellComponent({ snapshot: { data: { routeContext: { sessionCode: 'ab12' } } } } as never);
const refreshSpy = vi.spyOn(component, 'refreshSession').mockResolvedValue();
component.ngOnInit();
expect(component.sessionCode).toBe('AB12');
expect(refreshSpy).toHaveBeenCalledOnce();
});
});

View File

@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, Optional } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
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 {
@@ -82,14 +83,14 @@ export class HostShellComponent implements OnInit {
private readonly api = createApiClient();
private readonly controller = createVerticalSliceController(this.api);
constructor(@Optional() private readonly route: ActivatedRoute | null = null) {}
constructor(private readonly route: Pick<ActivatedRoute, 'snapshot'> = { snapshot: { data: {} } as never }) {}
ngOnInit(): void {
if (typeof window === 'undefined') {
return;
}
const routeContext = this.route?.snapshot.data['routeContext'] as RouteSessionContext | undefined;
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;
@@ -147,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();
}
@@ -167,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();
});

View File

@@ -254,28 +254,6 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.loadingTransition).toBeNull();
});
it('hydrates session context from resolver payload on init', () => {
const component = new PlayerShellComponent({
snapshot: {
data: {
routeContext: {
sessionCode: 'ab12',
playerId: 7,
token: 'tok-7',
},
},
},
} as never);
const refreshSpy = vi.spyOn(component, 'refreshSession').mockResolvedValue();
component.ngOnInit();
expect(component.sessionCode).toBe('AB12');
expect(component.playerId).toBe(7);
expect(component.sessionToken).toBe('tok-7');
expect(refreshSpy).toHaveBeenCalledOnce();
});
it('returnToJoin clears persisted session context and transient state', () => {
const values = new Map<string, string>();
const localStorage = {

View File

@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, Optional } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
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 {
@@ -110,7 +111,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor(@Optional() private readonly route: ActivatedRoute | null = null) {
constructor(private readonly route: Pick<ActivatedRoute, 'snapshot'> = { snapshot: { data: {} } as never }) {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.connectionState = 'offline';
}
@@ -122,7 +123,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
const routeContext = this.route?.snapshot.data['routeContext'] as RouteSessionContext | undefined;
const routeContext = this.route.snapshot.data['routeContext'] as RouteSessionContext | undefined;
const persistedContext = this.sessionContextStore.get();
this.playerId = routeContext?.playerId ?? persistedContext?.playerId ?? 0;
@@ -289,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 = '';
}
@@ -314,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;

View File

@@ -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();
});
});

View File

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