- 서버: 끝난 게임을 공개된 시드 + room_log 행동으로 다시 돌려 플레이어별 view 장면을 만든다 (rooms/replay.ts, 장면은 view 차이만 전송: shared/view-patch.ts). 행동 로그의 at = 적용한 정확한 시각, start 로그에 stateVersion. GET /api/me/games, GET /api/replays/:gamePk(그 판 플레이어만, no-store), RoomView.lastGamePk. 로그가 지워졌거나 규칙 버전이 바뀌었거나 결과가 다르면 한국어 안내(410). - 웹: 다시 보기 화면(/replay/:gamePk, 게임 Board/Side 읽기 전용, 처음/이전/재생·멈춤/다음/끝, 슬라이더), 내 정보의 내 게임 기록 목록, 결과 화면의 [처음부터 다시 보기]. - 테스트: 모든 게임을 Room으로 끝까지 둔 뒤 다시 만든 상태·결과·장면이 같은지, 권한, view-patch, e2e/replay.e2e.ts. - 문서: docs/09 §8, docs/07 §10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
83 lines
3.3 KiB
TypeScript
83 lines
3.3 KiB
TypeScript
import type { CatalogEntry, MyGameSummary, PublicUser, ReplayData } from '@bg/shared/lite';
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly status: number,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, {
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
} catch {
|
|
throw new ApiError('인터넷 연결을 확인해 주세요.', 0);
|
|
}
|
|
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
|
|
if (!res.ok) throw new ApiError(data.error ?? '잠시 후 다시 시도해 주세요.', res.status);
|
|
return data;
|
|
}
|
|
|
|
export interface Settings {
|
|
fontScale?: 'normal' | 'large' | 'xlarge';
|
|
theme?: 'auto' | 'light' | 'dark';
|
|
sound?: boolean;
|
|
vibrate?: boolean;
|
|
colorBlind?: boolean;
|
|
tapConfirm?: boolean;
|
|
}
|
|
|
|
export type Role = 'user' | 'admin' | 'superadmin';
|
|
|
|
export interface MeResponse {
|
|
me: PublicUser | null;
|
|
role?: Role;
|
|
settings?: Settings;
|
|
useAvatar?: boolean;
|
|
hasDiscordAvatar?: boolean;
|
|
activeRoom?: string | null;
|
|
}
|
|
|
|
export interface RoomPreview {
|
|
code: string;
|
|
codeLabel: string;
|
|
gameId: string;
|
|
host: string;
|
|
seated: number;
|
|
maxPlayers: number;
|
|
status: string;
|
|
}
|
|
|
|
export const api = {
|
|
config: () =>
|
|
call<{ discord: boolean; catalog: (CatalogEntry & { notice?: string })[]; siteName: string; announcement: string; maintenance: boolean }>('GET', '/api/config'),
|
|
me: () => call<MeResponse>('GET', '/api/me'),
|
|
guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }),
|
|
updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch),
|
|
logout: () => call<{ ok: true }>('POST', '/api/auth/logout', {}),
|
|
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
|
|
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
|
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
|
|
myGames: (before?: string | null) =>
|
|
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
|
|
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
|
|
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
|
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
|
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
|
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),
|
|
publicRooms: (gameId: string) =>
|
|
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
|
|
};
|
|
|
|
export { call as apiCall };
|
|
|
|
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;
|