Compare commits
1 Commits
dev/issue-
...
pr-181
| Author | SHA1 | Date | |
|---|---|---|---|
| acc3420a86 |
@@ -71,50 +71,4 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,14 +66,6 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
|
||||
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');
|
||||
|
||||
@@ -83,49 +75,4 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
type SessionContextInput,
|
||||
type SessionContextStore as PersistedSessionContextStore
|
||||
} 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';
|
||||
|
||||
@@ -16,7 +14,6 @@ export type SessionContextStore = Pick<PersistedSessionContextStore, 'get' | 'se
|
||||
export interface VerticalSliceState {
|
||||
sessionCode: string;
|
||||
session: SessionDetailResponse | null;
|
||||
gameplayPhase: GameplayPhase | null;
|
||||
joinState: AsyncState;
|
||||
startRoundState: AsyncState;
|
||||
loadingSession: boolean;
|
||||
@@ -39,7 +36,6 @@ export function createVerticalSliceController(
|
||||
const state: VerticalSliceState = {
|
||||
sessionCode: persistedContext?.sessionCode ?? '',
|
||||
session: null,
|
||||
gameplayPhase: null,
|
||||
joinState: 'idle',
|
||||
startRoundState: 'idle',
|
||||
loadingSession: false,
|
||||
@@ -58,7 +54,7 @@ export function createVerticalSliceController(
|
||||
|
||||
if (!state.sessionCode) {
|
||||
state.loadingSession = false;
|
||||
state.errorMessage = lobbyMessage('session_code_required');
|
||||
state.errorMessage = 'Session-kode mangler.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
@@ -66,13 +62,11 @@ export function createVerticalSliceController(
|
||||
state.loadingSession = false;
|
||||
|
||||
if (!result.ok) {
|
||||
state.errorMessage = lobbyMessageFromApiPayload(result.error.payload, 'session_fetch_failed');
|
||||
state.gameplayPhase = null;
|
||||
state.errorMessage = 'Kunne ikke hente lobby-status.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
state.session = result.data;
|
||||
state.gameplayPhase = deriveGameplayPhase(result.data);
|
||||
state.sessionCode = normalizeCode(result.data.session.code);
|
||||
|
||||
if (persistedContext && state.sessionCode === normalizeCode(persistedContext.sessionCode)) {
|
||||
@@ -93,7 +87,7 @@ export function createVerticalSliceController(
|
||||
const join = await api.joinSession({ code: requestCode, nickname });
|
||||
if (!join.ok) {
|
||||
state.joinState = 'error';
|
||||
state.errorMessage = lobbyMessageFromApiPayload(join.error.payload, 'join_failed');
|
||||
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
@@ -120,14 +114,14 @@ export function createVerticalSliceController(
|
||||
|
||||
if (!codeToUse) {
|
||||
state.startRoundState = 'error';
|
||||
state.errorMessage = lobbyMessage('session_code_required');
|
||||
state.errorMessage = 'Session-kode mangler.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
const start = await api.startRound(codeToUse, { category_slug: categorySlug });
|
||||
if (!start.ok) {
|
||||
state.startRoundState = 'error';
|
||||
state.errorMessage = lobbyMessageFromApiPayload(start.error.payload, 'start_round_failed');
|
||||
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,6 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
vi.doUnmock('../src/spa/session-context-store');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('tracks loading and success state for join + start flow', async () => {
|
||||
const api = makeApiMock();
|
||||
const controller = createVerticalSliceController(api);
|
||||
@@ -161,7 +160,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
joinSession: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found', error_code: 'session_not_found' } }
|
||||
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } }
|
||||
})
|
||||
});
|
||||
|
||||
@@ -170,7 +169,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
|
||||
const state = controller.getState();
|
||||
expect(state.joinState).toBe('error');
|
||||
expect(state.errorMessage).toBe('Session code is invalid or the session no longer exists.');
|
||||
expect(state.errorMessage).toContain('Join fejlede');
|
||||
});
|
||||
|
||||
it('surfaces a friendly error when round start fails', async () => {
|
||||
@@ -178,7 +177,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
startRound: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby', error_code: 'round_start_invalid_phase' } }
|
||||
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } }
|
||||
})
|
||||
});
|
||||
|
||||
@@ -187,7 +186,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
|
||||
const state = controller.getState();
|
||||
expect(state.startRoundState).toBe('error');
|
||||
expect(state.errorMessage).toBe('Could not start round. Refresh the lobby and try again.');
|
||||
expect(state.errorMessage).toContain('Kunne ikke starte runden');
|
||||
});
|
||||
|
||||
it('shows local validation error and avoids API call when hydrating without any session code', async () => {
|
||||
@@ -197,7 +196,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
await controller.hydrateLobby(' ');
|
||||
|
||||
const state = controller.getState();
|
||||
expect(state.errorMessage).toBe('Session code is required.');
|
||||
expect(state.errorMessage).toBe('Session-kode mangler.');
|
||||
expect(state.loadingSession).toBe(false);
|
||||
expect(api.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -210,7 +209,7 @@ describe('vertical slice controller: lobby -> join -> start round', () => {
|
||||
|
||||
const state = controller.getState();
|
||||
expect(state.startRoundState).toBe('error');
|
||||
expect(state.errorMessage).toBe('Session code is required.');
|
||||
expect(state.errorMessage).toBe('Session-kode mangler.');
|
||||
expect(api.startRound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src", "tests", "../shared/i18n/*.json"]
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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)
|
||||
@@ -5,7 +5,6 @@ from django.shortcuts import render
|
||||
from fupogfakta.models import Category
|
||||
|
||||
from .feature_flags import use_spa_ui
|
||||
from .i18n import lobby_i18n_catalog
|
||||
|
||||
|
||||
def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
||||
@@ -16,7 +15,6 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
|
||||
"shell_route": shell_route,
|
||||
"shell_kind": shell_kind,
|
||||
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
|
||||
"lobby_i18n": lobby_i18n_catalog(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -32,18 +30,11 @@ def host_screen(request, spa_path=None):
|
||||
return _render_spa_shell(request, host_route, "host")
|
||||
|
||||
categories = Category.objects.filter(is_active=True).order_by("name")
|
||||
return render(
|
||||
request,
|
||||
"lobby/host_screen.html",
|
||||
{
|
||||
"categories": categories,
|
||||
"lobby_i18n": lobby_i18n_catalog(),
|
||||
},
|
||||
)
|
||||
return render(request, "lobby/host_screen.html", {"categories": categories})
|
||||
|
||||
|
||||
def player_screen(request):
|
||||
if use_spa_ui():
|
||||
return _render_spa_shell(request, "/player", "player")
|
||||
|
||||
return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()})
|
||||
return render(request, "lobby/player_screen.html")
|
||||
|
||||
@@ -20,8 +20,6 @@ from fupogfakta.models import (
|
||||
ScoreEvent,
|
||||
)
|
||||
|
||||
from .i18n import api_error, lobby_i18n_errors
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
MAX_CODE_GENERATION_ATTEMPTS = 20
|
||||
@@ -31,7 +29,6 @@ JOINABLE_STATUSES = {
|
||||
GameSession.Status.GUESS,
|
||||
GameSession.Status.REVEAL,
|
||||
}
|
||||
ERROR_CODES = lobby_i18n_errors()
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> dict:
|
||||
@@ -127,41 +124,21 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("session_code_required", "session_code_required"),
|
||||
message="Session code is required",
|
||||
status=400,
|
||||
)
|
||||
return JsonResponse({"error": "Session code is required"}, status=400)
|
||||
|
||||
if len(nickname) < 2 or len(nickname) > 40:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
|
||||
message="Nickname must be between 2 and 40 characters",
|
||||
status=400,
|
||||
)
|
||||
return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=code)
|
||||
except GameSession.DoesNotExist:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||
message="Session not found",
|
||||
status=404,
|
||||
)
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.status not in JOINABLE_STATUSES:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
|
||||
message="Session is not joinable",
|
||||
status=400,
|
||||
)
|
||||
return JsonResponse({"error": "Session is not joinable"}, status=400)
|
||||
|
||||
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("nickname_taken", "nickname_taken"),
|
||||
message="Nickname already taken",
|
||||
status=409,
|
||||
)
|
||||
return JsonResponse({"error": "Nickname already taken"}, status=409)
|
||||
|
||||
player = Player.objects.create(session=session, nickname=nickname)
|
||||
|
||||
@@ -189,11 +166,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||
message="Session not found",
|
||||
status=404,
|
||||
)
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
players = list(
|
||||
session.players.order_by("nickname").values(
|
||||
@@ -250,41 +223,25 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
category_slug = str(payload.get("category_slug", "")).strip()
|
||||
|
||||
if not category_slug:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("category_slug_required", "category_slug_required"),
|
||||
message="category_slug is required",
|
||||
status=400,
|
||||
)
|
||||
return JsonResponse({"error": "category_slug is required"}, status=400)
|
||||
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("session_not_found", "session_not_found"),
|
||||
message="Session not found",
|
||||
status=404,
|
||||
)
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can start round"}, status=403)
|
||||
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
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,
|
||||
)
|
||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||
|
||||
try:
|
||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
||||
except Category.DoesNotExist:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("category_not_found", "category_not_found"),
|
||||
message="Category not found",
|
||||
status=404,
|
||||
)
|
||||
return JsonResponse({"error": "Category not found"}, status=404)
|
||||
|
||||
if not Question.objects.filter(category=category, is_active=True).exists():
|
||||
return JsonResponse({"error": "Category has no active questions"}, status=400)
|
||||
@@ -292,11 +249,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
with transaction.atomic():
|
||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
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,
|
||||
)
|
||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||
|
||||
round_config, created = RoundConfig.objects.get_or_create(
|
||||
session=session,
|
||||
@@ -304,11 +257,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
defaults={"category": category},
|
||||
)
|
||||
if not created:
|
||||
return api_error(
|
||||
code=ERROR_CODES.get("round_already_configured", "round_already_configured"),
|
||||
message="Round already configured",
|
||||
status=409,
|
||||
)
|
||||
return JsonResponse({"error": "Round already configured"}, status=409)
|
||||
|
||||
session.status = GameSession.Status.LIE
|
||||
session.save(update_fields=["status"])
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user