Compare commits
1 Commits
8c0a561a64
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
| 62090d7e64 |
@@ -1,10 +1,13 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import type { ActivatedRoute } from '@angular/router';
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { createApiClient } from '../../../../../src/api/client';
|
import { createApiClient } from '../../../../../src/api/client';
|
||||||
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
|
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
|
||||||
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
|
import { syncHashRoute } from '../../gameplay-route-sync';
|
||||||
|
import type { RouteSessionContext } from '../../session-route-context';
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
session: { code: string; status: string; current_round: number };
|
session: { code: string; status: string; current_round: number };
|
||||||
@@ -80,14 +83,15 @@ export class HostShellComponent implements OnInit {
|
|||||||
private readonly api = createApiClient();
|
private readonly api = createApiClient();
|
||||||
private readonly controller = createVerticalSliceController(this.api);
|
private readonly controller = createVerticalSliceController(this.api);
|
||||||
|
|
||||||
|
constructor(private readonly route: Pick<ActivatedRoute, 'snapshot'> = { snapshot: { data: {} } as never }) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
const routeContext = this.route.snapshot.data['routeContext'] as RouteSessionContext | undefined;
|
||||||
const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
const codeFromRoute = routeContext?.sessionCode ?? '';
|
||||||
const codeFromRoute = match?.[1] ?? '';
|
|
||||||
const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? '';
|
const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? '';
|
||||||
const candidate = codeFromRoute || storedCode;
|
const candidate = codeFromRoute || storedCode;
|
||||||
|
|
||||||
@@ -144,6 +148,9 @@ export class HostShellComponent implements OnInit {
|
|||||||
this.sessionCode = this.session.session.code;
|
this.sessionCode = this.session.session.code;
|
||||||
this.persistSessionCode(this.sessionCode);
|
this.persistSessionCode(this.sessionCode);
|
||||||
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
|
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
|
||||||
|
if (!syncHashRoute('host', this.session)) {
|
||||||
|
throw new Error(`Unsupported host phase status: ${this.session.session.status}`);
|
||||||
|
}
|
||||||
if (this.session.session.status !== 'finished') {
|
if (this.session.session.status !== 'finished') {
|
||||||
this.resetFinalLeaderboard();
|
this.resetFinalLeaderboard();
|
||||||
}
|
}
|
||||||
@@ -164,6 +171,9 @@ export class HostShellComponent implements OnInit {
|
|||||||
this.sessionCode = this.session.session.code;
|
this.sessionCode = this.session.session.code;
|
||||||
this.persistSessionCode(this.sessionCode);
|
this.persistSessionCode(this.sessionCode);
|
||||||
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
|
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
|
||||||
|
if (!syncHashRoute('host', this.session)) {
|
||||||
|
throw new Error(`Unsupported host phase status: ${this.session.session.status}`);
|
||||||
|
}
|
||||||
this.scoreboardPayload = '';
|
this.scoreboardPayload = '';
|
||||||
this.resetFinalLeaderboard();
|
this.resetFinalLeaderboard();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import type { ActivatedRoute } from '@angular/router';
|
||||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { createApiClient } from '../../../../../src/api/client';
|
import { createApiClient } from '../../../../../src/api/client';
|
||||||
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
||||||
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
|
import { syncHashRoute } from '../../gameplay-route-sync';
|
||||||
|
import type { RouteSessionContext } from '../../session-route-context';
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
session: { code: string; status: string; current_round: number };
|
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 readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly route: Pick<ActivatedRoute, 'snapshot'> = { snapshot: { data: {} } as never }) {
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||||
this.connectionState = 'offline';
|
this.connectionState = 'offline';
|
||||||
}
|
}
|
||||||
@@ -120,17 +123,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
const routeContext = this.route.snapshot.data['routeContext'] as RouteSessionContext | undefined;
|
||||||
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
|
||||||
const codeFromRoute = match?.[1] ?? '';
|
|
||||||
|
|
||||||
const persistedContext = this.sessionContextStore.get();
|
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) {
|
if (!candidate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -291,6 +290,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.session = state.session as SessionDetail;
|
this.session = state.session as SessionDetail;
|
||||||
this.sessionCode = this.session.session.code;
|
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') {
|
if (this.session.session.status !== 'guess') {
|
||||||
this.selectedGuess = '';
|
this.selectedGuess = '';
|
||||||
}
|
}
|
||||||
@@ -316,6 +318,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.session = state.session as SessionDetail;
|
this.session = state.session as SessionDetail;
|
||||||
this.sessionCode = this.session.session.code;
|
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();
|
const sessionContext = this.sessionContextStore.get();
|
||||||
this.playerId = sessionContext?.playerId ?? 0;
|
this.playerId = sessionContext?.playerId ?? 0;
|
||||||
|
|||||||
29
frontend/angular/src/app/gameplay-route-sync.spec.ts
Normal file
29
frontend/angular/src/app/gameplay-route-sync.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
frontend/angular/src/app/gameplay-route-sync.ts
Normal file
73
frontend/angular/src/app/gameplay-route-sync.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user