From 43156a9f6301cd0c194776e03be5cdd68fa7fa55 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Thu, 8 Oct 2026 11:34:08 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=A9:=20=EB=82=98=EA=B0=80=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9D=84=20=EB=A7=89=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B9=BC=EA=B3=A0=20=E2=86=90=20=ED=99=88=EB=8F=84=20=EB=82=98?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=EB=A1=9C,=20=EC=9D=B4=EB=A6=84=20=EB=88=8C?= =?UTF-8?q?=EB=9F=AC=20=EA=B8=B0=EB=A1=9D=20=EC=B9=B4=EB=93=9C,=20?= =?UTF-8?q?=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=BD=EA=B3=A0,=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EA=B3=84=EC=A0=95=EB=B3=84=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ๋ฐฉ ๋ง‰๋Œ€์— [๐Ÿšช ๋‚˜๊ฐ€๊ธฐ]๋ฅผ ๋‘๊ณ  ๋ฉ”๋‰ด์—์„œ๋Š” ๋บ€๋‹ค. [โ† ํ™ˆ]๋„ ๊ฐ™์€ ๋™์ž‘. ํ•œ ๋ฒˆ ๋ˆ„๋ฅด๋ฉด ๋ฐ”๋กœ ๋‚˜๊ฐ€๊ณ , ๊ฒŒ์ž„ ์ค‘์— ์ž๋ฆฌ์— ์•‰์•„ ์žˆ์„ ๋•Œ๋งŒ ๊ธฐ๊ถŒ ํ™•์ธ์„ ๋ฌป๋Š”๋‹ค. ํœด๋Œ€ํฐ์€ ๋ฐฉ ๋งํฌ ๋ณต์‚ฌ๋ฅผ ๋ฉ”๋‰ด๋กœ ์˜ฎ๊ฒจ ํ•œ ์ค„์— ๋งž์ถ˜๋‹ค. - ์ž๋ฆฌยท๊ด€์ „์žยท๊ฒฐ๊ณผ ํ™”๋ฉด์˜ ์ด๋ฆ„์„ ๋ˆ„๋ฅด๋ฉด ๊ธฐ๋ก ์นด๋“œ(์ด ๊ฒŒ์ž„ ํŒยท์ŠนยทํŒจยท๋ฌดยท์Šน๋ฅ , ์ด ๋ฐฉ ์ „์ , ์ „์ฒด ํ•ฉ๊ณ„). GET /api/users/:id/stats (๋กœ๊ทธ์ธ ํ•„์š”). - ๊ฒŒ์ŠคํŠธ ๊ฒฝ๊ณ : ์ด๋ฆ„ ์ •ํ•˜๊ธฐ, ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€, /me, ๊ฒŒ์ŠคํŠธ๊ฐ€ ์žˆ๋Š” ๋Œ€๊ธฐ์‹ค. - ๊ด€๋ฆฌ์ž ์‚ฌ์šฉ์ž ์ƒ์„ธ: ์ „์ฒด ๊ธฐ๋ก, ๊ฒŒ์ž„๋ณ„ ๊ธฐ๋ก ํ‘œ, ์ตœ๊ทผ ๊ฒŒ์ž„(GET /api/admin/users/:id/games). ๋ชฉ๋ก์— ์ „์  ์—ด(PC). Co-Authored-By: Claude Opus 5.5 --- apps/server/src/admin.test.ts | 50 +++++++++ apps/server/src/auth/users.ts | 11 +- apps/server/src/http/admin.ts | 14 +++ apps/server/src/http/app.ts | 15 ++- apps/web/src/components/NicknameForm.tsx | 12 +++ apps/web/src/components/PlayerCard.tsx | 114 ++++++++++++++++++++ apps/web/src/net/api.ts | 9 ++ apps/web/src/pages/LoginPage.tsx | 5 +- apps/web/src/pages/MePage.tsx | 12 +-- apps/web/src/pages/RoomPage.tsx | 42 ++++++-- apps/web/src/pages/admin/AdminApp.tsx | 126 ++++++++++++++++++++++- apps/web/src/styles/global.css | 51 +++++++++ docs/05-accounts-auth.md | 2 + docs/06-lobby-rooms.md | 9 +- docs/14-admin.md | 2 + e2e/bots.e2e.ts | 24 +++-- e2e/replay.e2e.ts | 3 +- 17 files changed, 462 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/components/PlayerCard.tsx 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();