- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야 서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은 바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도 먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다. - 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와 그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로. - 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개 설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
90 lines
3.3 KiB
TypeScript
90 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 GameStat {
|
|
gameId: string;
|
|
played: number;
|
|
wins: number;
|
|
draws: number;
|
|
}
|
|
|
|
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'),
|
|
/** Another player's record (room player card). */
|
|
userStats: (id: string) => call<{ user: PublicUser; stats: GameStat[] }>('GET', `/api/users/${encodeURIComponent(id)}/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) => call<{ code: string }>('POST', '/api/rooms', { gameId }),
|
|
/** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */
|
|
practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }),
|
|
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
|
};
|
|
|
|
export { call as apiCall };
|
|
|
|
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;
|