Files
joke-app/apps/web/src/net/api.ts
EJClaw c84c1ba95e M9: 게임 기록 다시 보기
- 서버: 끝난 게임을 공개된 시드 + 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>
2026-10-06 13:20:05 +09:00

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)}`;