10 Commits

Author SHA1 Message Date
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
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
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
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
16 changed files with 1974 additions and 29 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ng serve", "start": "ng serve",
"build": "ng build" "build": "ng build",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@angular/common": "^19.2.0", "@angular/common": "^19.2.0",
@@ -21,6 +22,7 @@
"@angular/build": "^19.2.0", "@angular/build": "^19.2.0",
"@angular/cli": "^19.2.0", "@angular/cli": "^19.2.0",
"@angular/compiler-cli": "^19.2.0", "@angular/compiler-cli": "^19.2.0",
"typescript": "~5.7.2" "typescript": "~5.7.2",
"vitest": "^2.1.9"
} }
} }

View File

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('HostShellComponent gameplay wiring', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('runs startRound transition and refreshes session details', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(201, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'lie', current_round: 2 },
round_question: { id: 41, prompt: 'Q?', answers: [] },
players: [],
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.categorySlug = ' history ';
await component.startRound();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/rounds/start',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('41');
expect(component.loading).toBe(false);
});
it('captures scoreboard error for retry path', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(500, { error: 'Scoreboard unavailable' })
);
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.loadScoreboard();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12/scoreboard',
expect.objectContaining({ method: 'GET' })
);
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
expect(component.loading).toBe(false);
});
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
const sessionAfterAction = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, prompt: 'Q?', answers: [] },
players: [],
};
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction));
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 ';
await component.showQuestion();
await component.mixAnswers();
await component.calculateScores();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/show',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/lobby/sessions/ABCD12/questions/77/answers/mix',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
5,
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(component.error).toBe('');
expect(component.loading).toBe(false);
});
});

View File

@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('PlayerShellComponent gameplay wiring', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('clears selected guess when refreshed status is no longer guess', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] },
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'abcd12';
component.selectedGuess = 'A';
await component.refreshSession();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(component.selectedGuess).toBe('');
});
it('surfaces lie submit error and allows retry success flow', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [] },
};
await component.submitLie();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/11/lies/submit',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ player_id: 9, session_token: 'token-1', text: 'my lie' }),
})
);
expect(component.submitError?.kind).toBe('lie');
expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage');
await component.submitLie();
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('guess');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('surfaces guess submit error and retries with selected answer payload', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = ' abcd12 ';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
component.session = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/11/guesses/submit',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ player_id: 9, session_token: 'token-1', selected_text: 'B' }),
})
);
expect(component.submitError?.kind).toBe('guess');
expect(component.submitError?.message).toContain('Guess submit failed: Guess queue busy');
await component.submitGuess();
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('reveal');
expect(component.selectedGuess).toBe('');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import type { SessionDetailResponse } from '../api/types';
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
export type GameplayPhaseEvent =
| 'LIES_LOCKED'
| 'GUESSES_LOCKED'
| 'SCOREBOARD_READY'
| 'NEXT_ROUND';
export interface GameplayTransitionResult {
phase: GameplayPhase;
changed: boolean;
}
const TRANSITIONS: Record<GameplayPhase, Partial<Record<GameplayPhaseEvent, GameplayPhase>>> = {
lie: {
LIES_LOCKED: 'guess'
},
guess: {
GUESSES_LOCKED: 'reveal'
},
reveal: {
SCOREBOARD_READY: 'scoreboard'
},
scoreboard: {
NEXT_ROUND: 'lie'
}
};
export function transitionGameplayPhase(phase: GameplayPhase, event: GameplayPhaseEvent): GameplayTransitionResult {
const next = TRANSITIONS[phase][event] ?? phase;
return {
phase: next,
changed: next !== phase
};
}
export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[] {
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
}
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
const status = session?.session.status;
if (!status) {
return null;
}
if (status === 'lie' || status === 'guess' || status === 'reveal') {
return status;
}
if (status === 'finished') {
return 'scoreboard';
}
return null;
}

View File

