방: 나가기 버튼을 막대로 빼고 ← 홈도 나가기로, 이름 눌러 기록 카드, 게스트 경고, 관리자 계정별 기록
- 방 막대에 [🚪 나가기]를 두고 메뉴에서는 뺀다. [← 홈]도 같은 동작. 한 번 누르면 바로 나가고, 게임 중에 자리에 앉아 있을 때만 기권 확인을 묻는다. 휴대폰은 방 링크 복사를 메뉴로 옮겨 한 줄에 맞춘다. - 자리·관전자·결과 화면의 이름을 누르면 기록 카드(이 게임 판·승·패·무·승률, 이 방 전적, 전체 합계). GET /api/users/:id/stats (로그인 필요). - 게스트 경고: 이름 정하기, 로그인 페이지, /me, 게스트가 있는 대기실. - 관리자 사용자 상세: 전체 기록, 게임별 기록 표, 최근 게임(GET /api/admin/users/:id/games). 목록에 전적 열(PC). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -255,4 +255,54 @@ describe('site settings, rooms, users', () => {
|
|||||||
expect(byId(sup.id).deleteAt).toBeNull();
|
expect(byId(sup.id).deleteAt).toBeNull();
|
||||||
expect((await api(s.base, `/api/admin/users/${away.id}`, { cookie: sup.cookie })).body.user.deleteAt).toBe(seen + GUEST_IDLE_MS);
|
expect((await api(s.base, `/api/admin/users/${away.id}`, { cookie: sup.cookie })).body.user.deleteAt).toBe(seen + GUEST_IDLE_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('records: totals in the user list, per-game stats and recent games for superadmin; player card for logged-in users', async () => {
|
||||||
|
const s = start();
|
||||||
|
const sup = await discordLogin(s, SUPER);
|
||||||
|
const a = await guest(s.base, '호랑이');
|
||||||
|
const b = await guest(s.base, '고양이');
|
||||||
|
// Two finished omok games (a won one, b won one) and one draw-less yacht game for a.
|
||||||
|
const games: [string, string, number, [string, number][]][] = [
|
||||||
|
['01J00000000000000000000001', 'omok', 1, [[a.id, 1], [b.id, 2]]],
|
||||||
|
['01J00000000000000000000002', 'omok', 2, [[a.id, 2], [b.id, 1]]],
|
||||||
|
['01J00000000000000000000003', 'yacht', 3, [[a.id, 1]]],
|
||||||
|
];
|
||||||
|
for (const [id, gameId, no, players] of games) {
|
||||||
|
s.db
|
||||||
|
.query("INSERT INTO games (id, room_id, game_no, game_id, options_json, seed_hash, seed, started_at, ended_at, end_reason, result_json) VALUES (?, 'r', ?, ?, '{}', 'h', 's', ?, ?, 'normal', ?)")
|
||||||
|
.run(id, no, gameId, 1000 * no, 1000 * no + 500, JSON.stringify({ summary: '끝' }));
|
||||||
|
players.forEach(([u, rank], seat) => s.db.query('INSERT INTO game_players (game_pk, user_id, seat, rank, score) VALUES (?, ?, ?, ?, NULL)').run(id, u, seat, rank));
|
||||||
|
}
|
||||||
|
s.db.query("INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, 'omok', 2, 1, 0), (?, 'yacht', 1, 1, 0), (?, 'omok', 2, 1, 0)").run(a.id, a.id, b.id);
|
||||||
|
|
||||||
|
const list = (await api(s.base, '/api/admin/users', { cookie: sup.cookie })).body.users;
|
||||||
|
expect(list.find((u: any) => u.id === a.id)).toMatchObject({ played: 3, wins: 2, draws: 0 });
|
||||||
|
expect(list.find((u: any) => u.id === sup.id)).toMatchObject({ played: 0, wins: 0, draws: 0 });
|
||||||
|
|
||||||
|
const detail = (await api(s.base, `/api/admin/users/${a.id}`, { cookie: sup.cookie })).body;
|
||||||
|
expect(detail.stats).toEqual([
|
||||||
|
{ gameId: 'omok', played: 2, wins: 1, draws: 0 },
|
||||||
|
{ gameId: 'yacht', played: 1, wins: 1, draws: 0 },
|
||||||
|
]);
|
||||||
|
const recent = (await api(s.base, `/api/admin/users/${a.id}/games`, { cookie: sup.cookie })).body;
|
||||||
|
expect(recent.next).toBeNull();
|
||||||
|
expect(recent.games.map((g: any) => [g.gameId, g.myRank])).toEqual([
|
||||||
|
['yacht', 1],
|
||||||
|
['omok', 2],
|
||||||
|
['omok', 1],
|
||||||
|
]);
|
||||||
|
expect(recent.games[1].players.map((p: any) => p.nickname)).toEqual(['고양이', '호랑이']);
|
||||||
|
expect((await api(s.base, `/api/admin/users/${a.id}/games?before=bad`, { cookie: sup.cookie })).res.status).toBe(400);
|
||||||
|
expect((await api(s.base, '/api/admin/users/nobody/games', { cookie: sup.cookie })).res.status).toBe(404);
|
||||||
|
// Recent games are superadmin-only like the rest of the user pages.
|
||||||
|
expect((await api(s.base, `/api/admin/users/${a.id}/games`, { cookie: b.cookie })).res.status).toBe(403);
|
||||||
|
|
||||||
|
// Player card in a room: any logged-in user can read another player's record.
|
||||||
|
const card = await api(s.base, `/api/users/${a.id}/stats`, { cookie: b.cookie });
|
||||||
|
expect(card.res.status).toBe(200);
|
||||||
|
expect(card.body.user).toMatchObject({ id: a.id, nickname: '호랑이', kind: 'guest' });
|
||||||
|
expect(card.body.stats).toEqual(detail.stats);
|
||||||
|
expect((await api(s.base, `/api/users/${a.id}/stats`)).res.status).toBe(401);
|
||||||
|
expect((await api(s.base, '/api/users/nobody/stats', { cookie: b.cookie })).res.status).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,9 +126,16 @@ export class Users {
|
|||||||
search(q: string, limit: number, offset: number) {
|
search(q: string, limit: number, offset: number) {
|
||||||
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.query<UserRow & { discord_id: string | null; discord_name: string | null }, [string, string, string, string, number, number]>(
|
.query<
|
||||||
`SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name FROM users u
|
UserRow & { discord_id: string | null; discord_name: string | null; played: number; wins: number; draws: number },
|
||||||
|
[string, string, string, string, number, number]
|
||||||
|
>(
|
||||||
|
`SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name,
|
||||||
|
COALESCE(s.played, 0) AS played, COALESCE(s.wins, 0) AS wins, COALESCE(s.draws, 0) AS draws
|
||||||
|
FROM users u
|
||||||
LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord'
|
LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord'
|
||||||
|
LEFT JOIN (SELECT user_id, SUM(played) AS played, SUM(wins) AS wins, SUM(draws) AS draws FROM user_stats GROUP BY user_id) s
|
||||||
|
ON s.user_id = u.id
|
||||||
WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?)
|
WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?)
|
||||||
ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`,
|
ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../a
|
|||||||
import type { Sessions } from '../auth/sessions';
|
import type { Sessions } from '../auth/sessions';
|
||||||
import { toPublicUser, type Users } from '../auth/users';
|
import { toPublicUser, type Users } from '../auth/users';
|
||||||
import type { RoomManager } from '../rooms/manager';
|
import type { RoomManager } from '../rooms/manager';
|
||||||
|
import type { Replays } from '../rooms/replay';
|
||||||
|
|
||||||
export interface AdminDeps {
|
export interface AdminDeps {
|
||||||
users: Users;
|
users: Users;
|
||||||
sessions: Sessions;
|
sessions: Sessions;
|
||||||
rooms: RoomManager;
|
rooms: RoomManager;
|
||||||
|
replays: Replays;
|
||||||
roles: Roles;
|
roles: Roles;
|
||||||
audit: Audit;
|
audit: Audit;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
@@ -148,6 +150,9 @@ export function createAdminApp(d: AdminDeps) {
|
|||||||
createdAt: u.created_at,
|
createdAt: u.created_at,
|
||||||
lastSeenAt: u.last_seen_at,
|
lastSeenAt: u.last_seen_at,
|
||||||
deleteAt: d.guestDeleteAt(u),
|
deleteAt: d.guestDeleteAt(u),
|
||||||
|
played: u.played,
|
||||||
|
wins: u.wins,
|
||||||
|
draws: u.draws,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -171,6 +176,15 @@ export function createAdminApp(d: AdminDeps) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A user's finished games, newest first (same shape as 내 게임 기록). */
|
||||||
|
app.get('/users/:id/games', (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
if (!d.users.get(id)) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||||
|
const before = c.req.query('before') ?? null;
|
||||||
|
if (before !== null && !/^[0-9A-Z]{26}$/.test(before)) return c.json({ error: '잘못된 요청이에요.' }, 400);
|
||||||
|
return c.json(d.replays.listForUser(id, before, 20));
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/users/:id', async (c) => {
|
app.patch('/users/:id', async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const u = d.users.get(id);
|
const u = d.users.get(id);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface HttpDeps {
|
|||||||
health: () => { ok: boolean; [k: string]: unknown };
|
health: () => { ok: boolean; [k: string]: unknown };
|
||||||
roles: Roles;
|
roles: Roles;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
|
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'replays' | 'roles' | 'settings'>;
|
||||||
/** Deletes a guest account everywhere (rooms, sockets, DB). */
|
/** Deletes a guest account everywhere (rooms, sockets, DB). */
|
||||||
removeGuest: (userId: string) => void;
|
removeGuest: (userId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,7 @@ export function createHttpApp(d: HttpDeps) {
|
|||||||
const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
|
const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
|
||||||
const guestLimiter = new WindowLimiter(20 * lift, 60_000);
|
const guestLimiter = new WindowLimiter(20 * lift, 60_000);
|
||||||
const replayLimiter = new WindowLimiter(30 * lift, 60_000);
|
const replayLimiter = new WindowLimiter(30 * lift, 60_000);
|
||||||
|
const profileLimiter = new WindowLimiter(60 * lift, 60_000);
|
||||||
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
|
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
|
||||||
|
|
||||||
const cookieOpts = (maxAgeMs: number) => ({
|
const cookieOpts = (maxAgeMs: number) => ({
|
||||||
@@ -246,6 +247,16 @@ export function createHttpApp(d: HttpDeps) {
|
|||||||
return c.json({ stats: d.users.stats(u) });
|
return c.json({ stats: d.users.stats(u) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Another player's record, for the player card in a room (docs/06 §5). Login only. */
|
||||||
|
app.get('/api/users/:id/stats', (c) => {
|
||||||
|
const u = requireUser(c);
|
||||||
|
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||||
|
if (!profileLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||||
|
const row = d.users.get(c.req.param('id'));
|
||||||
|
if (!row) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||||
|
return c.json({ user: toPublicUser(row), stats: d.users.stats(row.id) });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/me/games', (c) => {
|
app.get('/api/me/games', (c) => {
|
||||||
const u = requireUser(c);
|
const u = requireUser(c);
|
||||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||||
@@ -315,7 +326,7 @@ export function createHttpApp(d: HttpDeps) {
|
|||||||
|
|
||||||
app.route(
|
app.route(
|
||||||
'/api/admin',
|
'/api/admin',
|
||||||
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }),
|
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, replays: d.replays, roles: d.roles, settings: d.settings }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -65,7 +65,19 @@ export function NicknameForm({
|
|||||||
디스코드로 로그인
|
디스코드로 로그인
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
<GuestWarning />
|
||||||
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
|
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Guests are deleted with their records a day after they leave the site (docs/05 §4). */
|
||||||
|
export function GuestWarning() {
|
||||||
|
const discord = useSession((s) => s.discordEnabled);
|
||||||
|
return (
|
||||||
|
<p className="guest-warning small" role="note">
|
||||||
|
⚠️ 게스트는 기록이 오래 저장되지 않아요. 사이트에 들어오지 않은 채 하루가 지나면 계정과 기록이 모두 지워져요.
|
||||||
|
{discord && ' 기록을 남기려면 디스코드로 로그인해 주세요.'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
114
apps/web/src/components/PlayerCard.tsx
Normal file
114
apps/web/src/components/PlayerCard.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/** 방에서 사람 이름을 누르면 뜨는 기록 카드 (docs/06 §5): 이 게임의 판·승·패·무와 전체 합계. */
|
||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { isBotId, type MyGameSummary, type PublicUser } from '@bg/shared/lite';
|
||||||
|
import { api, type GameStat } from '../net/api';
|
||||||
|
import { Avatar, Sheet } from './ui';
|
||||||
|
import { gameName } from './GameNotice';
|
||||||
|
|
||||||
|
/** 패 = 끝까지 한 판 - 승 - 무 (1등이 아니면 패로 센다). */
|
||||||
|
export function record(s: Pick<GameStat, 'played' | 'wins' | 'draws'>) {
|
||||||
|
const losses = Math.max(0, s.played - s.wins - s.draws);
|
||||||
|
const rate = s.played ? Math.round((s.wins / s.played) * 100) : null;
|
||||||
|
return { played: s.played, wins: s.wins, draws: s.draws, losses, rate };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumStats(stats: GameStat[]) {
|
||||||
|
return stats.reduce((t, s) => ({ played: t.played + s.played, wins: t.wins + s.wins, draws: t.draws + s.draws }), { played: 0, wins: 0, draws: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordText(s: Pick<GameStat, 'played' | 'wins' | 'draws'>): string {
|
||||||
|
const r = record(s);
|
||||||
|
return `${r.played}판 ${r.wins}승 ${r.losses}패 ${r.draws}무${r.rate !== null ? ` (승률 ${r.rate}%)` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One finished game from the player's side (내 게임 기록, 관리자 사용자 기록). */
|
||||||
|
export function resultLabel(g: MyGameSummary): string {
|
||||||
|
if (g.endReason === 'void') return '무효';
|
||||||
|
if (g.myRank === null) return '끝남';
|
||||||
|
if (g.players.length === 1) return '혼자 했어요';
|
||||||
|
if (g.players.every((p) => p.rank === 1)) return '무승부';
|
||||||
|
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
|
||||||
|
return `${g.myRank}등`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A player's name that opens their record card. Computer players are plain text. */
|
||||||
|
export function PlayerName({ user, gameId, session, children }: { user: PublicUser; gameId: string; session?: { played: number; wins: number }; children?: ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (isBotId(user.id)) return <>{children ?? user.nickname}</>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="name-btn" onClick={() => setOpen(true)} aria-label={`${user.nickname} 기록 보기`}>
|
||||||
|
{children ?? user.nickname}
|
||||||
|
</button>
|
||||||
|
{/* Portal: the name sits in one-line, bold text that the card must not inherit. */}
|
||||||
|
{open && createPortal(<PlayerCard user={user} gameId={gameId} session={session} onClose={() => setOpen(false)} />, document.body)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerCard({ user, gameId, session, onClose }: { user: PublicUser; gameId: string; session?: { played: number; wins: number }; onClose: () => void }) {
|
||||||
|
const [stats, setStats] = useState<GameStat[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
api.userStats(user.id).then(
|
||||||
|
(r) => setStats(r.stats),
|
||||||
|
(e) => setError((e as Error).message),
|
||||||
|
);
|
||||||
|
}, [user.id]);
|
||||||
|
const name = gameName(gameId);
|
||||||
|
const here = stats ? record(stats.find((s) => s.gameId === gameId) ?? { played: 0, wins: 0, draws: 0 }) : null;
|
||||||
|
const total = stats ? sumStats(stats) : null;
|
||||||
|
return (
|
||||||
|
<Sheet onClose={onClose} label={`${user.nickname} 기록`}>
|
||||||
|
<div className="row" style={{ gap: 12, flexWrap: 'nowrap' }}>
|
||||||
|
<Avatar user={user} size={52} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h2 style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{user.nickname}</h2>
|
||||||
|
<span className="badge">{user.kind === 'member' ? '디스코드 회원' : '게스트'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p className="field-error">{error}</p>
|
||||||
|
) : !here || !total ? (
|
||||||
|
<p className="muted">기록을 불러오는 중…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="stack" style={{ gap: 8 }} aria-label={`${name} 기록`}>
|
||||||
|
<strong>{name} 기록</strong>
|
||||||
|
{here.played === 0 ? (
|
||||||
|
<p className="muted">아직 이 게임을 끝까지 한 적이 없어요.</p>
|
||||||
|
) : (
|
||||||
|
<div className="record-grid">
|
||||||
|
<RecordCell label="판" value={here.played} />
|
||||||
|
<RecordCell label="승" value={here.wins} />
|
||||||
|
<RecordCell label="패" value={here.losses} />
|
||||||
|
<RecordCell label="무" value={here.draws} />
|
||||||
|
<RecordCell label="승률" value={`${here.rate}%`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{session && session.played > 0 && (
|
||||||
|
<p className="small">
|
||||||
|
이 방에서 {session.played}판 {session.wins}승
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="muted small">전체 게임 {total.played ? recordText(total) : '기록 없음'}</p>
|
||||||
|
{user.kind === 'guest' && <p className="muted small">게스트 기록은 사이트에 들어오지 않은 채 하루가 지나면 계정과 함께 지워져요.</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-secondary btn-block" onClick={onClose}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordCell({ label, value }: { label: string; value: number | string }) {
|
||||||
|
return (
|
||||||
|
<div className="record-cell">
|
||||||
|
<span className="muted small">{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,6 +46,13 @@ export interface MeResponse {
|
|||||||
activeRoom?: string | null;
|
activeRoom?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameStat {
|
||||||
|
gameId: string;
|
||||||
|
played: number;
|
||||||
|
wins: number;
|
||||||
|
draws: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomPreview {
|
export interface RoomPreview {
|
||||||
code: string;
|
code: string;
|
||||||
codeLabel: string;
|
codeLabel: string;
|
||||||
@@ -66,6 +73,8 @@ export const api = {
|
|||||||
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
|
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
|
||||||
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
|
||||||
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
|
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) =>
|
myGames: (before?: string | null) =>
|
||||||
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
|
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
|
||||||
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
|
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Link, Navigate, useMatch, useNavigate, useSearchParams } from 'react-router';
|
import { Link, Navigate, useMatch, useNavigate, useSearchParams } from 'react-router';
|
||||||
import { TopBar } from '../components/ui';
|
import { TopBar } from '../components/ui';
|
||||||
import { NicknameForm } from '../components/NicknameForm';
|
import { GuestWarning, NicknameForm } from '../components/NicknameForm';
|
||||||
import { discordLoginUrl } from '../net/api';
|
import { discordLoginUrl } from '../net/api';
|
||||||
import { useSession } from '../store/session';
|
import { useSession } from '../store/session';
|
||||||
|
|
||||||
@@ -58,7 +58,8 @@ export default function LoginPage() {
|
|||||||
<Link className={`btn btn-block ${discord ? 'btn-secondary' : 'btn-primary'}`} to={`/login/guest${query}`}>
|
<Link className={`btn btn-block ${discord ? 'btn-secondary' : 'btn-primary'}`} to={`/login/guest${query}`}>
|
||||||
게스트로 시작하기
|
게스트로 시작하기
|
||||||
</Link>
|
</Link>
|
||||||
<p className="muted small">가입 없이 이름만 정하면 돼요. 기록은 이 기기에만 남아요.</p>
|
<p className="muted small">가입 없이 이름만 정하면 돼요.</p>
|
||||||
|
<GuestWarning />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link, Navigate, useNavigate } from 'react-router';
|
import { Link, Navigate, useNavigate } from 'react-router';
|
||||||
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
|
import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite';
|
||||||
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
|
||||||
|
import { GuestWarning } from '../components/NicknameForm';
|
||||||
|
import { resultLabel } from '../components/PlayerCard';
|
||||||
import { gameName } from '../components/GameNotice';
|
import { gameName } from '../components/GameNotice';
|
||||||
import { api, discordLoginUrl } from '../net/api';
|
import { api, discordLoginUrl } from '../net/api';
|
||||||
import { useSession } from '../store/session';
|
import { useSession } from '../store/session';
|
||||||
@@ -98,6 +100,7 @@ export default function MePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
|
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
|
||||||
|
{me.kind === 'guest' && <GuestWarning />}
|
||||||
{me.kind === 'guest' && discordEnabled && (
|
{me.kind === 'guest' && discordEnabled && (
|
||||||
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
|
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
|
||||||
디스코드 연결하기 (기록을 안전하게 보관)
|
디스코드 연결하기 (기록을 안전하게 보관)
|
||||||
@@ -201,15 +204,6 @@ export default function MePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resultLabel(g: MyGameSummary): string {
|
|
||||||
if (g.endReason === 'void') return '무효';
|
|
||||||
if (g.myRank === null) return '끝남';
|
|
||||||
if (g.players.length === 1) return '혼자 했어요';
|
|
||||||
if (g.players.every((p) => p.rank === 1)) return '무승부';
|
|
||||||
if (g.players.length === 2) return g.myRank === 1 ? '이겼어요' : '졌어요';
|
|
||||||
return `${g.myRank}등`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
|
/** 내 게임 기록: finished games, newest first, with a 다시 보기 button (docs/09 §8). */
|
||||||
function MyGames() {
|
function MyGames() {
|
||||||
const [games, setGames] = useState<MyGameSummary[] | null>(null);
|
const [games, setGames] = useState<MyGameSummary[] | null>(null);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Link, useNavigate, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite';
|
import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||||
import { NicknameForm } from '../components/NicknameForm';
|
import { GuestWarning, NicknameForm } from '../components/NicknameForm';
|
||||||
import { GameNotice, gameName } from '../components/GameNotice';
|
import { GameNotice, gameName } from '../components/GameNotice';
|
||||||
import { QrModal } from '../components/QrModal';
|
import { QrModal } from '../components/QrModal';
|
||||||
import { api, type RoomPreview } from '../net/api';
|
import { api, type RoomPreview } from '../net/api';
|
||||||
@@ -15,6 +15,7 @@ import { sounds } from '../lib/feedback';
|
|||||||
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
|
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
|
||||||
import { useWide } from '../lib/media';
|
import { useWide } from '../lib/media';
|
||||||
import { GameHelp } from '../components/GameHelp';
|
import { GameHelp } from '../components/GameHelp';
|
||||||
|
import { PlayerName } from '../components/PlayerCard';
|
||||||
|
|
||||||
export default function RoomPage() {
|
export default function RoomPage() {
|
||||||
const { code = '' } = useParams();
|
const { code = '' } = useParams();
|
||||||
@@ -146,6 +147,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
|||||||
socket.leave();
|
socket.leave();
|
||||||
nav('/');
|
nav('/');
|
||||||
};
|
};
|
||||||
|
// Leaving mid-game forfeits, so only that case asks first; otherwise one tap leaves.
|
||||||
|
const forfeits = room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id);
|
||||||
|
const askLeave = () => (forfeits ? setLeaveAsk(true) : doLeave());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,13 +158,17 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
|||||||
{g.nameKo} 방 {formatRoomCode(code)}
|
{g.nameKo} 방 {formatRoomCode(code)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="room-bar-inner">
|
<div className="room-bar-inner">
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => nav('/')} aria-label="홈으로">
|
{/* ← 홈 leaves the room, same as [나가기]. */}
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={askLeave} aria-label="방 나가고 홈으로">
|
||||||
← 홈
|
← 홈
|
||||||
</button>
|
</button>
|
||||||
<strong className="rb-game" style={{ fontSize: '1.1rem' }}>
|
<strong className="rb-game" style={{ fontSize: '1.1rem' }}>
|
||||||
{g.nameKo}
|
{g.nameKo}
|
||||||
</strong>
|
</strong>
|
||||||
|
{/* On phones the link sits in the menu so ← 홈 · 채팅 · 🚪 나가기 · 메뉴 fit on one line. */}
|
||||||
|
<span className="wide-only">
|
||||||
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
||||||
|
</span>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
{!roomChatClosed && (
|
{!roomChatClosed && (
|
||||||
<span className="phone-only">
|
<span className="phone-only">
|
||||||
@@ -170,6 +178,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
|||||||
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
|
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||||
📖 규칙
|
📖 규칙
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={askLeave}>
|
||||||
|
🚪 나가기
|
||||||
|
</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => setMenu(true)} aria-label="메뉴">
|
<button className="btn btn-secondary btn-sm" onClick={() => setMenu(true)} aria-label="메뉴">
|
||||||
⋯ 메뉴
|
⋯ 메뉴
|
||||||
</button>
|
</button>
|
||||||
@@ -201,6 +212,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
|||||||
{menu && (
|
{menu && (
|
||||||
<Sheet onClose={() => setMenu(false)} label="메뉴">
|
<Sheet onClose={() => setMenu(false)} label="메뉴">
|
||||||
<h2>메뉴</h2>
|
<h2>메뉴</h2>
|
||||||
|
<span className="phone-only">
|
||||||
|
<CopyButton text={`${location.origin}/r/${code}`} label={<>🔗 방 <CodeLabel code={code} /> 링크 복사</>} done="링크 복사됨 ✓" className="btn btn-secondary btn-block" />
|
||||||
|
</span>
|
||||||
<button className="btn btn-secondary btn-block" onClick={() => nav(`/rules/${room.gameId}`)}>
|
<button className="btn btn-secondary btn-block" onClick={() => nav(`/rules/${room.gameId}`)}>
|
||||||
📖 규칙 보기
|
📖 규칙 보기
|
||||||
</button>
|
</button>
|
||||||
@@ -212,15 +226,12 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
|||||||
게임 끝내기(무효)
|
게임 끝내기(무효)
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-danger btn-block" onClick={() => (setMenu(false), setLeaveAsk(true))}>
|
|
||||||
🚪 방 나가기
|
|
||||||
</button>
|
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
{leaveAsk && (
|
{leaveAsk && (
|
||||||
<ConfirmSheet
|
<ConfirmSheet
|
||||||
title="방에서 나갈까요?"
|
title="방에서 나갈까요?"
|
||||||
body={room.status === 'playing' && room.seats.some((s) => s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined}
|
body="게임 중에 나가면 기권으로 처리돼요."
|
||||||
confirm="나가기"
|
confirm="나가기"
|
||||||
danger
|
danger
|
||||||
onConfirm={doLeave}
|
onConfirm={doLeave}
|
||||||
@@ -322,7 +333,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
|
{room.hostId === s.user.id && <span aria-label="방장">👑 </span>}
|
||||||
{s.user.nickname}
|
<PlayerName user={s.user} gameId={room.gameId} session={room.sessionStats[s.user.id]} />
|
||||||
{s.user.id === me.id && <span className="muted small"> (나)</span>}
|
{s.user.id === me.id && <span className="muted small"> (나)</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="row small" style={{ gap: 6 }}>
|
<div className="row small" style={{ gap: 6 }}>
|
||||||
@@ -347,7 +358,17 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{room.spectators.length > 0 && <p className="muted small">관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}</p>}
|
{room.spectators.length > 0 && (
|
||||||
|
<p className="muted small">
|
||||||
|
관전 {room.spectators.length}명:{' '}
|
||||||
|
{room.spectators.map((u, i) => (
|
||||||
|
<span key={u.id}>
|
||||||
|
{i > 0 && ', '}
|
||||||
|
<PlayerName user={u} gameId={room.gameId} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{mySeat >= 0 && room.allowSpectators && (
|
{mySeat >= 0 && room.allowSpectators && (
|
||||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => socket.send({ t: 'unseat' })}>
|
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => socket.send({ t: 'unseat' })}>
|
||||||
관전으로 바꾸기
|
관전으로 바꾸기
|
||||||
@@ -370,6 +391,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
|||||||
</div>
|
</div>
|
||||||
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
|
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
|
||||||
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
|
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
|
||||||
|
{me.kind === 'guest' && !practice && <GuestWarning />}
|
||||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
||||||
<GameNotice gameId={room.gameId} />
|
<GameNotice gameId={room.gameId} />
|
||||||
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
|
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
|
||||||
@@ -643,7 +665,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
|
|||||||
<div key={u} className="row">
|
<div key={u} className="row">
|
||||||
<strong style={{ width: 40 }}>{i + 1}등</strong>
|
<strong style={{ width: 40 }}>{i + 1}등</strong>
|
||||||
{players[u] && <Avatar user={players[u]!} size={28} />}
|
{players[u] && <Avatar user={players[u]!} size={28} />}
|
||||||
<span>{players[u]?.nickname ?? '나간 사람'}</span>
|
<span style={{ minWidth: 0 }}>{players[u] ? <PlayerName user={players[u]!} gameId={room.gameId} session={room.sessionStats[u]} /> : '나간 사람'}</span>
|
||||||
<span className="muted small" style={{ marginLeft: 'auto' }}>
|
<span className="muted small" style={{ marginLeft: 'auto' }}>
|
||||||
이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
|
이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */
|
/** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router';
|
import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router';
|
||||||
import { formatRoomCode } from '@bg/shared/lite';
|
import { formatRoomCode, type MyGameSummary } from '@bg/shared/lite';
|
||||||
import { ConfirmSheet, Sheet } from '../../components/ui';
|
import { ConfirmSheet, Sheet } from '../../components/ui';
|
||||||
import { apiCall, discordLoginUrl, type Role } from '../../net/api';
|
import { apiCall, discordLoginUrl, type Role } from '../../net/api';
|
||||||
import { useSession } from '../../store/session';
|
import { useSession } from '../../store/session';
|
||||||
import { toast } from '../../store/toast';
|
import { toast } from '../../store/toast';
|
||||||
import { SchemaForm, type JsonSchema } from './SchemaForm';
|
import { SchemaForm, type JsonSchema } from './SchemaForm';
|
||||||
|
import { gameName } from '../../components/GameNotice';
|
||||||
|
import { RecordCell, record, recordText, resultLabel, sumStats } from '../../components/PlayerCard';
|
||||||
|
import type { GameStat } from '../../net/api';
|
||||||
|
|
||||||
const a = {
|
const a = {
|
||||||
get: <T,>(p: string) => apiCall<T>('GET', `/api/admin${p}`),
|
get: <T,>(p: string) => apiCall<T>('GET', `/api/admin${p}`),
|
||||||
@@ -448,6 +451,7 @@ function Users() {
|
|||||||
<th>닉네임</th>
|
<th>닉네임</th>
|
||||||
<th className="wide-only">로그인</th>
|
<th className="wide-only">로그인</th>
|
||||||
<th>권한</th>
|
<th>권한</th>
|
||||||
|
<th className="wide-only">전적</th>
|
||||||
<th className="wide-only">마지막 접속</th>
|
<th className="wide-only">마지막 접속</th>
|
||||||
<th>상태</th>
|
<th>상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -461,6 +465,7 @@ function Users() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="wide-only small">{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'}</td>
|
<td className="wide-only small">{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'}</td>
|
||||||
<td>{u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'}</td>
|
<td>{u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'}</td>
|
||||||
|
<td className="wide-only small">{u.played ? `${u.played}판 ${u.wins}승` : '-'}</td>
|
||||||
<td className="wide-only small">{time(u.lastSeenAt)}</td>
|
<td className="wide-only small">{time(u.lastSeenAt)}</td>
|
||||||
<td>{u.banned ? <span className="badge warning">제한</span> : <span className="badge success">정상</span>}</td>
|
<td>{u.banned ? <span className="badge warning">제한</span> : <span className="badge success">정상</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -474,7 +479,7 @@ function Users() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
||||||
const [d, reload] = useLoad<{ user: any; stats: any[] }>(`/users/${id}`);
|
const [d, reload] = useLoad<{ user: any; stats: GameStat[] }>(`/users/${id}`);
|
||||||
const [nick, setNick] = useState('');
|
const [nick, setNick] = useState('');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [ask, setAsk] = useState<null | 'delete' | 'logout'>(null);
|
const [ask, setAsk] = useState<null | 'delete' | 'logout'>(null);
|
||||||
@@ -504,7 +509,8 @@ function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
|||||||
{d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}</>}
|
{d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}</>}
|
||||||
{guestDeletion(d.user) && <> · {guestDeletion(d.user)}</>}
|
{guestDeletion(d.user) && <> · {guestDeletion(d.user)}</>}
|
||||||
</p>
|
</p>
|
||||||
<p className="small">{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}</p>
|
<UserRecord stats={d.stats} />
|
||||||
|
<UserGames id={id} />
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<input className="input" style={{ flex: 1 }} value={nick} onChange={(e) => setNick(e.target.value)} maxLength={20} aria-label="닉네임" />
|
<input className="input" style={{ flex: 1 }} value={nick} onChange={(e) => setNick(e.target.value)} maxLength={20} aria-label="닉네임" />
|
||||||
<button className="btn btn-secondary" onClick={() => run(a.patch(`/users/${id}`, { nickname: nick }), '닉네임을 바꿨어요.')}>
|
<button className="btn btn-secondary" onClick={() => run(a.patch(`/users/${id}`, { nickname: nick }), '닉네임을 바꿨어요.')}>
|
||||||
@@ -557,6 +563,120 @@ function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 전체 기록 + 게임별 기록 (연습 게임·무효 판은 빠져요). */
|
||||||
|
function UserRecord({ stats }: { stats: GameStat[] }) {
|
||||||
|
const total = record(sumStats(stats));
|
||||||
|
return (
|
||||||
|
<section className="stack" style={{ gap: 10 }} aria-label="기록">
|
||||||
|
<h3>전체 기록</h3>
|
||||||
|
{total.played === 0 ? (
|
||||||
|
<p className="muted small">끝까지 한 게임이 없어요.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="record-grid">
|
||||||
|
<RecordCell label="판" value={total.played} />
|
||||||
|
<RecordCell label="승" value={total.wins} />
|
||||||
|
<RecordCell label="패" value={total.losses} />
|
||||||
|
<RecordCell label="무" value={total.draws} />
|
||||||
|
<RecordCell label="승률" value={`${total.rate}%`} />
|
||||||
|
</div>
|
||||||
|
<h3>게임별 기록</h3>
|
||||||
|
<table className="data-table compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>게임</th>
|
||||||
|
<th>판</th>
|
||||||
|
<th>승</th>
|
||||||
|
<th>패</th>
|
||||||
|
<th>무</th>
|
||||||
|
<th>승률</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.map((s) => {
|
||||||
|
const r = record(s);
|
||||||
|
return (
|
||||||
|
<tr key={s.gameId}>
|
||||||
|
<td>{gameName(s.gameId)}</td>
|
||||||
|
<td>{r.played}</td>
|
||||||
|
<td>{r.wins}</td>
|
||||||
|
<td>{r.losses}</td>
|
||||||
|
<td>{r.draws}</td>
|
||||||
|
<td>{r.rate}%</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="muted small">컴퓨터와 한 연습 게임과 무효 처리된 판은 기록에 넣지 않아요.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최근 게임: 끝난 판, 최신순 (내 게임 기록과 같은 목록). */
|
||||||
|
function UserGames({ id }: { id: string }) {
|
||||||
|
const [games, setGames] = useState<MyGameSummary[] | null>(null);
|
||||||
|
const [next, setNext] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const load = useCallback(
|
||||||
|
(before: string | null) => {
|
||||||
|
setLoading(true);
|
||||||
|
a.get<{ games: MyGameSummary[]; next: string | null }>(`/users/${id}/games${before ? `?before=${before}` : ''}`)
|
||||||
|
.then((r) => {
|
||||||
|
setGames((prev) => [...(before ? (prev ?? []) : []), ...r.games]);
|
||||||
|
setNext(r.next);
|
||||||
|
})
|
||||||
|
.catch((e) => toast((e as Error).message, 'error'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
},
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
useEffect(() => load(null), [load]);
|
||||||
|
return (
|
||||||
|
<section className="stack" style={{ gap: 10 }} aria-label="최근 게임">
|
||||||
|
<h3>최근 게임</h3>
|
||||||
|
{games === null ? (
|
||||||
|
<p className="muted small">불러오는 중…</p>
|
||||||
|
) : games.length === 0 ? (
|
||||||
|
<p className="muted small">끝난 게임이 없어요.</p>
|
||||||
|
) : (
|
||||||
|
<table className="data-table compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>날짜</th>
|
||||||
|
<th>게임</th>
|
||||||
|
<th>결과</th>
|
||||||
|
<th className="wide-only">함께 한 사람</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{games.map((g) => (
|
||||||
|
<tr key={g.gamePk}>
|
||||||
|
<td className="small">{time(g.startedAt)}</td>
|
||||||
|
<td>{gameName(g.gameId)}</td>
|
||||||
|
<td>{resultLabel(g)}</td>
|
||||||
|
<td className="wide-only small">
|
||||||
|
{g.players
|
||||||
|
.filter((p) => !p.me)
|
||||||
|
.map((p) => p.nickname)
|
||||||
|
.join(', ') || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{next && (
|
||||||
|
<button className="btn btn-secondary btn-block" disabled={loading} onClick={() => load(next)}>
|
||||||
|
{loading ? '불러오는 중…' : '더 보기'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ admins (superadmin)
|
// ------------------------------------------------------------------ admins (superadmin)
|
||||||
|
|
||||||
function Admins() {
|
function Admins() {
|
||||||
|
|||||||
@@ -1206,3 +1206,54 @@ button.header-user {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Player name that opens the record card (room) */
|
||||||
|
.name-btn {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
.name-btn:hover {
|
||||||
|
text-decoration-style: solid;
|
||||||
|
}
|
||||||
|
.record-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.record-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.record-cell strong {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
.guest-warning {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.data-table.compact th,
|
||||||
|
.data-table.compact td {
|
||||||
|
padding: 8px 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 게스트 계정을 지운다(방 자리, 전적, 세션 포함, 지난 대국 기록에는 '알 수 없음'으로 남음). 확인 창에서 지워진다고 알린다.
|
- 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 게스트 계정을 지운다(방 자리, 전적, 세션 포함, 지난 대국 기록에는 '알 수 없음'으로 남음). 확인 창에서 지워진다고 알린다.
|
||||||
- 게스트가 사이트를 닫고 하루(24시간) 동안 오지 않으면 같은 방식으로 지운다. 서버가 1분마다 `users.last_seen_at`(요청·소켓 종료 때 갱신)을 보고 정리하며, 소켓이 열려 있거나 방 자리를 갖고 있으면 건너뛴다.
|
- 게스트가 사이트를 닫고 하루(24시간) 동안 오지 않으면 같은 방식으로 지운다. 서버가 1분마다 `users.last_seen_at`(요청·소켓 종료 때 갱신)을 보고 정리하며, 소켓이 열려 있거나 방 자리를 갖고 있으면 건너뛴다.
|
||||||
- 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제.
|
- 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제.
|
||||||
|
- 게스트로 시작하는 곳(로그인 페이지, 이름 정하기 칸), 게스트의 /me, 게스트가 있는 대기실에 "⚠️ 게스트는 기록이 오래 저장되지 않아요. 사이트에 들어오지 않은 채 하루가 지나면 계정과 기록이 모두 지워져요." 경고를 보인다.
|
||||||
|
|
||||||
## 6. HTTP API
|
## 6. HTTP API
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
| GET | `/api/me` | 내 정보(id, 닉네임, 종류, 아바타, 설정) |
|
| GET | `/api/me` | 내 정보(id, 닉네임, 종류, 아바타, 설정) |
|
||||||
| PATCH | `/api/me` | `{nickname?, useDiscordAvatar?, settings?}` |
|
| PATCH | `/api/me` | `{nickname?, useDiscordAvatar?, settings?}` |
|
||||||
| GET | `/api/me/stats` | 게임별 전적 |
|
| GET | `/api/me/stats` | 게임별 전적 |
|
||||||
|
| GET | `/api/users/:id/stats` | 다른 사람의 공개 정보 + 게임별 전적(방의 기록 카드, 로그인 필요) |
|
||||||
| DELETE | `/api/me` | 계정 삭제(확인 문구 입력 필요). 전적 익명화 |
|
| DELETE | `/api/me` | 계정 삭제(확인 문구 입력 필요). 전적 익명화 |
|
||||||
|
|
||||||
## 7. 계정 페이지(/me)
|
## 7. 계정 페이지(/me)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
- 복사하면 버튼이 1.5초간 "복사했어요 ✓"로 바뀜 + 진동(지원 기기).
|
- 복사하면 버튼이 1.5초간 "복사했어요 ✓"로 바뀜 + 진동(지원 기기).
|
||||||
- 공유 문구: "OO님이 오목 방에 초대했어요! 코드 123 456 · https://<도메인>/r/123456"
|
- 공유 문구: "OO님이 오목 방에 초대했어요! 코드 123 456 · https://<도메인>/r/123456"
|
||||||
2. **자리 목록**: 게임 인원만큼 자리 칸. 각 칸: 프로필, 닉네임, 방장 왕관, 준비 체크, 연결 상태 점(초록/노랑/회색). 빈자리는 점선 "+ 초대하기"(누르면 초대 카드로 스크롤).
|
2. **자리 목록**: 게임 인원만큼 자리 칸. 각 칸: 프로필, 닉네임, 방장 왕관, 준비 체크, 연결 상태 점(초록/노랑/회색). 빈자리는 점선 "+ 초대하기"(누르면 초대 카드로 스크롤).
|
||||||
|
- 닉네임(자리, 관전자 목록, 결과 화면)을 누르면 기록 카드가 뜬다: 이 게임의 판·승·패·무·승률, 이 방 전적, 전체 게임 합계. 패 = 판 - 승 - 무(1등이 아니면 패). 컴퓨터는 누를 수 없다. `GET /api/users/:id/stats`(로그인 필요).
|
||||||
|
- 게스트에게는 설정 카드에 "기록이 오래 저장되지 않고, 하루 동안 안 들어오면 계정과 기록이 지워진다"는 경고를 보인다(연습 방 제외).
|
||||||
3. **게임 설정 요약**: "오목 · 렌주룰 · 한 수 30초" + 방장에게만 [설정 바꾸기].
|
3. **게임 설정 요약**: "오목 · 렌주룰 · 한 수 30초" + 방장에게만 [설정 바꾸기].
|
||||||
4. **아래 고정 버튼**(엄지 닿는 곳):
|
4. **아래 고정 버튼**(엄지 닿는 곳):
|
||||||
- 방장: [게임 시작] — 최소 인원이 안 되면 비활성 + "1명 더 필요해요" 안내. 모두 준비가 아니어도 시작 가능하되 "준비 안 된 사람이 있어요. 시작할까요?" 확인.
|
- 방장: [게임 시작] — 최소 인원이 안 되면 비활성 + "1명 더 필요해요" 안내. 모두 준비가 아니어도 시작 가능하되 "준비 안 된 사람이 있어요. 시작할까요?" 확인.
|
||||||
@@ -55,8 +57,9 @@
|
|||||||
|
|
||||||
## 8. 게임 중
|
## 8. 게임 중
|
||||||
- 위쪽 상태 줄: 지금 할 일 문장("내 차례예요" / "OO님 차례예요"), 남은 시간 링.
|
- 위쪽 상태 줄: 지금 할 일 문장("내 차례예요" / "OO님 차례예요"), 남은 시간 링.
|
||||||
- 메뉴(⋯): 규칙 보기, 소리, 글자 크기, 무르기 요청(지원 게임), 무승부 제안(지원 게임), 기권, 나가기.
|
- 방 막대: [← 홈] 게임 이름 · 방 코드 링크(휴대폰은 메뉴 안) · [💬 채팅](휴대폰) · [📖 규칙](PC) · **[🚪 나가기]** · [⋯ 메뉴].
|
||||||
- 나가기를 누르면: "게임 중에 나가면 OO 처리돼요. 정말 나갈까요?"(게임별: 2인 대전은 기권패, 카드 게임은 다이 후 자리 비움 등).
|
- [🚪 나가기]와 [← 홈]은 같은 동작이다: 방에서 나가고 홈으로. 한 번 누르면 바로 나가고, 게임 중에 자리에 앉아 있을 때만 "게임 중에 나가면 기권으로 처리돼요" 확인을 한 번 더 묻는다.
|
||||||
|
- 메뉴(⋯): 규칙 보기, 소리, 글자 크기, 무르기 요청(지원 게임), 무승부 제안(지원 게임), 기권. 나가기는 메뉴에 넣지 않는다.
|
||||||
- 관전자는 [자리 나면 참가] 대기 가능(다음 판부터).
|
- 관전자는 [자리 나면 참가] 대기 가능(다음 판부터).
|
||||||
|
|
||||||
## 9. 게임 끝
|
## 9. 게임 끝
|
||||||
@@ -97,7 +100,7 @@ interface Room {
|
|||||||
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
|
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개.
|
||||||
- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다(사이트를 닫았으면 아래 규칙대로 1분). 마지막 사람이 [방 나가기]로 나가서 자리에 앉은 사람이 없으면(컴퓨터만 남았거나 아무도 들어오지 않은 방) 바로 닫는다.
|
- 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다(사이트를 닫았으면 아래 규칙대로 1분). 마지막 사람이 [방 나가기]로 나가서 자리에 앉은 사람이 없으면(컴퓨터만 남았거나 아무도 들어오지 않은 방) 바로 닫는다.
|
||||||
- 게임 중이거나 끝난 방(대기실 제외)에서 자리에 앉은 사람이 모두 사이트를 닫으면(서버와 연결된 창이 하나도 없으면) 마지막 사람이 닫은 때부터 1분 뒤 정리 때 닫는다. 진행 중인 게임은 무효 처리되고, 관전자는 셈하지 않고, 남아 있던 관전자도 방에서 내보낸다. 대기실은 초대받은 사람이 들어올 수 있으므로 위 규칙을 따른다. 자리가 있는 사람은 다른 화면에 있어도 서버 연결을 유지한다(새로 연 창에서도 `/api/me`의 `activeRoom`이 있으면 연결).
|
- 게임 중이거나 끝난 방(대기실 제외)에서 자리에 앉은 사람이 모두 사이트를 닫으면(서버와 연결된 창이 하나도 없으면) 마지막 사람이 닫은 때부터 1분 뒤 정리 때 닫는다. 진행 중인 게임은 무효 처리되고, 관전자는 셈하지 않고, 남아 있던 관전자도 방에서 내보낸다. 대기실은 초대받은 사람이 들어올 수 있으므로 위 규칙을 따른다. 자리가 있는 사람은 다른 화면에 있어도 서버 연결을 유지한다(새로 연 창에서도 `/api/me`의 `activeRoom`이 있으면 연결).
|
||||||
- 방 화면을 벗어나면(홈, 규칙 등) 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다.
|
- [← 홈]·[🚪 나가기]가 아닌 길(사이트 로고·위쪽 '홈' 링크, 규칙, 브라우저 뒤로 가기 등)로 방 화면을 벗어나면 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다.
|
||||||
|
|
||||||
## 12. 컴퓨터 상대 (혼자 연습)
|
## 12. 컴퓨터 상대 (혼자 연습)
|
||||||
- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명).
|
- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명).
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
- **이용 제한**: 사유와 함께 설정. 즉시 그 사용자의 모든 세션 삭제와 접속 종료. 제한된 디스코드 계정은 다시 로그인할 수 없다("이용이 제한된 계정이에요"). 해제하면 다시 로그인 가능.
|
- **이용 제한**: 사유와 함께 설정. 즉시 그 사용자의 모든 세션 삭제와 접속 종료. 제한된 디스코드 계정은 다시 로그인할 수 없다("이용이 제한된 계정이에요"). 해제하면 다시 로그인 가능.
|
||||||
- **닉네임 변경**: 관리자가 바꾸면 하루 변경 횟수 제한을 무시.
|
- **닉네임 변경**: 관리자가 바꾸면 하루 변경 횟수 제한을 무시.
|
||||||
- **삭제**: `05-accounts-auth.md`의 계정 삭제와 같음(전적 익명화).
|
- **삭제**: `05-accounts-auth.md`의 계정 삭제와 같음(전적 익명화).
|
||||||
|
- **기록**: 목록에 사람마다 전체 판·승(PC), 상세에 전체 기록(판·승·패·무·승률), 게임별 기록 표, 최근 게임(끝난 판, 최신순 20개씩 [더 보기]: 날짜, 게임, 결과, 함께 한 사람). 연습 게임·무효 판은 판·승 수에 넣지 않지만 최근 게임 목록에는 나온다.
|
||||||
- **게스트 삭제 예정**: 게스트는 사이트를 나가고 하루 지나면 자동 삭제되므로(`05-accounts-auth.md` §5), 목록과 상세에 삭제 예정 시각(마지막 접속 + 24시간)을 보여 준다. 아직 사이트에 접속해 있거나 방에 자리가 있으면 "삭제 보류"로 표시한다.
|
- **게스트 삭제 예정**: 게스트는 사이트를 나가고 하루 지나면 자동 삭제되므로(`05-accounts-auth.md` §5), 목록과 상세에 삭제 예정 시각(마지막 접속 + 24시간)을 보여 준다. 아직 사이트에 접속해 있거나 방에 자리가 있으면 "삭제 보류"로 표시한다.
|
||||||
- 슈퍼어드민 자신은 제한·삭제할 수 없다.
|
- 슈퍼어드민 자신은 제한·삭제할 수 없다.
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ ALTER TABLE users ADD COLUMN banned_reason TEXT;
|
|||||||
| GET | `/api/admin/audit` | 어드민+ |
|
| GET | `/api/admin/audit` | 어드민+ |
|
||||||
| GET | `/api/admin/users?q=&page=` | 슈퍼어드민 |
|
| GET | `/api/admin/users?q=&page=` | 슈퍼어드민 |
|
||||||
| GET / PATCH / DELETE | `/api/admin/users/:id` | 슈퍼어드민 |
|
| GET / PATCH / DELETE | `/api/admin/users/:id` | 슈퍼어드민 |
|
||||||
|
| GET | `/api/admin/users/:id/games?before=` | 슈퍼어드민 (최근 게임, `/api/me/games`와 같은 모양) |
|
||||||
| POST | `/api/admin/users/:id/logout` | 슈퍼어드민 |
|
| POST | `/api/admin/users/:id/logout` | 슈퍼어드민 |
|
||||||
| GET / POST | `/api/admin/admins` | 슈퍼어드민 |
|
| GET / POST | `/api/admin/admins` | 슈퍼어드민 |
|
||||||
| DELETE | `/api/admin/admins/:discordId` | 슈퍼어드민 |
|
| DELETE | `/api/admin/admins/:discordId` | 슈퍼어드민 |
|
||||||
|
|||||||
@@ -112,9 +112,9 @@ try {
|
|||||||
const codeOf = (p: Page) => new URL(p.url()).pathname.split('/').pop()!;
|
const codeOf = (p: Page) => new URL(p.url()).pathname.split('/').pop()!;
|
||||||
const userId = (nick: string) => app.db.query<{ id: string }, [string]>('SELECT id FROM users WHERE nickname = ?').get(nick)?.id;
|
const userId = (nick: string) => app.db.query<{ id: string }, [string]>('SELECT id FROM users WHERE nickname = ?').get(nick)?.id;
|
||||||
|
|
||||||
await step('오목(게임 중): ← 홈·새로고침 → 자리는 남고 접속은 끊김, 사이트가 열려 있으면 30분 뒤 정리', async () => {
|
await step('오목(게임 중): 사이트 로고로 홈·새로고침 → 자리는 남고 접속은 끊김, 사이트가 열려 있으면 30분 뒤 정리', async () => {
|
||||||
const code = codeOf(a);
|
const code = codeOf(a);
|
||||||
await a.getByRole('button', { name: '홈으로' }).click();
|
await a.locator('.site-logo').click();
|
||||||
await a.waitForURL(`${ORIGIN}/`);
|
await a.waitForURL(`${ORIGIN}/`);
|
||||||
const room = app.rooms.get(code)!;
|
const room = app.rooms.get(code)!;
|
||||||
for (let i = 0; i < 50 && room.conns.size; i++) await Bun.sleep(20);
|
for (let i = 0; i < 50 && room.conns.size; i++) await Bun.sleep(20);
|
||||||
@@ -213,15 +213,15 @@ try {
|
|||||||
console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`);
|
console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------- leaving with the menu, guest logout
|
// ---------------------------------------------------------------- leaving with ← 홈 / 🚪 나가기, guest logout
|
||||||
await step('원카드: 메뉴 → 방 나가기 → 방이 바로 닫힘', async () => {
|
await step('원카드(게임 중): ← 홈 → 기권 확인 → 방이 바로 닫힘', async () => {
|
||||||
const code = codeOf(b);
|
const code = codeOf(b);
|
||||||
if (!app.rooms.get(code)) throw new Error('room missing before leaving');
|
if (!app.rooms.get(code)) throw new Error('room missing before leaving');
|
||||||
const result = b.getByRole('dialog', { name: '게임 결과' });
|
const result = b.getByRole('dialog', { name: '게임 결과' });
|
||||||
if (await result.count()) await result.getByRole('button', { name: '방 나가기' }).click();
|
if (await result.count()) await result.getByRole('button', { name: '방 나가기' }).click();
|
||||||
else {
|
else {
|
||||||
await b.getByRole('button', { name: '메뉴' }).click();
|
await b.getByRole('button', { name: '방 나가고 홈으로' }).click();
|
||||||
await b.getByRole('button', { name: '🚪 방 나가기' }).click();
|
await b.getByText('게임 중에 나가면 기권으로 처리돼요.').waitFor();
|
||||||
await b.getByRole('button', { name: '나가기', exact: true }).click();
|
await b.getByRole('button', { name: '나가기', exact: true }).click();
|
||||||
}
|
}
|
||||||
await b.waitForURL(`${ORIGIN}/`);
|
await b.waitForURL(`${ORIGIN}/`);
|
||||||
@@ -229,6 +229,18 @@ try {
|
|||||||
if (app.rooms.get(code)) throw new Error('room still open after everyone left');
|
if (app.rooms.get(code)) throw new Error('room still open after everyone left');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await step('대기실: 🚪 나가기 한 번에 나감(확인 없음) → 방이 바로 닫힘', async () => {
|
||||||
|
await b.goto(`${ORIGIN}/g/one-card`);
|
||||||
|
await b.getByRole('button', { name: '🤖 컴퓨터와 연습하기' }).click();
|
||||||
|
await b.waitForURL(/\/r\/\d{6}$/);
|
||||||
|
await b.getByLabel('자리').getByText('컴퓨터 1').waitFor();
|
||||||
|
const code = codeOf(b);
|
||||||
|
await b.getByRole('button', { name: '🚪 나가기' }).click();
|
||||||
|
await b.waitForURL(`${ORIGIN}/`);
|
||||||
|
for (let i = 0; i < 50 && app.rooms.get(code); i++) await Bun.sleep(20);
|
||||||
|
if (app.rooms.get(code)) throw new Error('room still open after leaving the lobby');
|
||||||
|
});
|
||||||
|
|
||||||
await step('게스트 로그아웃 → 계정 삭제', async () => {
|
await step('게스트 로그아웃 → 계정 삭제', async () => {
|
||||||
const id = userId('호랑이')!;
|
const id = userId('호랑이')!;
|
||||||
await a.getByRole('button', { name: '내 계정: 호랑이' }).click();
|
await a.getByRole('button', { name: '내 계정: 호랑이' }).click();
|
||||||
|
|||||||
@@ -229,8 +229,7 @@ try {
|
|||||||
if (liveA.length < 5 || liveB.length < 5) throw new Error(`hands too small: ${liveA.length}, ${liveB.length}`);
|
if (liveA.length < 5 || liveB.length < 5) throw new Error(`hands too small: ${liveA.length}, ${liveB.length}`);
|
||||||
if (liveA.some((c) => liveB.includes(c))) throw new Error('hands overlap');
|
if (liveA.some((c) => liveB.includes(c))) throw new Error('hands overlap');
|
||||||
// B leaves mid-game → A wins.
|
// B leaves mid-game → A wins.
|
||||||
await b.getByRole('button', { name: '메뉴' }).tap();
|
await b.getByRole('button', { name: '🚪 나가기' }).tap();
|
||||||
await b.getByRole('button', { name: '🚪 방 나가기' }).tap();
|
|
||||||
await b.getByRole('button', { name: '나가기', exact: true }).tap();
|
await b.getByRole('button', { name: '나가기', exact: true }).tap();
|
||||||
await b.waitForURL(`${ORIGIN}/`);
|
await b.waitForURL(`${ORIGIN}/`);
|
||||||
await a.getByText('🎉 이겼어요!').waitFor();
|
await a.getByText('🎉 이겼어요!').waitFor();
|
||||||
|
|||||||
Reference in New Issue
Block a user