diff --git a/apps/server/src/admin.test.ts b/apps/server/src/admin.test.ts index 92a0230..8a47fa8 100644 --- a/apps/server/src/admin.test.ts +++ b/apps/server/src/admin.test.ts @@ -255,4 +255,54 @@ describe('site settings, rooms, users', () => { 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); }); + + 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); + }); }); diff --git a/apps/server/src/auth/users.ts b/apps/server/src/auth/users.ts index 5fa29ef..d2551b8 100644 --- a/apps/server/src/auth/users.ts +++ b/apps/server/src/auth/users.ts @@ -126,9 +126,16 @@ export class Users { search(q: string, limit: number, offset: number) { const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`; const rows = this.db - .query( - `SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name FROM users u + .query< + 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 (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 = ?) ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`, ) diff --git a/apps/server/src/http/admin.ts b/apps/server/src/http/admin.ts index 8eea99d..0c6b6ba 100644 --- a/apps/server/src/http/admin.ts +++ b/apps/server/src/http/admin.ts @@ -8,11 +8,13 @@ import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../a import type { Sessions } from '../auth/sessions'; import { toPublicUser, type Users } from '../auth/users'; import type { RoomManager } from '../rooms/manager'; +import type { Replays } from '../rooms/replay'; export interface AdminDeps { users: Users; sessions: Sessions; rooms: RoomManager; + replays: Replays; roles: Roles; audit: Audit; settings: SettingsStore; @@ -148,6 +150,9 @@ export function createAdminApp(d: AdminDeps) { createdAt: u.created_at, lastSeenAt: u.last_seen_at, 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) => { const id = c.req.param('id'); const u = d.users.get(id); diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 77f6b59..ad7ec58 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -26,7 +26,7 @@ export interface HttpDeps { health: () => { ok: boolean; [k: string]: unknown }; roles: Roles; settings: SettingsStore; - admin: Omit; + admin: Omit; /** Deletes a guest account everywhere (rooms, sockets, DB). */ removeGuest: (userId: string) => void; } @@ -42,6 +42,7 @@ export function createHttpApp(d: HttpDeps) { const lookupLimiter = new WindowLimiter(30 * lift, 60_000); const guestLimiter = new WindowLimiter(20 * 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 cookieOpts = (maxAgeMs: number) => ({ @@ -246,6 +247,16 @@ export function createHttpApp(d: HttpDeps) { 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) => { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); @@ -315,7 +326,7 @@ export function createHttpApp(d: HttpDeps) { app.route( '/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; diff --git a/apps/web/src/components/NicknameForm.tsx b/apps/web/src/components/NicknameForm.tsx index 529304e..9410b3f 100644 --- a/apps/web/src/components/NicknameForm.tsx +++ b/apps/web/src/components/NicknameForm.tsx @@ -65,7 +65,19 @@ export function NicknameForm({ 디스코드로 로그인 )} +

이름은 나중에 언제든 바꿀 수 있어요.

); } + +/** 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 ( +

+ ⚠️ 게스트는 기록이 오래 저장되지 않아요. 사이트에 들어오지 않은 채 하루가 지나면 계정과 기록이 모두 지워져요. + {discord && ' 기록을 남기려면 디스코드로 로그인해 주세요.'} +

+ ); +} diff --git a/apps/web/src/components/PlayerCard.tsx b/apps/web/src/components/PlayerCard.tsx new file mode 100644 index 0000000..468d74e --- /dev/null +++ b/apps/web/src/components/PlayerCard.tsx @@ -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) { + 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): 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 ( + <> + + {/* Portal: the name sits in one-line, bold text that the card must not inherit. */} + {open && createPortal( 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(null); + const [error, setError] = useState(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 ( + +
+ +
+

{user.nickname}

+ {user.kind === 'member' ? '디스코드 회원' : '게스트'} +
+
+ {error ? ( +

{error}

+ ) : !here || !total ? ( +

기록을 불러오는 중…

+ ) : ( + <> +
+ {name} 기록 + {here.played === 0 ? ( +

아직 이 게임을 끝까지 한 적이 없어요.

+ ) : ( +
+ + + + + +
+ )} +
+ {session && session.played > 0 && ( +

+ 이 방에서 {session.played}판 {session.wins}승 +

+ )} +

전체 게임 {total.played ? recordText(total) : '기록 없음'}

+ {user.kind === 'guest' &&

게스트 기록은 사이트에 들어오지 않은 채 하루가 지나면 계정과 함께 지워져요.

} + + )} + +
+ ); +} + +export function RecordCell({ label, value }: { label: string; value: number | string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/web/src/net/api.ts b/apps/web/src/net/api.ts index 9d59938..a8a2804 100644 --- a/apps/web/src/net/api.ts +++ b/apps/web/src/net/api.ts @@ -46,6 +46,13 @@ export interface MeResponse { activeRoom?: string | null; } +export interface GameStat { + gameId: string; + played: number; + wins: number; + draws: number; +} + export interface RoomPreview { code: string; codeLabel: string; @@ -66,6 +73,8 @@ export const api = { 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('GET', `/api/replays/${encodeURIComponent(gamePk)}`), diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 277413c..c227d59 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -4,7 +4,7 @@ */ import { Link, Navigate, useMatch, useNavigate, useSearchParams } from 'react-router'; import { TopBar } from '../components/ui'; -import { NicknameForm } from '../components/NicknameForm'; +import { GuestWarning, NicknameForm } from '../components/NicknameForm'; import { discordLoginUrl } from '../net/api'; import { useSession } from '../store/session'; @@ -58,7 +58,8 @@ export default function LoginPage() { 게스트로 시작하기 -

가입 없이 이름만 정하면 돼요. 기록은 이 기기에만 남아요.

+

가입 없이 이름만 정하면 돼요.

+ diff --git a/apps/web/src/pages/MePage.tsx b/apps/web/src/pages/MePage.tsx index 7d3c334..6322c1e 100644 --- a/apps/web/src/pages/MePage.tsx +++ b/apps/web/src/pages/MePage.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router'; import { catalogById, nicknameError, normalizeNickname, type MyGameSummary } from '@bg/shared/lite'; import { Avatar, ConfirmSheet, TopBar } from '../components/ui'; +import { GuestWarning } from '../components/NicknameForm'; +import { resultLabel } from '../components/PlayerCard'; import { gameName } from '../components/GameNotice'; import { api, discordLoginUrl } from '../net/api'; import { useSession } from '../store/session'; @@ -98,6 +100,7 @@ export default function MePage() { )}

{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}

+ {me.kind === 'guest' && } {me.kind === 'guest' && discordEnabled && ( 디스코드 연결하기 (기록을 안전하게 보관) @@ -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). */ function MyGames() { const [games, setGames] = useState(null); diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 917635e..f891089 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router'; import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite'; 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 { QrModal } from '../components/QrModal'; import { api, type RoomPreview } from '../net/api'; @@ -15,6 +15,7 @@ import { sounds } from '../lib/feedback'; import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel'; import { useWide } from '../lib/media'; import { GameHelp } from '../components/GameHelp'; +import { PlayerName } from '../components/PlayerCard'; export default function RoomPage() { const { code = '' } = useParams(); @@ -146,6 +147,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) { socket.leave(); 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 ( <> @@ -154,13 +158,17 @@ function Room({ code, me }: { code: string; me: PublicUser }) { {g.nameKo} 방 {formatRoomCode(code)}
- {g.nameKo} - 방 🔗} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" /> + {/* On phones the link sits in the menu so ← 홈 · 채팅 · 🚪 나가기 · 메뉴 fit on one line. */} + + 방 🔗} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" /> + {!roomChatClosed && ( @@ -170,6 +178,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) { + @@ -201,6 +212,9 @@ function Room({ code, me }: { code: string; me: PublicUser }) { {menu && ( setMenu(false)} label="메뉴">

메뉴

+ + 🔗 방 링크 복사} done="링크 복사됨 ✓" className="btn btn-secondary btn-block" /> + @@ -212,15 +226,12 @@ function Room({ code, me }: { code: string; me: PublicUser }) { 게임 끝내기(무효) )} -
)} {leaveAsk && ( s?.user.id === me.id) ? '게임 중에 나가면 기권으로 처리돼요.' : undefined} + body="게임 중에 나가면 기권으로 처리돼요." confirm="나가기" danger onConfirm={doLeave} @@ -322,7 +333,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
{room.hostId === s.user.id && 👑 } - {s.user.nickname} + {s.user.id === me.id && (나)}
@@ -347,7 +358,17 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) ), )}
- {room.spectators.length > 0 &&

관전 {room.spectators.length}명: {room.spectators.map((u) => u.nickname).join(', ')}

} + {room.spectators.length > 0 && ( +

+ 관전 {room.spectators.length}명:{' '} + {room.spectators.map((u, i) => ( + + {i > 0 && ', '} + + + ))} +

+ )} {mySeat >= 0 && room.allowSpectators && (

{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}

{practice &&

🤖 연습 게임이에요. 기록에는 남지 않아요.

} + {me.kind === 'guest' && !practice && } {room.lockedOptions.length > 0 &&

🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.

} {isHost && wide && } @@ -643,7 +665,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
{i + 1}등 {players[u] && } - {players[u]?.nickname ?? '나간 사람'} + {players[u] ? : '나간 사람'} 이 방에서 {room.sessionStats[u]?.wins ?? 0}승 / {room.sessionStats[u]?.played ?? 0}판 diff --git a/apps/web/src/pages/admin/AdminApp.tsx b/apps/web/src/pages/admin/AdminApp.tsx index 6da8d54..77f3b9c 100644 --- a/apps/web/src/pages/admin/AdminApp.tsx +++ b/apps/web/src/pages/admin/AdminApp.tsx @@ -1,12 +1,15 @@ /** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */ import { useCallback, useEffect, useState, type ReactNode } from 'react'; 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 { apiCall, discordLoginUrl, type Role } from '../../net/api'; import { useSession } from '../../store/session'; import { toast } from '../../store/toast'; 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 = { get: (p: string) => apiCall('GET', `/api/admin${p}`), @@ -448,6 +451,7 @@ function Users() { 닉네임 로그인 권한 + 전적 마지막 접속 상태 @@ -461,6 +465,7 @@ function Users() { {u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'} {u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'} + {u.played ? `${u.played}판 ${u.wins}승` : '-'} {time(u.lastSeenAt)} {u.banned ? 제한 : 정상} @@ -474,7 +479,7 @@ function Users() { } 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 [reason, setReason] = useState(''); const [ask, setAsk] = useState(null); @@ -504,7 +509,8 @@ function UserSheet({ id, onClose }: { id: string; onClose: () => void }) { {d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}} {guestDeletion(d.user) && <> · {guestDeletion(d.user)}}

-

{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}

+ +
setNick(e.target.value)} maxLength={20} aria-label="닉네임" /> + )} + + ); +} + // ------------------------------------------------------------------ admins (superadmin) function Admins() { diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 87a061d..92506c7 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -1206,3 +1206,54 @@ button.header-user { flex: 1; 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; +} diff --git a/docs/05-accounts-auth.md b/docs/05-accounts-auth.md index f48e09e..f268c0b 100644 --- a/docs/05-accounts-auth.md +++ b/docs/05-accounts-auth.md @@ -42,6 +42,7 @@ - 로그아웃: 세션 삭제 + 쿠키 삭제. 게스트가 로그아웃하면 게스트 계정을 지운다(방 자리, 전적, 세션 포함, 지난 대국 기록에는 '알 수 없음'으로 남음). 확인 창에서 지워진다고 알린다. - 게스트가 사이트를 닫고 하루(24시간) 동안 오지 않으면 같은 방식으로 지운다. 서버가 1분마다 `users.last_seen_at`(요청·소켓 종료 때 갱신)을 보고 정리하며, 소켓이 열려 있거나 방 자리를 갖고 있으면 건너뛴다. - 계정 페이지의 "모든 기기에서 로그아웃"은 그 사용자의 모든 세션 삭제. +- 게스트로 시작하는 곳(로그인 페이지, 이름 정하기 칸), 게스트의 /me, 게스트가 있는 대기실에 "⚠️ 게스트는 기록이 오래 저장되지 않아요. 사이트에 들어오지 않은 채 하루가 지나면 계정과 기록이 모두 지워져요." 경고를 보인다. ## 6. HTTP API | 메서드 | 경로 | 설명 | @@ -54,6 +55,7 @@ | GET | `/api/me` | 내 정보(id, 닉네임, 종류, 아바타, 설정) | | PATCH | `/api/me` | `{nickname?, useDiscordAvatar?, settings?}` | | GET | `/api/me/stats` | 게임별 전적 | +| GET | `/api/users/:id/stats` | 다른 사람의 공개 정보 + 게임별 전적(방의 기록 카드, 로그인 필요) | | DELETE | `/api/me` | 계정 삭제(확인 문구 입력 필요). 전적 익명화 | ## 7. 계정 페이지(/me) diff --git a/docs/06-lobby-rooms.md b/docs/06-lobby-rooms.md index 19773f6..acc8cff 100644 --- a/docs/06-lobby-rooms.md +++ b/docs/06-lobby-rooms.md @@ -35,6 +35,8 @@ - 복사하면 버튼이 1.5초간 "복사했어요 ✓"로 바뀜 + 진동(지원 기기). - 공유 문구: "OO님이 오목 방에 초대했어요! 코드 123 456 · https://<도메인>/r/123456" 2. **자리 목록**: 게임 인원만큼 자리 칸. 각 칸: 프로필, 닉네임, 방장 왕관, 준비 체크, 연결 상태 점(초록/노랑/회색). 빈자리는 점선 "+ 초대하기"(누르면 초대 카드로 스크롤). + - 닉네임(자리, 관전자 목록, 결과 화면)을 누르면 기록 카드가 뜬다: 이 게임의 판·승·패·무·승률, 이 방 전적, 전체 게임 합계. 패 = 판 - 승 - 무(1등이 아니면 패). 컴퓨터는 누를 수 없다. `GET /api/users/:id/stats`(로그인 필요). + - 게스트에게는 설정 카드에 "기록이 오래 저장되지 않고, 하루 동안 안 들어오면 계정과 기록이 지워진다"는 경고를 보인다(연습 방 제외). 3. **게임 설정 요약**: "오목 · 렌주룰 · 한 수 30초" + 방장에게만 [설정 바꾸기]. 4. **아래 고정 버튼**(엄지 닿는 곳): - 방장: [게임 시작] — 최소 인원이 안 되면 비활성 + "1명 더 필요해요" 안내. 모두 준비가 아니어도 시작 가능하되 "준비 안 된 사람이 있어요. 시작할까요?" 확인. @@ -55,8 +57,9 @@ ## 8. 게임 중 - 위쪽 상태 줄: 지금 할 일 문장("내 차례예요" / "OO님 차례예요"), 남은 시간 링. -- 메뉴(⋯): 규칙 보기, 소리, 글자 크기, 무르기 요청(지원 게임), 무승부 제안(지원 게임), 기권, 나가기. -- 나가기를 누르면: "게임 중에 나가면 OO 처리돼요. 정말 나갈까요?"(게임별: 2인 대전은 기권패, 카드 게임은 다이 후 자리 비움 등). +- 방 막대: [← 홈] 게임 이름 · 방 코드 링크(휴대폰은 메뉴 안) · [💬 채팅](휴대폰) · [📖 규칙](PC) · **[🚪 나가기]** · [⋯ 메뉴]. +- [🚪 나가기]와 [← 홈]은 같은 동작이다: 방에서 나가고 홈으로. 한 번 누르면 바로 나가고, 게임 중에 자리에 앉아 있을 때만 "게임 중에 나가면 기권으로 처리돼요" 확인을 한 번 더 묻는다. +- 메뉴(⋯): 규칙 보기, 소리, 글자 크기, 무르기 요청(지원 게임), 무승부 제안(지원 게임), 기권. 나가기는 메뉴에 넣지 않는다. - 관전자는 [자리 나면 참가] 대기 가능(다음 판부터). ## 9. 게임 끝 @@ -97,7 +100,7 @@ interface Room { - 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개. - 방 정리(1분마다): 접속이 하나도 없는 방 중 대기실은 10분, 끝난 방·멈춘 방은 30분 동안 활동이 없으면 닫는다. 컴퓨터가 있는 방(연습 방)은 마지막 사람이 나간 때부터 재서 대기실·끝난 방은 1분, 게임 중인 방은 30분 뒤 닫는다(사이트를 닫았으면 아래 규칙대로 1분). 마지막 사람이 [방 나가기]로 나가서 자리에 앉은 사람이 없으면(컴퓨터만 남았거나 아무도 들어오지 않은 방) 바로 닫는다. - 게임 중이거나 끝난 방(대기실 제외)에서 자리에 앉은 사람이 모두 사이트를 닫으면(서버와 연결된 창이 하나도 없으면) 마지막 사람이 닫은 때부터 1분 뒤 정리 때 닫는다. 진행 중인 게임은 무효 처리되고, 관전자는 셈하지 않고, 남아 있던 관전자도 방에서 내보낸다. 대기실은 초대받은 사람이 들어올 수 있으므로 위 규칙을 따른다. 자리가 있는 사람은 다른 화면에 있어도 서버 연결을 유지한다(새로 연 창에서도 `/api/me`의 `activeRoom`이 있으면 연결). -- 방 화면을 벗어나면(홈, 규칙 등) 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다. +- [← 홈]·[🚪 나가기]가 아닌 길(사이트 로고·위쪽 '홈' 링크, 규칙, 브라우저 뒤로 가기 등)로 방 화면을 벗어나면 클라이언트가 `detach`를 보낸다. 자리는 그대로이고 '접속 중'으로 남지만, 방 메시지를 받지 않고 방의 접속 수에서도 빠진다. 그 상태로 사이트를 닫으면 연결 끊김으로 처리한다. ## 12. 컴퓨터 상대 (혼자 연습) - 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명). diff --git a/docs/14-admin.md b/docs/14-admin.md index 6ff82d3..601353b 100644 --- a/docs/14-admin.md +++ b/docs/14-admin.md @@ -62,6 +62,7 @@ - **이용 제한**: 사유와 함께 설정. 즉시 그 사용자의 모든 세션 삭제와 접속 종료. 제한된 디스코드 계정은 다시 로그인할 수 없다("이용이 제한된 계정이에요"). 해제하면 다시 로그인 가능. - **닉네임 변경**: 관리자가 바꾸면 하루 변경 횟수 제한을 무시. - **삭제**: `05-accounts-auth.md`의 계정 삭제와 같음(전적 익명화). +- **기록**: 목록에 사람마다 전체 판·승(PC), 상세에 전체 기록(판·승·패·무·승률), 게임별 기록 표, 최근 게임(끝난 판, 최신순 20개씩 [더 보기]: 날짜, 게임, 결과, 함께 한 사람). 연습 게임·무효 판은 판·승 수에 넣지 않지만 최근 게임 목록에는 나온다. - **게스트 삭제 예정**: 게스트는 사이트를 나가고 하루 지나면 자동 삭제되므로(`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/users?q=&page=` | 슈퍼어드민 | | GET / PATCH / DELETE | `/api/admin/users/:id` | 슈퍼어드민 | +| GET | `/api/admin/users/:id/games?before=` | 슈퍼어드민 (최근 게임, `/api/me/games`와 같은 모양) | | POST | `/api/admin/users/:id/logout` | 슈퍼어드민 | | GET / POST | `/api/admin/admins` | 슈퍼어드민 | | DELETE | `/api/admin/admins/:discordId` | 슈퍼어드민 | diff --git a/e2e/bots.e2e.ts b/e2e/bots.e2e.ts index 6b4f2dd..cf26c25 100644 --- a/e2e/bots.e2e.ts +++ b/e2e/bots.e2e.ts @@ -112,9 +112,9 @@ try { 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; - await step('오목(게임 중): ← 홈·새로고침 → 자리는 남고 접속은 끊김, 사이트가 열려 있으면 30분 뒤 정리', async () => { + await step('오목(게임 중): 사이트 로고로 홈·새로고침 → 자리는 남고 접속은 끊김, 사이트가 열려 있으면 30분 뒤 정리', async () => { const code = codeOf(a); - await a.getByRole('button', { name: '홈으로' }).click(); + await a.locator('.site-logo').click(); await a.waitForURL(`${ORIGIN}/`); const room = app.rooms.get(code)!; 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}`); }); - // ---------------------------------------------------------------- leaving with the menu, guest logout - await step('원카드: 메뉴 → 방 나가기 → 방이 바로 닫힘', async () => { + // ---------------------------------------------------------------- leaving with ← 홈 / 🚪 나가기, guest logout + await step('원카드(게임 중): ← 홈 → 기권 확인 → 방이 바로 닫힘', async () => { const code = codeOf(b); if (!app.rooms.get(code)) throw new Error('room missing before leaving'); const result = b.getByRole('dialog', { name: '게임 결과' }); if (await result.count()) await result.getByRole('button', { name: '방 나가기' }).click(); 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.waitForURL(`${ORIGIN}/`); @@ -229,6 +229,18 @@ try { 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 () => { const id = userId('호랑이')!; await a.getByRole('button', { name: '내 계정: 호랑이' }).click(); diff --git a/e2e/replay.e2e.ts b/e2e/replay.e2e.ts index 9155dca..468d040 100644 --- a/e2e/replay.e2e.ts +++ b/e2e/replay.e2e.ts @@ -229,8 +229,7 @@ try { 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'); // 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.waitForURL(`${ORIGIN}/`); await a.getByText('🎉 이겼어요!').waitFor();