@@ -0,0 +1,31 @@
import lobbyCatalog from '../../../shared/i18n/lobby.json';
type FrontendErrorKey = keyof typeof lobbyCatalog.frontend.errors;
const frontendErrors = lobbyCatalog.frontend.errors;
const apiErrorMap: Record<string, FrontendErrorKey> = {
session_code_required: 'session_code_required',
session_not_found: 'session_not_found',
nickname_invalid: 'nickname_invalid',
nickname_taken: 'nickname_taken'
};
export function lobbyMessage(key: FrontendErrorKey): string {
return frontendErrors[key] ?? frontendErrors.unknown;
}
export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey): string {
if (!payload || typeof payload !== 'object') {
return lobbyMessage(fallbackKey);
}
const record = payload as Record<string, unknown>;
const code = typeof record.error_code === 'string' ? record.error_code : '';
const mappedKey = apiErrorMap[code];
if (!mappedKey) {
return lobbyMessage(fallbackKey);
}
return lobbyMessage(mappedKey);
}

View File

@@ -6,6 +6,8 @@ import {
type SessionContextInput, type SessionContextInput,
type SessionContextStore as PersistedSessionContextStore type SessionContextStore as PersistedSessionContextStore
} from './session-context-store'; } from './session-context-store';
import { deriveGameplayPhase, type GameplayPhase } from './gameplay-phase-machine';
import { lobbyMessage, lobbyMessageFromApiPayload } from './lobby-i18n';
export type AsyncState = 'idle' | 'loading' | 'success' | 'error'; export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
@@ -14,6 +16,7 @@ export type SessionContextStore = Pick<PersistedSessionContextStore, 'get' | 'se
export interface VerticalSliceState { export interface VerticalSliceState {
sessionCode: string; sessionCode: string;
session: SessionDetailResponse | null; session: SessionDetailResponse | null;
gameplayPhase: GameplayPhase | null;
joinState: AsyncState; joinState: AsyncState;
startRoundState: AsyncState; startRoundState: AsyncState;
loadingSession: boolean; loadingSession: boolean;
@@ -36,6 +39,7 @@ export function createVerticalSliceController(
const state: VerticalSliceState = { const state: VerticalSliceState = {
sessionCode: persistedContext?.sessionCode ?? '', sessionCode: persistedContext?.sessionCode ?? '',
session: null, session: null,
gameplayPhase: null,
joinState: 'idle', joinState: 'idle',
startRoundState: 'idle', startRoundState: 'idle',
loadingSession: false, loadingSession: false,
@@ -54,7 +58,7 @@ export function createVerticalSliceController(
if (!state.sessionCode) { if (!state.sessionCode) {
state.loadingSession = false; state.loadingSession = false;
state.errorMessage = 'Session-kode mangler.'; state.errorMessage = lobbyMessage('session_code_required');
return { ...state }; return { ...state };
} }
@@ -62,11 +66,13 @@ export function createVerticalSliceController(
state.loadingSession = false; state.loadingSession = false;
if (!result.ok) { if (!result.ok) {
state.errorMessage = 'Kunne ikke hente lobby-status.'; state.errorMessage = lobbyMessageFromApiPayload(result.error.payload, 'session_fetch_failed');
state.gameplayPhase = null;
return { ...state }; return { ...state };
} }
state.session = result.data; state.session = result.data;
state.gameplayPhase = deriveGameplayPhase(result.data);
state.sessionCode = normalizeCode(result.data.session.code); state.sessionCode = normalizeCode(result.data.session.code);
if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) { if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) {
@@ -87,7 +93,7 @@ export function createVerticalSliceController(
const join = await api.joinSession({ code: requestCode, nickname }); const join = await api.joinSession({ code: requestCode, nickname });
if (!join.ok) { if (!join.ok) {
state.joinState = 'error'; state.joinState = 'error';
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.'; state.errorMessage = lobbyMessageFromApiPayload(join.error.payload, 'join_failed');
return { ...state }; return { ...state };
} }
@@ -114,14 +120,14 @@ export function createVerticalSliceController(
if (!codeToUse) { if (!codeToUse) {
state.startRoundState = 'error'; state.startRoundState = 'error';
state.errorMessage = 'Session-kode mangler.'; state.errorMessage = lobbyMessage('session_code_required');
return { ...state }; return { ...state };
} }
const start = await api.startRound(codeToUse, { category_slug: categorySlug }); const start = await api.startRound(codeToUse, { category_slug: categorySlug });
if (!start.ok) { if (!start.ok) {
state.startRoundState = 'error'; state.startRoundState = 'error';
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.'; state.errorMessage = lobbyMessageFromApiPayload(start.error.payload, 'start_round_failed');
return { ...state }; return { ...state };
} }

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import {
allowedGameplayEvents,
deriveGameplayPhase,
transitionGameplayPhase,
type GameplayPhase
} from '../src/spa/gameplay-phase-machine';
describe('gameplay phase machine skeleton', () => {
it('supports canonical phase progression lie -> guess -> reveal -> scoreboard -> lie', () => {
let phase: GameplayPhase = 'lie';
phase = transitionGameplayPhase(phase, 'LIES_LOCKED').phase;
expect(phase).toBe('guess');
phase = transitionGameplayPhase(phase, 'GUESSES_LOCKED').phase;
expect(phase).toBe('reveal');
phase = transitionGameplayPhase(phase, 'SCOREBOARD_READY').phase;
expect(phase).toBe('scoreboard');
phase = transitionGameplayPhase(phase, 'NEXT_ROUND').phase;
expect(phase).toBe('lie');
});
it('keeps state unchanged for invalid transition events', () => {
const transition = transitionGameplayPhase('lie', 'NEXT_ROUND');
expect(transition.phase).toBe('lie');
expect(transition.changed).toBe(false);
});
it('exposes allowed events per phase', () => {
expect(allowedGameplayEvents('guess')).toEqual(['GUESSES_LOCKED']);
expect(allowedGameplayEvents('scoreboard')).toEqual(['NEXT_ROUND']);
});
it('derives gameplay phase from session detail status', () => {
expect(
deriveGameplayPhase({
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
phase_view_model: {
status: 'lie',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: true,
can_mix_answers: true,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: false,
can_submit_lie: true,
can_submit_guess: false,
can_view_final_result: false
}
}
})
).toBe('lie');
expect(
deriveGameplayPhase({
session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
phase_view_model: {
status: 'finished',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: 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
}
}
})
).toBe('scoreboard');
});
});

View File

@@ -105,6 +105,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
vi.doUnmock('../src/spa/session-context-store'); vi.doUnmock('../src/spa/session-context-store');
vi.resetModules(); vi.resetModules();
}); });
it('tracks loading and success state for join + start flow', async () => { it('tracks loading and success state for join + start flow', async () => {
const api = makeApiMock(); const api = makeApiMock();
const controller = createVerticalSliceController(api); const controller = createVerticalSliceController(api);
@@ -160,7 +161,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
joinSession: vi.fn().mockResolvedValue({ joinSession: vi.fn().mockResolvedValue({
ok: false, ok: false,
status: 404, status: 404,
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } } error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } }
}) })
}); });
@@ -169,7 +170,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
const state = controller.getState(); const state = controller.getState();
expect(state.joinState).toBe('error'); expect(state.joinState).toBe('error');
expect(state.errorMessage).toContain('Join fejlede'); expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.');
}); });
it('surfaces a friendly error when round start fails', async () => { it('surfaces a friendly error when round start fails', async () => {
@@ -177,7 +178,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
startRound: vi.fn().mockResolvedValue({ startRound: vi.fn().mockResolvedValue({
ok: false, ok: false,
status: 400, status: 400,
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } } error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby', error_code: 'round_start_invalid_phase' } }
}) })
}); });
@@ -186,7 +187,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
const state = controller.getState(); const state = controller.getState();
expect(state.startRoundState).toBe('error'); expect(state.startRoundState).toBe('error');
expect(state.errorMessage).toContain('Kunne ikke starte runden'); expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.');
}); });
it('shows local validation error and avoids API call when hydrating without any session code', async () => { it('shows local validation error and avoids API call when hydrating without any session code', async () => {
@@ -196,7 +197,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
await controller.hydrateLobby(' '); await controller.hydrateLobby(' ');
const state = controller.getState(); const state = controller.getState();
expect(state.errorMessage).toBe('Session-kode mangler.'); expect(state.errorMessage).toBe('Session code is required.');
expect(state.loadingSession).toBe(false); expect(state.loadingSession).toBe(false);
expect(api.getSession).not.toHaveBeenCalled(); expect(api.getSession).not.toHaveBeenCalled();
}); });
@@ -209,7 +210,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
const state = controller.getState(); const state = controller.getState();
expect(state.startRoundState).toBe('error'); expect(state.startRoundState).toBe('error');
expect(state.errorMessage).toBe('Session-kode mangler.'); expect(state.errorMessage).toBe('Session code is required.');
expect(api.startRound).not.toHaveBeenCalled(); expect(api.startRound).not.toHaveBeenCalled();
}); });

View File

@@ -3,10 +3,11 @@
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM"],
"types": ["vitest/globals", "node"] "types": ["vitest/globals", "node"]
}, },
"include": ["src", "tests"] "include": ["src", "tests", "../shared/i18n/*.json"]
} }

20
lobby/i18n.py Normal file
View File

@@ -0,0 +1,20 @@
import json
from functools import lru_cache
from pathlib import Path
from django.http import JsonResponse
@lru_cache(maxsize=1)
def lobby_i18n_catalog() -> dict:
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json"
with catalog_path.open(encoding="utf-8") as handle:
return json.load(handle)
def lobby_i18n_errors() -> dict:
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {})
def api_error(*, code: str, message: str, status: int) -> JsonResponse:
return JsonResponse({"error": message, "error_code": code}, status=status)

View File

@@ -5,6 +5,7 @@ from django.shortcuts import render
from fupogfakta.models import Category from fupogfakta.models import Category
from .feature_flags import use_spa_ui from .feature_flags import use_spa_ui
from .i18n import lobby_i18n_catalog
def _render_spa_shell(request, shell_route: str, shell_kind: str): def _render_spa_shell(request, shell_route: str, shell_kind: str):
@@ -15,6 +16,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
"shell_route": shell_route, "shell_route": shell_route,
"shell_kind": shell_kind, "shell_kind": shell_kind,
"spa_asset_base": settings.WPP_SPA_ASSET_BASE, "spa_asset_base": settings.WPP_SPA_ASSET_BASE,
"lobby_i18n": lobby_i18n_catalog(),
}, },
) )
@@ -30,11 +32,18 @@ def host_screen(request, spa_path=None):
return _render_spa_shell(request, host_route, "host") return _render_spa_shell(request, host_route, "host")
categories = Category.objects.filter(is_active=True).order_by("name") categories = Category.objects.filter(is_active=True).order_by("name")
return render(request, "lobby/host_screen.html", {"categories": categories}) return render(
request,
"lobby/host_screen.html",
{
"categories": categories,
"lobby_i18n": lobby_i18n_catalog(),
},
)
def player_screen(request): def player_screen(request):
if use_spa_ui(): if use_spa_ui():
return _render_spa_shell(request, "/player", "player") return _render_spa_shell(request, "/player", "player")
return render(request, "lobby/player_screen.html") return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()})

View File

@@ -20,6 +20,8 @@ from fupogfakta.models import (
ScoreEvent, ScoreEvent,
) )
from .i18n import api_error, lobby_i18n_errors
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6 SESSION_CODE_LENGTH = 6
MAX_CODE_GENERATION_ATTEMPTS = 20 MAX_CODE_GENERATION_ATTEMPTS = 20
@@ -29,6 +31,7 @@ JOINABLE_STATUSES = {
GameSession.Status.GUESS, GameSession.Status.GUESS,
GameSession.Status.REVEAL, GameSession.Status.REVEAL,
} }
ERROR_CODES = lobby_i18n_errors()
def _json_body(request: HttpRequest) -> dict: def _json_body(request: HttpRequest) -> dict:
@@ -124,21 +127,41 @@ def join_session(request: HttpRequest) -> JsonResponse:
nickname = str(payload.get("nickname", "")).strip() nickname = str(payload.get("nickname", "")).strip()
if not code: if not code:
return JsonResponse({"error": "Session code is required"}, status=400) return api_error(
code=ERROR_CODES.get("session_code_required", "session_code_required"),
message="Session code is required",
status=400,
)
if len(nickname) < 2 or len(nickname) > 40: if len(nickname) < 2 or len(nickname) > 40:
return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400) return api_error(
code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
message="Nickname must be between 2 and 40 characters",
status=400,
)
try: try:
session = GameSession.objects.get(code=code) session = GameSession.objects.get(code=code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
status=404,
)
if session.status not in JOINABLE_STATUSES: if session.status not in JOINABLE_STATUSES:
return JsonResponse({"error": "Session is not joinable"}, status=400) return api_error(
code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
message="Session is not joinable",
status=400,
)
if Player.objects.filter(session=session, nickname__iexact=nickname).exists(): if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
return JsonResponse({"error": "Nickname already taken"}, status=409) return api_error(
code=ERROR_CODES.get("nickname_taken", "nickname_taken"),
message="Nickname already taken",
status=409,
)
player = Player.objects.create(session=session, nickname=nickname) player = Player.objects.create(session=session, nickname=nickname)
@@ -166,7 +189,11 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
status=404,
)
players = list( players = list(
session.players.order_by("nickname").values( session.players.order_by("nickname").values(
@@ -223,25 +250,41 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
category_slug = str(payload.get("category_slug", "")).strip() category_slug = str(payload.get("category_slug", "")).strip()
if not category_slug: if not category_slug:
return JsonResponse({"error": "category_slug is required"}, status=400) return api_error(
code=ERROR_CODES.get("category_slug_required", "category_slug_required"),
message="category_slug is required",
status=400,
)
session_code = _normalize_session_code(code) session_code = _normalize_session_code(code)
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
status=404,
)
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start round"}, status=403) return JsonResponse({"error": "Only host can start round"}, status=403)
if session.status != GameSession.Status.LOBBY: if session.status != GameSession.Status.LOBBY:
return JsonResponse({"error": "Round can only be started from lobby"}, status=400) return api_error(
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
message="Round can only be started from lobby",
status=400,
)
try: try:
category = Category.objects.get(slug=category_slug, is_active=True) category = Category.objects.get(slug=category_slug, is_active=True)
except Category.DoesNotExist: except Category.DoesNotExist:
return JsonResponse({"error": "Category not found"}, status=404) return api_error(
code=ERROR_CODES.get("category_not_found", "category_not_found"),
message="Category not found",
status=404,
)
if not Question.objects.filter(category=category, is_active=True).exists(): if not Question.objects.filter(category=category, is_active=True).exists():
return JsonResponse({"error": "Category has no active questions"}, status=400) return JsonResponse({"error": "Category has no active questions"}, status=400)
@@ -249,7 +292,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
with transaction.atomic(): with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk) session = GameSession.objects.select_for_update().get(pk=session.pk)
if session.status != GameSession.Status.LOBBY: if session.status != GameSession.Status.LOBBY:
return JsonResponse({"error": "Round can only be started from lobby"}, status=400) return api_error(
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
message="Round can only be started from lobby",
status=400,
)
round_config, created = RoundConfig.objects.get_or_create( round_config, created = RoundConfig.objects.get_or_create(
session=session, session=session,
@@ -257,7 +304,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
defaults={"category": category}, defaults={"category": category},
) )
if not created: if not created:
return JsonResponse({"error": "Round already configured"}, status=409) return api_error(
code=ERROR_CODES.get("round_already_configured", "round_already_configured"),
message="Round already configured",
status=409,
)
session.status = GameSession.Status.LIE session.status = GameSession.Status.LIE
session.save(update_fields=["status"]) session.save(update_fields=["status"])

27
shared/i18n/lobby.json Normal file
View File

@@ -0,0 +1,27 @@
{
"frontend": {
"errors": {
"session_code_required": "Session code is required.",
"session_fetch_failed": "Could not load lobby status.",
"join_failed": "Join failed. Check code or nickname and try again.",
"start_round_failed": "Could not start round. Refresh the lobby and try again.",
"session_not_found": "Session code is invalid or the session no longer exists.",
"nickname_invalid": "Nickname must be between 2 and 40 characters.",
"nickname_taken": "Nickname is already taken.",
"unknown": "Action failed. Refresh status and try again."
}
},
"backend": {
"error_codes": {
"session_code_required": "session_code_required",
"nickname_invalid": "nickname_invalid",
"session_not_found": "session_not_found",
"session_not_joinable": "session_not_joinable",
"nickname_taken": "nickname_taken",
"category_slug_required": "category_slug_required",
"category_not_found": "category_not_found",
"round_start_invalid_phase": "round_start_invalid_phase",
"round_already_configured": "round_already_configured"
}
}
}