Compare commits
38 Commits
6f462b8cb1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e1408302a | ||
|
|
b21aec716f | ||
|
|
475648f9bc | ||
|
|
43156a9f63 | ||
|
|
f93ff4f656 | ||
|
|
2fbd956cb5 | ||
|
|
e499c79464 | ||
|
|
4e4c195c79 | ||
|
|
beaee4675d | ||
|
|
f5671cda70 | ||
|
|
0a587a8b98 | ||
|
|
39223bacdd | ||
|
|
373231b553 | ||
|
|
4cb71d8cbe | ||
|
|
53fbbc1c9d | ||
|
|
843b18aea1 | ||
|
|
bdaaa23af6 | ||
|
|
6390e3e64c | ||
|
|
7da06d1b27 | ||
|
|
d8b8cdd8f3 | ||
|
|
6cc3b75975 | ||
|
|
94b813c8b5 | ||
|
|
c78d0fd1fc | ||
|
|
fde665b13e | ||
|
|
6efd7afbc5 | ||
|
|
166c89c08b | ||
|
|
5db651437a | ||
|
|
710ecfe4a5 | ||
|
|
68edd9b434 | ||
|
|
bd9272f4ce | ||
|
|
a3f81ded37 | ||
|
|
5a2d865377 | ||
|
|
c84c1ba95e | ||
|
|
2ed1a7ed33 | ||
|
|
61b7c4e326 | ||
|
|
32e3e89d8d | ||
|
|
57753a037c | ||
|
|
c27cb0a11a |
37
README.md
37
README.md
@@ -3,6 +3,26 @@
|
||||
광고 없이 가볍고, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임 사이트.
|
||||
계획서는 [docs/](docs/README.md)에 있고, 구현은 계획서를 따른다.
|
||||
|
||||
운영 중: https://game.tkrmagid.kr
|
||||
|
||||
## 게임 (26개)
|
||||
| 종류 | 게임 |
|
||||
|---|---|
|
||||
| 2인 수 싸움 | 오목, 오셀로, 체스(체스960 포함), 장기, 바둑, 쿼리도(2·4명) |
|
||||
| 카드·화투 | 원카드, 섯다, 포커, 고스톱, 달무티, 구십구, 바퀴벌레 포커, 뱅! |
|
||||
| 타일·주사위 | 루미큐브, 다빈치 코드, 마작, 요트 다이스, 윷놀이, 라스베가스, 부루마불, 스플렌더 |
|
||||
| 순발력·대화 | 할리갈리, 도블, 그림 맞히기, 마피아 |
|
||||
|
||||
칩·포인트·지폐는 모두 놀이용이다(실제 돈과 무관).
|
||||
|
||||
## 주요 기능
|
||||
- 로그인: 디스코드 또는 게스트(닉네임만). 게스트는 로그아웃하거나 하루 동안 접속하지 않으면 계정이 지워진다.
|
||||
- 방: 방 링크를 보내면 친구가 바로 들어온다. 방 막대에서 링크 복사·채팅·규칙·설정(글자 크기·소리)·나가기를 한 번에 누른다. 게임 중에 나가면 기권으로 처리된다.
|
||||
- 컴퓨터 상대: 실시간·대화 게임(할리갈리, 도블, 그림 맞히기, 마피아)을 뺀 22개 게임.
|
||||
- 처음 하는 사람을 위한 30초 규칙 요약, 게임별 규칙 페이지, 끝난 판 다시 보기, 이름을 누르면 나오는 기록 카드.
|
||||
- 홈 화면에 추가(PWA), 휴대폰(320px부터)·PC, 다크 모드.
|
||||
- 관리자 화면(`/admin`): 게임 규칙 기본값·잠금, 사용자·방 관리, 계정별 기록.
|
||||
|
||||
## 개발
|
||||
```bash
|
||||
bun install
|
||||
@@ -14,12 +34,19 @@ bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록
|
||||
## 확인
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun run test # 규칙·서버 통합·방 단위 테스트 (무작위 대국이 길어 제한 60초)
|
||||
bun run test # 바뀐 부분 테스트만 (origin/main 이후 + 커밋 안 한 변경, 없으면 마지막 커밋 기준)
|
||||
# 예: 체스만 고쳤으면 체스 테스트만, 문서만 고쳤으면 없음. -- --dry-run 으로 고른 것만 보기
|
||||
bun run test:all # 전체 테스트 (4개 병렬, 무작위 대국은 시드 1/10만, 1분 안쪽)
|
||||
bun run test:full # 배포 전 전체: 무작위 대국 시드 전부(1,000개 등), 순차 실행이라 10분쯤
|
||||
(cd apps/web && bunx vite build) # 웹 빌드
|
||||
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
||||
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
||||
bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지)
|
||||
bun e2e/a11y.e2e.ts # 접근성(axe) 검사
|
||||
bun e2e/pwa.e2e.ts # PWA: 매니페스트·아이콘, 서비스 워커, 오프라인 안내, 홈 화면에 추가
|
||||
bun e2e/bots.e2e.ts # 컴퓨터 상대, 방 나가기, 게스트 로그아웃
|
||||
bun e2e/replay.e2e.ts # 다시 보기
|
||||
bun e2e/tutorial.e2e.ts # 처음 하는 사람용 규칙 요약
|
||||
bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
|
||||
```
|
||||
|
||||
@@ -28,10 +55,16 @@ bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
|
||||
```bash
|
||||
cd deploy && docker compose up -d --build
|
||||
```
|
||||
이미 HTTPS 리버스 프록시가 있는 서버(지금 운영 중인 .5 + NPMplus)에서는 `docker compose -f docker-compose.proxy.yml up -d --build`로 앱만 띄운다.
|
||||
운영 서버 업데이트는 `main`에 푸시한 뒤 서버에서:
|
||||
```bash
|
||||
git pull && cd deploy && docker compose -f docker-compose.proxy.yml up -d --build
|
||||
curl -fsS http://localhost:2970/healthz
|
||||
```
|
||||
자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
|
||||
|
||||
## 관리자
|
||||
`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md).
|
||||
`/admin`. 슈퍼어드민은 서버의 `deploy/.env`에 있는 환경 변수 `SUPERADMIN_DISCORD_IDS`의 디스코드 ID(저장소에는 넣지 않음), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md).
|
||||
|
||||
## 구조
|
||||
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/** Admin site tests (docs/14-admin.md §9). */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
import { Client, TEST_SUPERADMINS, api, boot, guest, sleep } from './test-utils';
|
||||
import { GUEST_IDLE_MS } from './server';
|
||||
|
||||
const SUPER = '293719842274541579';
|
||||
const [SUPER, SUPER2] = TEST_SUPERADMINS;
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
@@ -47,6 +48,8 @@ describe('roles', () => {
|
||||
const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie });
|
||||
expect(me.body.role).toBe('superadmin');
|
||||
expect((await api(s.base, '/api/me', { cookie: sup.cookie })).body.role).toBe('superadmin');
|
||||
const sup2 = await discordLogin(s, SUPER2);
|
||||
expect((await api(s.base, '/api/admin/me', { cookie: sup2.cookie })).body.role).toBe('superadmin');
|
||||
});
|
||||
|
||||
test('superadmin adds/removes an admin by Discord ID; takes effect immediately; admin cannot manage users', async () => {
|
||||
@@ -58,7 +61,7 @@ describe('roles', () => {
|
||||
// Pre-register before the person ever logged in.
|
||||
const add = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: adminDiscord, note: '친구1' }) });
|
||||
expect(add.res.status).toBe(200);
|
||||
expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, adminDiscord]);
|
||||
expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, SUPER2, adminDiscord]);
|
||||
const adm = await discordLogin(s, adminDiscord);
|
||||
expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).body.role).toBe('admin');
|
||||
expect((await api(s.base, '/api/admin/users', { cookie: adm.cookie })).res.status).toBe(403);
|
||||
@@ -139,6 +142,37 @@ describe('game settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin-only options (threat guides)', () => {
|
||||
test('off by default; the host cannot turn them on; the admin default always wins and is not shown as a lock', async () => {
|
||||
const s = start();
|
||||
const host = await guest(s.base, '방장');
|
||||
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
const c = await new Client(s.base, host.cookie, host.id).connect();
|
||||
clients.push(c);
|
||||
c.send({ t: 'join', code: room.body.code, as: 'player' });
|
||||
const first = await c.next('room');
|
||||
expect((first.room.options as any).threatHints).toBe(false);
|
||||
expect(first.room.lockedOptions).toEqual([]);
|
||||
c.send({ t: 'config', options: { ...(first.room.options as any), threatHints: true, boardSize: 19 } });
|
||||
const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19);
|
||||
expect((after.room.options as any).threatHints).toBe(false);
|
||||
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const put = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ defaultOptions: { threatHints: true } }) });
|
||||
expect(put.res.status).toBe(200);
|
||||
const room2 = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
const c2 = await new Client(s.base, host.cookie, host.id).connect();
|
||||
clients.push(c2);
|
||||
c2.send({ t: 'join', code: room2.body.code, as: 'player' });
|
||||
const r2 = await c2.next('room');
|
||||
expect((r2.room.options as any).threatHints).toBe(true);
|
||||
expect(r2.room.lockedOptions).toEqual([]);
|
||||
c2.send({ t: 'config', options: { ...(r2.room.options as any), threatHints: false, boardSize: 19 } });
|
||||
const after2 = await c2.next('room', (m) => (m.room.options as any).boardSize === 19);
|
||||
expect((after2.room.options as any).threatHints).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('site settings, rooms, users', () => {
|
||||
test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => {
|
||||
const s = start();
|
||||
@@ -205,4 +239,70 @@ describe('site settings, rooms, users', () => {
|
||||
const actions = (await api(s.base, '/api/admin/audit', { cookie: sup.cookie })).body.entries.map((e: any) => e.action);
|
||||
expect(actions).toEqual(['user.unban', 'user.ban', 'user.rename']);
|
||||
});
|
||||
|
||||
test('superadmin sees when an idle guest will be deleted; none for members or guests still online', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const away = await guest(s.base, '떠난손님');
|
||||
const here = await guest(s.base, '있는손님');
|
||||
const c = await new Client(s.base, here.cookie, here.id).connect();
|
||||
clients.push(c);
|
||||
const seen = (s.db.query('SELECT last_seen_at AS t FROM users WHERE id = ?').get(away.id) as { t: number }).t;
|
||||
const list = (await api(s.base, '/api/admin/users', { cookie: sup.cookie })).body.users;
|
||||
const byId = (id: string) => list.find((u: any) => u.id === id);
|
||||
expect(byId(away.id).deleteAt).toBe(seen + GUEST_IDLE_MS);
|
||||
expect(byId(here.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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Database } from 'bun:sqlite';
|
||||
const DAY = 24 * 3600_000;
|
||||
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
|
||||
export const SESSION_COOKIE = 'sid';
|
||||
/** users.last_seen_at is written at most this often per user. */
|
||||
const SEEN_EVERY = 60_000;
|
||||
|
||||
export function randomToken(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
@@ -30,8 +32,8 @@ export class Sessions {
|
||||
resolve(token: string, now = Date.now()): string | null {
|
||||
const hash = sha256Hex(token);
|
||||
const row = this.db
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member'; last_seen_at: number }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind, u.last_seen_at FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`,
|
||||
)
|
||||
.get(hash);
|
||||
@@ -45,6 +47,8 @@ export class Sessions {
|
||||
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
|
||||
.run(now, now + SESSION_TTL[row.kind], hash);
|
||||
}
|
||||
// Idle guests are deleted after a day (docs/05 §4), so remember when the user was last here.
|
||||
if (now - row.last_seen_at > SEEN_EVERY) this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, row.user_id);
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserRow & { discord_id: string | null; discord_name: string | null }, [string, string, string, string, number, number]>(
|
||||
`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 ?`,
|
||||
)
|
||||
@@ -150,6 +157,32 @@ export class Users {
|
||||
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
|
||||
}
|
||||
|
||||
/** Guests not seen since `before`, oldest first. */
|
||||
idleGuests(before: number, limit = 500): string[] {
|
||||
return this.db
|
||||
.query<{ id: string }, [number, number]>(
|
||||
"SELECT id FROM users WHERE kind = 'guest' AND deleted_at IS NULL AND last_seen_at < ? ORDER BY last_seen_at LIMIT ?",
|
||||
)
|
||||
.all(before, limit)
|
||||
.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a guest account for good (logout, or idle for a day): the user row, its stats and sessions.
|
||||
* Past games keep the seat (shown as an unknown player). Returns false for non-guests.
|
||||
*/
|
||||
deleteGuest(userId: string): boolean {
|
||||
return this.db.transaction(() => {
|
||||
const row = this.db.query<{ kind: string }, [string]>('SELECT kind FROM users WHERE id = ?').get(userId);
|
||||
if (row?.kind !== 'guest') return false;
|
||||
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM users WHERE id = ?').run(userId);
|
||||
return true;
|
||||
})();
|
||||
}
|
||||
|
||||
softDelete(userId: string, now = Date.now()): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
|
||||
112
apps/server/src/bots.test.ts
Normal file
112
apps/server/src/bots.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/** Computer players over the real HTTP/WS stack (docs/06-lobby-rooms.md §12). */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) {
|
||||
try {
|
||||
c.close();
|
||||
} catch {}
|
||||
}
|
||||
for (const s of running.splice(0)) {
|
||||
try {
|
||||
await s.stop();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
const fastBots = { botDelay: () => 30 };
|
||||
|
||||
function start(dbPath?: string): Booted {
|
||||
const s = boot(dbPath, fastBots);
|
||||
running.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise<Client> {
|
||||
const g = cookie ?? (await guest(s.base, nick));
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async function practiceOmok(s: Booted, a: Client): Promise<string> {
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'omok', practice: true }) });
|
||||
expect(r.res.status).toBe(200);
|
||||
const code = r.body.code as string;
|
||||
a.send({ t: 'join', code, as: 'player' });
|
||||
await a.next('room');
|
||||
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
|
||||
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
|
||||
const st = a.next('state');
|
||||
a.send({ t: 'start' });
|
||||
await st;
|
||||
return code;
|
||||
}
|
||||
|
||||
describe('computer players', () => {
|
||||
test('practice room: host + computer seated, private, not listed; the computer answers moves', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '호랑이');
|
||||
const code = await practiceOmok(s, a);
|
||||
const room = a.lastOf('room')!.room;
|
||||
expect(room.visibility).toBe('private');
|
||||
expect(room.seats.map((x) => x?.user.kind)).toEqual(['guest', 'bot']);
|
||||
expect(room.seats[1]!.user.nickname).toBe('컴퓨터 1');
|
||||
const pub = await api(s.base, '/api/games/omok/rooms');
|
||||
expect(pub.body.rooms.find((r: { code: string }) => r.code === code)).toBeUndefined();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const answered = a.next('state', (m) => (m.view as any).moveCount === 2 * i + 2);
|
||||
const board = (a.lastOf('state')!.view as any).board as number[];
|
||||
const free = board.indexOf(0);
|
||||
a.act({ type: 'place', x: free % 15, y: Math.floor(free / 15) });
|
||||
await answered;
|
||||
}
|
||||
});
|
||||
|
||||
test('addBot / removeBot over the socket: host only, errors for others', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '호랑이');
|
||||
const b = await client(s, '고양이');
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'one-card', visibility: 'public' }) });
|
||||
const code = r.body.code as string;
|
||||
a.send({ t: 'join', code, as: 'player' });
|
||||
await a.next('room');
|
||||
b.send({ t: 'join', code, as: 'player' });
|
||||
await b.next('room');
|
||||
b.send({ t: 'addBot' });
|
||||
expect((await b.next('error')).message).toBe('방장만 컴퓨터를 넣을 수 있어요.');
|
||||
a.send({ t: 'addBot', seat: 3 });
|
||||
const withBot = await a.next('room', (m) => m.room.seats.some((x) => x?.user.kind === 'bot'));
|
||||
expect(withBot.room.visibility).toBe('private');
|
||||
const botId = withBot.room.seats[3]!.user.id;
|
||||
a.send({ t: 'removeBot', userId: botId });
|
||||
await a.next('room', (m) => !m.room.seats.some((x) => x?.user.kind === 'bot'));
|
||||
});
|
||||
|
||||
test('restart with the computer to move: after restore it plays on its own', async () => {
|
||||
const s1 = boot(undefined, fastBots);
|
||||
const a = await client(s1, '호랑이');
|
||||
const code = await practiceOmok(s1, a);
|
||||
// Stop the bot before it answers, then crash.
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
a.act({ type: 'place', x: 7, y: 7 });
|
||||
await a.next('state', (m) => (m.view as any).moveCount === 1);
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
await Promise.race([s1.server.stop(true), sleep(300)]);
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
s1.db.close();
|
||||
const s2 = start(s1.dbPath);
|
||||
await sleep(200); // the restored driver plays without anyone connected
|
||||
const room = s2.rooms.get(code)!;
|
||||
expect((room.game!.state as any).moves.length).toBe(2);
|
||||
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
|
||||
a2.send({ t: 'join', code, as: 'player' });
|
||||
const st = await a2.next('state');
|
||||
expect((st.view as any).moveCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,6 @@ export interface Config {
|
||||
loadTestNoHttpLimits: boolean;
|
||||
}
|
||||
|
||||
/** The site owner's Discord ID (docs/14-admin.md §2). */
|
||||
export const DEFAULT_SUPERADMIN = '293719842274541579';
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
const extra = (env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
@@ -36,6 +33,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
||||
secureCookies: publicOrigin.startsWith('https://'),
|
||||
trustProxy: env.TRUST_PROXY === '1',
|
||||
loadTestNoHttpLimits: env.LOADTEST_NO_HTTP_LIMITS === '1' && !publicOrigin.startsWith('https://'),
|
||||
superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? DEFAULT_SUPERADMIN).split(',').map((s) => s.trim()).filter(Boolean),
|
||||
/** Superadmin Discord IDs, comma-separated (docs/14-admin.md §2). Only in deploy/.env, never in the repo. */
|
||||
superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? '').split(',').map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
4
apps/server/src/db/migrations/003_guest_cleanup.sql
Normal file
4
apps/server/src/db/migrations/003_guest_cleanup.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Idle guests are deleted a day after their last visit (docs/05 §4).
|
||||
CREATE INDEX users_guest_seen ON users(kind, last_seen_at);
|
||||
-- last_seen_at was not kept up to date before this migration: start every guest's idle clock now.
|
||||
UPDATE users SET last_seen_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000 WHERE kind = 'guest';
|
||||
149
apps/server/src/guests.test.ts
Normal file
149
apps/server/src/guests.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/** Guest accounts are deleted on logout and after a day away (docs/05 §5); leaving a room page detaches. */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
import { GUEST_IDLE_MS } from './server';
|
||||
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) c.close();
|
||||
for (const s of running.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
function start(): Booted {
|
||||
const s = boot();
|
||||
running.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async function connect(s: Booted, g: { cookie: string; id: string }): Promise<Client> {
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async function practiceRoom(s: Booted, c: Client): Promise<string> {
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId: 'omok', practice: true }) });
|
||||
expect(r.res.status).toBe(200);
|
||||
c.send({ t: 'join', code: r.body.code, as: 'player' });
|
||||
await c.waitFor((m) => m.t === 'room');
|
||||
return r.body.code;
|
||||
}
|
||||
|
||||
const userRow = (s: Booted, id: string) => s.db.query('SELECT id FROM users WHERE id = ?').get(id);
|
||||
const setSeen = (s: Booted, id: string, at: number) => s.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(at, id);
|
||||
|
||||
describe('guest logout', () => {
|
||||
test('deletes the guest: account, sessions, seat (the practice room closes) and open sockets', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '지울게스트');
|
||||
const c = await connect(s, g);
|
||||
const code = await practiceRoom(s, c);
|
||||
expect(s.rooms.get(code)).toBeDefined();
|
||||
const out = await api(s.base, '/api/auth/logout', { method: 'POST', cookie: g.cookie });
|
||||
expect(out.res.status).toBe(200);
|
||||
expect(userRow(s, g.id)).toBeNull();
|
||||
expect(s.db.query('SELECT 1 FROM sessions WHERE user_id = ?').get(g.id)).toBeNull();
|
||||
expect(s.rooms.get(code)).toBeUndefined();
|
||||
for (let i = 0; i < 50 && !c.closed; i++) await sleep(10);
|
||||
expect(c.closed).not.toBeNull();
|
||||
expect((await api(s.base, '/api/me', { cookie: g.cookie })).body.me).toBeNull();
|
||||
});
|
||||
|
||||
test('a member logging out keeps the account', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '회원');
|
||||
s.users.promoteToMember(g.id, null);
|
||||
await api(s.base, '/api/auth/logout', { method: 'POST', cookie: g.cookie });
|
||||
expect(userRow(s, g.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('idle guests', () => {
|
||||
test('a guest away for a day is deleted; one still on the site, holding a seat, or seen recently is kept', async () => {
|
||||
const s = start();
|
||||
const old = Date.now() - GUEST_IDLE_MS - 60_000;
|
||||
const gone = await guest(s.base, '떠난게스트');
|
||||
const online = await guest(s.base, '접속중');
|
||||
const seated = await guest(s.base, '자리있음');
|
||||
const recent = await guest(s.base, '최근');
|
||||
await connect(s, online);
|
||||
const sc = await connect(s, seated);
|
||||
// A human lobby (no computers): the seat survives the socket closing.
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: seated.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
sc.send({ t: 'join', code: r.body.code, as: 'player' });
|
||||
await sc.waitFor((m) => m.t === 'room');
|
||||
sc.close();
|
||||
for (let i = 0; i < 50 && !sc.closed; i++) await sleep(10);
|
||||
await sleep(20);
|
||||
for (const g of [gone, online, seated]) setSeen(s, g.id, old);
|
||||
s.purgeIdleGuests();
|
||||
expect(userRow(s, gone.id)).toBeNull();
|
||||
expect(userRow(s, online.id)).not.toBeNull();
|
||||
expect(userRow(s, seated.id)).not.toBeNull();
|
||||
expect(userRow(s, recent.id)).not.toBeNull();
|
||||
// Coming back counts as activity.
|
||||
setSeen(s, recent.id, old);
|
||||
expect((await api(s.base, '/api/me', { cookie: recent.cookie })).body.me.id).toBe(recent.id);
|
||||
s.purgeIdleGuests();
|
||||
expect(userRow(s, recent.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaving the room page', () => {
|
||||
test('detach keeps the seat but the room no longer counts the connection', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '홈으로');
|
||||
const c = await connect(s, g);
|
||||
const code = await practiceRoom(s, c);
|
||||
const room = s.rooms.get(code)!;
|
||||
expect(room.conns.size).toBe(1);
|
||||
c.send({ t: 'detach' });
|
||||
for (let i = 0; i < 50 && room.conns.size; i++) await sleep(10);
|
||||
expect(room.conns.size).toBe(0);
|
||||
expect(room.seatOf(g.id)).toBeGreaterThanOrEqual(0);
|
||||
expect(room.emptySince).not.toBeNull();
|
||||
// Still on the site: counts as connected (no "연결 끊김", no auto moves).
|
||||
expect(room.presence.get(g.id)?.connected).toBe(true);
|
||||
// Detaching twice is harmless.
|
||||
c.send({ t: 'detach' });
|
||||
c.send({ t: 'ping', ts: 1 });
|
||||
await c.waitFor((m) => m.t === 'pong');
|
||||
expect(c.messages.some((m) => m.t === 'error')).toBe(false);
|
||||
// Coming back to the page re-attaches the same socket.
|
||||
c.send({ t: 'join', code, as: 'player' });
|
||||
for (let i = 0; i < 50 && !room.conns.size; i++) await sleep(10);
|
||||
expect(room.conns.size).toBe(1);
|
||||
expect(room.emptySince).toBeNull();
|
||||
// Closing the site while detached: the room learns the person went offline.
|
||||
c.send({ t: 'detach' });
|
||||
for (let i = 0; i < 50 && room.conns.size; i++) await sleep(10);
|
||||
c.close();
|
||||
for (let i = 0; i < 50 && room.presence.get(g.id)?.connected; i++) await sleep(10);
|
||||
expect(room.presence.get(g.id)?.connected).toBe(false);
|
||||
expect(room.detached.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on the site or not', () => {
|
||||
test('offlineSince: null while any socket is open, the close time once the last one closes', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '사이트');
|
||||
expect(s.gateway.offlineSince(g.id)).toBe(0); // never connected since the server started
|
||||
const c1 = await connect(s, g);
|
||||
const c2 = await connect(s, g);
|
||||
expect(s.gateway.offlineSince(g.id)).toBeNull();
|
||||
c1.close();
|
||||
for (let i = 0; i < 50 && !c1.closed; i++) await sleep(10);
|
||||
await sleep(20);
|
||||
expect(s.gateway.offlineSince(g.id)).toBeNull(); // another tab is still open
|
||||
const before = Date.now();
|
||||
c2.close();
|
||||
for (let i = 0; i < 50 && s.gateway.offlineSince(g.id) === null; i++) await sleep(10);
|
||||
expect(s.gateway.offlineSince(g.id)).toBeGreaterThanOrEqual(before);
|
||||
await connect(s, g);
|
||||
expect(s.gateway.offlineSince(g.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,22 +2,26 @@
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { nicknameError, normalizeNickname } from '@bg/shared';
|
||||
import { botProfile, nicknameError, normalizeNickname } from '@bg/shared';
|
||||
import type { Audit, Role, Roles } from '../admin/roles';
|
||||
import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings';
|
||||
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;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
disconnectUser: (userId: string) => void;
|
||||
/** When an idle guest will be deleted; null for members and for guests still online or seated. */
|
||||
guestDeleteAt: (u: { id: string; kind: 'guest' | 'member'; last_seen_at: number }) => number | null;
|
||||
stats: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -100,7 +104,11 @@ export function createAdminApp(d: AdminDeps) {
|
||||
gameId: r.config.gameId,
|
||||
status: r.status,
|
||||
host: d.users.get(r.hostId)?.nickname ?? null,
|
||||
seats: r.seats.map((s) => (s ? { id: s.userId, nickname: d.users.get(s.userId)?.nickname ?? '?', connected: !!r.presence.get(s.userId)?.connected } : null)),
|
||||
seats: r.seats.map((s) => {
|
||||
if (!s) return null;
|
||||
const bot = botProfile(s.userId);
|
||||
return { id: s.userId, nickname: bot?.nickname ?? d.users.get(s.userId)?.nickname ?? '?', connected: !!bot || !!r.presence.get(s.userId)?.connected };
|
||||
}),
|
||||
spectators: r.spectators.size,
|
||||
connections: r.conns.size,
|
||||
visibility: r.config.visibility,
|
||||
@@ -141,6 +149,10 @@ export function createAdminApp(d: AdminDeps) {
|
||||
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
|
||||
createdAt: u.created_at,
|
||||
lastSeenAt: u.last_seen_at,
|
||||
deleteAt: d.guestDeleteAt(u),
|
||||
played: u.played,
|
||||
wins: u.wins,
|
||||
draws: u.draws,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -157,12 +169,22 @@ export function createAdminApp(d: AdminDeps) {
|
||||
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
|
||||
createdAt: u.created_at,
|
||||
lastSeenAt: u.last_seen_at,
|
||||
deleteAt: d.guestDeleteAt(u),
|
||||
activeRoom: d.rooms.activeRoomFor(u.id),
|
||||
},
|
||||
stats: d.users.stats(u.id),
|
||||
});
|
||||
});
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } fro
|
||||
import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
|
||||
import { toPublicUser, type Users } from '../auth/users';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
import type { Replays } from '../rooms/replay';
|
||||
import { WindowLimiter } from '../ws/rate-limit';
|
||||
import { createAdminApp, type AdminDeps } from './admin';
|
||||
import type { Roles } from '../admin/roles';
|
||||
@@ -18,13 +19,16 @@ export interface HttpDeps {
|
||||
users: Users;
|
||||
sessions: Sessions;
|
||||
rooms: RoomManager;
|
||||
replays: Replays;
|
||||
/** Injected for tests. */
|
||||
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
|
||||
ipOf: (req: Request) => string;
|
||||
health: () => { ok: boolean; [k: string]: unknown };
|
||||
roles: Roles;
|
||||
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). */
|
||||
removeGuest: (userId: string) => void;
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null } };
|
||||
@@ -37,6 +41,8 @@ export function createHttpApp(d: HttpDeps) {
|
||||
const createLimiter = new WindowLimiter(10 * lift, 60_000);
|
||||
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) => ({
|
||||
@@ -166,7 +172,10 @@ export function createHttpApp(d: HttpDeps) {
|
||||
|
||||
app.post('/api/auth/logout', (c) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
if (token) d.sessions.revoke(token);
|
||||
const u = requireUser(c);
|
||||
// A guest has no way to log back in, so logging out deletes the account (docs/05 §4).
|
||||
if (u && d.users.get(u)?.kind === 'guest') d.removeGuest(u);
|
||||
else if (token) d.sessions.revoke(token);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -238,15 +247,47 @@ 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);
|
||||
const before = c.req.query('before') ?? null;
|
||||
const limit = Math.min(Math.max(Number(c.req.query('limit')) || 20, 1), 50);
|
||||
if (before !== null && !/^[0-9A-Z]{26}$/.test(before)) return c.json({ error: '잘못된 요청이에요.' }, 400);
|
||||
return c.json(d.replays.listForUser(u, before, limit));
|
||||
});
|
||||
|
||||
/** Replay of a finished game, for its players only (docs/09 §8). Rendered per viewer like the live game. */
|
||||
app.get('/api/replays/:gamePk', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
if (!replayLimiter.allow(u)) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const gamePk = c.req.param('gamePk');
|
||||
if (!/^[0-9A-Z]{26}$/.test(gamePk)) return c.json({ error: '게임 기록을 찾을 수 없어요.' }, 404);
|
||||
const r = await d.replays.get(gamePk, u);
|
||||
if (!r.ok) return c.json({ error: r.error }, r.status);
|
||||
// no-store: the body holds this viewer's private view (e.g. their hand); a shared device must not cache it.
|
||||
return c.body(r.body, 200, { 'content-type': 'application/json; charset=UTF-8', 'cache-control': 'private, no-store' });
|
||||
});
|
||||
|
||||
app.post('/api/rooms', async (c) => {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const body = z
|
||||
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() })
|
||||
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional(), practice: z.boolean().optional() })
|
||||
.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
const r = d.rooms.create(u, body.data.gameId, body.data.visibility);
|
||||
const r = body.data.practice ? d.rooms.createPractice(u, body.data.gameId) : d.rooms.create(u, body.data.gameId, body.data.visibility);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
return c.json({ code: r.code });
|
||||
});
|
||||
@@ -285,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;
|
||||
|
||||
78
apps/server/src/replay-api.test.ts
Normal file
78
apps/server/src/replay-api.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/** Replay HTTP API (docs/09 §8): my games list and replays, players only, finished games only. */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { applyViewPatch, type ReplayData } from '@bg/shared';
|
||||
import { Client, api, boot, guest } from './test-utils';
|
||||
|
||||
const running: ReturnType<typeof boot>[] = [];
|
||||
const clients: Client[] = [];
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) c.close();
|
||||
for (const s of running.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
describe('replay API', () => {
|
||||
test('players of a finished game can replay it; others, logged-out users and running games are refused', async () => {
|
||||
const s = boot();
|
||||
running.push(s);
|
||||
const ga = await guest(s.base, '호랑이');
|
||||
const gb = await guest(s.base, '고양이');
|
||||
const gc = await guest(s.base, '구경꾼');
|
||||
const a = await new Client(s.base, ga.cookie, ga.id).connect();
|
||||
const b = await new Client(s.base, gb.cookie, gb.id).connect();
|
||||
clients.push(a, b);
|
||||
const code = (await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'omok' }) })).body.code;
|
||||
a.send({ t: 'join', code, as: 'player' });
|
||||
await a.next('room');
|
||||
b.send({ t: 'join', code, as: 'player' });
|
||||
await b.next('room');
|
||||
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
|
||||
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
|
||||
const started = b.next('state');
|
||||
a.send({ t: 'start' });
|
||||
await started;
|
||||
const gamePk = s.db.query<{ id: string }, []>('SELECT id FROM games').get()!.id;
|
||||
|
||||
const running409 = await api(s.base, `/api/replays/${gamePk}`, { cookie: a.cookie });
|
||||
expect(running409.res.status).toBe(409);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let p = b.next('state', (m) => (m.view as any).turn === 'white');
|
||||
a.act({ type: 'place', x: 3 + i, y: 7 });
|
||||
await p;
|
||||
p = a.next('state', (m) => (m.view as any).turn === 'black');
|
||||
b.act({ type: 'place', x: 3 + i, y: 0 });
|
||||
await p;
|
||||
}
|
||||
const room = a.next('room', (m) => m.room.status === 'finished');
|
||||
a.act({ type: 'place', x: 7, y: 7 });
|
||||
expect((await room).room.lastGamePk).toBe(gamePk);
|
||||
|
||||
const r = await api(s.base, `/api/replays/${gamePk}`, { cookie: a.cookie });
|
||||
expect(r.res.status).toBe(200);
|
||||
expect(r.res.headers.get('cache-control')).toBe('private, no-store');
|
||||
const data = r.body as ReplayData;
|
||||
expect(data).toMatchObject({ gamePk, gameId: 'omok', me: a.id });
|
||||
expect(data.players[b.id]!.nickname).toBe('고양이');
|
||||
expect(data.frames).toHaveLength(10);
|
||||
expect(data.frames[1]!.actor).toBe(a.id);
|
||||
let view: unknown;
|
||||
for (const f of data.frames) view = f.view ?? applyViewPatch(view, f.patch);
|
||||
expect(view).toEqual(a.lastOf('state')!.view);
|
||||
const seed = s.db.query<{ seed: string }, []>('SELECT seed FROM games').get()!.seed;
|
||||
expect(JSON.stringify(r.body)).not.toContain(seed);
|
||||
|
||||
expect((await api(s.base, `/api/replays/${gamePk}`, { cookie: b.cookie })).body.me).toBe(b.id);
|
||||
expect((await api(s.base, `/api/replays/${gamePk}`, { cookie: gc.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, `/api/replays/${gamePk}`)).res.status).toBe(401);
|
||||
expect((await api(s.base, '/api/replays/01J0000000000000000000000X', { cookie: a.cookie })).res.status).toBe(404);
|
||||
expect((await api(s.base, '/api/replays/..%2Fadmin', { cookie: a.cookie })).res.status).toBe(404);
|
||||
|
||||
const mine = await api(s.base, '/api/me/games', { cookie: b.cookie });
|
||||
expect(mine.body.games).toHaveLength(1);
|
||||
expect(mine.body.games[0]).toMatchObject({ gamePk, gameId: 'omok', myRank: 2, replayable: true });
|
||||
expect(mine.body.games[0].players.map((p: { nickname: string }) => p.nickname)).toEqual(['호랑이', '고양이']);
|
||||
expect((await api(s.base, '/api/me/games', { cookie: gc.cookie })).body).toEqual({ games: [], next: null });
|
||||
expect((await api(s.base, '/api/me/games')).res.status).toBe(401);
|
||||
expect((await api(s.base, '/api/me/games?before=bad', { cookie: a.cookie })).res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
494
apps/server/src/rooms/bots.room.test.ts
Normal file
494
apps/server/src/rooms/bots.room.test.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
/** Computer players in rooms (docs/06-lobby-rooms.md §12): seating rules, practice rooms, driving, records, restore. */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { BOT_STRATEGIES } from '@bg/games/bots';
|
||||
import { BOT_GAMES, ClientMessage, botProfile, isBotId, type ServerMessage } from '@bg/shared';
|
||||
import { openDb } from '../db';
|
||||
import { defaultBotDelay } from './bots';
|
||||
import { RoomManager } from './manager';
|
||||
import { Room, type Conn, type RoomSnapshot } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
const rooms: Room[] = [];
|
||||
afterEach(() => {
|
||||
for (const r of rooms.splice(0)) r.clearTimer();
|
||||
});
|
||||
|
||||
function harness(log?: (level: 'info' | 'warn' | 'error', msg: string) => void, offlineSince?: (userId: string) => number | null) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const users = { publicUser: (id: string) => botProfile(id) ?? { id, nickname: id, avatar: null, kind: 'guest' as const } };
|
||||
const manager = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0, log, offlineSince });
|
||||
const conn = (userId: string) => {
|
||||
const msgs: ServerMessage[] = [];
|
||||
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
return c;
|
||||
};
|
||||
const create = (host: string, gameId = 'omok', visibility: 'private' | 'public' = 'private') => {
|
||||
const r = manager.create(host, gameId, visibility);
|
||||
if (typeof r === 'string') throw new Error(r);
|
||||
rooms.push(r);
|
||||
return r;
|
||||
};
|
||||
return { db, store, clock, manager, conn, create, users };
|
||||
}
|
||||
|
||||
/** Plays every planned bot move (the delay is 0 in these tests) until the game waits on a human. */
|
||||
function drive(room: Room, max = 500): number {
|
||||
let n = 0;
|
||||
while (room.bots.dueAt !== null && n < max) {
|
||||
room.bots.fire();
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const bots = (room: Room) => room.seatedIds().filter(isBotId);
|
||||
|
||||
describe('bot list', () => {
|
||||
test('BOT_GAMES matches the strategies and leaves out real-time / talk games', () => {
|
||||
expect([...BOT_GAMES].sort()).toEqual(Object.keys(BOT_STRATEGIES).sort());
|
||||
for (const id of BOT_GAMES) expect(GAMES[id]).toBeDefined();
|
||||
for (const id of ['dobble', 'halli-galli', 'catchmind', 'mafia']) expect(BOT_GAMES).not.toContain(id);
|
||||
});
|
||||
|
||||
test('thinking time: short for forced moves, longer for choices, longest when people act in the same window', () => {
|
||||
const r = () => 0.5;
|
||||
expect(defaultBotDelay(1, false, r)).toBe(550);
|
||||
expect(defaultBotDelay(5, false, r)).toBe(1100);
|
||||
expect(defaultBotDelay(5, true, r)).toBe(2600);
|
||||
});
|
||||
|
||||
test('protocol accepts addBot / removeBot', () => {
|
||||
expect(ClientMessage.safeParse({ t: 'addBot' }).success).toBe(true);
|
||||
expect(ClientMessage.safeParse({ t: 'addBot', seat: 1 }).success).toBe(true);
|
||||
expect(ClientMessage.safeParse({ t: 'addBot', seat: -1 }).success).toBe(false);
|
||||
expect(ClientMessage.safeParse({ t: 'removeBot', userId: 'bot:1:01J0000000000000000000000' }).success).toBe(true);
|
||||
expect(ClientMessage.safeParse({ t: 'removeBot' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addBot / removeBot', () => {
|
||||
test('host only, lobby only, empty seats only; the room turns private and drops out of the public list', () => {
|
||||
const { create, conn, manager } = harness();
|
||||
const room = create('A', 'omok', 'public');
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('S'), 'spectator');
|
||||
expect(manager.publicRooms('omok')).toHaveLength(1);
|
||||
expect(room.addBot('S')).toBe('방장만 컴퓨터를 넣을 수 있어요.');
|
||||
expect(room.addBot('A', 0)).toBe('이미 다른 사람이 앉은 자리예요.');
|
||||
expect(room.addBot('A')).toBeNull();
|
||||
const [bot] = bots(room);
|
||||
expect(bot).toMatch(/^bot:1:/);
|
||||
expect(room.addBot('A')).toBe('빈자리가 없어요.');
|
||||
expect(room.config.visibility).toBe('private');
|
||||
expect(manager.publicRooms('omok')).toHaveLength(0);
|
||||
expect(room.configure('A', { visibility: 'public' })).toBe('컴퓨터가 있는 방은 공개할 수 없어요.');
|
||||
expect(room.configure('A', { gameId: 'dobble' })).toContain('컴퓨터');
|
||||
const seat = room.roomView().seats[1]!;
|
||||
expect(seat).toMatchObject({ ready: true, connected: true, away: false });
|
||||
expect(seat.user).toMatchObject({ nickname: '컴퓨터 1', kind: 'bot' });
|
||||
expect(room.removeBot('S', bot!)).toBe('방장만 컴퓨터를 뺄 수 있어요.');
|
||||
expect(room.removeBot('A', 'B')).toBe('없는 컴퓨터예요.');
|
||||
expect(room.removeBot('A', bot!)).toBeNull();
|
||||
expect(bots(room)).toHaveLength(0);
|
||||
// Without computers the host may make it public again.
|
||||
expect(room.configure('A', { visibility: 'public' })).toBeNull();
|
||||
});
|
||||
|
||||
test('games without computer players refuse them', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'dobble');
|
||||
room.join(conn('A'), 'player');
|
||||
expect(room.addBot('A')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
|
||||
});
|
||||
|
||||
test('bots get the lowest free number; kick removes a bot; the host is never passed to a bot', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'one-card');
|
||||
room.join(conn('A'), 'player');
|
||||
room.addBot('A');
|
||||
room.addBot('A');
|
||||
room.addBot('A');
|
||||
const [b1, b2] = bots(room);
|
||||
expect(room.kick('A', b2!)).toBeNull();
|
||||
room.addBot('A');
|
||||
expect(bots(room).map((b) => botProfile(b)!.nickname).sort()).toEqual(['컴퓨터 1', '컴퓨터 2', '컴퓨터 3']);
|
||||
expect(room.transferHost('A', b1!)).toBe('컴퓨터에게는 방장을 넘길 수 없어요.');
|
||||
// A spectator joins, then the host leaves: the spectator becomes host, not a computer.
|
||||
room.join(conn('S'), 'spectator');
|
||||
room.leave('A');
|
||||
expect(room.hostId).toBe('S');
|
||||
});
|
||||
|
||||
test('a room needs a seated person to start', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'omok');
|
||||
room.join(conn('A'), 'spectator');
|
||||
room.addBot('A');
|
||||
room.addBot('A');
|
||||
expect(room.start('A')).toBe('사람이 한 명은 앉아야 해요.');
|
||||
});
|
||||
|
||||
test('bots cannot be added or removed during a game', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'one-card');
|
||||
room.join(conn('A'), 'player');
|
||||
room.addBot('A');
|
||||
expect(room.start('A')).toBeNull();
|
||||
expect(room.addBot('A')).toBe('게임 중에는 바꿀 수 없어요.');
|
||||
expect(room.removeBot('A', bots(room)[0]!)).toBe('게임 중에는 바꿀 수 없어요.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPractice', () => {
|
||||
test('a private room with just enough computers to start; never offered by quick play', () => {
|
||||
const { manager, conn } = harness();
|
||||
const expected: Record<string, number> = { omok: 1, mahjong: 2, bang: 3, dalmuti: 3, quoridor: 1, yacht: 1 };
|
||||
for (const [gameId, n] of Object.entries(expected)) {
|
||||
const room = manager.createPractice(`host-${gameId}`, gameId);
|
||||
if (typeof room === 'string') throw new Error(room);
|
||||
rooms.push(room);
|
||||
expect(room.config.visibility).toBe('private');
|
||||
expect(bots(room)).toHaveLength(n);
|
||||
room.join(conn(`host-${gameId}`), 'player');
|
||||
expect(room.start(`host-${gameId}`)).toBeNull();
|
||||
expect(room.game!.players).toContain(`host-${gameId}`);
|
||||
}
|
||||
expect(manager.createPractice('X', 'mafia')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
|
||||
// Quick play never lands in a practice room.
|
||||
const qp = manager.quickPlay('Q', 'omok');
|
||||
if (typeof qp === 'string') throw new Error(qp);
|
||||
rooms.push(qp);
|
||||
expect(qp.hasBots()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playing with bots', () => {
|
||||
function omokVsBot() {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
expect(room.start('A')).toBeNull();
|
||||
return { ...h, room, a };
|
||||
}
|
||||
|
||||
test('the bot answers each human move through the normal action path', () => {
|
||||
const { room, a, store } = omokVsBot();
|
||||
const moves = () => (room.game!.state as { moves: unknown[] }).moves.length;
|
||||
expect(room.bots.dueAt).toBeNull(); // black (human) to move
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
expect(room.bots.dueAt).not.toBeNull();
|
||||
expect(drive(room)).toBe(1);
|
||||
expect(moves()).toBe(2);
|
||||
expect(a.msgs.filter((m) => m.t === 'state').length).toBeGreaterThanOrEqual(3);
|
||||
// Logged like any action, with the bot as actor.
|
||||
const log = store.log(room.id);
|
||||
expect(log.some((e) => e.kind === 'act' && e.actorId !== null && isBotId(e.actorId))).toBe(true);
|
||||
});
|
||||
|
||||
test('a bot move planned before a state change re-reads the state when it fires', () => {
|
||||
const { room } = omokVsBot();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
const due = room.bots.dueAt;
|
||||
room.scheduleTimer(); // e.g. a reconnect re-arms timers: the plan stays
|
||||
expect(room.bots.dueAt).toBe(due);
|
||||
drive(room);
|
||||
expect((room.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
|
||||
expect(room.bots.dueAt).toBeNull();
|
||||
});
|
||||
|
||||
test('a practice game is kept but not counted; rematch only needs the people', () => {
|
||||
const { room, db } = omokVsBot();
|
||||
// Human resigns; the game is recorded as practice.
|
||||
room.act('A', 1, { type: 'resign' });
|
||||
expect(room.status).toBe('finished');
|
||||
const row = db.query<{ result_json: string }, []>('SELECT result_json FROM games').get()!;
|
||||
expect(JSON.parse(row.result_json).practice).toBe(true);
|
||||
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
|
||||
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM game_players').get()!.n).toBe(2);
|
||||
expect(room.rematch('A')).toBeNull();
|
||||
expect(room.status).toBe('playing');
|
||||
expect(room.gameNo).toBe(2);
|
||||
});
|
||||
|
||||
test('when every person leaves mid-game, the game ends uncounted', () => {
|
||||
const { room, db } = omokVsBot();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
room.leave('A');
|
||||
expect(room.game!.finished).toBe(true);
|
||||
expect(room.bots.dueAt).toBeNull();
|
||||
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
|
||||
});
|
||||
|
||||
// The real driver + room timers for every bot game: the bots act on their own (reaction windows, simultaneous
|
||||
// choices, "ready" between hands) and the only person just lets the clock run out each time.
|
||||
test.each([...BOT_GAMES])('%s: a whole practice game with a person who only times out finishes', (gameId) => {
|
||||
const logs: string[] = [];
|
||||
const h = harness((level, msg) => level !== 'info' && logs.push(msg));
|
||||
const room = h.manager.createPractice('A', gameId) as Room;
|
||||
rooms.push(room);
|
||||
room.join(h.conn('A'), 'player');
|
||||
expect(room.start('A')).toBeNull();
|
||||
for (let i = 0; i < 5000 && !room.game!.finished; i++) {
|
||||
drive(room);
|
||||
if (room.game!.finished) break;
|
||||
const at = room.effectiveDeadline();
|
||||
expect(at).not.toBeNull();
|
||||
h.clock.advance(Math.max(0, at! - h.clock.now()));
|
||||
room.onTimer();
|
||||
}
|
||||
expect(room.game!.finished).toBe(true);
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('practice rooms close once nobody is there', () => {
|
||||
function finishedPractice() {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.act('A', 1, { type: 'resign' });
|
||||
expect(room.status).toBe('finished');
|
||||
return { ...h, room, a };
|
||||
}
|
||||
|
||||
test('leaving on purpose (only computers left) closes the room right away', () => {
|
||||
const { room, manager, store } = finishedPractice();
|
||||
room.leave('A');
|
||||
expect(manager.get(room.code)).toBeUndefined();
|
||||
expect(store.openRooms()).toHaveLength(0);
|
||||
expect(manager.activeRoomFor('A')).toBeNull();
|
||||
});
|
||||
|
||||
test('leaving mid-game also closes it', () => {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
room.join(h.conn('A'), 'player');
|
||||
room.start('A');
|
||||
room.leave('A');
|
||||
expect(h.manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('going to another page (seat kept) or closing the site: closed a minute later by the sweep', () => {
|
||||
const { room, a, manager, clock, conn } = finishedPractice();
|
||||
room.disconnect(a);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBe(room); // a refresh reconnects within seconds
|
||||
clock.advance(30_000);
|
||||
room.join(conn('A'), 'player');
|
||||
room.disconnect(room.conns.get('A')!);
|
||||
clock.advance(59_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBe(room); // the minute counts from the last disconnect
|
||||
clock.advance(1_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a running practice game left for another page (site still open) waits 30 minutes', () => {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
room.start('A');
|
||||
room.detach(a);
|
||||
h.clock.advance(29 * 60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(room.code)).toBe(room);
|
||||
h.clock.advance(60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a room nobody joined closes as soon as the host leaves it', () => {
|
||||
const h = harness();
|
||||
const room = h.create('A', 'omok', 'public');
|
||||
room.join(h.conn('A'), 'player');
|
||||
expect(h.manager.publicRooms('omok')).toHaveLength(1);
|
||||
room.leave('A');
|
||||
expect(h.manager.get(room.code)).toBeUndefined();
|
||||
expect(h.store.openRooms()).toHaveLength(0);
|
||||
expect(h.manager.publicRooms('omok')).toHaveLength(0);
|
||||
// Taking a seat in another room frees the lobby seat: same thing.
|
||||
const first = h.create('B');
|
||||
const b = h.conn('B');
|
||||
first.join(b, 'player');
|
||||
first.disconnect(b);
|
||||
h.manager.releaseOtherLobbySeats('B', 'other');
|
||||
expect(h.manager.get(first.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the last person leaving a game on purpose closes it; a spectator still watching keeps it', () => {
|
||||
const h = harness();
|
||||
const room = h.create('A');
|
||||
room.join(h.conn('A'), 'player');
|
||||
room.join(h.conn('B'), 'player');
|
||||
room.join(h.conn('S'), 'spectator');
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.leave('A');
|
||||
room.leave('B');
|
||||
expect(h.manager.get(room.code)).toBe(room); // S is still connected
|
||||
room.leave('S');
|
||||
expect(h.manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rooms without computers keep the old timeouts; someone still connected keeps any room', () => {
|
||||
const h = harness();
|
||||
const room = h.create('A');
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
room.join(h.conn('B'), 'player');
|
||||
room.leave('B');
|
||||
expect(h.manager.get(room.code)).toBe(room);
|
||||
const practice = h.manager.createPractice('C', 'omok') as Room;
|
||||
rooms.push(practice);
|
||||
practice.join(h.conn('C'), 'player');
|
||||
h.clock.advance(5 * 60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(practice.code)).toBe(practice);
|
||||
room.disconnect(a);
|
||||
h.clock.advance(5 * 60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(room.code)).toBe(room); // empty lobby: 10 minutes
|
||||
});
|
||||
});
|
||||
|
||||
describe('every player closed the site', () => {
|
||||
/** A running human omok game (A black) with spectator S watching; `online` = who still has the site open. */
|
||||
function humanGame() {
|
||||
const online = new Set<string>(['A', 'B', 'S']);
|
||||
const offAt = new Map<string, number>();
|
||||
const h = harness(undefined, (u) => (online.has(u) ? null : (offAt.get(u) ?? 0)));
|
||||
const room = h.create('A');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
const a = h.conn('A');
|
||||
const b = h.conn('B');
|
||||
const sp = h.conn('S');
|
||||
room.join(a, 'player');
|
||||
room.join(b, 'player');
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.join(sp, 'spectator');
|
||||
/** The socket closed and the user has no other page of the site open. */
|
||||
const closeSite = (u: string, c: Conn) => {
|
||||
room.disconnect(c);
|
||||
online.delete(u);
|
||||
offAt.set(u, h.clock.now());
|
||||
};
|
||||
return { ...h, room, a, b, sp, online, offAt, closeSite };
|
||||
}
|
||||
|
||||
test('a running game closes a minute after the last player left the site, spectators or not', () => {
|
||||
const { room, a, b, sp, manager, clock, store, closeSite } = humanGame();
|
||||
closeSite('A', a);
|
||||
clock.advance(30_000);
|
||||
closeSite('B', b);
|
||||
clock.advance(59_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBe(room); // a refresh comes back within seconds
|
||||
clock.advance(1_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBeUndefined();
|
||||
expect(store.openRooms()).toHaveLength(0);
|
||||
expect(sp.msgs.some((m) => m.t === 'bye' && m.reason === 'closed')).toBe(true);
|
||||
expect(manager.activeRoomFor('A')).toBeNull();
|
||||
expect(manager.activeRoomFor('B')).toBeNull();
|
||||
});
|
||||
|
||||
test('a player still on the site (other page, or reconnected after a drop) keeps the game', () => {
|
||||
const { room, a, b, manager, clock, online, closeSite } = humanGame();
|
||||
closeSite('B', b);
|
||||
room.detach(a); // A went to the home page
|
||||
clock.advance(60 * 60_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBe(room);
|
||||
// A's socket dropped and came back on the home page: not in the room any more, but still on the site.
|
||||
room.disconnect(a);
|
||||
online.add('A');
|
||||
clock.advance(60 * 60_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBe(room);
|
||||
});
|
||||
|
||||
test('a finished game closes too; a lobby waits for invited people (10 minutes)', () => {
|
||||
const { room, a, b, manager, clock, closeSite } = humanGame();
|
||||
room.act('A', 1, { type: 'resign' });
|
||||
expect(room.status).toBe('finished');
|
||||
closeSite('A', a);
|
||||
closeSite('B', b);
|
||||
clock.advance(60_000);
|
||||
manager.sweep();
|
||||
expect(manager.get(room.code)).toBeUndefined();
|
||||
|
||||
const h = harness(undefined, () => 0);
|
||||
const lobby = h.create('C');
|
||||
const c = h.conn('C');
|
||||
lobby.join(c, 'player');
|
||||
lobby.disconnect(c);
|
||||
h.clock.advance(5 * 60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(lobby.code)).toBe(lobby);
|
||||
});
|
||||
|
||||
test('a practice game closes a minute after the site is closed', () => {
|
||||
const h = harness(undefined, () => 0);
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
room.start('A');
|
||||
room.disconnect(a);
|
||||
h.clock.advance(60_000);
|
||||
h.manager.sweep();
|
||||
expect(h.manager.get(room.code)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore after restart', () => {
|
||||
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
|
||||
const { manager, conn, store, clock, users } = harness();
|
||||
const room = manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
room.join(conn('A'), 'player');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
room.start('A');
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
// Crash before the bot answers.
|
||||
room.clearTimer();
|
||||
const { seq, stateJson } = store.openRooms()[0]!;
|
||||
clock.advance(60_000);
|
||||
const restored = Room.restore(room.id, room.code, seq, JSON.parse(stateJson) as RoomSnapshot, {
|
||||
store,
|
||||
games: GAMES,
|
||||
users,
|
||||
now: clock.now,
|
||||
botDelay: () => 0,
|
||||
});
|
||||
rooms.push(restored);
|
||||
expect(restored.hasBots()).toBe(true);
|
||||
expect(restored.presence.has(bots(restored)[0]!)).toBe(false);
|
||||
expect(restored.roomView().seats[1]).toMatchObject({ connected: true, ready: true });
|
||||
expect(restored.bots.dueAt).not.toBeNull();
|
||||
drive(restored);
|
||||
expect((restored.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
|
||||
|
||||
// The manager path too: restoreAll keeps bots out of the seat index (no "active room" for a bot).
|
||||
const m2 = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0 });
|
||||
m2.restoreAll();
|
||||
for (const r of m2.all()) rooms.push(r);
|
||||
expect(m2.activeRoomFor('A')).toBe(room.code);
|
||||
expect(m2.activeRoomFor(bots(restored)[0]!)).toBeNull();
|
||||
});
|
||||
});
|
||||
122
apps/server/src/rooms/bots.ts
Normal file
122
apps/server/src/rooms/bots.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Computer players' driver (docs/06-lobby-rooms.md §12, docs/09-game-engine.md §9).
|
||||
* After every state change the room calls `schedule()`: if the game waits on a bot, one action is planned after a
|
||||
* short human-like pause and played through the room's normal action path (validated, logged as `act`).
|
||||
*/
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isBotId } from '@bg/shared';
|
||||
import { botCandidates, decideBotAction } from '@bg/games/bots';
|
||||
|
||||
export interface BotRoom {
|
||||
readonly status: string;
|
||||
readonly game: { state: unknown; players: string[]; finished: boolean } | null;
|
||||
readonly def: AnyGameDefinition;
|
||||
now(): number;
|
||||
/** Runs the action as `actor` through the room's normal path; returns an error message on rejection. */
|
||||
run(actor: string, action: unknown): string | null;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking time: a quick beat for forced moves, a little longer for real choices. When people may act in the same
|
||||
* window (e.g. 원카드 외치기, 마작 울기, simultaneous choices) the computer waits longer so it never beats a person
|
||||
* to a reaction just by being fast.
|
||||
*/
|
||||
export function defaultBotDelay(choices: number, withPeople = false, random: () => number = Math.random): number {
|
||||
const base = choices <= 1 ? 400 + Math.floor(random() * 300) : 700 + Math.floor(random() * 800);
|
||||
return withPeople ? base + 1500 : base;
|
||||
}
|
||||
|
||||
/** Bot move budget (ms); slower decisions are logged. */
|
||||
export const BOT_SLOW_MS = 50;
|
||||
|
||||
export class BotDriver {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** When the planned action is due (room clock); null when nothing is planned. */
|
||||
dueAt: number | null = null;
|
||||
/** Bot the planned action is for. */
|
||||
private planned: string | null = null;
|
||||
|
||||
constructor(
|
||||
private room: BotRoom,
|
||||
private random: () => number = Math.random,
|
||||
private delay: (choices: number, withPeople: boolean) => number = (n, w) => defaultBotDelay(n, w, random),
|
||||
) {}
|
||||
|
||||
/** Plans the next bot action if the game is waiting on a bot. Keeps an already planned one (the bot is "thinking"). */
|
||||
schedule(): void {
|
||||
if (this.dueAt !== null) return;
|
||||
const next = this.next();
|
||||
if (!next) return;
|
||||
this.dueAt = this.room.now() + this.delay(next.choices, next.withPeople);
|
||||
this.planned = next.bot;
|
||||
this.arm();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.dueAt = null;
|
||||
this.planned = null;
|
||||
}
|
||||
|
||||
private arm(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => this.fire(), Math.max(0, (this.dueAt ?? 0) - this.room.now()));
|
||||
}
|
||||
|
||||
/** Bot waited on by the game that has something to do, in seat order. */
|
||||
private next(): { bot: string; choices: number; withPeople: boolean } | null {
|
||||
const g = this.room.game;
|
||||
if (!g || g.finished || this.room.status !== 'playing' || !g.players.some(isBotId)) return null;
|
||||
const def = this.room.def;
|
||||
const active = def.activePlayers(g.state);
|
||||
const waiting = new Set([...active, ...(def.timeoutPlayers?.(g.state) ?? [])]);
|
||||
const withPeople = active.some((p) => !isBotId(p));
|
||||
for (const p of g.players) {
|
||||
if (!isBotId(p) || !waiting.has(p)) continue;
|
||||
const choices = botCandidates(def, g.state, p).length;
|
||||
if (choices > 0) return { bot: p, choices, withPeople };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Plays the planned action when due. Public for tests (with a fake clock). */
|
||||
fire(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
if (this.dueAt === null) return;
|
||||
if (this.room.now() < this.dueAt) return this.arm();
|
||||
const planned = this.planned;
|
||||
this.dueAt = null;
|
||||
this.planned = null;
|
||||
const next = this.next();
|
||||
const g = this.room.game;
|
||||
if (!next || !g) return;
|
||||
// Another bot's turn came up meanwhile: give it its own thinking time.
|
||||
if (next.bot !== planned) return this.schedule();
|
||||
const def = this.room.def;
|
||||
const t0 = performance.now();
|
||||
let action: unknown;
|
||||
try {
|
||||
action = decideBotAction(def, g.state, next.bot, { now: this.room.now(), random: this.random });
|
||||
} catch (err) {
|
||||
this.room.log?.('error', 'bot decision failed', { game: def.id, err: String(err) });
|
||||
action = null;
|
||||
}
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > BOT_SLOW_MS) this.room.log?.('warn', 'slow bot move', { game: def.id, ms: Math.round(ms) });
|
||||
let err = action == null ? 'no action' : this.room.run(next.bot, action);
|
||||
if (err) {
|
||||
this.room.log?.('warn', 'bot action rejected', { game: def.id, action, err });
|
||||
// Last resort before the turn timer: the game's own timeout action.
|
||||
try {
|
||||
if (!this.room.game?.finished) err = this.room.run(next.bot, def.onTimeout(this.room.game!.state, next.bot));
|
||||
} catch {
|
||||
// keep err
|
||||
}
|
||||
if (err) return; // give up until the next state change; the room's deadline still applies
|
||||
}
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
|
||||
import { ulid } from 'ulid';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
|
||||
import { botsAllowed, isAllowedRoomCode, isBotId, type PublicUser } from '@bg/shared';
|
||||
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
|
||||
import type { RoomStore } from './store';
|
||||
import type { SettingsStore } from '../admin/settings';
|
||||
@@ -10,6 +10,11 @@ export const MAX_ROOMS = 2000;
|
||||
export const MAX_ROOMS_PER_USER = 3;
|
||||
const EMPTY_LOBBY_TTL = 10 * 60_000;
|
||||
const FINISHED_TTL = 30 * 60_000;
|
||||
/** Rooms with computer players nobody is looking at: closed this long after the last person went away. */
|
||||
const PRACTICE_IDLE_TTL = 60_000;
|
||||
const PRACTICE_GAME_TTL = 30 * 60_000;
|
||||
/** Every player closed the site (spectators aside): the room closes this long after the last one went offline. */
|
||||
const PLAYERS_GONE_TTL = 60_000;
|
||||
|
||||
export interface ManagerDeps {
|
||||
store: RoomStore;
|
||||
@@ -19,6 +24,9 @@ export interface ManagerDeps {
|
||||
log?: RoomDeps['log'];
|
||||
random?: () => number;
|
||||
settings?: SettingsStore;
|
||||
botDelay?: RoomDeps['botDelay'];
|
||||
/** When the user's last socket to the site closed; null while the site is open somewhere (the gateway knows). */
|
||||
offlineSince?: (userId: string) => number | null;
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
@@ -40,7 +48,13 @@ export class RoomManager {
|
||||
now: this.now,
|
||||
log: this.deps.log,
|
||||
settings: this.deps.settings,
|
||||
botDelay: this.deps.botDelay,
|
||||
onEmpty: (room, why) => {
|
||||
// The last person left on purpose and nobody holds a seat (only computers, or nobody joined): close it now.
|
||||
if (why === 'leave' && this.byCode.get(room.code) === room && !room.hasPeopleSeated()) this.close(room);
|
||||
},
|
||||
onSeatChange: (room, userId, seated) => {
|
||||
if (isBotId(userId)) return;
|
||||
const set = this.seatIndex.get(userId) ?? new Set<string>();
|
||||
if (seated) set.add(room.code);
|
||||
else set.delete(room.code);
|
||||
@@ -112,6 +126,23 @@ export class RoomManager {
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* "컴퓨터랑 연습": a private room with just enough computer players to start right away (the host takes the
|
||||
* remaining seat on joining).
|
||||
*/
|
||||
createPractice(hostId: string, gameId: string): Room | string {
|
||||
if (!botsAllowed(gameId)) return '이 게임은 컴퓨터와 할 수 없어요.';
|
||||
const room = this.create(hostId, gameId, 'private');
|
||||
if (typeof room === 'string') return room;
|
||||
const def = this.deps.games[gameId]!;
|
||||
const { min, max } = room.playerRange();
|
||||
let bots = Math.max(1, min - 1);
|
||||
while (bots + 1 < max && def.playerCountError?.(room.config.options, bots + 1)) bots++;
|
||||
// Seat 0 stays free for the host.
|
||||
for (let i = 1; i <= Math.min(bots, room.config.maxPlayers - 1); i++) room.addBot(hostId, i);
|
||||
return room;
|
||||
}
|
||||
|
||||
/** Rooms where the user holds a seat in an unfinished game. */
|
||||
activeRoomFor(userId: string): string | null {
|
||||
for (const code of this.seatIndex.get(userId) ?? []) {
|
||||
@@ -130,9 +161,15 @@ export class RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Frees every seat the user holds (account removed). */
|
||||
leaveAll(userId: string): void {
|
||||
for (const code of [...(this.seatIndex.get(userId) ?? [])]) this.byCode.get(code)?.leave(userId);
|
||||
}
|
||||
|
||||
publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] {
|
||||
return this.all()
|
||||
.filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby')
|
||||
// Rooms with computer players are practice rooms: never listed, never filled by quick play.
|
||||
.filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby' && !r.hasBots())
|
||||
.map((r) => ({
|
||||
code: r.code,
|
||||
gameId: r.config.gameId,
|
||||
@@ -174,9 +211,25 @@ export class RoomManager {
|
||||
/** Periodic cleanup (call every minute). */
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
const offlineSince = this.deps.offlineSince ?? (() => 0);
|
||||
for (const room of this.all()) {
|
||||
// A game (running or over) whose players all closed the site: nobody is coming back to it. Lobbies wait for
|
||||
// people arriving by invite link, so they keep the timeouts below.
|
||||
const gone = room.status === 'lobby' ? null : room.playersOfflineSince(offlineSince);
|
||||
if (gone !== null && now - gone >= PLAYERS_GONE_TTL) {
|
||||
room.closeWith('플레이어가 모두 나가서 무효 처리됐어요.', '플레이어가 모두 나가서 방을 닫았어요.');
|
||||
this.close(room);
|
||||
continue;
|
||||
}
|
||||
if (room.conns.size > 0) continue;
|
||||
const idle = now - room.lastActiveAt;
|
||||
if (room.hasBots()) {
|
||||
// Practice rooms are private and only for the people who made them: once nobody is looking, close soon.
|
||||
const empty = now - (room.emptySince ?? now);
|
||||
const ttl = room.status === 'playing' || room.status === 'paused' ? PRACTICE_GAME_TTL : PRACTICE_IDLE_TTL;
|
||||
if (empty >= ttl) this.close(room);
|
||||
continue;
|
||||
}
|
||||
const closable =
|
||||
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
|
||||
(room.status === 'finished' && idle > FINISHED_TTL) ||
|
||||
|
||||
293
apps/server/src/rooms/replay.test.ts
Normal file
293
apps/server/src/rooms/replay.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Replays (docs/09 §8): every game played to the end through a real Room (random legal actions, timeouts,
|
||||
* streamed strokes, leaves) is rebuilt from the seed + room_log, and each player's replay shows exactly the
|
||||
* views that player was sent while playing.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng, type AnyGameDefinition } from '@bg/engine';
|
||||
import { applyViewPatch, type ReplayData, type ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
import { Replays, rebuildGame, type ReplayInput } from './replay';
|
||||
|
||||
const json = (v: unknown) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
function playerCount(def: AnyGameDefinition): number {
|
||||
const r = def.playersFor ? def.playersFor(def.defaultOptions) : { min: def.minPlayers, max: def.maxPlayers };
|
||||
const lo = Math.max(r.min, 2);
|
||||
for (const n of [lo + 1, lo, lo + 2, r.min]) {
|
||||
if (n >= r.min && n <= r.max && !def.playerCountError?.(def.defaultOptions, n)) return n;
|
||||
}
|
||||
throw new Error(`${def.id}: no player count`);
|
||||
}
|
||||
|
||||
/** Plays one game of `gameId` through a Room with a fake clock. Returns everything the assertions need. */
|
||||
function playThrough(gameId: string, seed: number) {
|
||||
const def = GAMES[gameId]!;
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_700_000_000_000;
|
||||
const clock = { now: () => now };
|
||||
const players = Array.from({ length: playerCount(def) }, (_, i) => `P${i + 1}`);
|
||||
const users = { publicUser: (id: string) => ({ id, nickname: `닉${id}`, avatar: null, kind: 'guest' as const }) };
|
||||
const config: RoomConfig = {
|
||||
gameId,
|
||||
options: def.defaultOptions,
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, { store, games: GAMES, users, now: clock.now });
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
|
||||
for (const p of players) {
|
||||
const msgs: ServerMessage[] = [];
|
||||
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
room.join(conns[p]!, 'player');
|
||||
}
|
||||
const startIdx = Object.fromEntries(players.map((p) => [p, conns[p]!.msgs.length]));
|
||||
expect(room.start(players[0]!)).toBeNull();
|
||||
room.clearTimer();
|
||||
|
||||
const rng = new SeededRng([seed, seed * 7 + 1, 0x9e3779b9, seed ^ 0x55]);
|
||||
let cs = 0;
|
||||
const left = new Set<string>();
|
||||
const counts = { act: 0, timeout: 0, leave: 0 };
|
||||
const fireTimer = (at: number) => {
|
||||
now = Math.max(now, at);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
counts.timeout++;
|
||||
};
|
||||
const finished = () => !room.game || room.game.finished;
|
||||
for (let step = 0; step < 4000 && !finished(); step++) {
|
||||
const deadline = room.effectiveDeadline();
|
||||
const dt = 50 + rng.int(1500);
|
||||
// A due deadline fires before anyone's next move, as the real timer would; sometimes jump to it on purpose.
|
||||
if (deadline !== null && (now + dt >= deadline || rng.next() < 0.03)) {
|
||||
fireTimer(deadline);
|
||||
continue;
|
||||
}
|
||||
now += dt;
|
||||
const state = room.game!.state;
|
||||
const actives = def.activePlayers(state).filter((p: string) => !left.has(p));
|
||||
if (actives.length === 0) continue;
|
||||
const actor = actives[rng.int(actives.length)]!;
|
||||
const legal = (def.legalActions?.(state, actor) ?? []).filter(
|
||||
(a: unknown) => def.actionSchema.safeParse(a).success && def.validate(state, actor, a, { now }).ok,
|
||||
);
|
||||
if (legal.length === 0) continue;
|
||||
room.act(actor, ++cs, legal[rng.int(legal.length)]);
|
||||
room.clearTimer();
|
||||
counts.act++;
|
||||
}
|
||||
// Rare long games: players leave one by one (onLeave auto actions) until the game ends.
|
||||
for (const p of [...players].reverse()) {
|
||||
if (finished()) break;
|
||||
room.leave(p);
|
||||
room.clearTimer();
|
||||
left.add(p);
|
||||
counts.leave++;
|
||||
for (let i = 0; i < 50 && !finished() && room.effectiveDeadline() !== null; i++) fireTimer(room.effectiveDeadline()!);
|
||||
}
|
||||
room.flushStream();
|
||||
expect(finished()).toBe(true);
|
||||
return { def, db, store, room, conns, players, startIdx, users, counts, left };
|
||||
}
|
||||
|
||||
/** Decodes frames into full views, as the replay page does. */
|
||||
function decode(data: ReplayData): unknown[] {
|
||||
const views: unknown[] = [];
|
||||
let v: unknown;
|
||||
for (const f of data.frames) {
|
||||
v = f.view !== undefined ? f.view : applyViewPatch(v, f.patch);
|
||||
views.push(v);
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
describe('replay: every game, played through a Room, rebuilds exactly', () => {
|
||||
for (const gameId of Object.keys(GAMES)) {
|
||||
test(gameId, async () => {
|
||||
const seed = 1 + Object.keys(GAMES).indexOf(gameId);
|
||||
const { def, db, store, room, conns, players, startIdx, users, counts, left } = playThrough(gameId, seed);
|
||||
const g = room.game!;
|
||||
const row = db.query<{ seed: string; result_json: string; started_at: number; options_json: string }, [string]>('SELECT * FROM games WHERE id = ?').get(g.gamePk)!;
|
||||
expect(row.result_json).toBe(JSON.stringify(room.lastResult));
|
||||
|
||||
// The rebuilt state equals the live one, field for field.
|
||||
const log = store.log('room1').filter((e) => e.gameNo === room.gameNo);
|
||||
const start = log.find((e) => e.kind === 'start')!;
|
||||
expect(start.at).toBe(row.started_at);
|
||||
const input: ReplayInput = {
|
||||
seedHex: row.seed,
|
||||
options: JSON.parse(row.options_json),
|
||||
players: (start.data as { players: string[] }).players,
|
||||
startedAt: row.started_at,
|
||||
gameNo: room.gameNo,
|
||||
actions: log.filter((e) => e.kind === 'act' || e.kind === 'auto').map((e) => ({ actorId: e.actorId!, kind: e.kind, data: e.data, at: e.at })),
|
||||
};
|
||||
const final = rebuildGame(def, input);
|
||||
expect(JSON.stringify(def.result(final))).toBe(row.result_json);
|
||||
expect(JSON.stringify(final)).toBe(JSON.stringify(g.state));
|
||||
|
||||
const replays = new Replays(db, GAMES, users, () => 1_700_000_000_000);
|
||||
for (const p of players) {
|
||||
const r = await replays.get(g.gamePk, p);
|
||||
if (!r.ok) throw new Error(`${gameId} ${p}: ${r.status} ${r.error}`);
|
||||
const data = JSON.parse(r.body) as ReplayData;
|
||||
expect(data.me).toBe(p);
|
||||
expect(data.result).toEqual(json(room.lastResult));
|
||||
const views = decode(data);
|
||||
const frameCount = 1 + input.actions.filter((a) => !(a.kind === 'act' && def.isStreamAction?.(a.data))).length;
|
||||
expect(views).toHaveLength(frameCount);
|
||||
// Final frame = what a (re)joining player is sent now.
|
||||
const c = conns[p]!;
|
||||
const before = c.msgs.length;
|
||||
room.sendSync(c);
|
||||
const sync = c.msgs.slice(before).find((m) => m.t === 'state') as Extract<ServerMessage, { t: 'state' }>;
|
||||
expect(views.at(-1)).toEqual(json(sync.view));
|
||||
// Every frame = the view this player was sent live, in order (games without a separate sync view).
|
||||
if (!def.syncView) {
|
||||
const live = c.msgs
|
||||
.slice(startIdx[p], before)
|
||||
.filter((m): m is Extract<ServerMessage, { t: 'state' }> => m.t === 'state')
|
||||
.map((m) => json(m.view));
|
||||
if (left.has(p)) expect(views.slice(0, live.length)).toEqual(live);
|
||||
else expect([...live.slice(0, -1)]).toEqual(views); // the last message repeats the final view (gameOver)
|
||||
}
|
||||
}
|
||||
if (process.env.REPLAY_STATS) {
|
||||
const t0 = performance.now();
|
||||
const r = await new Replays(db, GAMES, users, () => 1_700_000_000_000).get(g.gamePk, players[0]!);
|
||||
const ms = performance.now() - t0;
|
||||
const body = r.ok ? r.body : '';
|
||||
const views = decode(JSON.parse(body));
|
||||
const fullViews = views.reduce<number>((n, v) => n + JSON.stringify(v).length, 0);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
gameId,
|
||||
players: players.length,
|
||||
...counts,
|
||||
log: input.actions.length,
|
||||
frames: views.length,
|
||||
bodyKB: +(body.length / 1024).toFixed(1),
|
||||
gzipKB: +(Bun.gzipSync(body).length / 1024).toFixed(1),
|
||||
fullViewsKB: +(fullViews / 1024).toFixed(1),
|
||||
rebuildMs: +ms.toFixed(1),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('replay: access and edge cases', () => {
|
||||
function omokRoom() {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_700_000_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const users = { publicUser: (id: string) => ({ id, nickname: `닉${id}`, avatar: null, kind: 'guest' as const }) };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'omok',
|
||||
options: { ...GAMES.omok!.defaultOptions, colorAssignment: 'hostBlack' },
|
||||
maxPlayers: 2,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, 'A', { store, games: GAMES, users, now: clock.now });
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: 'omok', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
for (const u of ['A', 'B']) room.join({ userId: u, send: () => {}, close: () => {} }, 'player');
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.clearTimer();
|
||||
return { db, room, clock, users };
|
||||
}
|
||||
|
||||
test('leaving mid-game: the onLeave action is the last (auto) frame; only finished games, only players', async () => {
|
||||
const { db, room, clock, users } = omokRoom();
|
||||
const replays = new Replays(db, GAMES, users);
|
||||
const pk = room.game!.gamePk;
|
||||
expect(room.roomView().lastGamePk).toBeNull();
|
||||
expect(await replays.get(pk, 'A')).toMatchObject({ ok: false, status: 409 });
|
||||
clock.advance(1234);
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
clock.advance(999);
|
||||
room.leave('B');
|
||||
room.clearTimer();
|
||||
expect(room.game!.finished).toBe(true);
|
||||
expect(room.roomView().lastGamePk).toBe(pk);
|
||||
const r = await replays.get(pk, 'A');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
const data = JSON.parse(r.body) as ReplayData;
|
||||
expect(data.frames.map((f) => [f.actor, f.auto ?? false])).toEqual([
|
||||
[null, false],
|
||||
['A', false],
|
||||
['B', true],
|
||||
]);
|
||||
expect(data.frames[1]!.at).toBe(1_700_000_001_234);
|
||||
expect(data.players.B!.nickname).toBe('닉B');
|
||||
// The seed and the raw log stay on the server.
|
||||
const seed = db.query<{ seed: string }, [string]>('SELECT seed FROM games WHERE id = ?').get(pk)!.seed;
|
||||
expect(r.body).not.toContain(seed);
|
||||
expect(await replays.get(pk, 'C')).toMatchObject({ ok: false, status: 403 });
|
||||
expect(await replays.get('01J0000000000000000000000X', 'A')).toMatchObject({ ok: false, status: 404 });
|
||||
});
|
||||
|
||||
test('guest merged into a member keeps their seat; changed rules or a purged log are not replayable', async () => {
|
||||
const { db, room, users } = omokRoom();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
room.leave('B');
|
||||
const pk = room.game!.gamePk;
|
||||
db.query("UPDATE game_players SET user_id = 'M' WHERE user_id = 'B'").run();
|
||||
const merged = await new Replays(db, GAMES, users).get(pk, 'M');
|
||||
if (!merged.ok) throw new Error(merged.error);
|
||||
expect((JSON.parse(merged.body) as ReplayData).me).toBe('B');
|
||||
|
||||
const bumped = { ...GAMES, omok: { ...GAMES.omok!, stateVersion: GAMES.omok!.stateVersion + 1 } };
|
||||
expect(await new Replays(db, bumped, users).get(pk, 'A')).toMatchObject({ ok: false, status: 410 });
|
||||
// A rules change that alters the outcome is caught by the rebuilt-result check.
|
||||
const changed = { ...GAMES, omok: { ...GAMES.omok!, result: () => null } };
|
||||
expect(await new Replays(db, changed, users).get(pk, 'A')).toMatchObject({ ok: false, status: 410 });
|
||||
|
||||
db.query("DELETE FROM room_log WHERE kind = 'start'").run();
|
||||
expect(await new Replays(db, GAMES, users).get(pk, 'A')).toMatchObject({ ok: false, status: 410 });
|
||||
});
|
||||
|
||||
test('my games list: newest first, paged, with ranks and nicknames', async () => {
|
||||
const { db, room, clock, users } = omokRoom();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
room.leave('B');
|
||||
const first = room.game!.gamePk;
|
||||
// Second game: B comes back and wins when A leaves.
|
||||
room.join({ userId: 'B', send: () => {}, close: () => {} }, 'player');
|
||||
clock.advance(10);
|
||||
expect(room.start('A')).toBeNull();
|
||||
room.clearTimer();
|
||||
room.leave('A');
|
||||
const second = room.game!.gamePk;
|
||||
const replays = new Replays(db, GAMES, users, clock.now);
|
||||
const p1 = replays.listForUser('B', null, 1);
|
||||
expect(p1.games.map((g) => g.gamePk)).toEqual([second]);
|
||||
expect(p1.games[0]).toMatchObject({ gameId: 'omok', myRank: 1, replayable: true });
|
||||
expect(p1.games[0]!.players).toEqual([
|
||||
{ nickname: '닉B', rank: 1, me: true },
|
||||
{ nickname: '닉A', rank: 2, me: false },
|
||||
]);
|
||||
const p2 = replays.listForUser('B', p1.next, 1);
|
||||
expect(p2.games.map((g) => [g.gamePk, g.myRank])).toEqual([[first, 2]]);
|
||||
expect(p2.next).toBeNull();
|
||||
expect(replays.listForUser('C', null, 20)).toEqual({ games: [], next: null });
|
||||
// Past the log retention the list no longer offers a replay.
|
||||
const later = new Replays(db, GAMES, users, () => clock.now() + 91 * 24 * 3600_000);
|
||||
expect(later.listForUser('B', null, 5).games.every((g) => !g.replayable)).toBe(true);
|
||||
});
|
||||
});
|
||||
288
apps/server/src/rooms/replay.ts
Normal file
288
apps/server/src/rooms/replay.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Game replays (docs/09 §8): a finished game is rebuilt from its revealed seed and the action log
|
||||
* (`room_log` act/auto entries) by running the pure game module again, then rendered with the same
|
||||
* per-viewer `view` the player saw while playing. The seed and the raw log never leave the server.
|
||||
*/
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { SeededRng, seedFromHex, type AnyGameDefinition, type GameResult } from '@bg/engine';
|
||||
import { diffView, type MyGameSummary, type PublicUser, type ReplayData, type ReplayFrame } from '@bg/shared';
|
||||
|
||||
/** room_log rows are purged after this long (store.purgeOldLogs). */
|
||||
export const LOG_RETENTION_MS = 90 * 24 * 3600_000;
|
||||
/** Rebuilt replays are kept briefly (people step through right after opening one); bounded in count and size. */
|
||||
const CACHE_ENTRIES = 32;
|
||||
const CACHE_CHARS = 16_000_000;
|
||||
const CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
export interface ReplayInput {
|
||||
seedHex: string;
|
||||
options: unknown;
|
||||
players: string[];
|
||||
startedAt: number;
|
||||
gameNo: number;
|
||||
/** act/auto log entries of this game in seq order. */
|
||||
actions: { actorId: string; kind: string; data: unknown; at: number }[];
|
||||
}
|
||||
|
||||
type Step = { state: unknown; action: ReplayInput['actions'][number] | null; events: unknown[] };
|
||||
|
||||
/**
|
||||
* Re-runs a game exactly as the room did: setup with the seed and start time, then every logged action with
|
||||
* the RNG continuing and the time it was applied at (the room logs that exact `now`).
|
||||
*/
|
||||
function* replaySteps(def: AnyGameDefinition, input: ReplayInput): Generator<Step> {
|
||||
const rng = SeededRng.fromSeed(seedFromHex(input.seedHex));
|
||||
let state = def.setup({ players: input.players, options: input.options, rng, now: input.startedAt, gameNo: input.gameNo });
|
||||
yield { state, action: null, events: [{ type: 'gameStarted' }] };
|
||||
for (const a of input.actions) {
|
||||
const next = def.apply(state, a.actorId, a.data, { rng, now: a.at });
|
||||
state = next.state;
|
||||
yield { state, action: a, events: next.events };
|
||||
}
|
||||
}
|
||||
|
||||
/** Final state of a rebuilt game. */
|
||||
export function rebuildGame(def: AnyGameDefinition, input: ReplayInput): unknown {
|
||||
let state: unknown;
|
||||
for (const step of replaySteps(def, input)) state = step.state;
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frames for one viewer. Streamed actions (drawing strokes) are folded into the next frame, as their effect
|
||||
* reaches clients through `syncView` (the whole drawing). Views are sent as deltas against the previous frame.
|
||||
* The work is cut into slices of about `sliceMs` so a long game (마작: ~300 ms in total) never blocks the
|
||||
* rooms sharing this thread for long.
|
||||
*/
|
||||
export async function buildFrames(
|
||||
def: AnyGameDefinition,
|
||||
input: ReplayInput,
|
||||
viewer: string,
|
||||
sliceMs = 8,
|
||||
): Promise<{ frames: ReplayFrame[]; final: unknown }> {
|
||||
const frames: ReplayFrame[] = [];
|
||||
const render = (s: unknown) => (def.syncView ? def.syncView(s, viewer) : def.view(s, viewer));
|
||||
let prevView: unknown;
|
||||
let final: unknown;
|
||||
let slice = performance.now();
|
||||
for (const { state, action: a, events } of replaySteps(def, input)) {
|
||||
final = state;
|
||||
if (performance.now() - slice > sliceMs) {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
slice = performance.now();
|
||||
}
|
||||
if (a && a.kind === 'act' && def.isStreamAction?.(a.data)) continue;
|
||||
const view = render(state);
|
||||
const active = def.result(state) ? [] : def.activePlayers(state);
|
||||
if (!a) frames.push({ at: input.startedAt, actor: null, events, active, view });
|
||||
else {
|
||||
const patch = diffView(prevView, view);
|
||||
frames.push({ at: a.at, actor: a.actorId, ...(a.kind === 'auto' ? { auto: true } : {}), events, active, ...(patch ? { patch } : {}) });
|
||||
}
|
||||
prevView = view;
|
||||
}
|
||||
return { frames, final };
|
||||
}
|
||||
|
||||
interface GameRow {
|
||||
id: string;
|
||||
room_id: string;
|
||||
game_no: number;
|
||||
game_id: string;
|
||||
options_json: string;
|
||||
seed: string | null;
|
||||
started_at: number;
|
||||
ended_at: number | null;
|
||||
end_reason: string | null;
|
||||
result_json: string | null;
|
||||
}
|
||||
|
||||
export type ReplayResult = { ok: true; body: string } | { ok: false; status: 400 | 403 | 404 | 409 | 410; error: string };
|
||||
|
||||
export class Replays {
|
||||
private cache = new Map<string, { body: string; at: number }>();
|
||||
private cacheChars = 0;
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private inflight = new Map<string, Promise<Awaited<ReturnType<typeof buildFrames>>>>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private db: Database,
|
||||
private games: Record<string, AnyGameDefinition>,
|
||||
private users: { publicUser(id: string): PublicUser | null },
|
||||
now?: () => number,
|
||||
) {
|
||||
this.now = now ?? Date.now;
|
||||
}
|
||||
|
||||
/** My finished games, newest first. `before` is the last gamePk of the previous page. */
|
||||
listForUser(userId: string, before: string | null, limit: number): { games: MyGameSummary[]; next: string | null } {
|
||||
const rows = this.db
|
||||
.query<GameRow & { rank: number | null }, [string, string, number]>(
|
||||
`SELECT g.id, g.room_id, g.game_no, g.game_id, g.options_json, g.seed, g.started_at, g.ended_at, g.end_reason, g.result_json, gp.rank
|
||||
FROM game_players gp JOIN games g ON g.id = gp.game_pk
|
||||
WHERE gp.user_id = ? AND g.ended_at IS NOT NULL AND g.id < ?
|
||||
ORDER BY g.id DESC LIMIT ?`,
|
||||
)
|
||||
.all(userId, before ?? '~', limit + 1);
|
||||
const page = rows.slice(0, limit);
|
||||
const others = new Map<string, { user_id: string; rank: number | null }[]>();
|
||||
if (page.length) {
|
||||
const marks = page.map(() => '?').join(',');
|
||||
const list = this.db
|
||||
.query<{ game_pk: string; user_id: string; rank: number | null }, string[]>(
|
||||
`SELECT game_pk, user_id, rank FROM game_players WHERE game_pk IN (${marks}) ORDER BY rank IS NULL, rank, seat`,
|
||||
)
|
||||
.all(...page.map((r) => r.id));
|
||||
for (const p of list) {
|
||||
const arr = others.get(p.game_pk) ?? [];
|
||||
arr.push(p);
|
||||
others.set(p.game_pk, arr);
|
||||
}
|
||||
}
|
||||
const cutoff = this.now() - LOG_RETENTION_MS;
|
||||
const games = page.map((r): MyGameSummary => {
|
||||
const result = r.result_json ? (JSON.parse(r.result_json) as GameResult) : null;
|
||||
return {
|
||||
gamePk: r.id,
|
||||
gameId: r.game_id,
|
||||
startedAt: r.started_at,
|
||||
endedAt: r.ended_at!,
|
||||
endReason: r.end_reason ?? 'normal',
|
||||
myRank: r.rank,
|
||||
players: (others.get(r.id) ?? []).map((p) => ({
|
||||
nickname: this.users.publicUser(p.user_id)?.nickname ?? '알 수 없음',
|
||||
rank: p.rank,
|
||||
me: p.user_id === userId,
|
||||
})),
|
||||
summary: result?.summary ?? '',
|
||||
// Cheap check for the list; the replay request itself rebuilds and verifies the game.
|
||||
replayable: !!r.seed && !!this.games[r.game_id] && r.started_at > cutoff,
|
||||
};
|
||||
});
|
||||
return { games, next: rows.length > limit ? page[page.length - 1]!.id : null };
|
||||
}
|
||||
|
||||
async get(gamePk: string, userId: string): Promise<ReplayResult> {
|
||||
const g = this.db
|
||||
.query<GameRow, [string]>(
|
||||
'SELECT id, room_id, game_no, game_id, options_json, seed, started_at, ended_at, end_reason, result_json FROM games WHERE id = ?',
|
||||
)
|
||||
.get(gamePk);
|
||||
if (!g) return { ok: false, status: 404, error: '게임 기록을 찾을 수 없어요.' };
|
||||
const seats = this.db.query<{ user_id: string }, [string]>('SELECT user_id FROM game_players WHERE game_pk = ?').all(gamePk);
|
||||
// game_players is written when the game ends; before that nobody (not even players) can open a replay.
|
||||
if (g.ended_at === null || !g.seed || !g.result_json) return { ok: false, status: 409, error: '게임이 끝난 뒤에 다시 볼 수 있어요.' };
|
||||
if (!seats.some((s) => s.user_id === userId)) return { ok: false, status: 403, error: '이 게임에 참여한 사람만 다시 볼 수 있어요.' };
|
||||
|
||||
const key = `${gamePk}:${userId}`;
|
||||
const hit = this.cache.get(key);
|
||||
if (hit) {
|
||||
this.cache.delete(key);
|
||||
if (this.now() - hit.at < CACHE_TTL_MS) {
|
||||
this.cache.set(key, hit);
|
||||
return { ok: true, body: hit.body };
|
||||
}
|
||||
this.cacheChars -= hit.body.length;
|
||||
}
|
||||
|
||||
const gone = { ok: false as const, status: 410 as const, error: '기록이 남아 있지 않아 다시 볼 수 없어요. (90일이 지난 게임은 다시 볼 수 없어요.)' };
|
||||
const def = this.games[g.game_id];
|
||||
if (!def) return gone;
|
||||
const log = this.db
|
||||
.query<{ actor_id: string | null; kind: string; data_json: string; at: number }, [string, number]>(
|
||||
'SELECT actor_id, kind, data_json, at FROM room_log WHERE room_id = ? AND game_no = ? ORDER BY seq',
|
||||
)
|
||||
.all(g.room_id, g.game_no);
|
||||
const startIdx = log.findIndex((e) => e.kind === 'start');
|
||||
const endIdx = log.findIndex((e, i) => i > startIdx && e.kind === 'finish');
|
||||
if (startIdx < 0 || endIdx < 0) return gone;
|
||||
const start = JSON.parse(log[startIdx]!.data_json) as { players: string[]; stateVersion?: number };
|
||||
// Games logged before stateVersion was recorded all ran at version 1. Actions logged under other
|
||||
// rules cannot be re-applied safely (a state migration does not translate actions).
|
||||
if ((start.stateVersion ?? 1) !== def.stateVersion) {
|
||||
return { ok: false, status: 410, error: '그 뒤로 게임 규칙이 바뀌어서 이 게임은 다시 볼 수 없어요.' };
|
||||
}
|
||||
const viewer = this.playerIdOf(userId, start.players, seats.map((s) => s.user_id));
|
||||
if (!viewer) return { ok: false, status: 410, error: '이 게임은 다시 볼 수 없어요.' };
|
||||
const input: ReplayInput = {
|
||||
seedHex: g.seed,
|
||||
options: JSON.parse(g.options_json),
|
||||
players: start.players,
|
||||
startedAt: g.started_at,
|
||||
gameNo: g.game_no,
|
||||
actions: log
|
||||
.slice(startIdx + 1, endIdx)
|
||||
.filter((e) => (e.kind === 'act' || e.kind === 'auto') && e.actor_id !== null)
|
||||
.map((e) => ({ actorId: e.actor_id!, kind: e.kind, data: JSON.parse(e.data_json), at: e.at })),
|
||||
};
|
||||
|
||||
// Rebuilds run one at a time (each already yields between slices); identical requests share one rebuild.
|
||||
let job = this.inflight.get(key);
|
||||
if (!job) {
|
||||
job = this.queue.then(() => buildFrames(def, input, viewer));
|
||||
this.queue = job.catch(() => {});
|
||||
this.inflight.set(key, job);
|
||||
const done = () => this.inflight.delete(key);
|
||||
job.then(done, done);
|
||||
}
|
||||
let built: Awaited<ReturnType<typeof buildFrames>>;
|
||||
try {
|
||||
built = await job;
|
||||
} catch {
|
||||
return { ok: false, status: 410, error: '이 게임은 다시 볼 수 없어요.' };
|
||||
}
|
||||
// The rebuilt game must end exactly as recorded; otherwise the replay would show a different game.
|
||||
const result = JSON.parse(g.result_json) as GameResult;
|
||||
const rebuilt = def.result(built.final);
|
||||
const matches = g.end_reason === 'void' ? rebuilt === null : JSON.stringify(rebuilt) === g.result_json;
|
||||
if (!matches) return { ok: false, status: 410, error: '이 게임은 다시 볼 수 없어요.' };
|
||||
|
||||
const ids = this.idMap(start.players, seats.map((s) => s.user_id));
|
||||
const players: Record<string, PublicUser> = {};
|
||||
for (const p of start.players) {
|
||||
const u = this.users.publicUser(ids.get(p) ?? p);
|
||||
players[p] = u ? { ...u, id: p } : { id: p, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
}
|
||||
const data: ReplayData = {
|
||||
gamePk,
|
||||
gameId: g.game_id,
|
||||
options: input.options,
|
||||
startedAt: g.started_at,
|
||||
endedAt: g.ended_at,
|
||||
me: viewer,
|
||||
players,
|
||||
result,
|
||||
frames: built.frames,
|
||||
};
|
||||
const body = JSON.stringify(data);
|
||||
this.cache.set(key, { body, at: this.now() });
|
||||
this.cacheChars += body.length;
|
||||
while (this.cache.size > CACHE_ENTRIES || (this.cacheChars > CACHE_CHARS && this.cache.size > 1)) {
|
||||
const [k, v] = this.cache.entries().next().value!;
|
||||
this.cache.delete(k);
|
||||
this.cacheChars -= v.body.length;
|
||||
}
|
||||
return { ok: true, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* In-game player id → current user id. A guest who later linked Discord into an existing member has their
|
||||
* game_players row moved to the member id while the game state keeps the old id; pair those up when the
|
||||
* leftover is unambiguous.
|
||||
*/
|
||||
private idMap(players: string[], current: string[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
const cur = new Set(current);
|
||||
const missing = players.filter((p) => !cur.has(p));
|
||||
const extra = current.filter((u) => !players.includes(u));
|
||||
for (const p of players) if (cur.has(p)) map.set(p, p);
|
||||
if (missing.length === 1 && extra.length === 1) map.set(missing[0]!, extra[0]!);
|
||||
return map;
|
||||
}
|
||||
|
||||
private playerIdOf(userId: string, players: string[], current: string[]): string | null {
|
||||
for (const [p, u] of this.idMap(players, current)) if (u === userId) return p;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
import { ulid } from 'ulid';
|
||||
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
|
||||
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { botsAllowed, isBotId, makeBotId, botNumber, maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { sha256Hex } from '../auth/sessions';
|
||||
import { BotDriver } from './bots';
|
||||
import type { LogEntry, RoomStore } from './store';
|
||||
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
|
||||
|
||||
@@ -75,10 +76,13 @@ export interface RoomDeps {
|
||||
users: { publicUser(id: string): PublicUser | null };
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
onEmpty?: (room: Room) => void;
|
||||
/** The last connection went away: `leave` = someone left on purpose, `disconnect` = a socket dropped or the page was left. */
|
||||
onEmpty?: (room: Room, why: 'leave' | 'disconnect') => void;
|
||||
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
||||
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
|
||||
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
|
||||
/** Computer players' thinking time (ms) for a decision with `choices` options; tests may pin it. */
|
||||
botDelay?: (choices: number, withPeople: boolean) => number;
|
||||
}
|
||||
|
||||
const CHAT_HISTORY = 30;
|
||||
@@ -94,6 +98,8 @@ export class Room {
|
||||
spectators = new Set<string>();
|
||||
presence = new Map<string, Presence>();
|
||||
conns = new Map<string, Conn>();
|
||||
/** Seated people still on the site but away from the room page (no room messages; still count as connected). */
|
||||
detached = new Map<string, Conn>();
|
||||
kicked = new Set<string>();
|
||||
/** Players who left during a game; their seat is freed when the game ends. */
|
||||
leftDuringGame = new Set<string>();
|
||||
@@ -105,6 +111,8 @@ export class Room {
|
||||
game: GameSlot | null = null;
|
||||
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
||||
lastActiveAt: number;
|
||||
/** When the last connection went away (null while someone is connected). */
|
||||
emptySince: number | null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
||||
private pendingLogs: LogEntry[] = [];
|
||||
@@ -113,6 +121,8 @@ export class Room {
|
||||
private lastChatAt = new Map<string, number>();
|
||||
private consecutiveErrors = 0;
|
||||
private readonly now: () => number;
|
||||
/** Plays the computer players' moves (docs/06 §12). */
|
||||
readonly bots: BotDriver;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
@@ -125,6 +135,29 @@ export class Room {
|
||||
this.seats = new Array(config.maxPlayers).fill(null);
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.lastActiveAt = this.now();
|
||||
this.emptySince = this.now();
|
||||
const room = this;
|
||||
this.bots = new BotDriver(
|
||||
{
|
||||
get status() {
|
||||
return room.status;
|
||||
},
|
||||
get game() {
|
||||
return room.game;
|
||||
},
|
||||
get def() {
|
||||
return room.def;
|
||||
},
|
||||
now: this.now,
|
||||
run: (bot, action) => {
|
||||
const parsed = this.def.actionSchema.safeParse(action);
|
||||
return parsed.success ? this.runAction(bot, parsed.data, undefined, false) : '잘못된 요청이에요.';
|
||||
},
|
||||
log: deps.log,
|
||||
},
|
||||
Math.random,
|
||||
deps.botDelay,
|
||||
);
|
||||
}
|
||||
|
||||
get def(): AnyGameDefinition {
|
||||
@@ -148,9 +181,14 @@ export class Room {
|
||||
return min <= max ? { min, max } : base;
|
||||
}
|
||||
|
||||
/** Options forced to the admin value: admin-locked ones plus the game's admin-only options (e.g. threat guides). */
|
||||
private locked(gameId: string): { defaults: unknown; paths: string[] } {
|
||||
const eff = this.deps.settings?.game(gameId);
|
||||
return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] };
|
||||
const adminOnly = this.deps.games[gameId]?.adminOnlyOptions ?? [];
|
||||
return {
|
||||
defaults: eff?.defaultOptions ?? this.deps.games[gameId]?.defaultOptions,
|
||||
paths: [...new Set([...(eff?.lockedOptions ?? []), ...adminOnly])],
|
||||
};
|
||||
}
|
||||
|
||||
seatOf(userId: string): number {
|
||||
@@ -169,6 +207,32 @@ export class Room {
|
||||
return !!this.game && !this.game.finished && this.game.players.includes(userId);
|
||||
}
|
||||
|
||||
hasBots(): boolean {
|
||||
return this.seats.some((s) => !!s && isBotId(s.userId));
|
||||
}
|
||||
|
||||
/** A person still holds a seat (not counting people who already left a running game). */
|
||||
hasPeopleSeated(): boolean {
|
||||
return this.seats.some((s) => !!s && !isBotId(s.userId) && !this.leftDuringGame.has(s.userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Since when every seated person has been off the site, or null while one of them is still on it (in this room or
|
||||
* on any other page) or nobody is seated. `offlineSince(u)` is null while u has the site open. Computer players,
|
||||
* people who already left a running game, and spectators don't count.
|
||||
*/
|
||||
playersOfflineSince(offlineSince: (userId: string) => number | null): number | null {
|
||||
let since: number | null = null;
|
||||
for (const s of this.seats) {
|
||||
if (!s || isBotId(s.userId) || this.leftDuringGame.has(s.userId)) continue;
|
||||
const p = this.presence.get(s.userId);
|
||||
const off = offlineSince(s.userId);
|
||||
if (p?.connected || off === null) return null;
|
||||
since = Math.max(since ?? 0, p?.since ?? 0, off);
|
||||
}
|
||||
return since;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
snapshot(): RoomSnapshot {
|
||||
@@ -203,7 +267,8 @@ export class Room {
|
||||
room.game = snap.game;
|
||||
const now = room.now();
|
||||
for (const s of room.seats) {
|
||||
if (s) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
|
||||
// Computer players have no connection (and so no reconnect grace period).
|
||||
if (s && !isBotId(s.userId)) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
|
||||
}
|
||||
if (room.game && !room.game.finished) {
|
||||
const def = room.def;
|
||||
@@ -220,7 +285,8 @@ export class Room {
|
||||
return room;
|
||||
}
|
||||
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
|
||||
/** `log.at` defaults to the current time; game actions pass the exact `now` they were applied with (replays, docs/09 §8). */
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown; at?: number } | null): void {
|
||||
const nextSeq = this.seq + 1;
|
||||
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
||||
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
||||
@@ -242,9 +308,9 @@ export class Room {
|
||||
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
||||
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
||||
*/
|
||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
|
||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown; at: number }): void {
|
||||
this.seq += 1;
|
||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
|
||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, ...log });
|
||||
this.lastActiveAt = this.now();
|
||||
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
||||
}
|
||||
@@ -261,6 +327,8 @@ export class Room {
|
||||
const pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const seats: (SeatView | null)[] = this.seats.map((s) => {
|
||||
if (!s) return null;
|
||||
// Computer players are always there and ready.
|
||||
if (isBotId(s.userId)) return { user: pub(s.userId), ready: true, connected: true, away: false };
|
||||
const p = this.presence.get(s.userId);
|
||||
const connected = !!p?.connected;
|
||||
const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000;
|
||||
@@ -286,8 +354,9 @@ export class Room {
|
||||
rematchVotes: [...this.rematchVotes],
|
||||
sessionStats: this.sessionStats,
|
||||
seedHash: this.game?.seedHash ?? null,
|
||||
lockedOptions: this.locked(this.config.gameId).paths,
|
||||
lockedOptions: this.deps.settings?.game(this.config.gameId)?.lockedOptions ?? [],
|
||||
lastSeed: this.lastSeed,
|
||||
lastGamePk: this.game?.finished ? this.game.gamePk : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -368,6 +437,8 @@ export class Room {
|
||||
this.leftDuringGame.delete(u);
|
||||
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
|
||||
this.conns.set(u, conn);
|
||||
this.detached.delete(u);
|
||||
this.emptySince = null;
|
||||
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
|
||||
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
|
||||
}
|
||||
@@ -381,11 +452,25 @@ export class Room {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left the room page but still on the site (홈, 규칙…): no more room messages and the room no longer counts the
|
||||
* connection, but the seat stays and the person still counts as connected (no "연결 끊김", no auto moves).
|
||||
*/
|
||||
detach(conn: Conn): void {
|
||||
const u = conn.userId;
|
||||
if (this.conns.get(u) !== conn) return;
|
||||
if (this.spectators.has(u)) return this.disconnect(conn);
|
||||
this.conns.delete(u);
|
||||
this.detached.set(u, conn);
|
||||
if (this.conns.size === 0) this.becameEmpty('disconnect');
|
||||
}
|
||||
|
||||
/** Connection dropped (not an intentional leave). */
|
||||
disconnect(conn: Conn): void {
|
||||
const u = conn.userId;
|
||||
if (this.conns.get(u) !== conn) return;
|
||||
this.conns.delete(u);
|
||||
if (this.detached.get(u) === conn) this.detached.delete(u);
|
||||
else if (this.conns.get(u) === conn) this.conns.delete(u);
|
||||
else return;
|
||||
const p = this.presence.get(u);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
@@ -399,12 +484,13 @@ export class Room {
|
||||
}
|
||||
this.broadcastRoom();
|
||||
this.scheduleTimer();
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
if (this.conns.size === 0) this.becameEmpty('disconnect');
|
||||
}
|
||||
|
||||
leave(userId: string): void {
|
||||
const conn = this.conns.get(userId);
|
||||
this.conns.delete(userId);
|
||||
this.detached.delete(userId);
|
||||
this.spectators.delete(userId);
|
||||
this.presence.delete(userId);
|
||||
const seat = this.seatOf(userId);
|
||||
@@ -415,6 +501,11 @@ export class Room {
|
||||
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
||||
const action = this.def.onLeave?.(this.game!.state, userId);
|
||||
if (action) this.runAction(userId, action, undefined, true);
|
||||
// Only computer players left: nobody to practise with, so the game ends uncounted.
|
||||
const g = this.game;
|
||||
if (g && !g.finished && g.players.every((u) => isBotId(u) || this.leftDuringGame.has(u))) {
|
||||
this.finishGame({ ranking: [g.players], summary: '모두 나가서 연습 게임을 끝냈어요.', reason: 'abandoned' }, true);
|
||||
}
|
||||
} else {
|
||||
this.seats[seat] = null;
|
||||
this.rematchVotes.delete(userId);
|
||||
@@ -425,14 +516,19 @@ export class Room {
|
||||
this.persist({ actorId: userId, kind: 'leave', data: null });
|
||||
this.broadcastRoom();
|
||||
conn?.send({ t: 'bye', reason: 'closed' });
|
||||
if (this.conns.size === 0) this.deps.onEmpty?.(this);
|
||||
if (this.conns.size === 0) this.becameEmpty('leave');
|
||||
}
|
||||
|
||||
private becameEmpty(why: 'leave' | 'disconnect'): void {
|
||||
this.emptySince ??= this.now();
|
||||
this.deps.onEmpty?.(this, why);
|
||||
}
|
||||
|
||||
private pickNewHost(): void {
|
||||
const candidates = [...this.presence.entries()]
|
||||
.filter(([u, p]) => this.seatOf(u) >= 0 && p.connected)
|
||||
.sort((a, b) => a[1].joinedAt - b[1].joinedAt);
|
||||
const next = candidates[0]?.[0] ?? this.seatedIds()[0] ?? [...this.spectators][0];
|
||||
const next = candidates[0]?.[0] ?? this.seatedIds().find((u) => !isBotId(u)) ?? [...this.spectators][0];
|
||||
if (next) this.hostId = next;
|
||||
}
|
||||
|
||||
@@ -501,6 +597,8 @@ export class Room {
|
||||
next.options = eff?.defaultOptions ?? def.defaultOptions;
|
||||
next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers;
|
||||
}
|
||||
if (this.hasBots() && !botsAllowed(next.gameId)) return '컴퓨터는 이 게임을 할 수 없어요. 컴퓨터를 먼저 빼 주세요.';
|
||||
if (this.hasBots() && patch.visibility === 'public') return '컴퓨터가 있는 방은 공개할 수 없어요.';
|
||||
const def = this.deps.games[next.gameId]!;
|
||||
if (patch.options !== undefined) {
|
||||
const parsed = def.optionsSchema.safeParse(patch.options);
|
||||
@@ -543,6 +641,7 @@ export class Room {
|
||||
kick(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
|
||||
if (target === userId) return '자기 자신은 내보낼 수 없어요.';
|
||||
if (isBotId(target)) return this.removeBot(userId, target);
|
||||
if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
|
||||
const conn = this.conns.get(target);
|
||||
this.kicked.add(target);
|
||||
@@ -554,6 +653,7 @@ export class Room {
|
||||
|
||||
transferHost(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
|
||||
if (isBotId(target)) return '컴퓨터에게는 방장을 넘길 수 없어요.';
|
||||
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
|
||||
this.hostId = target;
|
||||
this.persist({ actorId: userId, kind: 'host', data: { target } });
|
||||
@@ -561,6 +661,39 @@ export class Room {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Host seats a computer player on an empty seat (docs/06 §12). Rooms with computers are private practice rooms. */
|
||||
addBot(userId: string, seat?: number): string | null {
|
||||
if (userId !== this.hostId) return '방장만 컴퓨터를 넣을 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
if (!botsAllowed(this.config.gameId)) return '이 게임은 컴퓨터와 할 수 없어요.';
|
||||
const target = seat ?? this.seats.findIndex((s) => s === null);
|
||||
if (target < 0 || target >= this.seats.length) return '빈자리가 없어요.';
|
||||
if (this.seats[target]) return '이미 다른 사람이 앉은 자리예요.';
|
||||
const used = new Set(this.seatedIds().map(botNumber));
|
||||
let n = 1;
|
||||
while (used.has(n)) n++;
|
||||
const id = makeBotId(n, ulid(this.now()));
|
||||
this.seats[target] = { userId: id, ready: true };
|
||||
if (this.config.visibility === 'public') this.config = { ...this.config, visibility: 'private' };
|
||||
this.persist({ actorId: userId, kind: 'addBot', data: { seat: target, bot: id } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
removeBot(userId: string, botId: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 컴퓨터를 뺄 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const seat = this.seatOf(botId);
|
||||
if (seat < 0 || !isBotId(botId)) return '없는 컴퓨터예요.';
|
||||
this.seats[seat] = null;
|
||||
this.rematchVotes.delete(botId);
|
||||
this.persist({ actorId: userId, kind: 'removeBot', data: { bot: botId } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
chat(userId: string, text: string): string | null {
|
||||
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
|
||||
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
|
||||
@@ -605,6 +738,8 @@ export class Room {
|
||||
const range = this.playerRange();
|
||||
if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`;
|
||||
if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`;
|
||||
if (this.hasBots() && !botsAllowed(this.config.gameId)) return '컴퓨터는 이 게임을 할 수 없어요.';
|
||||
if (players.every(isBotId)) return '사람이 한 명은 앉아야 해요.';
|
||||
return this.startGame(players);
|
||||
}
|
||||
|
||||
@@ -614,7 +749,7 @@ export class Room {
|
||||
this.rematchVotes.add(userId);
|
||||
const seated = this.seatedIds();
|
||||
const range = this.playerRange();
|
||||
if (seated.length >= range.min && seated.every((u) => this.rematchVotes.has(u))) {
|
||||
if (seated.length >= range.min && seated.every((u) => isBotId(u) || this.rematchVotes.has(u))) {
|
||||
return this.startGame(this.orderedPlayers());
|
||||
}
|
||||
this.persist(null);
|
||||
@@ -673,7 +808,7 @@ export class Room {
|
||||
this.lastResult = null;
|
||||
this.consecutiveErrors = 0;
|
||||
for (const s of this.seats) if (s) s.ready = false;
|
||||
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } });
|
||||
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash, stateVersion: def.stateVersion }, at: now });
|
||||
this.broadcastRoom();
|
||||
this.broadcastState([{ type: 'gameStarted' }]);
|
||||
this.scheduleTimer();
|
||||
@@ -727,11 +862,11 @@ export class Room {
|
||||
g.deadlineFloor = null;
|
||||
if (!system && def.isStreamAction?.(action)) {
|
||||
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
||||
this.persistStream({ actorId: userId, kind: 'act', data: action });
|
||||
this.persistStream({ actorId: userId, kind: 'act', data: action, at: now });
|
||||
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
||||
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
||||
} else {
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action, at: now });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
}
|
||||
const result = def.result(g.state);
|
||||
@@ -772,6 +907,7 @@ export class Room {
|
||||
endReason: voided ? 'void' : result.reason,
|
||||
result,
|
||||
players,
|
||||
practice: g.players.some(isBotId),
|
||||
});
|
||||
const allFirst = players.every((p) => p.rank === 1);
|
||||
if (!voided) {
|
||||
@@ -828,16 +964,21 @@ export class Room {
|
||||
}
|
||||
|
||||
scheduleTimer(): void {
|
||||
this.clearTimer();
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
// A computer move already planned stays planned (it re-reads the state when it fires).
|
||||
this.bots.schedule();
|
||||
const at = this.effectiveDeadline();
|
||||
if (at === null) return;
|
||||
const delay = Math.max(0, at - this.now());
|
||||
this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1));
|
||||
}
|
||||
|
||||
/** Clears the turn timer and any planned computer move. */
|
||||
clearTimer(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.bots.cancel();
|
||||
}
|
||||
|
||||
/** Fires due timeouts. Public for tests. */
|
||||
@@ -883,10 +1024,15 @@ export class Room {
|
||||
|
||||
/** Admin closes the room: void any running game, tell everyone, disconnect. */
|
||||
closeByAdmin(): void {
|
||||
this.closeWith('관리자가 방을 닫아 무효 처리됐어요.', '관리자가 방을 닫았어요.');
|
||||
}
|
||||
|
||||
/** Ends a running game uncounted and sends everyone still in the room away (the manager then closes it). */
|
||||
closeWith(summary: string, notice: string): void {
|
||||
if (this.game && !this.game.finished) {
|
||||
this.finishGame({ ranking: [this.game.players], summary: '관리자가 방을 닫아 무효 처리됐어요.', reason: 'abandoned' }, true);
|
||||
this.finishGame({ ranking: [this.game.players], summary, reason: 'abandoned' }, true);
|
||||
}
|
||||
this.notice('closed', '관리자가 방을 닫았어요.');
|
||||
this.notice('closed', notice);
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
c.send({ t: 'bye', reason: 'closed' });
|
||||
|
||||
@@ -100,12 +100,15 @@ export class RoomStore {
|
||||
endReason: string;
|
||||
result: unknown;
|
||||
players: { userId: string; seat: number; rank: number | null; score: number | null }[];
|
||||
/** Played with computer players: kept in history (result_json.practice) but not counted in user_stats. */
|
||||
practice?: boolean;
|
||||
}): void {
|
||||
this.db.transaction(() => {
|
||||
const result = g.practice ? { ...(g.result as object), practice: true } : g.result;
|
||||
this.db
|
||||
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?')
|
||||
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(g.result), g.id);
|
||||
const counted = g.endReason !== 'void';
|
||||
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(result), g.id);
|
||||
const counted = g.endReason !== 'void' && !g.practice;
|
||||
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
|
||||
const firstCount = ranks.filter((r) => r === 1).length;
|
||||
for (const p of g.players) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import type { Server } from 'bun';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { botProfile } from '@bg/shared';
|
||||
import type { Config } from './config';
|
||||
import { openDb } from './db';
|
||||
import { SESSION_COOKIE, Sessions } from './auth/sessions';
|
||||
@@ -10,10 +11,14 @@ import { Users, toPublicUser } from './auth/users';
|
||||
import { createHttpApp, type HttpDeps } from './http/app';
|
||||
import { RoomManager } from './rooms/manager';
|
||||
import { RoomStore } from './rooms/store';
|
||||
import { Replays } from './rooms/replay';
|
||||
import { Gateway, type WsData } from './ws/gateway';
|
||||
import { Audit, Roles } from './admin/roles';
|
||||
import { SettingsStore } from './admin/settings';
|
||||
|
||||
/** A guest who closed the site and stayed away this long is deleted. */
|
||||
export const GUEST_IDLE_MS = 24 * 3600_000;
|
||||
|
||||
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
|
||||
export const jsonLog: LogFn = (level, msg, extra) => {
|
||||
@@ -27,6 +32,10 @@ export interface StartOptions {
|
||||
log?: LogFn;
|
||||
staticDir?: string | null;
|
||||
discordExchange?: HttpDeps['discordExchange'];
|
||||
/** Computer players' thinking time (tests shorten it). */
|
||||
botDelay?: (choices: number, withPeople: boolean) => number;
|
||||
/** Upper bound for waiting on Bun's server.stop() (tests shorten it). */
|
||||
stopWaitMs?: number;
|
||||
}
|
||||
|
||||
export function startServer(opts: StartOptions) {
|
||||
@@ -37,6 +46,8 @@ export function startServer(opts: StartOptions) {
|
||||
const sessions = new Sessions(db);
|
||||
const store = new RoomStore(db);
|
||||
const publicUser = (id: string) => {
|
||||
const bot = botProfile(id);
|
||||
if (bot) return bot;
|
||||
const u = users.get(id);
|
||||
return u ? toPublicUser(u) : null;
|
||||
};
|
||||
@@ -44,10 +55,24 @@ export function startServer(opts: StartOptions) {
|
||||
const roles = new Roles(db, config.superadminDiscordIds);
|
||||
const audit = new Audit(db);
|
||||
const startedAt = Date.now();
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings });
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings, botDelay: opts.botDelay, offlineSince: (id): number | null => gateway.offlineSince(id) });
|
||||
const restored = rooms.restoreAll();
|
||||
log('info', 'rooms restored', restored);
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log });
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log, onClose: (id) => users.touch(id) });
|
||||
const removeGuest = (id: string) => {
|
||||
rooms.leaveAll(id);
|
||||
gateway.closeUser(id);
|
||||
if (users.deleteGuest(id)) log('info', 'guest removed', { user: id });
|
||||
};
|
||||
/** Still on the site (socket open) or still holding a seat: not idle. */
|
||||
const guestKept = (id: string) => gateway.isOnline(id) || !!rooms.activeRoomFor(id);
|
||||
/** Guests who closed the site and stayed away a day are deleted (docs/05 §4). */
|
||||
const purgeIdleGuests = (now = Date.now()) => {
|
||||
for (const id of users.idleGuests(now - GUEST_IDLE_MS)) {
|
||||
if (guestKept(id)) continue;
|
||||
removeGuest(id);
|
||||
}
|
||||
};
|
||||
let ready = true;
|
||||
|
||||
const ipOf = (req: Request) =>
|
||||
@@ -60,15 +85,18 @@ export function startServer(opts: StartOptions) {
|
||||
users,
|
||||
sessions,
|
||||
rooms,
|
||||
replays: new Replays(db, GAMES, { publicUser }),
|
||||
ipOf,
|
||||
discordExchange: opts.discordExchange,
|
||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||
roles,
|
||||
settings,
|
||||
removeGuest,
|
||||
admin: {
|
||||
audit,
|
||||
games: GAMES,
|
||||
disconnectUser: (id) => gateway.disconnectUser(id),
|
||||
guestDeleteAt: (u) => (u.kind !== 'guest' || guestKept(u.id) ? null : u.last_seen_at + GUEST_IDLE_MS),
|
||||
stats: () => {
|
||||
const all = rooms.all();
|
||||
const day = Date.now() - 24 * 3600_000;
|
||||
@@ -121,6 +149,7 @@ export function startServer(opts: StartOptions) {
|
||||
try {
|
||||
rooms.sweep();
|
||||
sessions.purgeExpired();
|
||||
purgeIdleGuests();
|
||||
store.purgeOldLogs();
|
||||
} catch (err) {
|
||||
log('error', 'sweep failed', { err: String(err) });
|
||||
@@ -134,11 +163,11 @@ export function startServer(opts: StartOptions) {
|
||||
rooms.shutdown();
|
||||
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
|
||||
// listener is already closed, so bound the wait.
|
||||
await Promise.race([server.stop(true), Bun.sleep(1000)]);
|
||||
await Promise.race([server.stop(true), Bun.sleep(opts.stopWaitMs ?? 1000)]);
|
||||
db.close();
|
||||
};
|
||||
|
||||
return { server, stop, rooms, users, sessions, db, gateway, settings, roles };
|
||||
return { server, stop, rooms, users, sessions, db, gateway, settings, roles, purgeIdleGuests };
|
||||
}
|
||||
|
||||
async function serveFile(root: string, pathname: string): Promise<Response> {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { loadConfig } from './config';
|
||||
import { startServer, type StartOptions } from './server';
|
||||
|
||||
export const ORIGIN = 'http://localhost:5173';
|
||||
/** Made-up superadmin Discord IDs for tests (the real ones live only in deploy/.env). */
|
||||
export const TEST_SUPERADMINS = ['900000000000000001', '900000000000000002'] as const;
|
||||
|
||||
export function tempDbPath(): string {
|
||||
const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-'));
|
||||
@@ -14,8 +16,8 @@ export function tempDbPath(): string {
|
||||
}
|
||||
|
||||
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
|
||||
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
|
||||
const app = startServer({ config, log: () => {}, staticDir: null, ...extra });
|
||||
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN, SUPERADMIN_DISCORD_IDS: TEST_SUPERADMINS.join(',') });
|
||||
const app = startServer({ config, log: () => {}, staticDir: null, stopWaitMs: 50, ...extra });
|
||||
const base = `http://localhost:${app.server.port}`;
|
||||
return { ...app, base, dbPath, config };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface GatewayDeps {
|
||||
me(userId: string): PublicUser | null;
|
||||
now?: () => number;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
/** A socket closed (the user may have left the site). */
|
||||
onClose?: (userId: string) => void;
|
||||
}
|
||||
|
||||
export const MAX_CONNS_PER_USER = 5;
|
||||
@@ -27,6 +29,8 @@ const MAX_BUFFERED = 1024 * 1024;
|
||||
export class Gateway {
|
||||
private nextId = 1;
|
||||
private byUser = new Map<string, Set<ServerWebSocket<WsData>>>();
|
||||
/** userId → when their last socket closed (removed again when one opens). */
|
||||
private offlineAt = new Map<string, number>();
|
||||
private closing = false;
|
||||
|
||||
constructor(private deps: GatewayDeps) {}
|
||||
@@ -68,6 +72,7 @@ export class Gateway {
|
||||
const set = this.byUser.get(ws.data.userId) ?? new Set();
|
||||
set.add(ws);
|
||||
this.byUser.set(ws.data.userId, set);
|
||||
this.offlineAt.delete(ws.data.userId);
|
||||
const me = this.deps.me(ws.data.userId);
|
||||
if (!me) {
|
||||
ws.close(4401, 'unauthorized');
|
||||
@@ -115,9 +120,13 @@ export class Gateway {
|
||||
|
||||
close: (ws: ServerWebSocket<WsData>) => {
|
||||
this.byUser.get(ws.data.userId)?.delete(ws);
|
||||
if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId);
|
||||
if (this.byUser.get(ws.data.userId)?.size === 0) {
|
||||
this.byUser.delete(ws.data.userId);
|
||||
this.offlineAt.set(ws.data.userId, (this.deps.now ?? Date.now)());
|
||||
}
|
||||
const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (room && ws.data.conn) room.disconnect(ws.data.conn);
|
||||
this.deps.onClose?.(ws.data.userId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,6 +152,11 @@ export class Gateway {
|
||||
return;
|
||||
}
|
||||
const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
|
||||
if (m.t === 'detach') {
|
||||
// ws.data.room stays: when this socket closes, the room still learns that the person went offline.
|
||||
if (room && ws.data.conn) room.detach(ws.data.conn);
|
||||
return;
|
||||
}
|
||||
if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room');
|
||||
let e: string | null = null;
|
||||
switch (m.t) {
|
||||
@@ -173,6 +187,12 @@ export class Gateway {
|
||||
case 'host':
|
||||
e = room.transferHost(u, m.userId);
|
||||
break;
|
||||
case 'addBot':
|
||||
e = room.addBot(u, m.seat);
|
||||
break;
|
||||
case 'removeBot':
|
||||
e = room.removeBot(u, m.userId);
|
||||
break;
|
||||
case 'act':
|
||||
room.act(u, m.cs, m.a);
|
||||
return;
|
||||
@@ -203,6 +223,23 @@ export class Gateway {
|
||||
}
|
||||
}
|
||||
|
||||
isOnline(userId: string): boolean {
|
||||
return (this.byUser.get(userId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** When the user's last socket closed; null while one is open, 0 if not seen since the server started. */
|
||||
offlineSince(userId: string): number | null {
|
||||
return this.isOnline(userId) ? null : (this.offlineAt.get(userId) ?? 0);
|
||||
}
|
||||
|
||||
/** The account is gone (guest logged out or expired): close its sockets quietly. */
|
||||
closeUser(userId: string): void {
|
||||
for (const ws of [...(this.byUser.get(userId) ?? [])]) {
|
||||
this.send(ws, { t: 'bye', reason: 'closed' });
|
||||
ws.close(4401, 'logged out');
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
|
||||
shutdown(): void {
|
||||
this.closing = true;
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="같이 놀자" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>같이 놀자 보드게임</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#E3B76B"/><g stroke="#7a5a22" stroke-width="2"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40"/></g><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c3aed"/><stop offset=".55" stop-color="#db2777"/><stop offset="1" stop-color="#f97316"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#bg)"/><ellipse cx="32" cy="14.5" rx="11" ry="3.6" fill="none" stroke="#fde047" stroke-width="2.6"/><path d="M20 21h24a10 10 0 0 1 9.6 7.2l3.3 11.6a5.6 5.6 0 0 1-9.5 5.3L43 40H21l-4.4 5.1a5.6 5.6 0 0 1-9.5-5.3l3.3-11.6A10 10 0 0 1 20 21z" fill="#fff" stroke="#1e1b4b" stroke-width="2.2" stroke-linejoin="round"/><path d="M19.6 26.2h2.8v3h3v2.8h-3v3h-2.8v-3h-3v-2.8h3z" fill="#1e1b4b"/><circle cx="43" cy="26.6" r="2.3" fill="#facc15" stroke="#1e1b4b" stroke-width="1"/><circle cx="46.8" cy="30.6" r="2.3" fill="#ef4444" stroke="#1e1b4b" stroke-width="1"/><circle cx="43" cy="34.6" r="2.3" fill="#22c55e" stroke="#1e1b4b" stroke-width="1"/><circle cx="39.2" cy="30.6" r="2.3" fill="#3b82f6" stroke="#1e1b4b" stroke-width="1"/><path d="M53 6.4Q53 11 57.6 11Q53 11 53 15.6Q53 11 48.4 11Q53 11 53 6.4z" fill="#fde047"/><path d="M10.5 11Q10.5 14 13.5 14Q10.5 14 10.5 17Q10.5 14 7.5 14Q10.5 14 10.5 11z" fill="#fff"/><path d="M54 50Q54 53 57 53Q54 53 54 56Q54 53 51 53Q54 53 54 50z" fill="#fff"/><path d="M11 51.8Q11 54 13.2 54Q11 54 11 56.2Q11 54 8.8 54Q11 54 11 51.8z" fill="#fde047"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.4 KiB |
BIN
apps/web/public/icons/apple-touch-icon.png
Normal file
BIN
apps/web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/web/public/icons/icon-192.png
Normal file
BIN
apps/web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
apps/web/public/icons/icon-512.png
Normal file
BIN
apps/web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
apps/web/public/icons/icon-maskable-512.png
Normal file
BIN
apps/web/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
19
apps/web/public/manifest.webmanifest
Normal file
19
apps/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "같이 놀자 보드게임",
|
||||
"short_name": "같이 놀자",
|
||||
"description": "광고 없이 가볍게, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임",
|
||||
"lang": "ko",
|
||||
"dir": "ltr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f7f5f0",
|
||||
"theme_color": "#f7f5f0",
|
||||
"categories": ["games", "entertainment"],
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
95
apps/web/public/offline.html
Normal file
95
apps/web/public/offline.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#F7F5F0" />
|
||||
<!-- Served by the service worker when a page can't load. No scripts (CSP blocks inline JS) and no other files. -->
|
||||
<title>인터넷 연결이 없어요 · 같이 놀자 보드게임</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f5f0;
|
||||
--surface: #fff;
|
||||
--text: #1c1e21;
|
||||
--text-sub: #4a4f57;
|
||||
--primary: #2563eb;
|
||||
--primary-text: #fff;
|
||||
--focus: #f59e0b;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #16181d;
|
||||
--surface: #20232a;
|
||||
--text: #f2f3f5;
|
||||
--text-sub: #b8bcc4;
|
||||
--primary: #5b8def;
|
||||
--primary-text: #0b1020;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 500 17px/1.5 -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
padding: 32px 24px;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
p {
|
||||
color: var(--text-sub);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
a.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 56px;
|
||||
padding: 0 28px;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-text);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.btn:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<div class="icon" aria-hidden="true">📡</div>
|
||||
<h1>인터넷 연결이 없어요</h1>
|
||||
<p>와이파이나 데이터가 켜져 있는지 확인한 뒤 [다시 시도]를 눌러 주세요.</p>
|
||||
<a class="btn" href="">다시 시도</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
157
apps/web/src/components/GameHelp.tsx
Normal file
157
apps/web/src/components/GameHelp.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 초보자 도움 (docs/07 §5): "30초 규칙 요약" cards (first game of a kind on this device) and the
|
||||
* in-game [?] sheet ("지금 할 수 있는 것" + full rules + summary again). Content lives in each game's chunk.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { PublicUser, RoomView } from '@bg/shared/lite';
|
||||
import { Sheet } from './ui';
|
||||
import { useRoom } from '../net/socket';
|
||||
import type { GameUI, QuickCard } from '../games/types';
|
||||
|
||||
const seenKey = (gameId: string) => `bg:qs:${gameId}`;
|
||||
|
||||
export function quickStartSeen(gameId: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(seenKey(gameId)) === '1';
|
||||
} catch {
|
||||
return true; // storage blocked: don't nag every game
|
||||
}
|
||||
}
|
||||
|
||||
function markQuickStartSeen(gameId: string) {
|
||||
try {
|
||||
localStorage.setItem(seenKey(gameId), '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** The 3 summary cards with [다음] / [건너뛰기] / [시작하기]. */
|
||||
export function QuickStartCards({ title, cards, onClose, finish = '시작하기' }: { title: string; cards: QuickCard[]; onClose: () => void; finish?: string }) {
|
||||
const [i, setI] = useState(0);
|
||||
const card = cards[i]!;
|
||||
const last = i === cards.length - 1;
|
||||
return (
|
||||
<Sheet onClose={onClose} label={`${title} 30초 규칙 요약`}>
|
||||
<p className="muted small" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{title} 30초 규칙 요약 · {i + 1}/{cards.length}
|
||||
</p>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={card.alt}
|
||||
style={{ minHeight: 170, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, flexWrap: 'wrap', padding: 12, borderRadius: 16, background: 'var(--bg)' }}
|
||||
>
|
||||
{card.pic}
|
||||
</div>
|
||||
<h2 style={{ margin: 0, textAlign: 'center' }} aria-live="polite">
|
||||
{card.title}
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: '1.15rem', lineHeight: 1.6, textAlign: 'center' }}>{card.text}</p>
|
||||
<div className="row" style={{ justifyContent: 'center', gap: 6 }} aria-hidden>
|
||||
{cards.map((_, k) => (
|
||||
<span key={k} className="dot" style={{ width: 10, height: 10, background: k === i ? 'var(--primary)' : 'var(--border)' }} />
|
||||
))}
|
||||
</div>
|
||||
{last ? (
|
||||
<button className="btn btn-primary btn-block" style={{ minHeight: 60 }} onClick={onClose} autoFocus>
|
||||
{finish}
|
||||
</button>
|
||||
) : (
|
||||
<div className="row" style={{ flexWrap: 'nowrap' }}>
|
||||
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onClose}>
|
||||
건너뛰기
|
||||
</button>
|
||||
<button className="btn btn-primary" style={{ flex: 2, minHeight: 60 }} onClick={() => setI(i + 1)}>
|
||||
다음 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** "30초 요약 보기" button for places outside a game (rules page). */
|
||||
export function QuickStartButton({ title, cards }: { title: string; cards: QuickCard[] | undefined }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
if (!cards?.length) return null;
|
||||
return (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setOpen(true)}>
|
||||
🃏 30초 요약 보기
|
||||
</button>
|
||||
{open && <QuickStartCards title={title} cards={cards} finish="닫기" onClose={close} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* [?] button next to the turn status while a game is on screen. Also opens the summary cards by itself
|
||||
* the first time this device sees a running game of this kind.
|
||||
*/
|
||||
export function GameHelp({ room, ui, title, me, players }: { room: RoomView; ui: GameUI; title: string; me: string; players: Record<string, PublicUser> }) {
|
||||
const view = useRoom((s) => s.game?.view);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rules, setRules] = useState(false);
|
||||
const [cards, setCards] = useState(false);
|
||||
const playing = room.status === 'playing';
|
||||
const gameId = room.gameId;
|
||||
|
||||
useEffect(() => {
|
||||
if (playing && ui.quickStart?.length && !quickStartSeen(gameId)) setCards(true);
|
||||
}, [playing, gameId, ui]);
|
||||
|
||||
// Stable callbacks: Sheet re-runs its focus effect when onClose changes (every game update otherwise).
|
||||
const closeCards = useCallback(() => {
|
||||
markQuickStartSeen(gameId);
|
||||
setCards(false);
|
||||
}, [gameId]);
|
||||
const closeHelp = useCallback(() => setOpen(false), []);
|
||||
const seated = room.seats.some((s) => s?.user.id === me);
|
||||
const now =
|
||||
room.status === 'paused'
|
||||
? '오류로 게임이 멈췄어요. 방장이 [게임 끝내기] 버튼으로 끝낼 수 있어요.'
|
||||
: view === undefined
|
||||
? ''
|
||||
: (ui.helpNow ?? ui.statusText)(view, me, players);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="btn btn-secondary" style={{ flexShrink: 0, padding: '0 12px', flexDirection: 'column', gap: 0, lineHeight: 1.1 }} onClick={() => (setRules(false), setOpen(true))} aria-label="도움말: 지금 할 수 있는 것" aria-haspopup="dialog">
|
||||
<span aria-hidden style={{ fontSize: '1.3rem' }}>?</span>
|
||||
<span className="small">도움</span>
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<Sheet onClose={closeHelp} label="지금 할 수 있는 것">
|
||||
<h2 style={{ margin: 0 }}>지금 할 수 있는 것</h2>
|
||||
{!seated && <p className="muted" style={{ margin: 0 }}>지금은 구경하고 있어요. 게임에는 손댈 수 없어요.</p>}
|
||||
<p className="help-now" style={{ margin: 0, fontSize: '1.2rem', lineHeight: 1.6, whiteSpace: 'pre-line', fontWeight: 600 }}>
|
||||
{now}
|
||||
</p>
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-block" aria-expanded={rules} onClick={() => setRules(!rules)}>
|
||||
📖 {rules ? '규칙 접기' : '전체 규칙 보기'}
|
||||
</button>
|
||||
{rules && (
|
||||
<section className="card stack" aria-label={`${title} 규칙`}>
|
||||
<ui.Rules options={room.options} />
|
||||
</section>
|
||||
)}
|
||||
{!!ui.quickStart?.length && (
|
||||
<button className="btn btn-secondary btn-block" onClick={() => (setOpen(false), setCards(true))}>
|
||||
🃏 30초 요약 다시 보기
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary btn-block" onClick={() => setOpen(false)}>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</Sheet>,
|
||||
document.body,
|
||||
)}
|
||||
{cards && ui.quickStart && createPortal(<QuickStartCards title={title} cards={ui.quickStart} onClose={closeCards} />, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/components/InstallCard.tsx
Normal file
28
apps/web/src/components/InstallCard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/** "홈 화면에 추가" offer (docs/07 §11). Hidden when already installed, dismissed, or not installable. */
|
||||
import { dismissInstall, promptInstall, useInstallMode } from '../lib/pwa';
|
||||
|
||||
export function InstallCard() {
|
||||
const mode = useInstallMode();
|
||||
if (!mode) return null;
|
||||
return (
|
||||
<section className="card row" aria-label="홈 화면에 추가" style={{ gap: 12 }}>
|
||||
<span style={{ fontSize: '1.8rem' }} aria-hidden>
|
||||
📲
|
||||
</span>
|
||||
<div style={{ flex: '1 1 220px' }}>
|
||||
<strong>홈 화면에 추가해 보세요</strong>
|
||||
<div className="muted small">
|
||||
{mode === 'ios' ? 'Safari 공유 버튼(네모에 화살표)을 누르고 [홈 화면에 추가]를 골라 주세요.' : '앱처럼 아이콘을 눌러 바로 열 수 있어요.'}
|
||||
</div>
|
||||
</div>
|
||||
{mode === 'prompt' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void promptInstall()}>
|
||||
추가하기
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" onClick={dismissInstall}>
|
||||
닫기
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,18 @@ import { nicknameError, normalizeNickname, suggestNickname } from '@bg/shared/li
|
||||
import { api, discordLoginUrl } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
export function NicknameForm({ next, submitLabel = '이 이름으로 시작하기', onDone }: { next: string; submitLabel?: string; onDone?: () => void }) {
|
||||
export function NicknameForm({
|
||||
next,
|
||||
submitLabel = '이 이름으로 시작하기',
|
||||
onDone,
|
||||
showDiscord = true,
|
||||
}: {
|
||||
next: string;
|
||||
submitLabel?: string;
|
||||
onDone?: () => void;
|
||||
/** The login page shows its own big Discord button, so it hides this one. */
|
||||
showDiscord?: boolean;
|
||||
}) {
|
||||
const [nick, setNick] = useState(() => suggestNickname());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -49,12 +60,24 @@ export function NicknameForm({ next, submitLabel = '이 이름으로 시작하
|
||||
<button className="btn btn-primary btn-block" disabled={busy}>
|
||||
{busy ? '잠시만요…' : submitLabel}
|
||||
</button>
|
||||
{discord && (
|
||||
{discord && showDiscord && (
|
||||
<a className="btn btn-secondary btn-block" href={discordLoginUrl(next)}>
|
||||
디스코드로 로그인
|
||||
</a>
|
||||
)}
|
||||
<GuestWarning />
|
||||
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,12 @@
|
||||
/** PC top menu with an always-available room-code box (docs/07-ui-ux.md §3.2). */
|
||||
import { useState } from 'react';
|
||||
/** PC top menu (docs/07-ui-ux.md §3.2): 로고·메뉴, 오른쪽에 테마 버튼과 로그인/계정 메뉴. */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router';
|
||||
import { normalizeRoomCode } from '@bg/shared/lite';
|
||||
import { api } from '../net/api';
|
||||
import { useSession } from '../store/session';
|
||||
import { toast } from '../store/toast';
|
||||
import { Avatar } from './ui';
|
||||
import { Avatar, ConfirmSheet } from './ui';
|
||||
|
||||
export function SiteHeader() {
|
||||
const { me, siteName, role } = useSession();
|
||||
const [code, setCode] = useState('');
|
||||
const nav = useNavigate();
|
||||
const join = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const c = normalizeRoomCode(code);
|
||||
if (!c) return toast('방 코드 6자리를 넣어 주세요.', 'error');
|
||||
try {
|
||||
await api.room(c);
|
||||
setCode('');
|
||||
nav(`/r/${c}`);
|
||||
} catch (ex) {
|
||||
toast((ex as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="site-header-inner">
|
||||
@@ -37,15 +21,100 @@ export function SiteHeader() {
|
||||
<NavLink to="/games">게임 목록</NavLink>
|
||||
{role !== 'user' && <NavLink to="/admin">관리자</NavLink>}
|
||||
</nav>
|
||||
<form className="header-join" onSubmit={join} aria-label="코드로 참여">
|
||||
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" />
|
||||
<button className="btn btn-primary btn-sm">참여</button>
|
||||
</form>
|
||||
<Link to="/me" className="header-user" aria-label="내 정보">
|
||||
{me ? <Avatar user={me} size={36} /> : <span style={{ fontSize: 24 }}>👤</span>}
|
||||
<span className="wide-only">{me ? me.nickname : '시작하기'}</span>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
{me ? (
|
||||
<AccountMenu />
|
||||
) : (
|
||||
<Link to="/login" className="header-user header-login">
|
||||
로그인
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
const darkQuery = () => matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
/** 누를 때마다 밝게 ↔ 어둡게. '자동'이면 지금 보이는 색의 반대로 바꿔요. 설정 페이지의 '화면 색'과 같은 값이에요. */
|
||||
function ThemeToggle() {
|
||||
const theme = useSession((s) => s.settings.theme);
|
||||
const updateSettings = useSession((s) => s.updateSettings);
|
||||
const [systemDark, setSystemDark] = useState(() => darkQuery().matches);
|
||||
useEffect(() => {
|
||||
const q = darkQuery();
|
||||
const on = () => setSystemDark(q.matches);
|
||||
q.addEventListener('change', on);
|
||||
return () => q.removeEventListener('change', on);
|
||||
}, []);
|
||||
const dark = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
const label = dark ? '밝은 화면으로 바꾸기' : '어두운 화면으로 바꾸기';
|
||||
return (
|
||||
<button type="button" className="theme-toggle" aria-label={label} title={label} onClick={() => updateSettings({ theme: dark ? 'light' : 'dark' })}>
|
||||
<span aria-hidden>{dark ? '☀️' : '🌙'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] ([관리자 메뉴]) [로그아웃] 메뉴. */
|
||||
function AccountMenu() {
|
||||
const me = useSession((s) => s.me)!;
|
||||
const role = useSession((s) => s.role);
|
||||
const nav = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
|
||||
document.addEventListener('pointerdown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="account-menu" ref={ref}>
|
||||
<button type="button" className="header-user" aria-haspopup="menu" aria-expanded={open} aria-label={`내 계정: ${me.nickname}`} onClick={() => setOpen((o) => !o)}>
|
||||
<Avatar user={me} size={36} />
|
||||
<span className="header-user-name">{me.nickname}</span>
|
||||
<span aria-hidden className="small muted">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="account-menu-list" role="menu" aria-label="내 계정">
|
||||
<button type="button" role="menuitem" onClick={() => (setOpen(false), nav('/me'))}>
|
||||
⚙️ 설정
|
||||
</button>
|
||||
{role !== 'user' && (
|
||||
<button type="button" role="menuitem" onClick={() => (setOpen(false), nav('/admin'))}>
|
||||
🛠 관리자 메뉴
|
||||
</button>
|
||||
)}
|
||||
<button type="button" role="menuitem" onClick={() => (setOpen(false), setConfirm(true))}>
|
||||
🚪 로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{confirm && (
|
||||
<ConfirmSheet
|
||||
title="로그아웃할까요?"
|
||||
body={me.kind === 'guest' ? '게스트 계정은 로그아웃하면 이름과 기록이 모두 지워져요.' : undefined}
|
||||
confirm="로그아웃"
|
||||
danger={me.kind === 'guest'}
|
||||
onConfirm={async () => {
|
||||
await api.logout();
|
||||
nav('/');
|
||||
await useSession.getState().refresh();
|
||||
}}
|
||||
onClose={() => setConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Shared UI primitives (docs/07 §4). */
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { formatRoomCode, isBotId } from '@bg/shared/lite';
|
||||
import { useToasts, toast } from '../store/toast';
|
||||
import { useRoom } from '../net/socket';
|
||||
import { haptic } from '../lib/feedback';
|
||||
import { useOnline } from '../lib/pwa';
|
||||
|
||||
const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626'];
|
||||
|
||||
@@ -14,12 +15,12 @@ export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nic
|
||||
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||||
return (
|
||||
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
|
||||
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]}
|
||||
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : isBotId(user.id) ? '🤖' : [...user.nickname][0]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label, done = '복사했어요 ✓', className = 'btn btn-secondary' }: { text: string; label: ReactNode; done?: string; className?: string }) {
|
||||
export function CopyButton({ text, label, done = '복사했어요 ✓', className = 'btn btn-secondary' }: { text: string; label: ReactNode; done?: ReactNode; className?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const onClick = async () => {
|
||||
try {
|
||||
@@ -138,7 +139,8 @@ export function Toasts() {
|
||||
export function ConnectionBanner() {
|
||||
const status = useRoom((s) => s.status);
|
||||
const code = useRoom((s) => s.code);
|
||||
if (!code) return null;
|
||||
const online = useOnline();
|
||||
if (!code) return online ? null : <div className="conn-banner" role="status">인터넷 연결이 없어요. 연결되면 다시 쓸 수 있어요.</div>;
|
||||
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
|
||||
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
|
||||
return null;
|
||||
|
||||
53
apps/web/src/games/baduk/help.test.ts
Normal file
53
apps/web/src/games/baduk/help.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.baduk!.view(s, who) as any;
|
||||
const opts = { colorAssignment: 'hostBlack', boardSize: 9 };
|
||||
|
||||
test('바둑: 내 차례 / 상대 차례 / 관전 / 덤 안내', () => {
|
||||
const { state } = setupGame('baduk', 2, opts);
|
||||
const a = helpNow(view(state, 'A'), 'A', users);
|
||||
expect(a).toContain('내 차례예요(흑)');
|
||||
expect(a).toContain('[✋ 패스]');
|
||||
expect(a).toContain('덤');
|
||||
expect(helpNow(view(state, 'B'), 'B', users)).toContain('상대(호랑이님) 차례예요');
|
||||
expect(helpNow(view(state, null), null, users)).toContain('호랑이님(흑) 차례예요');
|
||||
const s2 = applyAction('baduk', state, 'A', { type: 'place', x: 4, y: 4 });
|
||||
const b = helpNow(view(s2, 'B'), 'B', users);
|
||||
expect(b).toContain('내 차례예요(백)');
|
||||
expect(b).toContain('따낸 돌은 흑 0개, 백 0개');
|
||||
});
|
||||
|
||||
test('바둑: 접바둑이면 백이 먼저 둬요', () => {
|
||||
const { state } = setupGame('baduk', 2, { ...opts, handicap: 2 });
|
||||
expect(helpNow(view(state, 'B'), 'B', users)).toContain('접바둑이라 흑 돌 2개');
|
||||
});
|
||||
|
||||
test('바둑: 패스 → 사석 확인 → 동의', () => {
|
||||
const { state } = setupGame('baduk', 2, opts);
|
||||
let s = applyAction('baduk', state, 'A', { type: 'place', x: 4, y: 4 });
|
||||
s = applyAction('baduk', s, 'B', { type: 'pass' });
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('상대가 패스했어요');
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('내가 패스했어요');
|
||||
s = applyAction('baduk', s, 'A', { type: 'pass' });
|
||||
const a = helpNow(view(s, 'A'), 'A', users);
|
||||
expect(a).toContain('[✕ 사석 표시]');
|
||||
expect(a).toContain('[✔ 이 표시대로 동의]');
|
||||
expect(a).toContain('흑 ');
|
||||
s = applyAction('baduk', s, 'A', { type: 'agreeScore' });
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('나는 동의했어요');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('고양이님이 확인 중');
|
||||
s = applyAction('baduk', s, 'B', { type: 'agreeScore' });
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('대국이 끝났어요');
|
||||
});
|
||||
|
||||
test('바둑: 무르기 요청을 받으면 수락/거절 안내', () => {
|
||||
const { state } = setupGame('baduk', 2, opts);
|
||||
let s = applyAction('baduk', state, 'A', { type: 'place', x: 4, y: 4 });
|
||||
s = applyAction('baduk', s, 'A', { type: 'requestUndo' });
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('[수락]');
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('[요청 취소]');
|
||||
});
|
||||
77
apps/web/src/games/baduk/help.ts
Normal file
77
apps/web/src/games/baduk/help.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** 바둑 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { BadukView } from '@bg/games/baduk';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const COLOR_KO = { black: '흑', white: '백' } as const;
|
||||
|
||||
/** "2집 반" / "반집" (territory) or "1.5점" (area), like the result summary. */
|
||||
function margin(m: number, method: 'territory' | 'area'): string {
|
||||
if (method === 'area') return `${m}점`;
|
||||
const whole = Math.floor(m);
|
||||
const half = m - whole >= 0.5;
|
||||
if (whole === 0) return half ? '반집' : '0집';
|
||||
return `${whole}집${half ? ' 반' : ''}`;
|
||||
}
|
||||
|
||||
export function helpNow(v: BadukView, _me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') {
|
||||
const how = v.result?.score ? '\n판의 작은 네모는 누구의 집(영역)인지, ✕는 사석(죽은 돌)으로 센 돌이에요.' : '';
|
||||
return `대국이 끝났어요. ${v.result?.summaryKo ?? ''}`.trim() + how + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
}
|
||||
const nick = (c: 'black' | 'white') => `${players[v.players[c]]?.nickname ?? COLOR_KO[c]}님`;
|
||||
const start =
|
||||
v.moveCount === 0
|
||||
? [
|
||||
v.rules.handicap ? `접바둑이라 흑 돌 ${v.rules.handicap}개가 미리 놓여 있고 백이 먼저 둬요.` : '',
|
||||
v.rules.komi > 0 ? `백은 계가할 때 덤 ${margin(v.rules.komi, v.rules.scoring)}을 더 받아요(덤: 백에게 더 주는 점수).` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: '';
|
||||
const caps = start || `따낸 돌은 흑 ${v.captures.black}개, 백 ${v.captures.white}개예요.`;
|
||||
|
||||
if (v.phase === 'scoring' && v.scoring) {
|
||||
const p = v.scoring.preview;
|
||||
const unit = p.method === 'territory' ? '집 계산' : '영역 계산';
|
||||
const lead = p.diff === 0 ? '동점이에요' : `${p.diff > 0 ? '흑' : '백'}이 ${margin(Math.abs(p.diff), p.method)} 앞서요`;
|
||||
const now = `지금 표시대로 세면(${unit}) 흑 ${p.black.total} : 백 ${p.white.total}, ${lead}.`;
|
||||
const agreed = v.scoring.agreed;
|
||||
if (!v.myColor) {
|
||||
const who = (['black', 'white'] as const).filter((c) => !agreed[c]).map(nick);
|
||||
return `두 사람이 연달아 패스해서 대국이 멈췄어요. 지금은 사석(죽은 돌) 확인 중이에요${who.length ? `(${who.join('·')}이 확인 중)` : ''}.\n${now}`;
|
||||
}
|
||||
if (agreed[v.myColor]) return `나는 동의했어요. 상대가 [✔ 이 표시대로 동의]를 누르면 계가가 끝나요.\n상대가 표시를 바꾸면 내 동의가 풀리니 다시 확인해 주세요.\n${now}`;
|
||||
return [
|
||||
'두 사람이 연달아 패스해서 대국이 멈췄어요. 이제 사석(더는 살 수 없는 죽은 돌)을 정해요.',
|
||||
'[✕ 사석 표시]를 고른 채 죽은 돌 묶음을 누르면 ✕가 붙고, 다시 누르면 풀려요. 처음 표시는 컴퓨터의 추정이라 틀릴 수 있어요.',
|
||||
'빅(서로 잡을 수 없이 함께 사는 모양)이 있으면 [◆ 빅 표시]를 고르고 그 안의 빈 곳을 눌러요.',
|
||||
`표시가 맞으면 [✔ 이 표시대로 동의]를 눌러요. 두 사람 모두 동의하면 끝나요. 의견이 다르면 [이의 – 대국 재개]로 돌아가 직접 둬서 가려요. 제한 시간이 지나면 지금 표시대로 동의한 것으로 봐요.`,
|
||||
now,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (!v.myColor) {
|
||||
const passed = v.consecutivePasses > 0 ? ` ${nick(v.turn === 'black' ? 'white' : 'black')}이 방금 패스했어요.` : '';
|
||||
return `${nick(v.turn)}(${COLOR_KO[v.turn]}) 차례예요.${passed} 자기 돌로 둘러싼 빈 곳(집)이 더 많은 쪽이 이겨요.\n${caps}`;
|
||||
}
|
||||
const mine = COLOR_KO[v.myColor];
|
||||
if (v.undo.pending && v.undo.pending.by !== v.myColor) return `상대가 ${v.undo.pending.plies}수 무르기를 부탁했어요. [수락]을 누르면 돌과 따낸 돌이 그 전으로 돌아가고, [거절]을 누르면 그대로 계속해요.`;
|
||||
if (v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.';
|
||||
if (v.turn !== v.myColor) {
|
||||
const lines = [`상대(${nick(v.turn)}) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${mine}이에요.`];
|
||||
if (v.consecutivePasses > 0) lines.push('내가 패스했어요. 상대도 패스하면 대국이 멈추고 사석 확인으로 넘어가요.');
|
||||
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
|
||||
lines.push(caps);
|
||||
return lines.join('\n');
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (v.consecutivePasses > 0) {
|
||||
lines.push(`내 차례예요(${mine}). 상대가 패스했어요. 더 둘 곳이 없으면 나도 [✋ 패스]를 눌러요. 그러면 대국이 멈추고 사석 확인으로 넘어가요. 더 둘 곳이 있으면 계속 둬도 돼요.`);
|
||||
if (v.rules.scoring === 'area') lines.push('이 방은 영역 계산이라, 끝내기 전에 양쪽 사이의 빈 곳(공배)을 메워 두는 게 좋아요.');
|
||||
} else lines.push(`내 차례예요(${mine}). 선이 만나는 빈 점을 누르면 돌이 놓여요. 둘 곳이 없으면 [✋ 패스]를 눌러요.`);
|
||||
if (v.atariWarnings.length) lines.push('빨갛게 깜빡이는 내 돌은 단수예요(숨 쉴 곳이 하나만 남음). 돌을 잇거나 달아나지 않으면 잡혀요.');
|
||||
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
|
||||
if (v.illegalPoints.some((p) => p.reason !== 'suicide')) lines.push('빨간 네모 자리는 패(바로 되따내기 금지)나 같은 모양 반복 때문에 지금은 둘 수 없어요. 다른 곳에 한 수 둔 뒤에 다시 봐요.');
|
||||
lines.push(caps);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
@@ -782,8 +784,7 @@ function Options({ value: o, onChange }: OptionsProps<BadukOptions>) {
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>도움 표시</strong>
|
||||
<Seg value={o.atariWarning ? 'on' : 'off'} options={[['on', '단수 경고 켜기'], ['off', '단수 경고 끄기']]} onChange={(k) => set({ atariWarning: k === 'on' })} />
|
||||
<strong>무승부 제안</strong>
|
||||
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
@@ -854,6 +855,8 @@ const ui: GameUI<BadukView, BadukOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
55
apps/web/src/games/baduk/quick.tsx
Normal file
55
apps/web/src/games/baduk/quick.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/** 바둑 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
type St = [number, number, 'b' | 'w'];
|
||||
|
||||
function MiniBoard({ stones, terr = [], marks = [] }: { stones: St[]; terr?: [number, number, 'b' | 'w'][]; marks?: [number, number][] }) {
|
||||
const n = 7;
|
||||
return (
|
||||
<svg viewBox="-0.7 -0.7 7.4 7.4" width={170} height={170} aria-hidden>
|
||||
<rect x={-0.7} y={-0.7} width={7.4} height={7.4} rx={0.4} fill="#e9c27a" />
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="#7a5a2a" strokeWidth={0.05}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{terr.map(([x, y, c]) => (
|
||||
<rect key={`t${x}-${y}`} x={x - 0.17} y={y - 0.17} width={0.34} height={0.34} fill={c === 'b' ? '#111' : '#fafafa'} stroke={c === 'b' ? '#000' : '#555'} strokeWidth={0.03} />
|
||||
))}
|
||||
{stones.map(([x, y, c]) => (
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r={0.44} fill={c === 'b' ? '#151515' : '#fbfbf8'} stroke={c === 'b' ? '#000' : '#666'} strokeWidth={0.06} />
|
||||
))}
|
||||
{marks.map(([x, y]) => (
|
||||
<circle key={`m${x}-${y}`} cx={x} cy={y} r={0.2} fill="none" stroke="#dc2626" strokeWidth={0.08} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Left part black, right part white, each surrounding empty points (small squares = territory). */
|
||||
const wallB: St[] = [0, 1, 2, 3, 4, 5, 6].map((y) => [2, y, 'b'] as St);
|
||||
const wallW: St[] = [0, 1, 2, 3, 4, 5, 6].map((y) => [4, y, 'w'] as St);
|
||||
const terrB: [number, number, 'b'][] = [0, 1].flatMap((x) => [0, 1, 2, 3, 4, 5, 6].map((y) => [x, y, 'b'] as [number, number, 'b']));
|
||||
const terrW: [number, number, 'w'][] = [5, 6].flatMap((x) => [0, 1, 2, 3, 4, 5, 6].map((y) => [x, y, 'w'] as [number, number, 'w']));
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: <MiniBoard stones={[[2, 2, 'b'], [4, 2, 'w'], [2, 4, 'b'], [4, 4, 'w'], [3, 3, 'b']]} />,
|
||||
alt: '바둑판의 선이 만나는 점 위에 검은 돌과 흰 돌이 놓여 있어요',
|
||||
title: '번갈아 한 수씩 둬요',
|
||||
text: '보통 흑이 먼저 시작해요. 내 차례에 선이 만나는 빈 점을 누르면 돌이 놓여요. 놓은 돌은 옮기지 않아요.',
|
||||
},
|
||||
{
|
||||
pic: <MiniBoard stones={[[3, 2, 'b'], [2, 3, 'b'], [4, 3, 'b'], [3, 4, 'b'], [3, 3, 'w']]} marks={[[3, 3]]} />,
|
||||
alt: '흰 돌 하나를 검은 돌 네 개가 위아래 양옆에서 둘러싸고 있어요',
|
||||
title: '둘러싸면 따내요',
|
||||
text: '상대 돌의 위·아래·양옆 빈 곳(활로)을 모두 막으면 그 돌을 판에서 들어내요. 이걸 따낸다고 해요.',
|
||||
},
|
||||
{
|
||||
pic: <MiniBoard stones={[...wallB, ...wallW]} terr={[...terrB, ...terrW]} />,
|
||||
alt: '검은 돌과 흰 돌이 각자 판의 한쪽을 울타리처럼 둘러싸고 빈 곳에 작은 네모가 있어요',
|
||||
title: '집이 많으면 이겨요',
|
||||
text: '둘 다 연달아 [✋ 패스]하면 끝나요. 내 돌로 둘러싼 빈 곳(집)이 더 많은 쪽이 이겨요. 처음이면 9줄 판을 추천해요.',
|
||||
},
|
||||
];
|
||||
79
apps/web/src/games/bang/help.test.ts
Normal file
79
apps/web/src/games/bang/help.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { CARDS } from '@bg/games/bang';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B', 'C', 'D']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.bang!.view(s, who) as any;
|
||||
const nick = (id: string) => `${users[id]!.nickname}님`;
|
||||
const cardOf = (kind: string) => CARDS.find((c) => c.kind === kind)!.id;
|
||||
|
||||
/** Started game (sheriff to play); `who` holds exactly `hand`. */
|
||||
function game(handFor?: { who: (s: any) => string; hand: string[] }) {
|
||||
const { state } = setupGame('bang', 4, { characterDraft: 'random' });
|
||||
const s = structuredClone(state);
|
||||
const sheriff: string = s.players.find((p: any) => p.role === 'sheriff').id;
|
||||
const other: string = s.players.find((p: any) => p.id !== sheriff).id;
|
||||
// Plain characters so no draw-phase ability or unlimited 뱅! changes the texts.
|
||||
for (const p of s.players) p.character = 'paul';
|
||||
if (handFor) {
|
||||
const who = handFor.who({ sheriff, other });
|
||||
for (const p of s.players) p.hand = p.hand.filter((id: string) => !handFor.hand.includes(id));
|
||||
s.deck = s.deck.filter((id: string) => !handFor.hand.includes(id));
|
||||
s.players.find((p: any) => p.id === who).hand = [...handFor.hand];
|
||||
}
|
||||
return { s, sheriff, other };
|
||||
}
|
||||
|
||||
test('뱅!: 내 차례(카드 내기) / 남의 차례 / 관전은 보안관만 공개', () => {
|
||||
const { s, sheriff, other } = game();
|
||||
expect(s.turn.phase).toBe('play');
|
||||
const mine = helpNow(view(s, sheriff), sheriff, users);
|
||||
expect(mine).toContain('[턴 마치기]');
|
||||
expect(mine).toContain('사거리');
|
||||
expect(mine).toContain('한 턴에 1번');
|
||||
expect(helpNow(view(s, other), other, users)).toContain(`${nick(sheriff)} 차례예요`);
|
||||
const spec = helpNow(view(s, null), null, users);
|
||||
expect(spec).toContain(`⭐ 보안관(${nick(sheriff)})만 역할이 공개돼요`);
|
||||
for (const role of ['부관', '무법자', '배신자']) expect(spec).not.toContain(role);
|
||||
// My own hidden role is not spelled out either.
|
||||
const otherText = helpNow(view(s, other), other, users);
|
||||
for (const role of ['부관', '무법자', '배신자']) expect(otherText).not.toContain(role);
|
||||
});
|
||||
|
||||
test('뱅!: 뱅!을 받으면 빗나감!으로 막거나 맞기', () => {
|
||||
const missed = cardOf('missed');
|
||||
const { s, sheriff, other } = game({ who: (x) => x.other, hand: [missed] });
|
||||
s.reactions = [{ kind: 'bang', target: other, source: sheriff, via: 'bang', needed: 1, barrelTried: false, jourTried: false, deadline: 1_700_000_015_000 }];
|
||||
const t = helpNow(view(s, other), other, users);
|
||||
expect(t).toContain(`${nick(sheriff)}이 쏜 뱅!이 날아왔어요`);
|
||||
expect(t).toContain('[빗나감! 내기]');
|
||||
expect(t).toContain('[맞기 (생명 −1)]');
|
||||
expect(helpNow(view(s, null), null, users)).toContain(`${nick(other)}이 ${nick(sheriff)}의 뱅!을 빗나감!으로 막을지`);
|
||||
expect(helpNow(view(s, sheriff), sheriff, users)).toContain('다른 사람의 반응을 기다리는 중');
|
||||
// Answering ends the reaction window.
|
||||
const s2 = applyAction('bang', s, other, { type: 'respond', cardIds: [missed] });
|
||||
expect(helpNow(view(s2, sheriff), sheriff, users)).toContain('[턴 마치기]');
|
||||
});
|
||||
|
||||
test('뱅!: 쓰러지기 직전 / 버리기 / 캐릭터 고르기', () => {
|
||||
const beer = cardOf('beer');
|
||||
const { s, sheriff, other } = game({ who: (x) => x.other, hand: [beer] });
|
||||
const o = s.players.find((p: any) => p.id === other);
|
||||
o.life = 0;
|
||||
s.reactions = [{ kind: 'dying', player: other, killer: sheriff, lost: 1, source: sheriff, deadline: 1_700_000_015_000 }];
|
||||
expect(helpNow(view(s, other), other, users)).toContain('[맥주 1장 마시기]');
|
||||
|
||||
const d = game().s;
|
||||
const me = d.players.find((p: any) => p.id === d.turn.player);
|
||||
me.life = 1;
|
||||
d.turn.phase = 'discard';
|
||||
const need = me.hand.length - 1;
|
||||
expect(helpNow(view(d, me.id), me.id, users)).toContain(`[${need}장 버리기]`);
|
||||
|
||||
const { state: c } = setupGame('bang', 4, { characterDraft: 'choose2' });
|
||||
expect(helpNow(view(c, 'A'), 'A', users)).toContain('캐릭터 2명 중 1명');
|
||||
const c2 = applyAction('bang', c, 'A', { type: 'pickCharacter', character: c.players.find((p: any) => p.id === 'A').characterOffer[0] });
|
||||
expect(helpNow(view(c2, 'A'), 'A', users)).toContain('모두 캐릭터를 고르는 중');
|
||||
});
|
||||
134
apps/web/src/games/bang/help.ts
Normal file
134
apps/web/src/games/bang/help.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 뱅! [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view.
|
||||
* Roles stay secret: only the sheriff (public to everyone) is ever named; my own role is not spelled
|
||||
* out either, because the screen only shows it on the hold-to-see card.
|
||||
*/
|
||||
import type { BangView, ReactionView } from '@bg/games/bang';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
/** The two card names the reaction buttons use (display names otherwise live in index.tsx). */
|
||||
const NAME = { bang: '뱅!', missed: '빗나감!' } as const;
|
||||
|
||||
export function helpNow(v: BangView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const n = (id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`;
|
||||
if (v.result || v.winner) {
|
||||
return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n이제 모든 사람의 역할이 공개돼요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
}
|
||||
const my = v.my;
|
||||
const meP = my ? v.players.find((p) => p.id === me) : undefined;
|
||||
const sheriff = v.players.find((p) => p.role === 'sheriff');
|
||||
const tip = `⭐ 보안관${sheriff ? `(${n(sheriff.id)})` : ''}만 역할이 공개돼요. 다른 사람의 역할은 쓰러질 때 공개돼요.`;
|
||||
|
||||
// Who the game is waiting for, in neutral words (spectators, dead players, everyone waiting).
|
||||
const waitingText = (rs: ReactionView[]): string => {
|
||||
const groups = new Map<string, { r: ReactionView; who: string[] }>();
|
||||
for (const r of rs) {
|
||||
const key = `${r.kind}-${r.source ?? ''}-${r.via ?? ''}`;
|
||||
const g = groups.get(key) ?? { r, who: [] };
|
||||
if (!g.who.includes(r.who)) g.who.push(r.who);
|
||||
groups.set(key, g);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map(({ r, who }) => {
|
||||
const w = who.map(n).join(', ');
|
||||
switch (r.kind) {
|
||||
case 'bang':
|
||||
return `${w}이 ${n(r.source)}의 ${r.via === 'gatling' ? '개틀링' : '뱅!'}을 빗나감!으로 막을지 고르는 중이에요.`;
|
||||
case 'indians':
|
||||
return `${w}이 인디언!에 뱅!을 버릴지 고르는 중이에요.`;
|
||||
case 'duel':
|
||||
return `${w}이 결투에서 뱅!을 낼 차례예요.`;
|
||||
case 'store':
|
||||
return `${w}이 잡화점에서 카드를 고르는 중이에요.`;
|
||||
case 'dying':
|
||||
return `${w}이 쓰러지기 직전이에요. 맥주가 있으면 살아날 수 있어요.`;
|
||||
case 'kitKeep':
|
||||
return `${w}이 받을 카드를 고르는 중이에요.`;
|
||||
case 'choose2':
|
||||
return `${w}이 캐릭터를 고르는 중이에요.`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
const situation = (): string => {
|
||||
if (v.phase === 'draft') return '모두 캐릭터를 고르는 중이에요.';
|
||||
if (v.reactions.length) return `${n(v.current)} 차례예요.\n${waitingText(v.reactions)}`;
|
||||
return `${n(v.current)} 차례예요. 카드를 내며 공격하거나 준비하고 있어요.`;
|
||||
};
|
||||
|
||||
if (!my) return `${situation()}\n${tip}`;
|
||||
if (!meP?.alive) return `나는 쓰러졌어요. 남은 게임을 지켜봐요.\n${situation()}`;
|
||||
|
||||
const life = `지금 내 생명: ${meP.life}/${meP.maxLife}.`;
|
||||
const sid = my.canSidHeal ? '\n시드 케첨 능력으로 카드 2장을 버리고 생명 1을 회복할 수도 있어요.' : '';
|
||||
const p = my.prompt;
|
||||
if (p) {
|
||||
switch (p.kind) {
|
||||
case 'choose2':
|
||||
return '캐릭터 2명 중 1명을 눌러 골라요. 이름 아래에 능력이 적혀 있어요.\n고른 캐릭터에 따라 생명 수와 특별한 능력이 달라요.';
|
||||
case 'bang': {
|
||||
const what = p.via === 'gatling' ? `${n(p.source)}의 개틀링` : `${n(p.source)}이 쏜 뱅!`;
|
||||
const hand = p.options.map((id) => my.hand.find((c) => c.id === id)!).filter(Boolean);
|
||||
hand.sort((a, b) => Number(a.kind !== 'missed') - Number(b.kind !== 'missed'));
|
||||
const use = hand.slice(0, p.needed);
|
||||
const need = p.needed > 1 ? ` 이 뱅!은 빗나감! ${p.needed}장이 있어야 막을 수 있어요.` : '';
|
||||
const lines = [`${what}이 날아왔어요!${need}`];
|
||||
if (use.length >= p.needed) {
|
||||
const label = use.map((c) => NAME[c.kind as 'bang' | 'missed'] ?? '빗나감!').join(' + ');
|
||||
lines.push(`[${label} 내기]를 누르면 막아요. 막지 않으려면 [맞기 (생명 −1)]를 눌러요.`);
|
||||
if (v.options.autoDefend) lines.push('시간 안에 고르지 않으면 막을 카드를 자동으로 내요.');
|
||||
} else lines.push(`막을 카드가 ${p.options.length > 0 ? '모자라요' : '없어요'}. [맞기 (생명 −1)]를 눌러요.`);
|
||||
if (p.canBarrel) lines.push('[술통 판정하기]를 누르면 카드를 한 장 뒤집어 ♥가 나올 때 피해요.');
|
||||
lines.push(life);
|
||||
return lines.join('\n') + sid;
|
||||
}
|
||||
case 'indians':
|
||||
case 'duel': {
|
||||
const card = p.options.map((id) => my.hand.find((c) => c.id === id)!).filter(Boolean).sort((a, b) => Number(a.kind !== 'bang') - Number(b.kind !== 'bang'))[0];
|
||||
const give = card ? `[${NAME[card.kind as 'bang' | 'missed'] ?? '뱅!'} 내기]` : '';
|
||||
const hit = p.kind === 'duel' ? '[포기하고 맞기 (생명 −1)]' : '[맞기 (생명 −1)]';
|
||||
const head =
|
||||
p.kind === 'indians'
|
||||
? `${n(p.source)}의 인디언!이에요. 뱅! 1장을 버리거나 생명 1을 잃어요.`
|
||||
: `${n(p.opponent)}과 결투 중이에요. 번갈아 뱅!을 내고, 먼저 못 내는 사람이 생명 1을 잃어요.`;
|
||||
const how = card ? `${give}를 누르거나 ${hit}를 눌러요.` : `낼 뱅!이 없어요. ${hit}를 눌러요.`;
|
||||
return `${head}\n${how}\n${life}${sid}`;
|
||||
}
|
||||
case 'dying': {
|
||||
if (!p.beerAllowed) return `생명이 다 떨어졌어요. 두 명만 남아서 맥주로는 살아날 수 없어요. [쓰러지기]를 눌러요.${sid}`;
|
||||
if (p.beers.length >= p.needed) return `생명이 다 떨어졌어요! [맥주 ${p.needed}장 마시기]를 누르면 살아나요. 아니면 [쓰러지기]예요.${sid}`;
|
||||
return `생명이 다 떨어졌어요. 살아나려면 맥주 ${p.needed}장이 필요한데 모자라요. [쓰러지기]를 눌러요.${sid}`;
|
||||
}
|
||||
case 'store':
|
||||
return '잡화점이에요. 펼쳐진 카드 중 1장을 눌러 가져와요. 순서대로 한 사람씩 1장씩 가져가요.';
|
||||
case 'kitKeep':
|
||||
return '카드 3장 중 받을 2장을 눌러 고른 뒤 [이 2장 받기]를 눌러요. 남은 1장은 카드 더미 맨 위로 돌아가요.';
|
||||
case 'draw': {
|
||||
const alt = [p.pedro ? '버린 카드 맨 위 1장' : '', p.jesseTargets.length ? '다른 사람 손패에서 무작위 1장' : ''].filter(Boolean).join(' 또는 ');
|
||||
return `내 차례예요. 이번 턴 카드 2장을 받아요.\n[카드 더미에서 2장 받기]를 누르세요.${alt ? ` 캐릭터 능력으로 첫 1장은 ${alt}으로 받을 수도 있어요.` : ''}`;
|
||||
}
|
||||
case 'discard':
|
||||
return `턴을 마치려면 손패를 생명 수(${meP.life}장)까지 줄여야 해요.\n버릴 카드 ${p.count}장을 눌러 고르고 [${p.count}장 버리기]를 눌러요.${sid}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (v.phase === 'draft') return `${situation()} 다 고르면 보안관부터 시작해요.`;
|
||||
const mine = v.current === me;
|
||||
if (mine && v.reactions.length) return `내 차례예요. 다른 사람의 반응을 기다리는 중이에요.\n${waitingText(v.reactions)}`;
|
||||
if (mine && v.phase === 'play') {
|
||||
const usable = my.plays.filter((o) => o.ok).length;
|
||||
const lines = [
|
||||
usable > 0
|
||||
? '내 차례예요. 초록 테두리 카드를 누른 뒤 아래 버튼으로 내요. 대상이 필요한 카드는 초록 테두리 사람을 먼저 눌러요.'
|
||||
: '내 차례예요. 지금 낼 수 있는 카드가 없어요. [턴 마치기]를 눌러요.',
|
||||
];
|
||||
if (usable > 0) lines.push('흐린 카드를 누르면 지금 쓸 수 없는 이유를 알려 줘요. 다 냈으면 [턴 마치기]를 눌러요.');
|
||||
const bang = my.unlimitedBang ? '뱅!은 이번 턴에 몇 번이든 쓸 수 있어요.' : v.bangsPlayed > 0 ? '이번 턴에는 뱅!을 이미 썼어요(한 턴에 1번).' : '뱅!은 한 턴에 1번만 쓸 수 있어요.';
|
||||
lines.push(`내 사거리(${my.range})보다 거리가 먼 사람은 뱅!으로 쏠 수 없어요. ${bang}`);
|
||||
lines.push(`${life} 턴이 끝날 때 손패는 생명 수까지만 남길 수 있어요.${sid}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (mine) return `내 차례예요. 잠시만 기다려 주세요.\n${life}`;
|
||||
return `${situation()}\n기다리는 동안 공격을 받으면 막을지 묻는 창이 떠요. ${life}`;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
// ---------------------------------------------------------------- names (display names live only here, docs §12)
|
||||
|
||||
@@ -1280,6 +1282,8 @@ const ui: GameUI<BangView, BangOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
92
apps/web/src/games/bang/quick.tsx
Normal file
92
apps/web/src/games/bang/quick.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/** 뱅! 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
/** A small play card: brown (instant) or blue (stays in front of you). */
|
||||
function Mini({ icon, name, blue = false, back = false }: { icon: string; name: string; blue?: boolean; back?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 50,
|
||||
height: 72,
|
||||
borderRadius: 8,
|
||||
border: `3px solid ${back ? '#7c2d12' : blue ? '#2563eb' : '#92400e'}`,
|
||||
background: back ? 'repeating-linear-gradient(45deg, #9a3412 0 6px, #c2410c 6px 12px)' : '#fffbeb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{!back && <span style={{ fontSize: 28, lineHeight: 1 }}>{icon}</span>}
|
||||
{!back && <span style={{ fontSize: 12, fontWeight: 800, color: '#111827' }}>{name}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const row = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: 250, height: 150 } as const;
|
||||
const arrow = { fontSize: 22, fontWeight: 900, color: '#374151' } as const;
|
||||
|
||||
function Badge({ icon, name, color, hidden = false }: { icon: string; name: string; color: string; hidden?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, width: 52 }}>
|
||||
<span style={{ fontSize: 30, lineHeight: 1.1 }}>{hidden ? '🂠' : icon}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, color: '#fff', background: hidden ? '#6b7280' : color, borderRadius: 6, padding: '1px 5px', whiteSpace: 'nowrap' }}>{hidden ? '비밀' : name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<div aria-hidden style={row}>
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
<Mini icon="" name="" back />
|
||||
<Mini icon="" name="" back />
|
||||
</div>
|
||||
<span style={arrow}>➜</span>
|
||||
<Mini icon="💥" name="뱅!" />
|
||||
<Mini icon="🛢️" name="술통" blue />
|
||||
</div>
|
||||
),
|
||||
alt: '카드 2장을 받은 뒤 뱅! 카드를 내고 파란 술통 카드를 앞에 놓아요',
|
||||
title: '받고, 내고, 마쳐요',
|
||||
text: '내 차례엔 카드 2장을 받고, 원하는 만큼 카드를 내요. 파란 카드는 내 앞에 놓여 계속 효과가 있어요. 다 냈으면 [턴 마치기].',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<div aria-hidden style={row}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 34 }}>🤠</span>
|
||||
<Mini icon="💥" name="뱅!" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={arrow}>➜</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, color: '#15803d' }}>거리 1</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 34 }}>🤠</span>
|
||||
<Mini icon="💨" name="빗나감!" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
alt: '한 사람이 뱅!을 쏘고, 거리 1인 상대가 빗나감!으로 막아요',
|
||||
title: '뱅!은 빗나감!으로 막아요',
|
||||
text: '뱅!은 사거리 안의 한 명을 쏴요(한 턴에 1번). 맞은 사람은 빗나감!을 내서 막거나 생명 1을 잃어요. 생명이 다 떨어지면 쓰러져요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<div aria-hidden style={{ ...row, gap: 4 }}>
|
||||
<Badge icon="⭐" name="보안관" color="#b45309" />
|
||||
<Badge icon="🛡️" name="부관" color="#2563eb" hidden />
|
||||
<span style={{ ...arrow, fontSize: 20 }}>⚔️</span>
|
||||
<Badge icon="🎯" name="무법자" color="#b91c1c" hidden />
|
||||
<Badge icon="🃏" name="배신자" color="#7c3aed" hidden />
|
||||
</div>
|
||||
),
|
||||
alt: '보안관만 공개되어 있고 다른 사람들의 역할은 비밀로 가려져 있어요',
|
||||
title: '역할마다 목표가 달라요',
|
||||
text: '⭐보안관만 공개돼요. 보안관·부관은 무법자와 배신자를, 무법자는 보안관을 쓰러뜨리면 이겨요. 배신자는 끝까지 혼자 남아야 해요.',
|
||||
},
|
||||
];
|
||||
59
apps/web/src/games/catchmind/help.test.ts
Normal file
59
apps/web/src/games/catchmind/help.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B', 'C']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.catchmind!.view(s, who) as any;
|
||||
|
||||
test('그림 맞히기: 단어 고르기 — 출제자 / 맞히는 사람 / 관전', () => {
|
||||
const { state } = setupGame('catchmind', 3);
|
||||
const d: string = state.turn.drawer;
|
||||
const g = ['A', 'B', 'C'].find((p) => p !== d)!;
|
||||
const dn = users[d]!.nickname;
|
||||
expect(helpNow(view(state, d), d, users)).toContain('단어 하나를 눌러 골라요');
|
||||
const gt = helpNow(view(state, g), g, users);
|
||||
expect(gt).toContain(`${dn}님이 그릴 단어를 고르고 있어요`);
|
||||
expect(gt).not.toContain(state.turn.choices[0].word);
|
||||
expect(helpNow(view(state, null), null, users)).toContain(`${dn}님이 그릴 단어를 고르고 있어요`);
|
||||
});
|
||||
|
||||
test('그림 맞히기: 그리기 — 출제자는 단어를 보고, 맞히는 사람은 힌트만', () => {
|
||||
const { state } = setupGame('catchmind', 3);
|
||||
const d: string = state.turn.drawer;
|
||||
const [g1, g2] = ['A', 'B', 'C'].filter((p) => p !== d) as [string, string];
|
||||
const s = applyAction('catchmind', state, d, { type: 'chooseWord', index: 0 });
|
||||
const word: string = s.turn.word.word;
|
||||
const dt = helpNow(view(s, d), d, users);
|
||||
expect(dt).toContain(`"${word}"`);
|
||||
expect(dt).toContain('글자나 숫자는 쓰지 말고');
|
||||
expect(dt).toContain('[거의 다 왔어요]');
|
||||
const gt = helpNow(view(s, g1), g1, users);
|
||||
expect(gt).toContain('[맞히기]');
|
||||
expect(gt).toContain('글자예요');
|
||||
expect(gt).not.toContain(word);
|
||||
const spec = helpNow(view(s, null), null, users);
|
||||
expect(spec).toContain('그림을 그리고 있어요');
|
||||
expect(spec).not.toContain(word);
|
||||
|
||||
const s2 = applyAction('catchmind', s, g1, { type: 'guess', text: word });
|
||||
const st = helpNow(view(s2, g1), g1, users);
|
||||
expect(st).toContain('맞혔어요!');
|
||||
expect(st).toContain('이미 맞힌 사람에게만');
|
||||
expect(helpNow(view(s2, g2), g2, users)).not.toContain(word);
|
||||
});
|
||||
|
||||
test('그림 맞히기: 공개 / 끝', () => {
|
||||
const { state } = setupGame('catchmind', 2, { guessMode: 'firstOnly' });
|
||||
const d: string = state.turn.drawer;
|
||||
const g = d === 'A' ? 'B' : 'A';
|
||||
let s = applyAction('catchmind', state, d, { type: 'chooseWord', index: 0 });
|
||||
const word: string = s.turn.word.word;
|
||||
expect(helpNow(view(s, g), g, users)).toContain('가장 먼저 맞힌 사람만');
|
||||
s = applyAction('catchmind', s, g, { type: 'guess', text: word });
|
||||
expect(s.turn.phase).toBe('reveal');
|
||||
expect(helpNow(view(s, g), g, users)).toContain(`정답은 "${word}"였어요`);
|
||||
s.finished = true;
|
||||
s.final = { ranking: [], turns: [], summary: '호랑이님 우승!' };
|
||||
expect(helpNow(view(s, null), null, users)).toContain('호랑이님 우승!');
|
||||
});
|
||||
48
apps/web/src/games/catchmind/help.ts
Normal file
48
apps/web/src/games/catchmind/help.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** 그림 맞히기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { CatchmindView } from '@bg/games/catchmind';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
function hintLine(v: CatchmindView): string {
|
||||
if (!v.hint) return '이 방은 힌트가 없어요.';
|
||||
const letters = v.hint.filter((c) => c !== ' ').length;
|
||||
const more = v.options.hints === 'full' ? ' 시간이 지나면 첫소리(초성)나 글자 하나가 더 보일 수 있어요.' : '';
|
||||
return `그림 위 힌트 칸을 보면 정답은 ${letters}글자예요.${more}`;
|
||||
}
|
||||
|
||||
export function helpNow(v: CatchmindView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const nick = (id: string) => players[id]?.nickname ?? '다른 사람';
|
||||
const drawer = `${nick(v.drawer)}님`;
|
||||
const turn = `${v.round}/${v.rounds}라운드`;
|
||||
if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위와 지난 문제 목록에서 누가 무엇을 맞혔는지 볼 수 있어요.';
|
||||
const away = !!me && v.away.includes(me) ? '\n자리 비움으로 표시돼 그릴 차례를 건너뛰고 있어요. [돌아왔어요]를 누르면 다시 차례가 와요.' : '';
|
||||
const left = !!me && v.left.includes(me);
|
||||
if (v.phase === 'reveal') {
|
||||
const ans = v.word ? `정답은 "${v.word}"였어요. ` : '';
|
||||
const chat = v.canChat ? ' 아래 채팅 칸에서 이야기를 나눌 수 있어요.' : '';
|
||||
return `이번 그림이 끝났어요. ${ans}곧 다음 사람이 그려요.${chat}${away}`;
|
||||
}
|
||||
if (v.role === 'spectator' || left) {
|
||||
if (v.phase === 'choosing') return `${turn} · ${drawer}이 그릴 단어를 고르고 있어요. 곧 그림이 시작돼요.`;
|
||||
return `${turn} · ${drawer}이 그림을 그리고 있어요. 다른 사람들이 채팅으로 정답을 맞히는 중이에요.`;
|
||||
}
|
||||
if (v.role === 'drawer') {
|
||||
if (v.phase === 'choosing') {
|
||||
const pick = v.choices && v.choices.length > 1 ? '그림판 위 단어 버튼 중 그릴 단어 하나를 눌러 골라요. 10초 안에 안 고르면 첫 번째 단어로 정해져요.' : '그릴 단어를 눌러 정해요.';
|
||||
return `내가 그릴 차례예요. ${pick}\n단어는 나만 볼 수 있어요.`;
|
||||
}
|
||||
const word = v.word ? `그릴 단어는 "${v.word}"예요(나와 맞힌 사람만 보여요). ` : '';
|
||||
const report = v.options.letterReport ? ' 글자를 쓰면 신고를 받아 0점이 될 수 있어요.' : '';
|
||||
return `${word}그림판에 손가락이나 마우스로 그려요. 아래 버튼으로 색, 굵기, 지우개(🧽), 되돌리기(↶), 전체 지우기(🗑)를 쓸 수 있어요.\n글자나 숫자는 쓰지 말고 그림으로만 설명해요.${report}\n그리는 동안 채팅은 못 하고 [거의 다 왔어요]·[비슷해요]·[아니에요] 반응 버튼만 보낼 수 있어요.`;
|
||||
}
|
||||
// guesser / solver
|
||||
if (v.phase === 'choosing') return `${turn} · ${drawer}이 그릴 단어를 고르고 있어요. 그림이 시작되면 보고 정답을 입력해요.${v.canChat ? ' 지금은 채팅만 할 수 있어요.' : ''}${away}`;
|
||||
if (v.role === 'solver') {
|
||||
const word = v.word ? ` 정답은 "${v.word}"예요.` : '';
|
||||
return `맞혔어요!${word} 다른 사람들이 맞힐 때까지 기다려요.\n지금 보내는 채팅은 그린 사람과 이미 맞힌 사람에게만 보여요. 정답은 말하지 마세요.${away}`;
|
||||
}
|
||||
const pts = v.options.guessMode === 'firstOnly' ? '가장 먼저 맞힌 사람만 점수를 받고 바로 다음 그림으로 넘어가요.' : '먼저 맞힐수록 점수가 높아요(10, 8, 6, 4점...).';
|
||||
const lines = [`${drawer}이 그리고 있어요. 그림을 보고 생각나는 답을 아래 칸에 써서 [맞히기]를 눌러요. 여러 번 써도 돼요. ${pts}`, hintLine(v)];
|
||||
if (v.canReport) lines.push(`그림 대신 글자를 썼다면 [🚩 글자를 썼어요]로 신고할 수 있어요(지금 ${v.reports}/${v.reportsNeeded}명).`);
|
||||
else if (v.reportedByMe) lines.push(`신고했어요(${v.reports}/${v.reportsNeeded}명). 절반이 넘으면 이번 그림이 끝나요.`);
|
||||
return lines.join('\n') + away;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { haptic, sounds } from '../../lib/feedback';
|
||||
import { useWide } from '../../lib/media';
|
||||
import { socket } from '../../net/socket';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
// ---------------------------------------------------------------- constants (mirror packages/games/src/catchmind/types.ts)
|
||||
|
||||
@@ -1239,6 +1241,8 @@ const ui: GameUI<CatchmindView, CatchmindOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
ownChat: true,
|
||||
};
|
||||
|
||||
|
||||
130
apps/web/src/games/catchmind/quick.tsx
Normal file
130
apps/web/src/games/catchmind/quick.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/** 그림 맞히기 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { ReactNode } from 'react';
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
function Pic({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg viewBox="0 0 250 150" width={250} height={150} aria-hidden>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** White drawing paper with a simple cat doodle. */
|
||||
function Paper({ x, y, w = 120, blank = false }: { x: number; y: number; w?: number; blank?: boolean }) {
|
||||
const k = w / 120;
|
||||
return (
|
||||
<g transform={`translate(${x} ${y}) scale(${k})`}>
|
||||
<rect x={0} y={0} width={120} height={110} rx={8} fill="#fff" stroke="#94a3b8" strokeWidth={2} />
|
||||
{!blank && <Cat />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Cat() {
|
||||
return (
|
||||
<>
|
||||
<g fill="none" stroke="#1f2937" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx={60} cy={62} r={28} />
|
||||
<path d="M38 44 L36 22 L52 36" />
|
||||
<path d="M82 44 L84 22 L68 36" />
|
||||
<path d="M30 66 L12 62 M30 72 L12 76 M90 66 L108 62 M90 72 L108 76" />
|
||||
<path d="M54 72 Q60 78 66 72" />
|
||||
</g>
|
||||
<circle cx={50} cy={56} r={3.5} fill="#1f2937" />
|
||||
<circle cx={70} cy={56} r={3.5} fill="#1f2937" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({ x, y, text, ok }: { x: number; y: number; text: string; ok?: boolean }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={92} height={30} rx={15} fill={ok ? '#dcfce7' : '#f1f5f9'} stroke={ok ? '#16a34a' : '#94a3b8'} strokeWidth={2} />
|
||||
<text x={x + 46} y={y + 20} textAnchor="middle" fontSize={14} fontWeight={700} fill="#1f2937">
|
||||
{text}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<Pic>
|
||||
<Paper x={8} y={20} />
|
||||
<text x={118} y={36} fontSize={26} aria-hidden>
|
||||
🖍
|
||||
</text>
|
||||
<Bubble x={150} y={22} text="강아지?" />
|
||||
<Bubble x={150} y={62} text="호랑이?" />
|
||||
<Bubble x={150} y={102} text="고양이!" ok />
|
||||
</Pic>
|
||||
),
|
||||
alt: '그림판에 고양이 그림이 있고, 옆에 사람들이 정답을 써 보낸 말풍선이 있어요',
|
||||
title: '한 명이 그리고 모두 맞혀요',
|
||||
text: '차례대로 한 사람이 단어를 그림으로 그리고, 나머지는 그림을 보고 정답을 채팅 칸에 써서 맞혀요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Pic>
|
||||
<Paper x={20} y={20} blank />
|
||||
<text x={80} y={70} textAnchor="middle" fontSize={30} fontWeight={800} fill="#475569" opacity={0.85}>
|
||||
고양이
|
||||
</text>
|
||||
<g stroke="#dc2626" strokeWidth={7} strokeLinecap="round">
|
||||
<line x1={40} y1={34} x2={120} y2={114} />
|
||||
<line x1={120} y1={34} x2={40} y2={114} />
|
||||
</g>
|
||||
<g transform="translate(160 28)">
|
||||
{['○', '○', '○'].map((c, i) => (
|
||||
<g key={i}>
|
||||
<rect x={i * 28} y={0} width={24} height={30} rx={5} fill="#f1f5f9" stroke="#94a3b8" />
|
||||
<text x={i * 28 + 12} y={21} textAnchor="middle" fontSize={15} fill="#334155">
|
||||
{c}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{['ㄱ', 'ㅇ', 'ㅇ'].map((c, i) => (
|
||||
<g key={i}>
|
||||
<rect x={i * 28} y={50} width={24} height={30} rx={5} fill="#f1f5f9" stroke="#94a3b8" />
|
||||
<text x={i * 28 + 12} y={71} textAnchor="middle" fontSize={15} fontWeight={700} fill="#334155">
|
||||
{c}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</Pic>
|
||||
),
|
||||
alt: '그림판에 글자를 쓴 것에 빨간 ✕ 표시가 있고, 옆에 글자 수와 첫소리 힌트 칸이 있어요',
|
||||
title: '글자는 쓰면 안 돼요',
|
||||
text: '그리는 사람만 단어를 보고, 글자나 숫자 없이 그림으로만 설명해요. 방 설정에 따라 글자 수·첫소리 힌트가 보여요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Pic>
|
||||
{[
|
||||
['1등', '+10', 48],
|
||||
['2등', '+8', 125],
|
||||
['3등', '+6', 202],
|
||||
].map(([a, b, x]) => (
|
||||
<g key={a as string}>
|
||||
<circle cx={x as number} cy={60} r={32} fill="#fef3c7" stroke="#d97706" strokeWidth={3} />
|
||||
<text x={x as number} y={55} textAnchor="middle" fontSize={15} fontWeight={700} fill="#92400e">
|
||||
{a}
|
||||
</text>
|
||||
<text x={x as number} y={77} textAnchor="middle" fontSize={20} fontWeight={800} fill="#92400e">
|
||||
{b}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<text x={125} y={130} textAnchor="middle" fontSize={15} fontWeight={700} fill="currentColor">
|
||||
빨리 맞힐수록 점수가 커요
|
||||
</text>
|
||||
</Pic>
|
||||
),
|
||||
alt: '먼저 맞힌 순서대로 1등 10점, 2등 8점, 3등 6점을 받는 모습이에요',
|
||||
title: '빨리 맞히면 점수가 커요',
|
||||
text: '먼저 맞힐수록 점수를 많이 받고, 누가 맞히면 그린 사람도 점수를 받아요. 모두 다 그린 뒤 점수가 가장 높으면 우승!',
|
||||
},
|
||||
];
|
||||
53
apps/web/src/games/chess/help.test.ts
Normal file
53
apps/web/src/games/chess/help.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { SeededRng } from '../../../../../packages/engine/src/rng';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.chess!.view(s, who) as any;
|
||||
// Act a second after the previous action so the first-move limit (대국 취소) never fires.
|
||||
let clock = 1_700_000_000_000;
|
||||
const act = (s: any, who: string, a: unknown) => applyAction('chess', s, who, a, SeededRng.fromSeed([9, 9, 9, 9]), (clock += 1000));
|
||||
const move = (s: any, who: string, from: string, to: string) => act(s, who, { type: 'move', from, to });
|
||||
|
||||
test('체스: 내 차례(첫 수) / 상대 차례 / 관전', () => {
|
||||
const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite', hints: 'moves' });
|
||||
const mine = helpNow(view(state, 'A'), 'A', users);
|
||||
expect(mine).toContain('내 차례예요(백)');
|
||||
expect(mine).toContain('점이 찍혀요');
|
||||
expect(mine).toContain('대국이 취소돼요');
|
||||
expect(helpNow(view(state, 'B'), 'B', users)).toContain('상대(호랑이님) 차례예요');
|
||||
expect(helpNow(view(state, null), null, users)).toContain('호랑이님(백) 차례예요');
|
||||
});
|
||||
|
||||
test('체스: 체크를 받은 쪽과 건 쪽, 관전자', () => {
|
||||
const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite' });
|
||||
let s = move(state, 'A', 'e2', 'e4');
|
||||
s = move(s, 'B', 'f7', 'f6');
|
||||
s = move(s, 'A', 'd1', 'h5');
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('체크예요!');
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('체크를 걸었어요');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('흑 킹이 체크');
|
||||
});
|
||||
|
||||
test('체스: 승진 안내(선택 창 / 자동 퀸)와 기물 점수', () => {
|
||||
for (const autoQueen of [false, true]) {
|
||||
const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite', autoQueen });
|
||||
const s = structuredClone(state);
|
||||
s.fen = '4k3/P7/8/8/8/8/8/4K3 w - - 0 1';
|
||||
const t = helpNow(view(s, 'A'), 'A', users);
|
||||
expect(t).toContain(autoQueen ? '자동으로 퀸' : '고르는 창');
|
||||
expect(t).toContain('백이 1점 앞서요');
|
||||
}
|
||||
});
|
||||
|
||||
test('체스: 무승부 제안을 받으면 수락/거절, 끝나면 결과', () => {
|
||||
const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite', drawOffer: true });
|
||||
let s = move(state, 'A', 'e2', 'e4');
|
||||
s = act(s, 'A', { type: 'offerDraw' });
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('무승부를 제안');
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('[요청 취소]');
|
||||
s = act(s, 'B', { type: 'respondDraw', accept: true });
|
||||
expect(helpNow(view(s, null), null, users)).toContain('게임이 끝났어요. 무승부');
|
||||
});
|
||||
51
apps/web/src/games/chess/help.ts
Normal file
51
apps/web/src/games/chess/help.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** 체스 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { ChessView } from '@bg/games/chess';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const COLOR_KO = { white: '백', black: '흑' } as const;
|
||||
|
||||
/** Material lead as shown next to the player bars ("+3"). */
|
||||
function material(v: ChessView): string {
|
||||
if (v.materialDiff === 0) return '';
|
||||
const lead = v.materialDiff > 0 ? 'white' : 'black';
|
||||
return `기물 점수는 ${COLOR_KO[lead]}이 ${Math.abs(v.materialDiff)}점 앞서요(퀸 9, 룩 5, 비숍·나이트 3, 폰 1).`;
|
||||
}
|
||||
|
||||
export function helpNow(v: ChessView, _me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
const turnNick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
const mat = material(v);
|
||||
if (!v.myColor) {
|
||||
const check = v.checkSquare ? ` ${COLOR_KO[v.turn]} 킹이 체크(공격)를 받고 있어요.` : '';
|
||||
return [`${turnNick}님(${COLOR_KO[v.turn]}) 차례예요.${check} 상대 킹을 피할 곳 없이 잡히게 만들면(체크메이트) 이겨요.`, mat].filter(Boolean).join('\n');
|
||||
}
|
||||
const mine = COLOR_KO[v.myColor];
|
||||
if (v.undo.pending && v.undo.pending.by !== v.myColor)
|
||||
return `상대가 ${v.undo.pending.plies}수 무르기를 부탁했어요. [수락]을 누르면 기물이 되돌아가고(시계는 그대로), [거절]을 누르면 그대로 계속해요.`;
|
||||
if (v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.';
|
||||
if (v.myColor !== v.turn) {
|
||||
const lines = [`상대(${turnNick}님) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${mine}이에요.`];
|
||||
if (v.checkSquare) lines.push('내가 상대 킹에 체크를 걸었어요. 상대는 킹을 지켜야 해요.');
|
||||
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
|
||||
if (mat) lines.push(mat);
|
||||
return lines.join('\n');
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (v.checkSquare) lines.push('체크예요! 내 킹이 공격받고 있어요(빨갛게 빛나는 칸). 킹을 피하거나, 사이를 막거나, 공격한 기물을 잡는 수만 둘 수 있어요.');
|
||||
else lines.push(`내 차례예요(${mine}).`);
|
||||
lines.push(
|
||||
v.hints === 'off'
|
||||
? '움직일 기물을 누른 뒤 갈 칸을 누르거나, 기물을 끌어다 놓아요.'
|
||||
: '움직일 기물을 누르면 갈 수 있는 칸에 점이 찍혀요(잡을 수 있는 칸은 동그라미). 그 칸을 누르거나 끌어다 놓아요.',
|
||||
);
|
||||
if (!v.started[v.myColor]) lines.push(`첫 수는 ${v.firstMoveSec}초 안에 둬야 해요. 안 두면 대국이 취소돼요.`);
|
||||
const legal = v.legalMoves ?? [];
|
||||
if (legal.some((m) => m.promotion))
|
||||
lines.push(v.autoQueen ? '폰을 끝 줄로 보내면 자동으로 퀸이 돼요(승진).' : '폰을 끝 줄로 보내면 퀸·룩·비숍·나이트 중 무엇으로 바꿀지 고르는 창이 떠요(승진). 보통은 퀸이에요.');
|
||||
if (legal.some((m) => m.castle)) lines.push('지금 캐슬링을 할 수 있어요. 킹을 누르면 캐슬링할 칸도 표시돼요.');
|
||||
if (v.threatened?.length) lines.push('주황 점선 네모가 쳐진 내 기물은 지켜 주는 기물 없이 공격받고 있어요. 조심하세요.');
|
||||
if (v.drawMode === 'fide' && v.claimable)
|
||||
lines.push(`[⚖ 무승부 주장]을 누르면 상대 동의 없이 바로 비길 수 있어요(${v.claimable === 'threefold' ? '같은 국면 3번' : '50수 동안 폰 이동·잡기 없음'}).`);
|
||||
if (mat) lines.push(mat);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
type Color = 'white' | 'black';
|
||||
type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p';
|
||||
@@ -277,7 +279,8 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
|
||||
onPointerDown={(e) => {
|
||||
setKbd(false);
|
||||
const sq = cellAt(e);
|
||||
if (!sq || !ownMovable(sq) || pending) return;
|
||||
// Chess960: with the king picked, my own rook is a castling target, not a new selection.
|
||||
if (!sq || !ownMovable(sq) || pending || (selected && targets.some((m) => m.to === sq))) return;
|
||||
drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false };
|
||||
setSelected(sq);
|
||||
svgRef.current?.setPointerCapture(e.pointerId);
|
||||
@@ -361,6 +364,18 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
|
||||
{showDots &&
|
||||
targets.map((m) => {
|
||||
const c = cell(m.to);
|
||||
if (m.castle) {
|
||||
// Castling target (king's square or, in 960, the own rook): gold frame + label, so it is never mistaken for a capture.
|
||||
return (
|
||||
<g key={`m${m.to}`} style={{ pointerEvents: 'none' }}>
|
||||
<rect x={c.x + 0.06} y={c.y + 0.06} width={0.88} height={0.88} rx={0.12} fill="rgba(234,179,8,0.25)" stroke="#a16207" strokeWidth={0.07} />
|
||||
<rect x={c.x + 0.1} y={c.y + 0.68} width={0.8} height={0.24} rx={0.08} fill="#a16207" />
|
||||
<text x={c.x + 0.5} y={c.y + 0.8} textAnchor="middle" dominantBaseline="central" fontSize={0.16} fontWeight={800} fill="#fff">
|
||||
캐슬링
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
const capture = !!pieceAt(v, m.to) || (pieceAt(v, m.from)?.toLowerCase() === 'p' && m.from[0] !== m.to[0]);
|
||||
return capture ? (
|
||||
<circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.45} fill="none" stroke="rgba(20,40,10,0.45)" strokeWidth={0.08} style={{ pointerEvents: 'none' }} />
|
||||
@@ -386,6 +401,15 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{myTurn && selected && targets.some((m) => m.castle) && (showDots || v.chess960Index !== null) && (
|
||||
<p className="small" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{v.chess960Index === null
|
||||
? '캐슬링하려면 노란 테두리 칸을 누르세요.'
|
||||
: showDots
|
||||
? '캐슬링하려면 노란 테두리 칸(내 룩, 또는 킹이 갈 g·c열 칸)을 누르세요.'
|
||||
: '캐슬링하려면 내 룩이나 킹이 갈 g·c열 칸을 누르세요.'}
|
||||
</p>
|
||||
)}
|
||||
{!v.myColor && (
|
||||
<div className="row" style={{ justifyContent: 'center' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setFlip(!flip)}>
|
||||
@@ -450,8 +474,15 @@ function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<
|
||||
const bottom: Color = mine ?? 'white';
|
||||
const beforeFirst = !!mine && !v.started[mine];
|
||||
const canClaim = v.drawMode === 'fide' && !!v.claimable && !!mine && v.turn === mine;
|
||||
// Chess960: castling also as buttons (the king-onto-rook tap is easy to miss).
|
||||
const castles = v.chess960Index !== null ? (['king', 'queen'] as const).filter((side) => v.legalMoves?.some((m) => m.castle === side)) : [];
|
||||
return (
|
||||
<>
|
||||
{v.chess960Index !== null && (
|
||||
<div className="card small" style={{ padding: '8px 12px' }}>
|
||||
<strong>체스960</strong> · 시작 배치 {v.chess960Index}번
|
||||
</div>
|
||||
)}
|
||||
<PlayerBar view={v} color={top} players={players} deadline={deadline} me={me} />
|
||||
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
|
||||
{v.phase === 'playing' && mine && (
|
||||
@@ -476,6 +507,15 @@ function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{castles.length > 0 && (
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
{castles.map((side) => (
|
||||
<button key={side} className="btn btn-secondary btn-sm" style={{ flex: 1 }} disabled={pending} onClick={() => act({ type: 'castle', side })}>
|
||||
{side === 'king' ? '캐슬링: 킹 쪽 (O-O)' : '캐슬링: 퀸 쪽 (O-O-O)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{canClaim && (
|
||||
<button className="btn btn-primary" onClick={() => setConfirm('claim')}>
|
||||
⚖ 무승부 주장 ({v.claimable === 'threefold' ? '3회 반복' : '50수 규칙'})
|
||||
@@ -691,6 +731,11 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
|
||||
const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min));
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>규칙</strong>
|
||||
<Seg value={o.variant ?? 'standard'} options={[['standard', '일반 체스'], ['chess960', '체스960 (첫 줄 무작위)']]} onChange={(variant) => set({ variant })} />
|
||||
{o.variant === 'chess960' && <span className="muted small">뒷줄 기물 배치가 960가지 중 하나로 무작위로 정해져요. 외운 수보다 실력으로 겨뤄요.</span>}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>시간 (기본 분 + 한 수마다 추가 초)</strong>
|
||||
<Seg
|
||||
@@ -712,7 +757,7 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>초보 도움말</strong>
|
||||
<Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['movesAndThreats', '+ 위험한 기물 경고'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
<Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
@@ -788,7 +833,7 @@ function timeLabel(tc: ChessOptions['timeControl']): string {
|
||||
|
||||
function optionsSummary(o: ChessOptions): string {
|
||||
const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`;
|
||||
return ['표준', timeLabel(o.timeControl), undo].join(' · ');
|
||||
return [o.variant === 'chess960' ? '체스960' : '표준', timeLabel(o.timeControl), undo].join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
@@ -804,8 +849,15 @@ const PIECE_RULES: [PieceType, string][] = [
|
||||
|
||||
function Rules({ options }: { options?: ChessOptions }) {
|
||||
const fide = options?.drawMode === 'fide';
|
||||
const c960 = options?.variant === 'chess960';
|
||||
return (
|
||||
<div className="stack">
|
||||
{c960 && (
|
||||
<p>
|
||||
<strong>체스960</strong>: 뒷줄 기물(킹·퀸·룩·비숍·나이트) 배치가 960가지 중 하나로 무작위로 정해져요. 흑은 백과 똑같이 마주 보게 놓여요. 두 비숍은 서로 다른 색 칸에, 킹은
|
||||
두 룩 사이에 있어요. 그 밖의 규칙은 일반 체스와 같아요.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 <strong>킹</strong>을 공격해서(체크) 어디로도 피할 수 없게 만들면(<strong>체크메이트</strong>) 이겨요.
|
||||
내 킹이 공격받게 되는 수는 둘 수 없어요.
|
||||
@@ -820,10 +872,18 @@ function Rules({ options }: { options?: ChessOptions }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p>
|
||||
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
|
||||
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
|
||||
</p>
|
||||
{c960 ? (
|
||||
<p>
|
||||
<strong>캐슬링(체스960)</strong>: 끝나는 자리는 일반 체스와 같아요. 킹 쪽(O-O)은 킹이 g열, 룩이 f열로, 퀸 쪽(O-O-O)은 킹이 c열, 룩이 d열로 가요. 킹과 그 룩이 한 번도
|
||||
움직이지 않았고, 둘이 지나가는 칸이 (둘 자신 말고는) 모두 비어 있어야 해요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 공격받으면 안 돼요. 킹을 누른 뒤 노란 테두리 칸(내 룩이나
|
||||
g·c열 칸)을 누르거나, [캐슬링] 버튼을 눌러요.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
|
||||
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.
|
||||
</p>
|
||||
@@ -851,6 +911,8 @@ const ui: GameUI<ChessView, ChessOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
77
apps/web/src/games/chess/quick.tsx
Normal file
77
apps/web/src/games/chess/quick.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/** 체스 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
const GLYPH: Record<string, string> = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' };
|
||||
|
||||
/** One piece as a text glyph (filled shape, coloured by side). `︎` keeps ♟ from turning into an emoji. */
|
||||
function Piece({ t, x, y }: { t: string; x: number; y: number }) {
|
||||
const white = t === t.toUpperCase();
|
||||
return (
|
||||
<text
|
||||
x={x + 0.5}
|
||||
y={y + 0.56}
|
||||
fontSize={0.82}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontFamily="'Segoe UI Symbol', 'Noto Sans Symbols 2', 'DejaVu Sans', serif"
|
||||
fill={white ? '#fbfbf8' : '#1b1b1b'}
|
||||
stroke={white ? '#333' : '#000'}
|
||||
strokeWidth={0.03}
|
||||
>
|
||||
{GLYPH[t.toLowerCase()]}
|
||||
{'︎'}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small chess board; `rows` use FEN letters (uppercase = white) and '.' for empty. */
|
||||
function MiniBoard({ rows, dots = [], check, size = 150 }: { rows: string[]; dots?: [number, number][]; check?: [number, number]; size?: number }) {
|
||||
const h = rows.length;
|
||||
const w = rows[0]!.length;
|
||||
const scale = size / Math.max(w, h);
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} width={w * scale} height={h * scale} aria-hidden style={{ borderRadius: 6 }}>
|
||||
{rows.map((r, y) =>
|
||||
r.split('').map((_, x) => <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={(x + y) % 2 === 0 ? '#f0d9b5' : '#b58863'} />),
|
||||
)}
|
||||
{check && <rect x={check[0]} y={check[1]} width={1} height={1} fill="#ef4444" opacity={0.7} />}
|
||||
{dots.map(([x, y]) => (
|
||||
<circle key={`d${x}-${y}`} cx={x + 0.5} cy={y + 0.5} r={0.16} fill="rgba(20,85,30,0.55)" />
|
||||
))}
|
||||
{rows.map((r, y) => r.split('').map((c, x) => (c === '.' ? null : <Piece key={`p${x}-${y}`} t={c} x={x} y={y} />)))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Arrow = () => (
|
||||
<span aria-hidden style={{ fontSize: 28, fontWeight: 800 }}>
|
||||
→
|
||||
</span>
|
||||
);
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: <MiniBoard rows={['R.k.', '.ppp']} check={[2, 0]} size={200} />,
|
||||
alt: '흰 룩이 검은 킹을 공격하고, 킹은 자기 폰에 막혀 피할 곳이 없어요',
|
||||
title: '킹을 꼼짝 못 하게',
|
||||
text: '백이 먼저 두고 번갈아 한 수씩 둬요. 상대 킹을 공격해서 피할 곳이 없게 만들면(체크메이트) 이겨요.',
|
||||
},
|
||||
{
|
||||
pic: <MiniBoard rows={['.....', '.....', '..N..', '.....', '.....']} dots={[[1, 0], [3, 0], [0, 1], [4, 1], [0, 3], [4, 3], [1, 4], [3, 4]]} size={150} />,
|
||||
alt: '가운데 나이트 둘레로 ㄱ자 모양 여덟 칸에 점이 찍혀 있어요',
|
||||
title: '기물마다 길이 달라요',
|
||||
text: '기물을 누르면 갈 수 있는 칸에 점이 찍혀요. 그 칸을 누르면 움직여요. 상대 기물이 있는 칸으로 가면 잡아요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<>
|
||||
<MiniBoard rows={['.', 'P']} size={90} />
|
||||
<Arrow />
|
||||
<MiniBoard rows={['Q', '.']} size={90} />
|
||||
</>
|
||||
),
|
||||
alt: '흰 폰이 끝 줄에 닿아 퀸으로 바뀌어요',
|
||||
title: '폰이 끝까지 가면 변신',
|
||||
text: '폰이 끝 줄에 닿으면 퀸 같은 강한 기물로 바뀌어요(승진). 내 킹이 공격받으면(체크) 꼭 먼저 피하거나 막아야 해요.',
|
||||
},
|
||||
];
|
||||
64
apps/web/src/games/cockroach-poker/help.test.ts
Normal file
64
apps/web/src/games/cockroach-poker/help.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const ids = ['A', 'B', 'C'];
|
||||
const users = usersOf(ids);
|
||||
const view = (s: unknown, who: string | null) => GAMES['cockroach-poker']!.view(s, who) as any;
|
||||
const nickOf = (id: string) => users[id]!.nickname;
|
||||
|
||||
test('바퀴벌레 포커: 건네기 차례 / 기다림 / 관전', () => {
|
||||
const { state } = setupGame('cockroach-poker', 3);
|
||||
const starter = state.round.starter as string;
|
||||
const other = ids.find((p) => p !== starter)!;
|
||||
expect(helpNow(view(state, starter), starter, users)).toContain('[건네기]');
|
||||
const wait = helpNow(view(state, other), other, users);
|
||||
expect(wait).toContain(`${nickOf(starter)}님이 건넬 카드를 고르는 중`);
|
||||
expect(wait).toContain('4장 모이면 져요');
|
||||
expect(helpNow(view(state, null), null, users)).toContain(`${nickOf(starter)}님이 건넬 카드를 고르는 중`);
|
||||
});
|
||||
|
||||
test('바퀴벌레 포커: 받은 사람의 판정 / 몰래 보고 넘기기', () => {
|
||||
const { state } = setupGame('cockroach-poker', 3);
|
||||
const starter = state.round.starter as string;
|
||||
const [to, third] = ids.filter((p) => p !== starter) as [string, string];
|
||||
const card = state.hands[starter][0] as string;
|
||||
let s = applyAction('cockroach-poker', state, starter, { type: 'offer', cardId: card, to, claim: 'rat' });
|
||||
const t = helpNow(view(s, to), to, users);
|
||||
expect(t).toContain('"이건 쥐야!"');
|
||||
expect(t).toContain('[⭕ 진짜!]');
|
||||
expect(t).toContain('[👁 몰래 보고 넘기기]');
|
||||
expect(helpNow(view(s, starter), starter, users)).toContain('다시 받지 않아요');
|
||||
expect(helpNow(view(s, null), null, users)).toContain(`${nickOf(to)}님이 진짜인지 고민하는 중`);
|
||||
|
||||
s = applyAction('cockroach-poker', s, to, { type: 'peek' });
|
||||
const p = helpNow(view(s, to), to, users);
|
||||
expect(p).toContain('몰래 본 카드는');
|
||||
expect(p).toContain('[넘기기]');
|
||||
expect(helpNow(view(s, third), third, users)).toContain('나에게 넘어올 수도');
|
||||
|
||||
s = applyAction('cockroach-poker', s, to, { type: 'pass', to: third, claim: 'bat' });
|
||||
const last = helpNow(view(s, third), third, users);
|
||||
expect(last).toContain('"이건 박쥐야!"');
|
||||
expect(last).toContain('넘길 사람이 없어요');
|
||||
});
|
||||
|
||||
test('바퀴벌레 포커: 2명은 넘기기 없음, 위험 경고, 게임 끝', () => {
|
||||
const { state } = setupGame('cockroach-poker', 2);
|
||||
const starter = state.round.starter as string;
|
||||
const to = ids.slice(0, 2).find((p) => p !== starter)!;
|
||||
// 받는 사람 앞마당에 쥐 4장(2명은 5장이면 패배) → 위험 경고.
|
||||
const rats = ['rat-0', 'rat-1', 'rat-2', 'rat-3', 'rat-4'];
|
||||
for (const p of ['A', 'B']) state.hands[p] = state.hands[p].filter((c: string) => !rats.includes(c));
|
||||
state.fronts[to] = rats.slice(0, 4);
|
||||
state.hands[starter].push('rat-4');
|
||||
let s = applyAction('cockroach-poker', state, starter, { type: 'offer', cardId: 'rat-4', to, claim: 'rat' });
|
||||
const t = helpNow(view(s, to), to, users);
|
||||
expect(t).toContain('2명이 할 때는 몰래 보고 넘기기를 할 수 없어요');
|
||||
expect(t).toContain('조심! 내 앞마당의 쥐 4장');
|
||||
s = applyAction('cockroach-poker', s, to, { type: 'call', call: 'false' });
|
||||
expect(helpNow(view(s, to), to, users)).toContain('내가 졌어요');
|
||||
expect(helpNow(view(s, starter), starter, users)).toContain('나는 이겼어요');
|
||||
expect(helpNow(view(s, null), null, users)).toContain(`${nickOf(to)}님이 졌고`);
|
||||
});
|
||||
86
apps/web/src/games/cockroach-poker/help.ts
Normal file
86
apps/web/src/games/cockroach-poker/help.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/** 바퀴벌레 포커 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { CockroachView, Kind } from '@bg/games/cockroach-poker';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
// Kept local (like index.tsx) so the screen chunk does not pull in the rules module.
|
||||
const KIND_KO: Record<Kind, string> = {
|
||||
cockroach: '바퀴벌레',
|
||||
bat: '박쥐',
|
||||
fly: '파리',
|
||||
toad: '두꺼비',
|
||||
rat: '쥐',
|
||||
scorpion: '전갈',
|
||||
spider: '거미',
|
||||
stinkbug: '노린재',
|
||||
};
|
||||
|
||||
const hasBatchim = (word: string) => {
|
||||
const c = word.charCodeAt(word.length - 1);
|
||||
return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0;
|
||||
};
|
||||
/** "이건 쥐야!" / "이건 전갈이야!" — the same words as the screen. */
|
||||
const claimLine = (k: Kind) => `이건 ${KIND_KO[k]}${hasBatchim(KIND_KO[k]) ? '이야' : '야'}!`;
|
||||
const kindIs = (k: Kind) => `${KIND_KO[k]}${hasBatchim(KIND_KO[k]) ? '이에요' : '예요'}`;
|
||||
|
||||
export function helpNow(v: CockroachView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const mine = me && v.order.includes(me) ? me : null;
|
||||
const name = (id: string) => `${players[id]?.nickname ?? '다른 사람'}님`;
|
||||
const subj = (id: string) => (id === mine ? '내가' : `${name(id)}이`);
|
||||
const goal = `같은 벌레가 앞마당(내 앞에 펼친 카드)에 ${v.loseCount}장 모이면 져요.`;
|
||||
|
||||
if (v.phase === 'finished') {
|
||||
const f = v.final;
|
||||
if (!f) return '게임이 끝났어요.';
|
||||
const who = f.loser === mine ? '내가 졌어요.' : mine ? `${name(f.loser)}이 졌고, 나는 이겼어요!` : `${name(f.loser)}이 졌고, 나머지 모두 이겼어요.`;
|
||||
const why =
|
||||
f.reason === 'four' && f.losingKind
|
||||
? `앞마당에 ${KIND_KO[f.losingKind]} ${v.loseCount}장이 모였어요.`
|
||||
: f.reason === 'eight'
|
||||
? '앞마당에 벌레 8종이 모두 모였어요.'
|
||||
: f.reason === 'emptyHand'
|
||||
? '카드를 건넬 차례인데 손에 카드가 없었어요.'
|
||||
: '한 사람이 게임을 나갔어요.';
|
||||
return `게임이 끝났어요. ${why}\n${who}`;
|
||||
}
|
||||
|
||||
const r = v.round;
|
||||
const danger = mine ? (Object.keys(v.fronts[mine]?.level ?? {}) as Kind[]).filter((k) => v.fronts[mine]!.level[k] === 'danger') : [];
|
||||
const warn = danger.length ? `조심! 내 앞마당의 ${danger.map((k) => `${KIND_KO[k]} ${v.fronts[mine!]!.counts[k]}장`).join(', ')}은 한 장만 더 오면 져요.` : null;
|
||||
const lines: string[] = [];
|
||||
|
||||
if (r.holder === null) {
|
||||
// 건네기: the round starter picks a card, a receiver and what to say.
|
||||
if (r.starter === mine) {
|
||||
lines.push('내 차례예요. 카드 한 장을 뒤집어 다른 사람에게 건네며 무슨 벌레인지 말해요. 진짜를 말해도, 거짓말을 해도 돼요.');
|
||||
lines.push('아래에서 ① 건넬 카드 ② 받을 사람 ③ 할 말(벌레 그림)을 차례로 고르고, 맨 아래 [건네기] 버튼을 눌러요.');
|
||||
} else {
|
||||
lines.push(`${r.no}라운드예요. ${subj(r.starter)} 건넬 카드를 고르는 중이에요.${mine ? ' 나에게 올 수도 있으니 기다려 주세요.' : ''}`);
|
||||
lines.push(goal);
|
||||
}
|
||||
} else {
|
||||
const holder = r.holder;
|
||||
const passer = r.lastPasser ?? r.starter;
|
||||
const said = r.claim ? `"${claimLine(r.claim)}"` : '카드';
|
||||
if (holder === mine && !r.peeked) {
|
||||
lines.push(`${name(passer)}이 ${said}라며 카드를 줬어요. 진짜 같으면 [⭕ 진짜!], 거짓말 같으면 [❌ 거짓말!]을 눌러요.`);
|
||||
lines.push(`맞히면 ${name(passer)}이, 틀리면 내가 이 카드를 앞마당에 놓아요.`);
|
||||
if (v.my?.canPeek) lines.push('자신 없으면 [👁 몰래 보고 넘기기]로 카드를 본 뒤, 아직 안 본 사람에게 넘길 수 있어요. 보고 나면 판정은 못 해요.');
|
||||
else if (v.my?.peekBlocked) lines.push(v.my.peekBlocked);
|
||||
} else if (holder === mine) {
|
||||
const known = v.card && v.card.known ? `몰래 본 카드는 ${kindIs(v.card.kind)}. ` : '';
|
||||
lines.push(`${known}이제 판정은 못 하고 꼭 다른 사람에게 넘겨야 해요.`);
|
||||
lines.push('아직 이 카드를 안 본 사람(눌리는 버튼)과 할 말을 고르고 [넘기기] 버튼을 눌러요. 진짜를 말해도, 거짓말을 해도 돼요.');
|
||||
} else {
|
||||
if (r.peeked) lines.push(`${subj(holder)} 몰래 본 카드를 넘길 사람을 고르는 중이에요.`);
|
||||
else lines.push(`${subj(passer)} ${name(holder)}에게 ${said}라며 카드를 건넸어요. ${subj(holder)} 진짜인지 고민하는 중이에요.`);
|
||||
if (mine && r.seen.includes(mine)) {
|
||||
const known = v.card && v.card.known ? ` 실제 카드는 ${kindIs(v.card.kind)}.` : '';
|
||||
lines.push(`나는 이 카드를 봤기 때문에 다시 받지 않아요.${known}`);
|
||||
} else if (mine && r.peeked) lines.push('아직 이 카드를 안 봤으니 나에게 넘어올 수도 있어요.');
|
||||
else if (mine) lines.push('기다려 주세요.');
|
||||
lines.push(goal);
|
||||
}
|
||||
}
|
||||
if (warn) lines.push(warn);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
// Kept local (type-only import above) so the screen chunk does not pull in the rules module + zod.
|
||||
const KINDS: Kind[] = ['cockroach', 'bat', 'fly', 'toad', 'rat', 'scorpion', 'spider', 'stinkbug'];
|
||||
@@ -1054,6 +1056,8 @@ const ui: GameUI<CockroachView, CockroachOptions> = {
|
||||
Options,
|
||||
optionsSummary: (o) => optionsSummary(o),
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
106
apps/web/src/games/cockroach-poker/quick.tsx
Normal file
106
apps/web/src/games/cockroach-poker/quick.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/** 바퀴벌레 포커 30초 규칙 요약 (docs/07 §5). Simple inline SVG + emoji; the screen's critter art lives in index.tsx. */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
/** A small upright card: face-down (pattern) or face-up with a critter emoji. */
|
||||
function Card({ x, y, face, w = 44, h = 62, tilt = 0, red = false }: { x: number; y: number; face?: string; w?: number; h?: number; tilt?: number; red?: boolean }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y}) rotate(${tilt})`}>
|
||||
<rect x={-w / 2} y={-h / 2} width={w} height={h} rx={6} fill={face ? '#fffaf0' : '#5b3f8c'} stroke={red ? '#b91c1c' : '#3b2a5c'} strokeWidth={red ? 3 : 1.5} />
|
||||
{face ? (
|
||||
<text x={0} y={9} textAnchor="middle" fontSize={26}>
|
||||
{face}
|
||||
</text>
|
||||
) : (
|
||||
<text x={0} y={8} textAnchor="middle" fontSize={22} fill="#e7def5">
|
||||
?
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Person({ x, y, label }: { x: number; y: number; label: string }) {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={x} cy={y} r={20} fill="#e0e7ff" stroke="#4f46e5" strokeWidth={1.5} />
|
||||
<text x={x} y={y + 7} textAnchor="middle" fontSize={20}>
|
||||
🙂
|
||||
</text>
|
||||
<text x={x} y={y + 38} textAnchor="middle" fontSize={12} fill="#333">
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const GivePic = (
|
||||
<svg viewBox="0 0 260 170" width={260} height={170} aria-hidden>
|
||||
<Person x={36} y={110} label="나" />
|
||||
<Person x={224} y={110} label="받는 사람" />
|
||||
<Card x={130} y={112} tilt={-6} />
|
||||
<path d="M70 128 Q130 160 190 128" fill="none" stroke="#4f46e5" strokeWidth={2.5} strokeDasharray="5 4" markerEnd="url(#cpq-arrow)" />
|
||||
<defs>
|
||||
<marker id="cpq-arrow" viewBox="0 0 10 10" refX={6} refY={5} markerWidth={7} markerHeight={7} orient="auto">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="#4f46e5" />
|
||||
</marker>
|
||||
</defs>
|
||||
<rect x={64} y={14} width={132} height={44} rx={14} fill="#fff" stroke="#333" strokeWidth={1.5} />
|
||||
<path d="M90 58 L80 74 L104 58" fill="#fff" stroke="#333" strokeWidth={1.5} />
|
||||
<text x={130} y={43} textAnchor="middle" fontSize={18} fontWeight={700} fill="#222">
|
||||
이건 쥐야!
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChoosePic = (
|
||||
<svg viewBox="0 0 260 170" width={260} height={170} aria-hidden>
|
||||
<Card x={130} y={40} />
|
||||
<rect x={14} y={84} width={110} height={36} rx={10} fill="#dcfce7" stroke="#15803d" strokeWidth={2} />
|
||||
<text x={69} y={108} textAnchor="middle" fontSize={16} fontWeight={700} fill="#14532d">
|
||||
⭕ 진짜!
|
||||
</text>
|
||||
<rect x={136} y={84} width={110} height={36} rx={10} fill="#fee2e2" stroke="#b91c1c" strokeWidth={2} />
|
||||
<text x={191} y={108} textAnchor="middle" fontSize={16} fontWeight={700} fill="#7f1d1d">
|
||||
❌ 거짓말!
|
||||
</text>
|
||||
<rect x={30} y={128} width={200} height={36} rx={10} fill="#dbeafe" stroke="#1d4ed8" strokeWidth={2} />
|
||||
<text x={130} y={152} textAnchor="middle" fontSize={15} fontWeight={700} fill="#1e3a8a">
|
||||
👁 몰래 보고 넘기기
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LosePic = (
|
||||
<svg viewBox="0 0 260 170" width={260} height={170} aria-hidden>
|
||||
<Card x={40} y={66} face="🐀" red />
|
||||
<Card x={100} y={66} face="🐀" red />
|
||||
<Card x={160} y={66} face="🐀" red />
|
||||
<Card x={220} y={66} face="🐀" red />
|
||||
<Card x={70} y={140} face="🦇" w={34} h={46} />
|
||||
<Card x={110} y={140} face="🐸" w={34} h={46} />
|
||||
<text x={186} y={147} textAnchor="middle" fontSize={17} fontWeight={800} fill="#b91c1c">
|
||||
4장이면 패배
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: GivePic,
|
||||
alt: '내가 뒤집은 카드를 다른 사람에게 건네며 "이건 쥐야!"라고 말해요',
|
||||
title: '카드를 건네며 말해요',
|
||||
text: '내 차례에 카드 한 장을 뒤집어 다른 사람에게 주며 무슨 벌레인지 말해요. 진짜를 말해도, 거짓말을 해도 돼요.',
|
||||
},
|
||||
{
|
||||
pic: ChoosePic,
|
||||
alt: '받은 카드 아래에 진짜, 거짓말, 몰래 보고 넘기기 버튼이 있어요',
|
||||
title: '받으면 맞히거나 넘겨요',
|
||||
text: '받은 사람은 [진짜!]나 [거짓말!]을 골라요. 맞히면 건넨 사람이, 틀리면 받은 사람이 카드를 앞마당에 놓아요. 3명 이상이면 몰래 보고 넘길 수도 있어요.',
|
||||
},
|
||||
{
|
||||
pic: LosePic,
|
||||
alt: '앞마당에 쥐 카드 4장이 빨간 테두리로 모여 있어요',
|
||||
title: '같은 벌레가 모이면 져요',
|
||||
text: '앞마당에 같은 벌레가 4장(2명이면 5장, 방 설정에 따라 다름) 모인 사람 한 명이 지고, 나머지는 모두 이겨요.',
|
||||
},
|
||||
];
|
||||
68
apps/web/src/games/dalmuti/help.test.ts
Normal file
68
apps/web/src/games/dalmuti/help.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const ids = ['A', 'B', 'C', 'D'];
|
||||
const users = usersOf(ids);
|
||||
const view = (s: unknown, who: string | null) => GAMES.dalmuti!.view(s, who) as any;
|
||||
const nickOf = (id: string) => users[id]!.nickname;
|
||||
|
||||
/** Everyone confirms the hand without a revolution, then the two 달무티 give back their suggested cards. */
|
||||
function toPlaying(state: any) {
|
||||
let s = state;
|
||||
for (const p of ids) s = applyAction('dalmuti', s, p, { type: 'confirmHand' });
|
||||
return s;
|
||||
}
|
||||
|
||||
test('달무티: 손패 확인 단계와 혁명', () => {
|
||||
const { state } = setupGame('dalmuti', 4);
|
||||
expect(state.phase.kind).toBe('revolutionCheck');
|
||||
const top = state.order[0] as string;
|
||||
const last = state.order[3] as string;
|
||||
expect(helpNow(view(state, top), top, users)).toContain('[손패 확인]');
|
||||
expect(helpNow(view(state, top), top, users)).toContain('대농노에게서 가장 좋은 카드 2장');
|
||||
expect(helpNow(view(state, last), last, users)).toContain('대달무티에게 자동으로');
|
||||
expect(helpNow(view(state, null), null, users)).toContain('준비 중이에요');
|
||||
// Give both jesters to the 대농노: the 혁명 button appears and is explained as 대혁명.
|
||||
const jesters = Object.values(state.hands).flat().filter((c: any) => c.n === 13);
|
||||
for (const p of ids) state.hands[p] = state.hands[p].filter((c: any) => c.n !== 13);
|
||||
state.hands[last].push(...jesters);
|
||||
const t = helpNow(view(state, last), last, users);
|
||||
expect(t).toContain('[혁명!]');
|
||||
expect(t).toContain('대혁명');
|
||||
});
|
||||
|
||||
test('달무티: 세금 돌려주기', () => {
|
||||
const { state } = setupGame('dalmuti', 4);
|
||||
const s = toPlaying(state);
|
||||
expect(s.phase.kind).toBe('taxReturn');
|
||||
const top = s.order[0] as string;
|
||||
const last = s.order[3] as string;
|
||||
const t = helpNow(view(s, top), top, users);
|
||||
expect(t).toContain('돌려줄 카드 2장');
|
||||
expect(t).toContain('[돌려주기]');
|
||||
expect(helpNow(view(s, last), last, users)).toContain('세금으로 내 가장 좋은 카드');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('세금 카드를 주고받는 중');
|
||||
});
|
||||
|
||||
test('달무티: 새로 시작 / 따라 내기 / 기다리기 / 관전', () => {
|
||||
const { state } = setupGame('dalmuti', 4);
|
||||
let s = toPlaying(state);
|
||||
for (const p of [s.order[0], s.order[1]]) {
|
||||
const v = view(s, p);
|
||||
s = applyAction('dalmuti', s, p, { type: 'returnTax', cardIds: v.my.suggestedReturn });
|
||||
}
|
||||
expect(s.phase.kind).toBe('playing');
|
||||
const lead = s.current as string;
|
||||
expect(helpNow(view(s, lead), lead, users)).toContain('새로 시작해요');
|
||||
const other = ids.find((p) => p !== lead)!;
|
||||
expect(helpNow(view(s, other), other, users)).toContain(`${nickOf(lead)}님 차례예요. 기다려 주세요`);
|
||||
expect(helpNow(view(s, null), null, users)).toContain(`${nickOf(lead)}님 차례예요`);
|
||||
const card = s.hands[lead].find((c: any) => c.n === 12) ?? s.hands[lead].find((c: any) => c.n !== 13);
|
||||
s = applyAction('dalmuti', s, lead, { type: 'play', cardIds: [card.id] });
|
||||
const next = s.current as string;
|
||||
const t = helpNow(view(s, next), next, users);
|
||||
expect(t).toContain(`지금 바닥은 ${card.n} × 1장`);
|
||||
expect(t).toContain('[패스]');
|
||||
});
|
||||
102
apps/web/src/games/dalmuti/help.ts
Normal file
102
apps/web/src/games/dalmuti/help.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/** 달무티 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { DalmutiView } from '@bg/games/dalmuti';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const JESTER = 13;
|
||||
const nm = (players: Record<string, PublicUser>, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`;
|
||||
const rankText = (r: number) => (r === JESTER ? '광대(13)' : String(r));
|
||||
const faces = (cards: number[]) => cards.map((n) => (n === JESTER ? '광대' : String(n))).join(', ');
|
||||
|
||||
/** What the tax step will do for this title (no revolution). */
|
||||
function taxPlan(title: string): string {
|
||||
switch (title) {
|
||||
case '대달무티':
|
||||
return '혁명이 없으면 대농노에게서 가장 좋은 카드 2장을 받고, 아무 카드 2장을 골라 돌려줘요.';
|
||||
case '소달무티':
|
||||
return '혁명이 없으면 소농노에게서 가장 좋은 카드 1장을 받고, 아무 카드 1장을 골라 돌려줘요.';
|
||||
case '소농노':
|
||||
return '혁명이 없으면 내 가장 좋은(가장 낮은 숫자) 카드 1장이 소달무티에게 자동으로 넘어가요.';
|
||||
case '대농노':
|
||||
return '혁명이 없으면 내 가장 좋은(가장 낮은 숫자) 카드 2장이 대달무티에게 자동으로 넘어가요.';
|
||||
default:
|
||||
return '평민은 세금을 내지도 받지도 않아요.';
|
||||
}
|
||||
}
|
||||
|
||||
export function helpNow(v: DalmutiView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const goal = '숫자가 낮을수록 좋은 카드예요. 카드를 먼저 다 낼수록 다음 라운드에서 높은 신분이 돼요.';
|
||||
if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
|
||||
const seat = me ? v.seats.find((s) => s.id === me) : undefined;
|
||||
const my = seat ? v.my : null;
|
||||
const roundText = `${v.round}/${v.options.rounds}라운드`;
|
||||
|
||||
if (v.phase === 'revolutionCheck') {
|
||||
const done = v.seats.filter((s) => s.confirmed).length;
|
||||
if (!my) return `${roundText} 준비 중이에요. 모두 자기 카드를 보고 확인하는 중이에요(${done}/${v.seats.length}).\n${goal}`;
|
||||
const lines: string[] = [];
|
||||
if (my.canDeclareRevolution) {
|
||||
const great = v.seats[v.seats.length - 1]?.id === me;
|
||||
lines.push(`광대 2장이 다 있어요! [혁명!]을 누르면 광대 2장을 보여 주고, 이번 라운드는 세금이 없어요.${great ? ' 나는 대농노라서 대혁명이 되어 신분이 완전히 뒤집혀요.' : ''}`);
|
||||
lines.push('혁명하지 않으려면 [손패 확인]을 눌러요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (my.canConfirm) {
|
||||
lines.push(`${roundText} 준비예요(내 신분: ${seat!.title}). 내 카드를 보고 [손패 확인]을 눌러 주세요.`);
|
||||
lines.push(taxPlan(seat!.title));
|
||||
} else {
|
||||
lines.push(`손패를 확인했어요. 다른 사람이 확인하기를 기다리는 중이에요(${done}/${v.seats.length}).`);
|
||||
lines.push(taxPlan(seat!.title));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (v.phase === 'taxReturn') {
|
||||
const pending = v.taxPending.filter((p) => !p.done);
|
||||
const pendingText = pending.map((p) => nm(players, p.from)).join(', ');
|
||||
if (!my) return `세금 카드를 주고받는 중이에요. 농노는 가장 좋은 카드를 달무티에게 주고, 달무티는 아무 카드나 같은 장수만큼 돌려줘요.${pendingText ? `\n${pendingText}이 돌려줄 카드를 고르고 있어요.` : ''}`;
|
||||
if (my.owe) {
|
||||
const got = v.tax.find((t) => t.kind === 'tax' && t.to === me);
|
||||
const gotText = got?.cards ? `세금으로 ${nm(players, got.from)}에게서 좋은 카드를 받았어요(받은 카드: ${faces(got.cards)}). ` : '';
|
||||
return `${gotText}${nm(players, my.owe.to)}에게 돌려줄 카드 ${my.owe.count}장을 눌러 고르고 [돌려주기]를 눌러요.\n처음에는 가장 큰 숫자 카드가 골라져 있어요. 받은 카드를 돌려줘도 돼요.`;
|
||||
}
|
||||
const paid = v.tax.find((t) => t.kind === 'tax' && t.from === me);
|
||||
const lines: string[] = [];
|
||||
if (paid) lines.push(`세금으로 내 가장 좋은 카드${paid.cards ? `(${faces(paid.cards)})` : ''} ${paid.count}장이 ${nm(players, paid.to)}에게 넘어갔어요. 곧 ${paid.count}장을 돌려받아요.`);
|
||||
else lines.push('세금 카드를 주고받는 중이에요.');
|
||||
if (pendingText) lines.push(`${pendingText}이 돌려줄 카드를 고르고 있어요. 기다려 주세요.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// playing
|
||||
const top = v.trick?.top ?? null;
|
||||
const topText = top ? `${rankText(top.rank)} × ${top.count}장` : '';
|
||||
const cur = v.current;
|
||||
if (my && cur === me) {
|
||||
if (my.leading) {
|
||||
const lines = ['내 차례예요. 새로 시작해요. 같은 숫자 카드를 원하는 장수만큼 낼 수 있어요.', '숫자 묶음을 누르고 −/+로 장수를 정한 뒤 [내기]를 눌러요. 새로 시작할 때는 패스할 수 없어요.'];
|
||||
if (my.jesters > 0) lines.push('광대는 [🃏 광대 섞기]로 다른 숫자와 함께 내면 그 숫자로 쳐요. 광대만 내면 13이에요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
const lines = [`내 차례예요. 지금 바닥은 ${topText}이에요.`];
|
||||
if (top!.rank === 1) lines.push('1보다 낮은 숫자는 없으니 [패스]를 눌러 주세요.');
|
||||
else {
|
||||
lines.push(`같은 ${top!.count}장으로 ${rankText(top!.rank)}보다 낮은 숫자를 내거나 [패스]를 눌러요.`);
|
||||
if (my.choices.length > 0) {
|
||||
lines.push('밝게 보이는 숫자 묶음만 낼 수 있어요. 묶음을 누른 뒤 [내기]를 눌러요.');
|
||||
if (my.choices.some((c) => c.needsJester)) lines.push("'광대 필요'라고 적힌 숫자는 광대를 섞어야 장수가 맞아요.");
|
||||
} else lines.push('낼 수 있는 카드가 없어요. [패스]를 눌러 주세요.');
|
||||
}
|
||||
lines.push(v.options.passLocksOut ? '이 방 규칙에서는 패스하면 이번 트릭이 끝날 때까지 못 내요.' : '패스해도 차례가 다시 오면 낼 수 있어요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const curNick = nm(players, cur);
|
||||
const tableLine = top ? `지금 바닥은 ${topText}(${nm(players, top.by)}이 냄)이에요.` : `${curNick}이 새로 시작해요.`;
|
||||
if (!my || !seat) return `${curNick} 차례예요. ${tableLine}\n${goal}`;
|
||||
if (seat.place !== null) return `카드를 ${seat.place}번째로 다 냈어요! 다음 라운드에서 그만큼 높은 신분이 돼요.\n${curNick} 차례예요. 남은 사람을 지켜봐요.`;
|
||||
const lines = [`${curNick} 차례예요. 기다려 주세요. ${tableLine}`];
|
||||
if (my.lockedOut) lines.push('이번 트릭은 패스해서 빠졌어요. 다음 트릭을 기다려요.');
|
||||
lines.push(`내 신분은 ${seat.title}, 남은 카드는 ${my.hand.length}장이에요.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const JESTER = 13;
|
||||
/** Same names as the rules module (kept here so the screen chunk does not pull in the rules code). */
|
||||
@@ -1002,6 +1004,8 @@ const ui: GameUI<DalmutiView, DalmutiOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
85
apps/web/src/games/dalmuti/quick.tsx
Normal file
85
apps/web/src/games/dalmuti/quick.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/** 달무티 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
const HUE: Record<number, string> = { 1: '#b7791f', 2: '#7c3aed', 3: '#4338ca', 5: '#0f766e', 7: '#4d7c0f', 9: '#c2410c', 12: '#57534e', 13: '#be185d' };
|
||||
|
||||
/** One card in a 40 × 56 box at (x, y). */
|
||||
function Card({ n, x, y }: { n: number; x: number; y: number }) {
|
||||
const c = HUE[n] ?? '#475569';
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={40} height={56} rx={6} fill="#fffdf7" stroke={c} strokeWidth={2.5} />
|
||||
<rect width={40} height={12} rx={6} fill={c} />
|
||||
<text x={20} y={40} textAnchor="middle" fontSize={n === 13 ? 20 : 24} fontWeight={900} fill={c}>
|
||||
{n === 13 ? '★' : n}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Set({ n, count, x, y }: { n: number; count: number; x: number; y: number }) {
|
||||
return (
|
||||
<g>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<Card key={i} n={n} x={x + i * 16} y={y} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<svg viewBox="0 0 250 120" width={250} height={120} aria-hidden>
|
||||
<Card n={1} x={10} y={10} />
|
||||
<Card n={5} x={70} y={10} />
|
||||
<Card n={12} x={130} y={10} />
|
||||
<Card n={13} x={200} y={10} />
|
||||
<text x={30} y={90} textAnchor="middle" fontSize={14} fontWeight={800} fill="#15803d">좋음</text>
|
||||
<text x={150} y={90} textAnchor="middle" fontSize={14} fontWeight={800} fill="#b91c1c">나쁨</text>
|
||||
<text x={220} y={90} textAnchor="middle" fontSize={14} fontWeight={800} fill="#be185d">광대</text>
|
||||
<path d="M40 104 H140" stroke="#374151" strokeWidth={3} markerEnd="url(#dm-q-arrow)" />
|
||||
<defs>
|
||||
<marker id="dm-q-arrow" viewBox="0 0 10 10" refX={8} refY={5} markerWidth={6} markerHeight={6} orient="auto">
|
||||
<path d="M0 0 L10 5 L0 10 z" fill="#374151" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
),
|
||||
alt: '숫자 1, 5, 12 카드와 광대 카드. 1이 가장 좋고 12로 갈수록 나빠요',
|
||||
title: '낮은 숫자가 좋아요',
|
||||
text: '숫자 n인 카드는 n장씩 있어요. 숫자가 낮을수록 좋아요. 광대는 다른 숫자와 같이 내면 그 숫자가 돼요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<svg viewBox="0 0 250 150" width={250} height={150} aria-hidden>
|
||||
<Set n={9} count={3} x={10} y={10} />
|
||||
<text x={125} y={44} fontSize={26} fontWeight={900} fill="#374151">→</text>
|
||||
<Set n={5} count={3} x={160} y={10} />
|
||||
<text x={40} y={94} textAnchor="middle" fontSize={14} fontWeight={800} fill="#374151">9 × 3장</text>
|
||||
<text x={192} y={94} textAnchor="middle" fontSize={14} fontWeight={800} fill="#15803d">5 × 3장</text>
|
||||
<text x={125} y={130} textAnchor="middle" fontSize={14} fontWeight={700} fill="#6b7280">같은 장수 · 더 낮은 숫자</text>
|
||||
</svg>
|
||||
),
|
||||
alt: '9 세 장 위에 5 세 장을 내는 모습',
|
||||
title: '같은 장수로 더 낮게',
|
||||
text: '새로 시작하는 사람은 같은 숫자를 원하는 장수만큼 내요. 다음 사람은 같은 장수로 더 낮은 숫자를 내거나 패스해요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<svg viewBox="0 0 250 130" width={250} height={130} aria-hidden>
|
||||
<text x={40} y={50} textAnchor="middle" fontSize={40}>👑</text>
|
||||
<text x={40} y={80} textAnchor="middle" fontSize={14} fontWeight={800} fill="#374151">대달무티</text>
|
||||
<text x={210} y={50} textAnchor="middle" fontSize={40}>🧺</text>
|
||||
<text x={210} y={80} textAnchor="middle" fontSize={14} fontWeight={800} fill="#374151">대농노</text>
|
||||
<Card n={1} x={88} y={10} />
|
||||
<Card n={2} x={122} y={10} />
|
||||
<text x={125} y={92} textAnchor="middle" fontSize={22} fontWeight={900} fill="#b91c1c">← 세금</text>
|
||||
<text x={125} y={120} textAnchor="middle" fontSize={13} fontWeight={700} fill="#6b7280">먼저 다 내면 다음엔 👑</text>
|
||||
</svg>
|
||||
),
|
||||
alt: '대농노가 좋은 카드 2장을 대달무티에게 세금으로 주는 모습',
|
||||
title: '먼저 털면 높은 신분',
|
||||
text: '카드를 먼저 다 낸 순서로 다음 신분이 정해져요. 농노는 가장 좋은 카드를 달무티에게 세금으로 주고, 같은 장수를 돌려받아요.',
|
||||
},
|
||||
];
|
||||
50
apps/web/src/games/davinci-code/help.test.ts
Normal file
50
apps/web/src/games/davinci-code/help.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B']);
|
||||
const view = (s: unknown, who: string | null) => GAMES['davinci-code']!.view(s, who) as any;
|
||||
|
||||
function dealt() {
|
||||
const { state } = setupGame('davinci-code', 2, { startTiles: 4 });
|
||||
expect(helpNow(view(state, 'A'), 'A', users)).toContain('[이렇게 받기]');
|
||||
let s = applyAction('davinci-code', state, 'A', { type: 'chooseStart', black: 2, white: 2 });
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('검정 2개, 흰색 2개를 골랐어요');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('(1/2)');
|
||||
s = applyAction('davinci-code', s, 'B', { type: 'chooseStart', black: 2, white: 2 });
|
||||
const cur: string = s.current;
|
||||
const other = cur === 'A' ? 'B' : 'A';
|
||||
return { s, cur, other };
|
||||
}
|
||||
|
||||
test('다빈치 코드: 시작 색 고르기 → 가져오기 / 기다리기 / 관전', () => {
|
||||
const { s, cur, other } = dealt();
|
||||
expect(helpNow(view(s, cur), cur, users)).toContain('색 더미 중 원하는 색');
|
||||
const curNick = users[cur]!.nickname;
|
||||
expect(helpNow(view(s, other), other, users)).toContain(`${curNick}님이 타일을 가져오는 중이에요`);
|
||||
expect(helpNow(view(s, null), null, users)).toContain(`${curNick}님 차례예요`);
|
||||
});
|
||||
|
||||
test('다빈치 코드: 추리 → 맞히면 계속/멈추기, 틀리면 공개', () => {
|
||||
const { s: s0, cur, other } = dealt();
|
||||
const s = applyAction('davinci-code', s0, cur, { type: 'draw', color: 'black' });
|
||||
const g = helpNow(view(s, cur), cur, users);
|
||||
expect(g).toContain('가져온 타일(검정');
|
||||
expect(g).toContain('[이 숫자!]');
|
||||
expect(g).toContain('가져온 타일이 모두에게 공개돼요');
|
||||
expect(helpNow(view(s, other), other, users)).toContain('추리하는 중이에요');
|
||||
// Guess correctly using the real hidden state.
|
||||
const t = s.rows[other][0];
|
||||
const n = t.id.slice(1) === 'J' ? 'joker' : Number(t.id.slice(1));
|
||||
const s2 = applyAction('davinci-code', s, cur, { type: 'guess', target: other, index: 0, value: n });
|
||||
const after = helpNow(view(s2, cur), cur, users);
|
||||
expect(after).toContain('[한 번 더 맞히기]');
|
||||
expect(after).toContain('[여기서 멈추기]');
|
||||
});
|
||||
|
||||
test('다빈치 코드: 더미가 빈 뒤 틀리면 내 타일 공개', () => {
|
||||
const { s: s0, cur } = dealt();
|
||||
const s = { ...s0, phase: { kind: 'revealOwn' } };
|
||||
expect(helpNow(view(s, cur), cur, users)).toContain('내 숨은 타일 하나를 공개해야 해요');
|
||||
});
|
||||
65
apps/web/src/games/davinci-code/help.ts
Normal file
65
apps/web/src/games/davinci-code/help.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/** 다빈치 코드 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { DavinciView, TileValue } from '@bg/games/davinci-code';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const COLOR = { black: '검정', white: '흰색' } as const;
|
||||
const nm = (players: Record<string, PublicUser>, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`;
|
||||
const valueKo = (n: TileValue | undefined) => (n === 'joker' ? '조커' : n === undefined ? '' : String(n));
|
||||
const hidden = (v: DavinciView, p: string) => (v.rows[p] ?? []).filter((t) => !t.revealed).length;
|
||||
|
||||
export function helpNow(v: DavinciView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const goal = '상대 타일의 숫자를 맞혀 모두 드러내면 그 상대는 탈락해요. 마지막까지 숨은 타일이 남은 사람이 이겨요.';
|
||||
if (v.phase === 'finished') return `게임이 끝났어요. ${v.final?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
|
||||
const seated = !!me && v.seats.includes(me);
|
||||
const alive = seated && !!v.alive[me!];
|
||||
const cur = v.current;
|
||||
const curNick = nm(players, cur);
|
||||
|
||||
if (v.phase === 'chooseStart') {
|
||||
const done = v.picked ? Object.values(v.picked).filter(Boolean).length : 0;
|
||||
const total = v.seats.filter((p) => v.alive[p]).length;
|
||||
if (!alive) return `모두 시작 타일 ${v.startCount}개의 색을 고르는 중이에요(${done}/${total}).\n${goal}`;
|
||||
if (v.myPick) return `검정 ${v.myPick.black}개, 흰색 ${v.myPick.white}개를 골랐어요. 다른 사람을 기다리는 중이에요(${done}/${total}).`;
|
||||
return `시작 타일 ${v.startCount}개를 검정과 흰색 몇 개씩 받을지 골라 [이렇게 받기]를 눌러요.\n숫자는 고른 색 안에서 무작위로 정해지고, 내 줄에 작은 수부터 자동으로 놓여요.`;
|
||||
}
|
||||
|
||||
if (!alive) {
|
||||
const head = seated ? '탈락했어요. 끝까지 구경할 수 있어요.\n' : '';
|
||||
return `${head}${curNick} 차례예요. ${goal}`;
|
||||
}
|
||||
|
||||
if (cur !== me) {
|
||||
const doing: Record<string, string> = {
|
||||
draw: '타일을 가져오는 중이에요',
|
||||
guess: '추리하는 중이에요',
|
||||
afterCorrect: '맞혀서 계속할지 고르는 중이에요',
|
||||
placeJoker: '조커를 넣을 자리를 고르는 중이에요',
|
||||
revealOwn: '틀려서 자기 타일을 공개하는 중이에요',
|
||||
};
|
||||
return `${curNick}이 ${doing[v.phase] ?? '차례를 진행하는 중이에요'}. 기다려 주세요.\n내 숨은 타일은 ${hidden(v, me!)}개 남았어요. 공개된 숫자와 위치, 추리 기록을 보며 상대 숫자를 짐작해 보세요.`;
|
||||
}
|
||||
|
||||
const pend = v.pending?.n !== undefined ? `가져온 타일(${COLOR[v.pending.color]} ${valueKo(v.pending.n)})은 나만 볼 수 있어요. ` : '';
|
||||
switch (v.phase) {
|
||||
case 'draw':
|
||||
return v.options.chooseColors
|
||||
? '내 차례예요. 가운데 검정·흰색 더미 중 원하는 색을 눌러 타일을 1개 가져와요.\n가져온 타일의 숫자는 나만 봐요.'
|
||||
: '내 차례예요. 가운데 [타일 가져오기]를 눌러 타일을 1개 가져와요.\n가져온 타일의 숫자는 나만 봐요.';
|
||||
case 'guess': {
|
||||
const penalty = v.pending ? '가져온 타일이 모두에게 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 숨은 타일 하나를 공개해야 해요' : '차례가 넘어가요';
|
||||
const dim = v.options.deductionHelper ? '숫자판에서 흐린 숫자는 이미 보이는 숫자이거나 공개된 정보로 보면 어려운 숫자예요.' : '숫자판에서 흐린 숫자는 이미 다른 곳에 보이는 숫자예요.';
|
||||
return `${pend}상대 줄에서 파란 테두리가 있는 숨은 타일을 누르고, 숫자를 골라 [이 숫자!] 버튼을 눌러요.\n타일은 작은 수부터 놓여 있으니 옆에 공개된 숫자가 힌트예요. ${dim}\n틀리면 ${penalty}.`;
|
||||
}
|
||||
case 'afterCorrect':
|
||||
return v.pending
|
||||
? '맞혔어요! [한 번 더 맞히기]로 계속하거나 [여기서 멈추기]를 눌러요.\n멈추면 가져온 타일이 숨긴 채 내 줄에 들어가요. 계속하다 틀리면 그 타일이 공개돼요.'
|
||||
: `맞혔어요! [한 번 더 맞히기]로 계속하거나 [여기서 멈추기]를 눌러요.\n더미가 비어서 ${v.options.emptyPoolPenalty === 'reveal-own' ? '계속하다 틀리면 내 숨은 타일 하나를 공개해야 해요' : '계속하다 틀려도 벌칙 없이 차례만 넘어가요'}.`;
|
||||
case 'placeJoker':
|
||||
return '조커를 넣을 자리를 골라요. 내 줄의 [여기] 버튼 중 하나를 누르세요.\n조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요.';
|
||||
case 'revealOwn':
|
||||
return '틀렸어요. 더미가 비어서 내 숨은 타일 하나를 공개해야 해요.\n내 줄에서 파란 테두리 타일 중 하나를 누르세요.';
|
||||
default:
|
||||
return `내 차례예요. ${goal}`;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
|
||||
const COLOR_SHORT: Record<TColor, string> = { black: '검정', white: '흰색' };
|
||||
@@ -951,6 +953,8 @@ const ui: GameUI<DavinciView, DavinciOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
54
apps/web/src/games/davinci-code/quick.tsx
Normal file
54
apps/web/src/games/davinci-code/quick.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/** 다빈치 코드 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
type T = { c: 'b' | 'w'; n?: number | string; open?: boolean; mark?: 'ok' | 'ask' };
|
||||
|
||||
/** A row of tiles: black/white bodies; `n` shown when known, otherwise a hidden back. */
|
||||
function Row({ tiles, label }: { tiles: T[]; label?: string }) {
|
||||
const w = 40;
|
||||
const gap = 8;
|
||||
const width = tiles.length * (w + gap) - gap;
|
||||
return (
|
||||
<svg viewBox={`-4 -30 ${width + 8} 104`} width={Math.min(250, width + 8)} height={150} aria-hidden>
|
||||
{label && (
|
||||
<text x={width / 2} y={-12} textAnchor="middle" fontSize={15} fontWeight={700} fill="currentColor">
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
{tiles.map((t, i) => {
|
||||
const x = i * (w + gap);
|
||||
const dark = t.c === 'b';
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x} y={0} width={w} height={60} rx={6} fill={dark ? '#1f1f1f' : '#f7f7f2'} stroke={t.mark === 'ask' ? '#2563eb' : dark ? '#000' : '#888'} strokeWidth={t.mark === 'ask' ? 4 : 1.5} />
|
||||
<text x={x + w / 2} y={40} textAnchor="middle" fontSize={26} fontWeight={800} fill={dark ? '#fff' : '#111'} opacity={t.n === undefined ? 0.35 : 1}>
|
||||
{t.n === undefined ? '?' : t.n}
|
||||
</text>
|
||||
{t.open && <circle cx={x + w - 4} cy={4} r={7} fill="#16a34a" />}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: <Row label="내 줄" tiles={[{ c: 'b', n: 1 }, { c: 'w', n: 4 }, { c: 'w', n: 7 }, { c: 'b', n: 10 }]} />,
|
||||
alt: '검정 1, 흰색 4, 흰색 7, 검정 10 타일이 작은 수부터 차례로 놓여 있어요',
|
||||
title: '작은 수부터 놓여요',
|
||||
text: '검정·흰색 숫자 타일(0~11)이 왼쪽부터 작은 수 순서로 자동으로 놓여요. 상대는 내 타일의 색만 보고 숫자는 몰라요.',
|
||||
},
|
||||
{
|
||||
pic: <Row label="상대 줄: 몇일까요?" tiles={[{ c: 'w', n: 2, open: true }, { c: 'b', mark: 'ask' }, { c: 'w' }, { c: 'b', n: 9, open: true }]} />,
|
||||
alt: '상대 줄의 숨은 검정 타일 하나에 파란 테두리가 있고, 옆에 공개된 흰색 2와 검정 9가 보여요',
|
||||
title: '가져오고, 맞혀요',
|
||||
text: '내 차례엔 더미에서 타일 1개를 가져온 뒤, 상대의 숨은 타일 하나를 골라 숫자를 말해요. 옆 숫자가 힌트예요.',
|
||||
},
|
||||
{
|
||||
pic: <Row label="맞히면 공개!" tiles={[{ c: 'w', n: 2, open: true }, { c: 'b', n: 5, open: true }, { c: 'w' }, { c: 'b', n: 9, open: true }]} />,
|
||||
alt: '맞힌 검정 5 타일이 공개되어 초록 점이 붙어 있어요',
|
||||
title: '다 드러나면 탈락',
|
||||
text: '맞히면 그 타일이 공개되고 더 맞힐지 멈출지 골라요. 틀리면 가져온 타일이 공개돼요. 마지막까지 숨은 타일이 남은 사람이 이겨요.',
|
||||
},
|
||||
];
|
||||
47
apps/web/src/games/dobble/help.test.ts
Normal file
47
apps/web/src/games/dobble/help.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { answerArt, pairsFor } from '@bg/games/dobble';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B', 'C']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.dobble!.view(s, who) as any;
|
||||
|
||||
function playing(options: Record<string, unknown> = {}) {
|
||||
const { state } = setupGame('dobble', 3, options);
|
||||
state.phase = 'playing';
|
||||
return state;
|
||||
}
|
||||
|
||||
test('도블: 카운트다운 / 탑 쌓기 진행 중 / 관전', () => {
|
||||
const { state: s0 } = setupGame('dobble', 3);
|
||||
expect(helpNow(view(s0, 'A'), 'A', users)).toContain('곧 카드가 공개돼요');
|
||||
const s = playing();
|
||||
const t = helpNow(view(s, 'A'), 'A', users);
|
||||
expect(t).toContain('탑 쌓기');
|
||||
expect(t).toContain('가운데 카드와 내 카드');
|
||||
expect(t).toContain('1초 동안');
|
||||
const spec = helpNow(view(s, null), null, users);
|
||||
expect(spec).toContain('게임 중이에요');
|
||||
expect(spec).not.toContain('누르면 돼요');
|
||||
});
|
||||
|
||||
test('도블: 뜨거운 감자 — 대상 고르기, 탈출, 라운드 끝', () => {
|
||||
const s = playing({ miniGame: 'hotPotato', wrongLockoutMs: 0 });
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('넘길 사람을 먼저 골라요');
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('잠기지 않아요');
|
||||
const pair = pairsFor(s, 'A')[0]!;
|
||||
const s2 = applyAction('dobble', s, 'A', { type: 'claim', base: pair.base, target: pair.target!, mine: pair.mine!, symbol: answerArt(s, 'A', pair)! });
|
||||
expect(helpNow(view(s2, 'A'), 'A', users)).toContain('탈출했어요');
|
||||
s2.phase = 'roundEnd';
|
||||
s2.lastRound = { round: 1, loser: 'B', cards: 2 };
|
||||
expect(helpNow(view(s2, 'C'), 'C', users)).toContain('고양이님이 카드 2장을 벌점으로');
|
||||
});
|
||||
|
||||
test('도블: 독이 든 선물 목표와 게임 끝', () => {
|
||||
const s = playing({ miniGame: 'poisonedGift' });
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('가장 적게 받으면 1등');
|
||||
s.phase = 'ended';
|
||||
s.final = { miniGame: 'poisonedGift', ranking: [], stats: {}, summary: '독이 든 선물 1등 호랑이님!' };
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('1등 호랑이님');
|
||||
});
|
||||
45
apps/web/src/games/dobble/help.ts
Normal file
45
apps/web/src/games/dobble/help.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** 도블 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { DobbleView, MiniGame } from '@bg/games/dobble';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
|
||||
|
||||
/** What to compare, what a find does, how to win (§5.2). */
|
||||
const GOAL: Record<MiniGame, string> = {
|
||||
tower: '가운데 카드와 내 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 가운데 카드를 내 탑으로 가져와요. 카드를 가장 많이 모으면 1등이에요.',
|
||||
well: '가운데 카드와 내 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 내 카드를 가운데에 내려놓아요. 내 카드를 먼저 다 비우면 1등이에요.',
|
||||
hotPotato:
|
||||
'내 카드와 다른 사람 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 내 카드 묶음을 그 사람에게 넘기고 이번 라운드는 빠져요. 마지막까지 카드를 들고 있으면 그 카드가 벌점이에요.',
|
||||
poisonedGift: '가운데 카드와 다른 사람 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등이에요.',
|
||||
};
|
||||
|
||||
export function helpNow(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const nick = (id: string) => players[id]?.nickname ?? '다른 사람';
|
||||
const mode = v.options.miniGame;
|
||||
const name = MINI_KO[mode];
|
||||
if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위에서 각자 카드 수와 찾은 횟수를 볼 수 있어요.';
|
||||
const round = mode === 'hotPotato' ? ` (${v.round}/${v.rounds}라운드)` : '';
|
||||
if (v.phase === 'countdown') return `곧 카드가 공개돼요. 이번 게임은 '${name}'${round}이에요. 차례 없이 모두 동시에 찾아요.\n${GOAL[mode]}`;
|
||||
if (v.phase === 'roundEnd') {
|
||||
const r = v.lastRound;
|
||||
const who = r?.loser ? (r.loser === me ? '내가' : `${nick(r.loser)}님이`) : null;
|
||||
const what = r && who ? `${r.round}라운드에서 ${who} 카드 ${r.cards}장을 벌점으로 받았어요. ` : '';
|
||||
return `라운드가 끝났어요. ${what}잠시 뒤 다음 라운드가 시작돼요.`;
|
||||
}
|
||||
const mine = v.seats.find((s) => s.id === me) ?? null;
|
||||
if (!mine) return `'${name}' 게임 중이에요${round}. 모두 동시에 두 카드의 같은 그림을 찾고 있어요.\n${GOAL[mode]}`;
|
||||
if (mine.status === 'finished') return `내 카드를 다 비웠어요!${mine.place ? ` ${mine.place}등이에요.` : ''} 남은 사람들의 게임을 지켜봐요.`;
|
||||
if (mine.status === 'escaped') return `이번 라운드는 탈출했어요${round}. 남은 사람들이 카드를 넘기는 걸 지켜보고 다음 라운드를 기다려요.`;
|
||||
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요. 남은 사람들의 게임을 지켜봐요.';
|
||||
if (v.pairs.length === 0) return `지금은 비교할 카드가 없어요. 잠시 기다려 주세요.\n${GOAL[mode]}`;
|
||||
const lines = [`'${name}'${round} 게임 중이에요. 차례 없이 모두 동시에 찾아요. 어느 두 카드든 똑같은 그림은 딱 하나예요.`, GOAL[mode]];
|
||||
lines.push(`두 카드 중 아무 쪽에서나 그 그림을 누르면 돼요.${v.options.symbolStyle === 'classic' ? ' 크기와 방향이 달라도 같은 그림이에요.' : ''}`);
|
||||
if ((mode === 'hotPotato' || mode === 'poisonedGift') && v.pairs.length > 1) {
|
||||
lines.push(mode === 'poisonedGift' ? '위쪽 사람 목록에서 선물을 줄 사람을 먼저 골라요.' : '위쪽 사람 목록에서 카드를 넘길 사람을 먼저 골라요.');
|
||||
}
|
||||
const o = v.options;
|
||||
const lock = o.wrongLockoutMs ? `틀린 그림을 누르면 ${o.wrongLockoutMs / 1000}초 동안 못 눌러요.` : '이 방은 틀려도 잠기지 않아요.';
|
||||
const give = mode === 'tower' && o.wrongGivesCard ? ' 틀리면 내 탑 맨 위 카드도 1장 돌려줘요.' : '';
|
||||
lines.push(lock + give);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { socket, useRoom } from '../../net/socket';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
import { ICON_NAMES, SymbolArt } from './art';
|
||||
|
||||
/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */
|
||||
@@ -849,6 +851,8 @@ const ui: GameUI<DobbleView, DobbleOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
98
apps/web/src/games/dobble/quick.tsx
Normal file
98
apps/web/src/games/dobble/quick.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/** 도블 30초 규칙 요약 (docs/07 §5). Pictures reuse the game's own icons (art.tsx). */
|
||||
import type { ReactNode } from 'react';
|
||||
import { ICONS } from './art';
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
// Icon ids (art.tsx order): 사과 0, 해 1, 별 3, 하트 4, 꽃 11, 고양이 18, 물고기 20, 열쇠 28, 모자 36, 종 38, 자동차 41, 주사위 45.
|
||||
const SLOTS: [number, number][] = [
|
||||
[0, 0],
|
||||
[0, -26],
|
||||
[24, -12],
|
||||
[24, 14],
|
||||
[0, 28],
|
||||
[-24, 14],
|
||||
[-24, -12],
|
||||
];
|
||||
|
||||
function Icon({ art, x, y, size = 20, ring }: { art: number; x: number; y: number; size?: number; ring?: 'ok' | 'bad' }) {
|
||||
const draw = ICONS[art];
|
||||
return (
|
||||
<g>
|
||||
{ring && <circle cx={x} cy={y} r={size * 0.62} fill={ring === 'ok' ? '#bbf7d0' : '#fecaca'} stroke={ring === 'ok' ? '#16a34a' : '#dc2626'} strokeWidth={2.5} />}
|
||||
<g transform={`translate(${x - size / 2} ${y - size / 2}) scale(${size / 100})`}>{draw ? draw() : null}</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** A round card at (cx, cy) with up to 7 icons; `rings` marks icons by id. */
|
||||
function RoundCard({ cx, cy, arts, rings = {} }: { cx: number; cy: number; arts: number[]; rings?: Record<number, 'ok' | 'bad'> }) {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={50} fill="#fff" stroke="#334155" strokeWidth={2.5} />
|
||||
{arts.map((a, i) => (
|
||||
<Icon key={a} art={a} x={cx + SLOTS[i]![0]} y={cy + SLOTS[i]![1]} ring={rings[a]} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Pic({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg viewBox="0 0 250 150" width={250} height={150} aria-hidden>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const A = [18, 0, 1, 3, 11, 28, 45];
|
||||
const B = [20, 41, 18, 36, 4, 38, 33];
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<Pic>
|
||||
<RoundCard cx={64} cy={70} arts={A} />
|
||||
<RoundCard cx={186} cy={70} arts={B} />
|
||||
<text x={64} y={140} textAnchor="middle" fontSize={13} fontWeight={700} fill="currentColor">
|
||||
가운데 카드
|
||||
</text>
|
||||
<text x={186} y={140} textAnchor="middle" fontSize={13} fontWeight={700} fill="currentColor">
|
||||
내 카드
|
||||
</text>
|
||||
</Pic>
|
||||
),
|
||||
alt: '그림이 여러 개 그려진 동그란 카드 두 장이 나란히 있어요',
|
||||
title: '두 카드를 비교해요',
|
||||
text: '동그란 카드 두 장에는 똑같은 그림이 언제나 딱 하나 있어요. 차례 없이 모두 동시에 그 그림을 찾아요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Pic>
|
||||
<RoundCard cx={64} cy={70} arts={A} rings={{ 18: 'ok' }} />
|
||||
<RoundCard cx={186} cy={70} arts={B} rings={{ 18: 'ok', 20: 'bad' }} />
|
||||
<text x={125} y={142} textAnchor="middle" fontSize={14} fontWeight={800} fill="#16a34a">
|
||||
고양이 찾았다!
|
||||
</text>
|
||||
</Pic>
|
||||
),
|
||||
alt: '두 카드 모두에 있는 고양이 그림에 초록 동그라미, 한쪽에만 있는 물고기에 빨간 동그라미가 있어요',
|
||||
title: '같은 그림을 먼저 눌러요',
|
||||
text: '찾은 그림을 두 카드 중 아무 쪽에서나 누르면 돼요. 가장 먼저 누른 사람만 성공이에요. 틀리면 방 규칙에 따라 잠깐 못 눌러요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Pic>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<ellipse key={i} cx={90} cy={120 - i * 14} rx={50} ry={16} fill="#fff" stroke="#334155" strokeWidth={2.5} />
|
||||
))}
|
||||
<Icon art={18} x={90} y={78} size={22} />
|
||||
<text x={190} y={100} textAnchor="middle" fontSize={56} aria-hidden>
|
||||
🏆
|
||||
</text>
|
||||
</Pic>
|
||||
),
|
||||
alt: '동그란 카드가 탑처럼 쌓여 있고 옆에 트로피가 있어요',
|
||||
title: '미니게임마다 목표가 달라요',
|
||||
text: '탑 쌓기는 많이 모으면, 우물은 내 카드를 먼저 비우면, 독이 든 선물은 적게 받으면, 뜨거운 감자는 벌점이 적으면 이겨요.',
|
||||
},
|
||||
];
|
||||
148
apps/web/src/games/gostop/help.test.ts
Normal file
148
apps/web/src/games/gostop/help.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, assertCleanText, eachView, seatIds, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(seatIds(5));
|
||||
const view = (s: unknown, who: string | null) => GAMES.gostop!.view(s, who) as any;
|
||||
const help = (s: unknown, who: string | null) => helpNow(view(s, who), who, users);
|
||||
|
||||
// Card ids: month m → (m-1)*4 + slot. 1월 0광 1홍단, 2월 5홍단, 3월 8광 9홍단 10·11피, 4월 13초단, 6월 21청단, 8월 28광, 9월 32국진.
|
||||
const SEVEN = [0, 8, 28, 1, 5, 9, 13, 21]; // 삼광 3 + 홍단 3 + 띠 5장 1 = 7점
|
||||
|
||||
/** 2인 맞고 state with exactly these cards, A to act. */
|
||||
function rig(spec: { hands: number[][]; floor?: number[]; deck?: number[]; captured?: number[][] }, opts: Record<string, unknown> = {}) {
|
||||
const { state } = setupGame('gostop', 2, { chongtong: 'off', bonusCards: 'none', ...opts });
|
||||
const r = state.round;
|
||||
r.participants = [0, 1];
|
||||
r.turn = 0;
|
||||
r.step = 'play';
|
||||
r.hands = spec.hands.map((h) => [...h]);
|
||||
r.deck = [...(spec.deck ?? [36, 37, 38, 39])];
|
||||
r.captured = [spec.captured?.[0] ?? [], spec.captured?.[1] ?? []];
|
||||
const floor: { month: number; cards: number[]; ppeokBy: null; natural: boolean }[] = [];
|
||||
for (const c of spec.floor ?? []) {
|
||||
const m = Math.floor(c / 4) + 1;
|
||||
const g = floor.find((x) => x.month === m);
|
||||
if (g) g.cards.push(c);
|
||||
else floor.push({ month: m, cards: [c], ppeokBy: null, natural: false });
|
||||
}
|
||||
r.floor = floor.sort((a, b) => a.month - b.month);
|
||||
return state;
|
||||
}
|
||||
|
||||
test('고스톱: 내 차례 / 상대 차례 / 관전 (맞고)', () => {
|
||||
const s = rig({ hands: [[2, 25, 42], [11, 15, 43]], floor: [3, 24, 40], captured: [[0, 8], [44]] });
|
||||
const a = help(s, 'A');
|
||||
expect(a).toContain('내 차례예요');
|
||||
expect(a).toContain('초록 테두리 패');
|
||||
expect(a).toContain('지금 내 점수 0점');
|
||||
expect(a).toContain('7점부터 고·스톱');
|
||||
const b = help(s, 'B');
|
||||
expect(b).toContain('호랑이님 차례예요');
|
||||
expect(b).toContain('기다려 주세요');
|
||||
const spec = help(s, null);
|
||||
expect(spec).toContain('호랑이님 차례예요');
|
||||
expect(spec).not.toContain('내 점수');
|
||||
});
|
||||
|
||||
test('고스톱: 흔들기·폭탄·두 장 중 고르기·빈패 안내', () => {
|
||||
// 5월 3장(바닥 없음) → 흔들기, 7월 3장 + 바닥 1장 → 폭탄, 3월(바닥 광·피) → 고르기.
|
||||
const s = rig({ hands: [[16, 17, 18, 24, 25, 26, 11], [2]], floor: [27, 8, 10] });
|
||||
s.round.bombDummies[0] = 2;
|
||||
const a = help(s, 'A');
|
||||
expect(a).toContain('[흔들고 내기]');
|
||||
expect(a).toContain('[폭탄!]');
|
||||
expect(a).toContain('어느 패를 가져올까요?');
|
||||
expect(a).toContain('[빈패 내기 (2장 남음)]');
|
||||
});
|
||||
|
||||
test('고스톱: 고/스톱 선택 — 버튼 이름, 받을 점수, 상대가 낼 점수', () => {
|
||||
let s = rig({ hands: [[25, 42], [11, 15]], floor: [24, 40], captured: [SEVEN.slice(0, -1).concat([33]), [44]] });
|
||||
s = applyAction('gostop', s, 'A', { type: 'play', card: 25 });
|
||||
expect(s.round.step).toBe('goStop');
|
||||
const a = help(s, 'A');
|
||||
expect(a).toContain('[고]');
|
||||
expect(a).toContain('[스톱]');
|
||||
expect(a).toContain('8점이 났어요');
|
||||
expect(a).toContain('고박');
|
||||
const b = help(s, 'B');
|
||||
expect(b).toContain('호랑이님이 8점을 내서 고 또는 스톱을 고르는 중');
|
||||
expect(b).toMatch(/나는 \d+점을 내요/);
|
||||
expect(help(s, null)).toContain('고 또는 스톱을 고르는 중');
|
||||
// 스톱 → 판 결과
|
||||
const r = applyAction('gostop', s, 'A', { type: 'goStop', decision: 'stop' });
|
||||
expect(r.phase).toBe('roundResult');
|
||||
expect(help(r, 'A')).toContain('내가 스톱해서 이겼어요');
|
||||
expect(help(r, 'A')).toContain('[다음 판으로]');
|
||||
expect(help(r, 'B')).toContain('호랑이님이 스톱해서 이겼어요');
|
||||
expect(help(r, null)).toContain('모두 준비하면');
|
||||
});
|
||||
|
||||
test('고스톱: 마지막 판이 끝나면 최종 결과', () => {
|
||||
let s = rig({ hands: [[25, 42], [11, 15]], floor: [24, 40], captured: [SEVEN.slice(0, -1).concat([33]), [44]] }, { rounds: 1 });
|
||||
s = applyAction('gostop', s, 'A', { type: 'play', card: 25 });
|
||||
s = applyAction('gostop', s, 'A', { type: 'goStop', decision: 'stop' });
|
||||
expect(s.phase).toBe('finished');
|
||||
expect(help(s, 'A')).toContain('게임이 끝났어요');
|
||||
expect(help(s, 'A')).toContain('[한 판 더]');
|
||||
expect(help(s, null)).not.toContain('[한 판 더]');
|
||||
});
|
||||
|
||||
test('고스톱: 뒤집은 패 고르기, 국진, 총통', () => {
|
||||
const f = rig({ hands: [[25], [11]], floor: [8, 9] });
|
||||
f.round.step = 'chooseFlip';
|
||||
f.round.pending = { flipped: 10, options: [8, 9], ctx: { player: 0, played: [25], flipped: [10], captured: [], tags: [], steal: 0, lastOfRound: false } };
|
||||
expect(help(f, 'A')).toContain('창에서 가져올 패를 눌러 골라요');
|
||||
expect(help(f, 'B')).toContain('호랑이님이 뒤집은 패');
|
||||
|
||||
const g = rig({ hands: [[25], [11]], captured: [[0, 8, 28, 4, 12, 16, 20, 32, 2, 3, 6, 7, 10, 11, 14], [44]] }, { gukjinMode: 'ask' });
|
||||
g.round.step = 'gukjin';
|
||||
g.round.stopBy = 0;
|
||||
const gk = help(g, 'A');
|
||||
expect(gk).toContain('[열끗으로]');
|
||||
expect(gk).toContain('[쌍피로]');
|
||||
expect(help(g, 'B')).toContain('호랑이님이 9월 열끗(국진)');
|
||||
|
||||
const c = rig({ hands: [[0, 1, 2, 3, 4], [11]] }, { chongtong: 'win' });
|
||||
c.round.step = 'chongtong';
|
||||
const ct = help(c, 'A');
|
||||
expect(ct).toContain('[총통 선언]');
|
||||
expect(ct).toContain('[그냥 치기]');
|
||||
expect(ct).toContain('1월');
|
||||
const other = help(c, 'B');
|
||||
expect(other).toContain('총통');
|
||||
expect(other).not.toContain('1월');
|
||||
});
|
||||
|
||||
test('고스톱: 4명 광팔기 — 고르는 사람 / 기다리는 사람 / 관전', () => {
|
||||
const { state } = setupGame('gostop', 4, {});
|
||||
expect(state.round.step).toBe('sell');
|
||||
const cur = view(state, null).sell.current as string;
|
||||
expect(help(state, cur)).toContain('[치기]');
|
||||
expect(help(state, cur)).toContain('[죽기');
|
||||
const dealer = view(state, null).dealer as string;
|
||||
expect(help(state, dealer)).toContain('나는 선이라');
|
||||
expect(help(state, null)).toContain('칠지(치기)');
|
||||
});
|
||||
|
||||
test('고스톱: 2명·4명 무작위 판의 모든 화면에서 깨끗한 안내', () => {
|
||||
for (const [n, options] of [
|
||||
[2, {}],
|
||||
[2, { gukjinMode: 'ask', rounds: 3 }],
|
||||
[4, { rounds: 3 }],
|
||||
] as const) {
|
||||
const u = usersOf(seatIds(n));
|
||||
let count = 0;
|
||||
eachView(
|
||||
'gostop',
|
||||
n,
|
||||
(v, who) => {
|
||||
assertCleanText(helpNow(v, who, u), `gostop n=${n} viewer=${who} step=${v.step} phase=${v.phase}`);
|
||||
count++;
|
||||
},
|
||||
{ steps: 600, options: options as Record<string, unknown> },
|
||||
);
|
||||
expect(count).toBeGreaterThan(n * 10);
|
||||
}
|
||||
});
|
||||
165
apps/web/src/games/gostop/help.ts
Normal file
165
apps/web/src/games/gostop/help.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/** 고스톱 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { BakKind, GostopView, SeatView } from '@bg/games/gostop';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const BAK: Record<BakKind, string> = { pi: '피박', gwang: '광박', meong: '멍박', go: '고박' };
|
||||
|
||||
function myScoreLine(v: GostopView, seat: SeatView): string {
|
||||
const s = seat.score;
|
||||
const items = s.items.length ? `(${s.items.map((x) => `${x.name} ${x.points}점`).join(' · ')})` : '';
|
||||
const go = seat.goCount > 0 ? ` 나는 지금 ${seat.goCount}고예요.` : '';
|
||||
return `지금 내 점수 ${s.total}점${items}, ${v.rules.winScore}점부터 고·스톱을 골라요.${go}`;
|
||||
}
|
||||
|
||||
export function helpNow(v: GostopView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const nm = (id: string | null | undefined) => {
|
||||
const n = id ? players[id]?.nickname : undefined;
|
||||
return n ? `${n}님` : '다른 사람';
|
||||
};
|
||||
const seat = me ? v.seats.find((s) => s.id === me) : undefined;
|
||||
const seatOf = (id: string | null) => v.seats.find((s) => s.id === id);
|
||||
|
||||
if (v.phase === 'finished') {
|
||||
const lines = [`게임이 끝났어요. ${v.result?.summary ?? ''}`.trim()];
|
||||
lines.push(seat ? `내 방 점수 합계는 ${seat.total > 0 ? '+' : ''}${seat.total}점이에요. '최종 순위'에서 순위를 볼 수 있어요.` : "'최종 순위'에서 순위를 볼 수 있어요.");
|
||||
if (seat) lines.push('[한 판 더]를 누르면 다시 할 수 있어요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (v.phase === 'roundResult') {
|
||||
const o = v.outcome;
|
||||
const lines: string[] = [];
|
||||
if (!o || o.type === 'nagari') {
|
||||
const next = v.rules.nagariMultiplier === 'x2' ? Math.min(2 ** v.nagariStreak, v.rules.nagariMaxMultiplier) : 1;
|
||||
lines.push(`${v.roundNo}판은 나가리예요. 아무도 나지 못해서 점수가 오가지 않았어요.${next > 1 ? ` 다음 판은 점수 x${next}이에요.` : ''}`);
|
||||
} else {
|
||||
const who = o.winner && o.winner === me ? '내가' : `${nm(o.winner)}이`;
|
||||
const how = o.type === 'chongtong' ? '총통으로' : o.type === 'samppeok' ? '삼뻑으로' : '스톱해서';
|
||||
const d = seat ? (o.delta[seat.id] ?? 0) : 0;
|
||||
lines.push(`${v.roundNo}판은 ${who} ${how} 이겼어요.${seat ? ` 이번 판 내 점수 변화는 ${d > 0 ? '+' : ''}${d}점이에요.` : ''} 결과 표에서 족보별 점수와 배수를 볼 수 있어요.`);
|
||||
}
|
||||
if (seat && !seat.left) {
|
||||
lines.push(seat.ready ? '다른 사람이 [다음 판으로]를 누르기를 기다리는 중이에요.' : '[다음 판으로]를 눌러 주세요. 10초가 지나면 저절로 넘어가요.');
|
||||
} else lines.push('모두 준비하면 다음 판이 시작돼요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (seat?.autoPilot && !seat.left) lines.push('시간을 두 번 넘겨서 지금은 자동으로 진행돼요. 직접 고르면 자동이 풀려요.');
|
||||
const mult = v.nagariMult > 1 ? ` 이번 판은 나가리 다음 판이라 점수 x${v.nagariMult}이에요.` : '';
|
||||
|
||||
switch (v.step) {
|
||||
case 'sell': {
|
||||
const s = v.sell;
|
||||
const cur = s?.current ?? null;
|
||||
if (seat && cur === me && s) {
|
||||
const n = s.mySellable?.length ?? 0;
|
||||
const gain = n * v.rules.gwangPrice * (v.rules.gwangPayer === 'nonDealerPlayers' ? 2 : 3);
|
||||
lines.push('광팔기 차례예요. 4~5명이면 이번 판은 3명만 쳐요(선은 꼭 쳐요).');
|
||||
lines.push(
|
||||
`[치기]를 누르면 이번 판에 참가해요. ${n > 0 ? `[죽기 · 광 ${n}장 팔기]를 누르면 ${gain}점을 받고 이번 판은 쉬어요.` : '[죽기]를 누르면 팔 패가 없어 그냥 쉬어요.'} 시간이 지나면 죽기로 정해져요.`,
|
||||
);
|
||||
} else if (seat) {
|
||||
const mine = s?.decisions.find((d) => d.player === me);
|
||||
const self = seat.isDealer ? '나는 선이라 이번 판에 꼭 쳐요.' : mine ? `나는 ${mine.sell ? '죽기(광 팔기)' : '치기'}로 정했어요.` : '내 차례가 오면 칠지 죽을지 골라요.';
|
||||
lines.push(`${cur ? `${nm(cur)}이 이번 판에 칠지 죽을지 고르는 중이에요.` : '광팔기를 정리하는 중이에요.'} ${self}`);
|
||||
} else {
|
||||
lines.push(`${cur ? `${nm(cur)}이 이번 판에 칠지(치기), 광을 팔고 쉴지(죽기) 고르는 중이에요.` : '광팔기를 정리하는 중이에요.'} 4~5명이면 3명만 쳐요.`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'chongtong': {
|
||||
const who = v.chongtongAsk?.player ?? v.turn;
|
||||
if (seat && who === me) {
|
||||
const months = v.chongtongAsk?.months ?? [];
|
||||
const others = v.seats.filter((x) => x.participant && x.id !== me).length;
|
||||
const each = v.rules.chongtongPoints * v.nagariMult;
|
||||
lines.push(`총통이에요! 손에 같은 월 4장${months.length ? `(${months.map((m) => `${m}월`).join(', ')})` : ''}이 있어요.`);
|
||||
lines.push(`[총통 선언]을 누르면 이번 판을 바로 이기고 상대마다 ${each}점, 모두 ${each * others}점을 받아요. [그냥 치기]를 누르면 판을 계속하고, 그 월로 흔들기나 폭탄을 할 수 있어요.`);
|
||||
lines.push('시간이 지나면 총통 선언으로 정해져요.');
|
||||
} else {
|
||||
lines.push(`${nm(who)}이 총통(손에 같은 월 4장)을 선언할지 고르는 중이에요. 선언하면 ${nm(who)}이 이번 판을 바로 이겨요.`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'play': {
|
||||
const who = v.turn;
|
||||
if (seat && who === me && seat.participant) {
|
||||
lines.push(`내 차례예요. 손패에서 낼 패를 눌러요(패가 살짝 올라오면 한 번 더 눌러요). 낸 패와 더미에서 뒤집은 패가 바닥의 같은 월 패와 맞으면 함께 가져와요.${mult}`);
|
||||
const hand = v.myHand ?? [];
|
||||
const extra: string[] = [];
|
||||
if (v.rules.hints) {
|
||||
const canEat = hand.filter((h) => h.matches.length > 0).length;
|
||||
extra.push(canEat > 0 ? '초록 테두리 패는 바닥에 같은 월이 있어 먹을 수 있어요.' : '지금은 바닥과 맞는 패가 없어요. 덜 아까운 패를 내요.');
|
||||
const tips = hand.filter((h) => h.hint && h.card.kind !== 'bonus').slice(0, 2);
|
||||
if (tips.length) extra.push(`힌트: ${tips.map((h) => `${h.card.name} → ${h.hint}`).join(', ')}.`);
|
||||
}
|
||||
if (hand.some((h) => h.card.kind === 'bonus')) extra.push('보너스패는 내면 바로 내 피가 되고, 더미에서 1장을 받아 한 번 더 내요.');
|
||||
if (hand.some((h) => h.canBomb)) extra.push("'폭탄' 표시 패를 누르면 [폭탄!]과 [1장만 내기] 중에서 골라요. 폭탄은 같은 월 3장을 한꺼번에 내서 바닥 1장까지 가져오고, 상대마다 피 1장을 받아요(이기면 x2).");
|
||||
if (hand.some((h) => h.canShake)) extra.push("'흔들기' 표시 패를 누르면 [흔들고 내기]와 [그냥 내기] 중에서 골라요. 흔들면 같은 월 3장을 보여 주고, 이번 판을 이기면 점수가 2배예요.");
|
||||
if (hand.some((h) => h.needTarget)) extra.push("바닥에 같은 월이 2장인 패를 내면 '어느 패를 가져올까요?' 창에서 가져올 패를 눌러 골라요.");
|
||||
if (v.myDummies > 0) extra.push(`[빈패 내기 (${v.myDummies}장 남음)]를 누르면 손패를 쓰지 않고 더미만 뒤집어요.`);
|
||||
lines.push(...extra);
|
||||
lines.push(myScoreLine(v, seat));
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (seat && !seat.participant) {
|
||||
lines.push(`이번 판은 광을 팔고 쉬어요. 지금은 ${nm(who)} 차례예요.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (seat) {
|
||||
lines.push(`${nm(who)} 차례예요. 패를 낼 때까지 기다려 주세요.${mult}`);
|
||||
lines.push(myScoreLine(v, seat));
|
||||
return lines.join('\n');
|
||||
}
|
||||
const ws = seatOf(who);
|
||||
lines.push(`${nm(who)} 차례예요. 손패 1장을 내고 더미에서 1장을 뒤집어, 바닥의 같은 월 패를 가져가요.${mult}`);
|
||||
if (ws) lines.push(`${nm(who)}은 지금 ${ws.score.total}점이에요${ws.goCount > 0 ? `(${ws.goCount}고)` : ''}. ${v.rules.winScore}점부터 고·스톱을 골라요.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'chooseFlip': {
|
||||
const pf = v.pendingFlip;
|
||||
const who = pf?.player ?? v.turn;
|
||||
if (seat && who === me && pf) {
|
||||
lines.push(`뒤집은 ${pf.flipped.name}과(와) 같은 월 패가 바닥에 2장(${pf.options.map((c) => c.name).join(', ')}) 있어요. 창에서 가져올 패를 눌러 골라요.`);
|
||||
lines.push('시간이 지나면 더 좋은 패를 저절로 가져와요.');
|
||||
} else {
|
||||
lines.push(`${nm(who)}이 뒤집은 패${pf ? `(${pf.flipped.name})` : ''}로 가져올 바닥 패를 고르는 중이에요.`);
|
||||
}
|
||||
if (seat?.participant && who !== me) lines.push(myScoreLine(v, seat));
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'goStop': {
|
||||
const g = v.goStop;
|
||||
const who = g?.player ?? v.turn;
|
||||
if (seat && who === me && g) {
|
||||
const notes = g.stopNow.notes.length ? `(${g.stopNow.notes.join(', ')})` : '';
|
||||
const baks = g.stopNow.perLoser.filter((x) => x.baks.length).map((x) => `${nm(x.player)} ${x.baks.map((b) => BAK[b]).join('·')}`);
|
||||
const risk = v.rules.goBak ? (v.mode === 'matgo' ? ' 고를 하고 상대가 먼저 나면 고박으로 2배를 내요.' : ' 고를 하고 다른 사람이 먼저 나면 독박으로 진 사람 몫을 혼자 다 낼 수 있어요.') : ' 고를 하고 다른 사람이 먼저 나면 내가 지게 돼요.';
|
||||
lines.push(`${g.score}점이 났어요! [고] 또는 [스톱]을 골라요.${g.goCount > 0 ? ` 지금까지 ${g.goCount}고 했어요.` : ''}`);
|
||||
lines.push(`[스톱]: 판을 끝내고 ${g.stopNow.total}점을 받아요${notes}.${baks.length ? ` ${baks.join(', ')}이 붙어요.` : ''}`);
|
||||
lines.push(`[고]: ${g.goNext.goCount}고로 판을 계속해요. +${g.goNext.plus}점${g.goNext.factor > 1 ? `, 점수 x${g.goNext.factor}` : ''}.${risk}`);
|
||||
lines.push('시간이 지나면 스톱으로 정해져요.');
|
||||
} else {
|
||||
lines.push(`${nm(who)}이 ${g ? `${g.score}점을 내서 ` : ''}고 또는 스톱을 고르는 중이에요. 스톱하면 이번 판이 끝나요.`);
|
||||
const mine = g?.stopNow.perLoser.find((x) => x.player === me);
|
||||
if (seat && mine) lines.push(`${nm(who)}이 지금 스톱하면 나는 ${mine.amount}점을 내요${mine.baks.length ? `(${mine.baks.map((b) => BAK[b]).join('·')})` : ''}.`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'gukjin': {
|
||||
const g = v.gukjinAsk;
|
||||
const who = g?.player ?? v.turn;
|
||||
if (seat && who === me && g) {
|
||||
lines.push('판이 끝나기 전에 내가 가진 9월 열끗(국진)을 어떻게 셀지 골라요.');
|
||||
lines.push(`[열끗으로]: 내 점수 ${g.yeolScore}점. [쌍피로](피 2장 값): 내 점수 ${g.piScore}점. 시간이 지나면 나에게 유리한 쪽으로 정해져요.`);
|
||||
} else {
|
||||
lines.push(`스톱이 나왔어요. ${nm(who)}이 9월 열끗(국진)을 열끗으로 셀지 쌍피로 셀지 정하는 중이에요. 정하면 이번 판 점수를 계산해요.`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
default:
|
||||
lines.push('잠시만 기다려 주세요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
|
||||
import { ChipIcon } from '../../components/cards/Chips';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const nick = (players: Record<string, PublicUser>, id: string | null | undefined) => (id ? (players[id]?.nickname ?? '알 수 없음') : '');
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
@@ -1169,6 +1171,8 @@ const ui: GameUI<GostopView, GostopOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
87
apps/web/src/games/gostop/quick.tsx
Normal file
87
apps/web/src/games/gostop/quick.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/** 고스톱 30초 규칙 요약 (docs/07 §5). */
|
||||
import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
const card = (id: number, month: number, kind: HwatuInfo['kind'], name: string, tti?: HwatuInfo['tti']): HwatuInfo => ({ id, month, kind, name, tti });
|
||||
|
||||
const row = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 } as const;
|
||||
const label = { fontWeight: 900, fontSize: '1.4rem' } as const;
|
||||
|
||||
function Match() {
|
||||
return (
|
||||
<div style={row} aria-hidden>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<HwatuCard card={card(8, 3, 'gwang', '3월 광')} size="md" highlight />
|
||||
<small>내 손패</small>
|
||||
</div>
|
||||
<span style={label}>→</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<HwatuCard card={card(10, 3, 'pi', '3월 피')} size="md" />
|
||||
<small>바닥</small>
|
||||
</div>
|
||||
<span style={label}>=</span>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<HwatuCard card={card(8, 3, 'gwang', '3월 광')} size="sm" />
|
||||
<HwatuCard card={card(10, 3, 'pi', '3월 피')} size="sm" style={{ marginLeft: -16 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Jokbo() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }} aria-hidden>
|
||||
<div style={row}>
|
||||
<HwatuCard card={card(1, 1, 'tti', '1월 홍단', 'hong')} size="sm" />
|
||||
<HwatuCard card={card(5, 2, 'tti', '2월 홍단', 'hong')} size="sm" />
|
||||
<HwatuCard card={card(9, 3, 'tti', '3월 홍단', 'hong')} size="sm" />
|
||||
<span style={label}>홍단 3점</span>
|
||||
</div>
|
||||
<div style={row}>
|
||||
<HwatuCard card={card(0, 1, 'gwang', '1월 광')} size="sm" />
|
||||
<HwatuCard card={card(8, 3, 'gwang', '3월 광')} size="sm" />
|
||||
<HwatuCard card={card(28, 8, 'gwang', '8월 광')} size="sm" />
|
||||
<span style={label}>삼광 3점</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoStop() {
|
||||
const btn = (bg: string, text: string, sub: string) => (
|
||||
<div style={{ background: bg, color: '#fff', borderRadius: 14, width: 104, height: 84, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', fontWeight: 900, fontSize: '1.6rem', lineHeight: 1.1 }}>
|
||||
{text}
|
||||
<small style={{ fontSize: '.8rem', fontWeight: 600 }}>{sub}</small>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }} aria-hidden>
|
||||
<strong style={{ fontSize: '1.2rem' }}>7점 났어요!</strong>
|
||||
<div style={row}>
|
||||
{btn('#dc2626', '고', '계속해요')}
|
||||
{btn('#2563eb', '스톱', '받고 끝내요')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: <Match />,
|
||||
alt: '손패의 3월 광을 내서 바닥의 3월 피와 짝지어 두 장을 가져와요',
|
||||
title: '같은 월끼리 먹어요',
|
||||
text: '내 차례에 손패 1장을 내고 더미에서 1장을 뒤집어요. 바닥에 같은 월(숫자) 패가 있으면 함께 가져와요.',
|
||||
},
|
||||
{
|
||||
pic: <Jokbo />,
|
||||
alt: '홍단 띠 3장은 3점, 광 3장은 3점이라는 그림이에요',
|
||||
title: '모은 패로 점수를 내요',
|
||||
text: '광 3장, 홍단·청단·초단 띠 3장, 피 10장(쌍피는 2장 값)처럼 족보를 모으면 점수가 나요. 게임 중 [족보표]에서 볼 수 있어요.',
|
||||
},
|
||||
{
|
||||
pic: <GoStop />,
|
||||
alt: '7점이 나서 빨간 고 버튼과 파란 스톱 버튼이 보여요',
|
||||
title: '고? 스톱?',
|
||||
text: '기준 점수(기본 맞고 7점, 고스톱 3점)가 나면 스톱으로 끝내고 이기거나, 고로 더 키워요. 고 뒤에 남이 먼저 나면 손해예요.',
|
||||
},
|
||||
];
|
||||
55
apps/web/src/games/halli-galli/help.test.ts
Normal file
55
apps/web/src/games/halli-galli/help.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B', 'C']);
|
||||
const view = (s: unknown, who: string | null) => GAMES['halli-galli']!.view(s, who) as any;
|
||||
|
||||
function playing(options: Record<string, unknown> = {}) {
|
||||
const { state } = setupGame('halli-galli', 3, options);
|
||||
state.phase = 'playing';
|
||||
return state;
|
||||
}
|
||||
|
||||
test('할리갈리: 카운트다운 / 내 차례 / 남의 차례 / 관전', () => {
|
||||
const { state: s0 } = setupGame('halli-galli', 3);
|
||||
expect(helpNow(view(s0, 'A'), 'A', users)).toContain('곧 시작해요');
|
||||
const s = playing();
|
||||
const turn: string = s.turn;
|
||||
const other = ['A', 'B', 'C'].find((p) => p !== turn)!;
|
||||
const t = helpNow(view(s, turn), turn, users);
|
||||
expect(t).toContain('[뒤집기]');
|
||||
expect(t).toContain('딱 5개');
|
||||
expect(t).toContain('3초');
|
||||
const o = helpNow(view(s, other), other, users);
|
||||
expect(o).toContain(`${users[turn]!.nickname}님이 뒤집을 차례예요`);
|
||||
expect(o).toContain('종은 언제든');
|
||||
expect(helpNow(view(s, null), null, users)).toContain(`${users[turn]!.nickname}님이 카드를 뒤집을 차례예요`);
|
||||
});
|
||||
|
||||
test('할리갈리: 자동 뒤집기·벌칙 규칙과 덱 0장', () => {
|
||||
const s = playing({ autoFlip: true, wrongToPot: true, strikeOut: true });
|
||||
const turn: string = s.turn;
|
||||
const t = helpNow(view(s, turn), turn, users);
|
||||
expect(t).toContain('자동으로 뒤집혀요');
|
||||
expect(t).toContain('종 밑에');
|
||||
expect(t).toContain('3번 틀리면 탈락');
|
||||
const other = ['A', 'B', 'C'].find((p) => p !== turn)!;
|
||||
s.decks[other] = [];
|
||||
expect(helpNow(view(s, other), other, users)).toContain('틀리면 바로 탈락');
|
||||
});
|
||||
|
||||
test('할리갈리: 2명 남은 원작 규칙 / 탈락 / 끝', () => {
|
||||
const s = playing({ endMode: 'official' });
|
||||
const [a, b, c] = s.order as string[];
|
||||
s.active = [a, b];
|
||||
s.eliminated = [c];
|
||||
s.outReason = { [c!]: 'empty' };
|
||||
s.officialDuel = true;
|
||||
expect(helpNow(view(s, a!), a!, users)).toContain('다음 종으로 게임이 끝나고');
|
||||
expect(helpNow(view(s, c!), c!, users)).toContain('탈락했어요');
|
||||
s.phase = 'ended';
|
||||
s.final = { endReason: 'official', ranking: [], summary: '호랑이님 우승!' };
|
||||
expect(helpNow(view(s, a!), a!, users)).toContain('호랑이님 우승!');
|
||||
});
|
||||
45
apps/web/src/games/halli-galli/help.ts
Normal file
45
apps/web/src/games/halli-galli/help.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** 할리갈리 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { HalliGalliView } from '@bg/games/halli-galli';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const BELL = '펼쳐진 카드 맨 위 그림을 모두 합쳐 한 과일이 딱 5개가 되면 가운데 [종!]을 먼저 눌러요. 4개나 6개일 때는 누르면 안 돼요.';
|
||||
|
||||
function penalty(v: HalliGalliView): string {
|
||||
const o = v.options;
|
||||
const where = o.wrongToPot ? '종 밑에 카드를 넣어야 해요' : '다른 사람들에게 카드를 1장씩 줘야 해요';
|
||||
const more = o.penaltyEscalation ? '(틀릴 때마다 벌칙 장수가 늘어요)' : '';
|
||||
const strike = o.strikeOut ? ' 3번 틀리면 탈락이에요.' : '';
|
||||
const alive = v.seats.filter((s) => s.status === 'playing').length;
|
||||
if (alive === 2 && o.duelWrongLoses && !v.officialDuel) return '1대1이라 이 방 규칙에서는 잘못 누르면 바로 져요.';
|
||||
return `잘못 누르면 ${where}${more}.${strike}`;
|
||||
}
|
||||
|
||||
export function helpNow(v: HalliGalliView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const nick = (id: string | null) => (id ? (players[id]?.nickname ?? '다른 사람') : '다른 사람');
|
||||
if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위에서 각자 남은 카드 수를 볼 수 있어요.';
|
||||
if (v.phase === 'countdown') {
|
||||
return `곧 시작해요. 차례대로 카드를 한 장씩 뒤집어요.\n${BELL}`;
|
||||
}
|
||||
const mine = v.seats.find((s) => s.id === me) ?? null;
|
||||
const duel = v.officialDuel ? '\n2명만 남았어요. 다음 종으로 게임이 끝나고 카드가 많은 사람이 이겨요.' : '';
|
||||
if (!mine) {
|
||||
return `${nick(v.turn)}님이 카드를 뒤집을 차례예요. 한 과일이 딱 5개가 되는 순간 종을 먼저 누른 사람이 펼친 카드를 모두 가져가요.${duel}`;
|
||||
}
|
||||
if (mine.status !== 'playing') {
|
||||
return `${mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요.'} 남은 사람들의 게임을 지켜봐요. 지금은 ${nick(v.turn)}님이 뒤집을 차례예요.`;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
if (v.turn === me) {
|
||||
if (v.options.autoFlip) lines.push('내 차례예요. 이 방은 카드가 자동으로 뒤집혀요.');
|
||||
else if (v.pendingFlip) lines.push('내 차례예요. 뒤집기를 눌렀어요. 곧 카드가 펼쳐져요.');
|
||||
else if (mine.deck === 0) lines.push('내 차례인데 내 덱(뒤집을 카드 더미)이 비었어요.');
|
||||
else lines.push(`내 차례예요. 아래 [뒤집기]를 눌러 카드를 한 장 펼쳐요. ${v.flipTotalMs / 1000}초가 지나면 자동으로 뒤집혀요.`);
|
||||
} else {
|
||||
lines.push(`${nick(v.turn)}님이 뒤집을 차례예요. 차례가 아니어도 종은 언제든 누를 수 있어요.`);
|
||||
if (mine.deck === 0) lines.push('내 덱이 0장이에요. 내 차례가 오기 전에 종을 맞히면 카드를 얻어 계속할 수 있지만, 틀리면 바로 탈락이에요.');
|
||||
}
|
||||
lines.push(BELL);
|
||||
if (v.options.kidMode) lines.push('아래 과일 합계 막대에서 숫자가 5인 과일이 있는지 보세요.');
|
||||
lines.push(`맞히면 펼친 카드를 모두 가져가요. ${penalty(v)}`);
|
||||
return lines.join('\n') + duel;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import { socket, useRoom } from '../../net/socket';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum'];
|
||||
const FRUIT_KO: Record<Fruit, string> = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' };
|
||||
@@ -955,6 +957,8 @@ const ui: GameUI<HalliGalliView, HalliGalliOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
130
apps/web/src/games/halli-galli/quick.tsx
Normal file
130
apps/web/src/games/halli-galli/quick.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/** 할리갈리 30초 규칙 요약 (docs/07 §5). Simple redraws (the real card art lives in index.tsx). */
|
||||
import type { ReactNode } from 'react';
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
type F = 'strawberry' | 'banana';
|
||||
const INK: Record<F, string> = { strawberry: '#e11d48', banana: '#ca8a04' };
|
||||
|
||||
/** One fruit centred at (x, y), about 16 units wide. */
|
||||
function Fruit({ f, x, y }: { f: F; x: number; y: number }) {
|
||||
if (f === 'strawberry') {
|
||||
return (
|
||||
<g transform={`translate(${x - 8} ${y - 8}) scale(0.4)`}>
|
||||
<path d="M20 38 C9 30 4 20 7.5 13.5 C10.5 8.5 29.5 8.5 32.5 13.5 C36 20 31 30 20 38 Z" fill="#e11d48" stroke="#9f1239" strokeWidth="2" />
|
||||
<path d="M11 11 L16 9 L20 4 L24 9 L29 11 L23.5 12.5 L20 15 L16.5 12.5 Z" fill="#16a34a" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<g transform={`translate(${x - 8} ${y - 8}) scale(0.4)`}>
|
||||
<path d="M7 9 C7 27 22 36 36 30 C37 28 36 27 34 27.5 C22 30 13 22 12 8 C11 6 8 6 7 9 Z" fill="#facc15" stroke="#a16207" strokeWidth="2" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const SPOTS: Record<number, [number, number][]> = {
|
||||
1: [[20, 28]],
|
||||
2: [[20, 16], [20, 40]],
|
||||
3: [[11, 14], [20, 28], [29, 42]],
|
||||
4: [[11, 15], [29, 15], [11, 41], [29, 41]],
|
||||
5: [[11, 13], [29, 13], [20, 28], [11, 43], [29, 43]],
|
||||
};
|
||||
|
||||
/** A fruit card 40×56 placed at (x, y). */
|
||||
function Card({ f, n, x, y }: { f: F; n: number; x: number; y: number }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect x={0} y={0} width={40} height={56} rx={5} fill="#fffef8" stroke={INK[f]} strokeWidth={2} />
|
||||
{SPOTS[n]!.map(([fx, fy], i) => (
|
||||
<Fruit key={i} f={f} x={fx} y={fy} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Back({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect x={0} y={0} width={40} height={56} rx={5} fill="#1e3a8a" stroke="#93c5fd" strokeWidth={2} />
|
||||
<circle cx={20} cy={28} r={7} fill="#fbbf24" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Bell({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g transform={`translate(${x - 22} ${y - 22}) scale(0.44)`}>
|
||||
<ellipse cx="50" cy="80" rx="40" ry="9" fill="#78350f" />
|
||||
<path d="M18 76 C18 50 30 26 50 26 C70 26 82 50 82 76 Z" fill="#fde68a" stroke="#92400e" strokeWidth="3" />
|
||||
<rect x="43" y="14" width="14" height="13" rx="4" fill="#92400e" />
|
||||
<circle cx="50" cy="12" r="8" fill="#b91c1c" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Table({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg viewBox="0 0 240 150" width={240} height={150} aria-hidden>
|
||||
<rect x={0} y={0} width={240} height={150} rx={14} fill="#166534" />
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<Table>
|
||||
<Back x={20} y={80} />
|
||||
<path d="M66 108 L88 108" stroke="#fff" strokeWidth={3} />
|
||||
<path d="M84 102 L92 108 L84 114" fill="none" stroke="#fff" strokeWidth={3} />
|
||||
<Card f="banana" n={3} x={100} y={80} />
|
||||
<Card f="strawberry" n={2} x={60} y={12} />
|
||||
<Card f="banana" n={1} x={140} y={12} />
|
||||
<Bell x={196} y={110} />
|
||||
</Table>
|
||||
),
|
||||
alt: '뒤집힌 내 카드 더미에서 한 장을 펼쳐 내 앞에 놓는 모습이에요',
|
||||
title: '차례대로 한 장씩 펼쳐요',
|
||||
text: '내 차례에 [뒤집기]를 누르면 내 카드가 한 장 펼쳐져요. 각자 앞에 맨 위 카드만 보여요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Table>
|
||||
<Card f="strawberry" n={2} x={14} y={20} />
|
||||
<Card f="strawberry" n={3} x={62} y={20} />
|
||||
<Card f="banana" n={4} x={110} y={20} />
|
||||
<text x={84} y={108} textAnchor="middle" fontSize={20} fontWeight={800} fill="#fff">
|
||||
딸기 2+3 = 5
|
||||
</text>
|
||||
<Bell x={196} y={52} />
|
||||
<text x={196} y={110} textAnchor="middle" fontSize={22} fontWeight={900} fill="#fde047">
|
||||
땡!
|
||||
</text>
|
||||
</Table>
|
||||
),
|
||||
alt: '딸기 2개 카드와 딸기 3개 카드가 보여서 딸기가 모두 5개예요. 종을 치면 땡!',
|
||||
title: '딱 5개면 종을 쳐요',
|
||||
text: '펼쳐진 카드에서 한 과일이 모두 합쳐 딱 5개면 가운데 [종!]을 먼저 눌러요. 맞히면 펼친 카드를 다 가져가요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Table>
|
||||
<Card f="strawberry" n={2} x={14} y={20} />
|
||||
<Card f="strawberry" n={3} x={62} y={20} />
|
||||
<Card f="strawberry" n={1} x={110} y={20} />
|
||||
<text x={84} y={108} textAnchor="middle" fontSize={20} fontWeight={800} fill="#fff">
|
||||
딸기 6개
|
||||
</text>
|
||||
<Bell x={196} y={52} />
|
||||
<g stroke="#ef4444" strokeWidth={6} strokeLinecap="round">
|
||||
<line x1={176} y1={92} x2={216} y2={132} />
|
||||
<line x1={216} y1={92} x2={176} y2={132} />
|
||||
</g>
|
||||
</Table>
|
||||
),
|
||||
alt: '딸기가 모두 6개라서 종을 치면 안 된다는 빨간 ✕ 표시예요',
|
||||
title: '틀리면 카드를 줘요',
|
||||
text: '4개나 6개일 때 누르면 벌칙으로 카드를 내줘요. 카드를 다 잃으면 탈락이고, 끝까지 남은 사람(시간이 끝나면 카드가 가장 많은 사람)이 이겨요.',
|
||||
},
|
||||
];
|
||||
66
apps/web/src/games/help-kit.test-util.ts
Normal file
66
apps/web/src/games/help-kit.test-util.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Test helpers for the beginner help (docs/07 §5): real views from the rule modules, so help text
|
||||
* is checked against the actual view shape. Only imported by *.test.ts files.
|
||||
*/
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { SeededRng, type RngState } from '../../../../packages/engine/src/rng';
|
||||
|
||||
export const NICKS = ['호랑이', '고양이', '토끼', '거북이', '여우', '사슴', '곰', '부엉이', '다람쥐', '오리'];
|
||||
|
||||
/** Player ids are 'A', 'B', ...; nicknames from NICKS. */
|
||||
export function seatIds(n: number): string[] {
|
||||
return Array.from({ length: n }, (_, i) => String.fromCharCode(65 + i));
|
||||
}
|
||||
|
||||
export function usersOf(ids: string[]): Record<string, PublicUser> {
|
||||
return Object.fromEntries(ids.map((id, i) => [id, { id, nickname: NICKS[i % NICKS.length]!, avatar: null, kind: 'guest' as const }]));
|
||||
}
|
||||
|
||||
/** Fresh state for a game with `n` players and options merged over the defaults. */
|
||||
export function setupGame(id: string, n: number, options: Record<string, unknown> = {}, seed: RngState = [1, 2, 3, 4], now = 1_700_000_000_000) {
|
||||
const def = GAMES[id]!;
|
||||
const players = seatIds(n);
|
||||
const opts = def.optionsSchema.parse({ ...(def.defaultOptions as object), ...options });
|
||||
const rng = SeededRng.fromSeed(seed);
|
||||
const state = def.setup({ players, options: opts, rng, now });
|
||||
return { def, players, rng, state: state as any, now };
|
||||
}
|
||||
|
||||
/** Apply an action (validated) and return the new state. */
|
||||
export function applyAction(id: string, state: any, actor: string, action: unknown, rng = SeededRng.fromSeed([9, 9, 9, 9]), now = 1_700_000_100_000): any {
|
||||
const def = GAMES[id]!;
|
||||
const v = def.validate(state, actor, action, { now });
|
||||
if (!v.ok) throw new Error(`${id}: rejected ${JSON.stringify(action)}: ${v.reason}`);
|
||||
return def.apply(state, actor, action, { rng, now }).state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random playout that hands every intermediate view (each player + spectator) to `visit`.
|
||||
* Uses legal actions when the module lists them, otherwise the timeout action.
|
||||
*/
|
||||
export function eachView(id: string, n: number, visit: (view: any, viewer: string | null, state: any) => void, { steps = 400, seed = [5, 6, 7, 8] as RngState, options = {} as Record<string, unknown> } = {}) {
|
||||
const { def, players, rng, state: s0 } = setupGame(id, n, options, seed);
|
||||
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
|
||||
let state = s0;
|
||||
let now = 1_700_000_000_000;
|
||||
for (let step = 0; step <= steps; step++) {
|
||||
for (const p of [...players, null]) visit(def.view(state, p), p, state);
|
||||
if (def.result(state)) return;
|
||||
const actives = def.activePlayers(state);
|
||||
if (actives.length === 0) return;
|
||||
const actor = actives[chooser.int(actives.length)]!;
|
||||
const legal = def.legalActions?.(state, actor) ?? [];
|
||||
const action = legal.length > 0 ? legal[chooser.int(legal.length)] : def.onTimeout(state, actor);
|
||||
now += 1000;
|
||||
if (!def.validate(state, actor, action, { now }).ok) return;
|
||||
state = def.apply(state, actor, action, { rng, now }).state;
|
||||
}
|
||||
}
|
||||
|
||||
/** Help text must read as finished Korean: no leaked placeholders. */
|
||||
export function assertCleanText(text: string, where: string) {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) throw new Error(`${where}: empty help`);
|
||||
const bad = /undefined|NaN|null|\[object|\$\{|님님/.exec(text);
|
||||
if (bad) throw new Error(`${where}: "${bad[0]}" in help: ${text}`);
|
||||
}
|
||||
50
apps/web/src/games/help.test.ts
Normal file
50
apps/web/src/games/help.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** Beginner help (docs/07 §5) for every game: 3 summary cards, and help text on every view of a random game. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { GAME_UI } from './registry';
|
||||
import { assertCleanText, eachView, seatIds, usersOf } from './help-kit.test-util';
|
||||
|
||||
const IDS = Object.keys(GAME_UI);
|
||||
|
||||
test('모든 게임에 도움말이 있어요', () => {
|
||||
expect(IDS).toHaveLength(26);
|
||||
for (const id of IDS) {
|
||||
const src = readFileSync(join(import.meta.dir, id, 'index.tsx'), 'utf8');
|
||||
expect(src, id).toMatch(/\bquickStart,/);
|
||||
expect(src, id).toMatch(/\bhelpNow,/);
|
||||
}
|
||||
});
|
||||
|
||||
describe.each(IDS)('%s', (id) => {
|
||||
test('30초 요약 카드 3장', async () => {
|
||||
const { quickStart } = await import(`./${id}/quick`);
|
||||
expect(quickStart).toHaveLength(3);
|
||||
for (const c of quickStart) {
|
||||
expect(c.pic).toBeTruthy();
|
||||
expect(c.alt.length).toBeGreaterThan(4);
|
||||
expect(c.title.length).toBeGreaterThan(1);
|
||||
expect(c.title.length).toBeLessThanOrEqual(20);
|
||||
expect(c.text.length).toBeGreaterThan(10);
|
||||
expect(c.text.length).toBeLessThanOrEqual(110);
|
||||
}
|
||||
});
|
||||
|
||||
test('무작위 한 판의 모든 화면(각 자리 + 관전)에서 지금 할 수 있는 것', async () => {
|
||||
const { helpNow } = await import(`./${id}/help`);
|
||||
const def = GAMES[id]!;
|
||||
const range = def.playersFor?.(def.defaultOptions) ?? { min: def.minPlayers, max: def.maxPlayers };
|
||||
// 3 players where allowed; otherwise the nearest count the game accepts (e.g. 쿼리도: 2 or 4).
|
||||
const want = Math.min(range.max, Math.max(range.min, 3));
|
||||
const counts = Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i).sort((a, b) => Math.abs(a - want) - Math.abs(b - want) || a - b);
|
||||
const n = counts.find((c) => !def.playerCountError?.(def.defaultOptions, c)) ?? want;
|
||||
const users = usersOf(seatIds(n));
|
||||
let count = 0;
|
||||
eachView(id, n, (view, who) => {
|
||||
assertCleanText(helpNow(view, who, users), `${id} viewer=${who}`);
|
||||
count++;
|
||||
});
|
||||
expect(count).toBeGreaterThan(n);
|
||||
});
|
||||
});
|
||||
72
apps/web/src/games/janggi/help.test.ts
Normal file
72
apps/web/src/games/janggi/help.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.janggi!.view(s, who) as any;
|
||||
const mv = (f1: number, r1: number, f2: number, r2: number) => ({ type: 'move', from: { f: f1, r: r1 }, to: { f: f2, r: r2 } });
|
||||
|
||||
/** A = 초, B = 한; both 상차림 chosen (한 first), 초 to move. */
|
||||
function started(o: Record<string, unknown> = {}) {
|
||||
const { state } = setupGame('janggi', 2, { sideAssignment: 'hostCho', setupOrder: 'hanFirst', ...o });
|
||||
const s = applyAction('janggi', state, 'B', { type: 'chooseSetup', arrangement: 'MSSM' });
|
||||
return applyAction('janggi', s, 'A', { type: 'chooseSetup', arrangement: 'SMMS' });
|
||||
}
|
||||
|
||||
/** Custom position in play (kept consistent with the rule module: 장군 flag from the real board). */
|
||||
function custom(pieces: [string, string, number, number][], turn: 'cho' | 'han', inCheck: 'cho' | 'han' | null) {
|
||||
const s = started();
|
||||
s.board = new Array(90).fill(null);
|
||||
for (const [side, kind, f, r] of pieces) s.board[r * 9 + f] = { side, kind };
|
||||
s.startBoard = s.board.slice();
|
||||
s.turn = turn;
|
||||
s.inCheck = inCheck;
|
||||
s.positionCounts = {};
|
||||
return s;
|
||||
}
|
||||
|
||||
test('장기: 상차림 단계 (한 먼저 고르기)', () => {
|
||||
const { state } = setupGame('janggi', 2, { sideAssignment: 'hostCho', setupOrder: 'hanFirst' });
|
||||
expect(helpNow(view(state, 'B'), 'B', users)).toContain('상차림을 골라 주세요(나는 한)');
|
||||
const cho = helpNow(view(state, 'A'), 'A', users);
|
||||
expect(cho).toContain('한이 먼저 상차림을 골라요');
|
||||
expect(cho).toContain('고양이님이 상차림을 고르는 중');
|
||||
expect(helpNow(view(state, null), null, users)).toContain('고양이님이 상차림');
|
||||
const s = applyAction('janggi', state, 'B', { type: 'chooseSetup', arrangement: 'MSSM' });
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('한이 안상차림을 골랐어요');
|
||||
});
|
||||
|
||||
test('장기: 내 차례 / 상대 차례 / 관전 (점수 포함)', () => {
|
||||
const s = started();
|
||||
const mine = helpNow(view(s, 'A'), 'A', users);
|
||||
expect(mine).toContain('내 차례예요(초)');
|
||||
expect(mine).toContain('초록 점');
|
||||
expect(mine).toContain('[⏸ 한수쉼]');
|
||||
expect(mine).toContain('초 72점, 한 73.5점');
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('나는 한이에요');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('호랑이님(초) 차례예요');
|
||||
});
|
||||
|
||||
test('장기: 장군을 받으면 한수쉼 없이 궁을 지키라고 안내', () => {
|
||||
const s = custom([['cho', 'king', 3, 0], ['han', 'king', 4, 9], ['cho', 'cha', 0, 9]], 'han', 'han');
|
||||
const t = helpNow(view(s, 'B'), 'B', users);
|
||||
expect(t).toContain('장군이에요!');
|
||||
expect(t).not.toContain('[⏸ 한수쉼]');
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('내가 장군을 불렀어요');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('한 궁이 장군을 받았어요');
|
||||
});
|
||||
|
||||
test('장기: 빅장을 받은 쪽 안내 (점수제/비점수제)', () => {
|
||||
for (const scoring of [true, false]) {
|
||||
const s0 = started({ bikjang: 'kja30', scoring });
|
||||
const s = custom([['cho', 'king', 4, 1], ['cho', 'jol', 4, 4], ['cho', 'cha', 0, 0], ['han', 'king', 4, 8], ['han', 'jol', 0, 6], ['han', 'cha', 8, 5], ['han', 'sa', 3, 9]], 'cho', null);
|
||||
s.options = s0.options;
|
||||
const after = applyAction('janggi', s, 'A', mv(4, 4, 3, 4));
|
||||
expect(after.bikjang?.calledBy).toBe('cho');
|
||||
const t = helpNow(view(after, 'B'), 'B', users);
|
||||
expect(t).toContain('빅장이에요!');
|
||||
expect(t).toContain(scoring ? '점수로 승부를 가려요' : '무승부로 끝나요');
|
||||
expect(helpNow(view(after, null), null, users)).toContain('초가 빅장을 불렀어요');
|
||||
}
|
||||
});
|
||||
81
apps/web/src/games/janggi/help.ts
Normal file
81
apps/web/src/games/janggi/help.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/** 장기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { Arrangement, JanggiView, Side } from '@bg/games/janggi';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' };
|
||||
const ARR_KO: Record<Arrangement, string> = { MSSM: '안상차림', SMMS: '바깥상차림', SMSM: '왼상차림', MSMS: '오른상차림' };
|
||||
const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho');
|
||||
/** 초가 / 한이 (subject particle). */
|
||||
const SUBJ: Record<Side, string> = { cho: '초가', han: '한이' };
|
||||
/** 초예요 / 한이에요. */
|
||||
const IS: Record<Side, string> = { cho: '초예요', han: '한이에요' };
|
||||
|
||||
export function helpNow(v: JanggiView, _me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return `대국이 끝났어요. ${v.result?.summaryKo ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
const nick = (s: Side) => `${players[v.players[s]]?.nickname ?? SIDE_KO[s]}님`;
|
||||
const judge = v.rules.scoring ? '점수로 승부를 가려요' : '무승부로 끝나요';
|
||||
const score = `지금 기물 점수는 초 ${v.scores.cho}점, 한 ${v.scores.han}점이에요${v.rules.scoring ? '(한은 덤 1.5점 포함)' : ''}.`;
|
||||
const me = v.mySide;
|
||||
|
||||
if (v.phase === 'setup') {
|
||||
const waiting = v.setup.waitingFor;
|
||||
if (!me) return waiting.length ? `${waiting.map(nick).join('·')}이 상차림(뒷줄 마·상 자리)을 고르는 중이에요. 다 고르면 초부터 둬요.` : '곧 대국이 시작돼요.';
|
||||
if (waiting.includes(me)) {
|
||||
const han = v.setup.choice.han;
|
||||
const lines = [
|
||||
me === 'cho' && v.setup.order === 'hanFirst' && han ? `한이 ${ARR_KO[han]}을 골랐어요. 이제 내 상차림을 골라요(나는 초).` : `상차림을 골라 주세요(나는 ${SIDE_KO[me]}).`,
|
||||
'아래 네 가지(안상차림·바깥상차림·왼상차림·오른상차림) 중 하나를 누르면 뒷줄의 마·상 자리가 정해져요. 내 쪽에서 본 왼쪽→오른쪽 순서예요.',
|
||||
];
|
||||
if (v.setup.order === 'simultaneous') lines.push('상대는 내가 고른 것을 대국이 시작될 때까지 볼 수 없어요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
const mine = v.setup.choice[me];
|
||||
const head = mine ? `${ARR_KO[mine]}을 골랐어요. ` : v.setup.order === 'hanFirst' ? '이 방은 한이 먼저 상차림을 골라요. ' : '';
|
||||
return `${head}${waiting.length ? `${waiting.map(nick).join('·')}이 상차림을 고르는 중이에요. 기다려 주세요.` : '곧 대국이 시작돼요.'}\n[대국 취소]를 누르면 승패 없이 그만둘 수 있어요.`;
|
||||
}
|
||||
|
||||
const lastMove = v.moves[v.moves.length - 1];
|
||||
if (!me) {
|
||||
const lines = [`${nick(v.turn)}(${SIDE_KO[v.turn]}) 차례예요. 상대 궁을 피할 곳 없이 몰면(외통) 이겨요.`];
|
||||
if (v.inCheck) lines.push(`${SIDE_KO[v.inCheck]} 궁이 장군을 받았어요.`);
|
||||
if (v.bikjang) lines.push(`${SUBJ[v.bikjang.calledBy]} 빅장을 불렀어요. ${SUBJ[other(v.bikjang.calledBy)]} 막지 못하면 ${judge}.`);
|
||||
lines.push(score);
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (v.undo.pending && v.undo.pending.by !== me) return `상대가 ${v.undo.pending.plies}수 무르기를 부탁했어요. [수락]을 누르면 기물이 되돌아가고, [거절]을 누르면 그대로 계속해요.`;
|
||||
if (v.draw.pendingBy && v.draw.pendingBy !== me) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.';
|
||||
|
||||
if (v.turn !== me) {
|
||||
const lines = [`상대(${nick(v.turn)}) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${IS[me]}`];
|
||||
if (v.inCheck === v.turn) lines.push('내가 장군을 불렀어요. 상대는 궁을 지켜야 해요.');
|
||||
if (v.bikjang?.calledBy === me) lines.push(`내가 빅장을 불렀어요. 상대가 막지 못하면 ${judge}.`);
|
||||
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
|
||||
lines.push(score);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (v.inCheck === me) lines.push('장군이에요! 내 궁이 공격받고 있어요(빨간 동그라미). 궁을 피하거나, 사이를 막거나, 공격한 기물을 잡아야 해요(멍군). 한수쉼은 할 수 없어요.');
|
||||
else lines.push(`내 차례예요(${SIDE_KO[me]}).`);
|
||||
if (v.bikjang && v.bikjang.calledBy !== me) lines.push(`빅장이에요! 두 궁이 마주보고 있어요. 사이에 기물을 넣거나 궁을 옆으로 옮기지 않으면 ${judge}.`);
|
||||
lines.push(
|
||||
v.rules.hints === 'off'
|
||||
? '움직일 기물을 누른 뒤 갈 자리를 눌러요. 못 가는 자리면 이유를 알려 줘요.'
|
||||
: '움직일 기물을 누르면 갈 수 있는 곳에 초록 점이 찍혀요(잡을 수 있는 상대 기물은 빨간 동그라미). 그 자리를 누르면 둬요.',
|
||||
);
|
||||
if (v.canPass) {
|
||||
if (lastMove?.kind === 'pass' && lastMove.side !== me) lines.push(`상대가 방금 한수쉼을 했어요. 나도 [⏸ 한수쉼]을 누르면 대국이 끝나고 ${judge}.`);
|
||||
else lines.push('기물을 움직이지 않고 [⏸ 한수쉼]으로 차례를 넘길 수도 있어요.');
|
||||
}
|
||||
if (v.repeatWarning)
|
||||
lines.push(
|
||||
v.rules.repetition === 'forbid'
|
||||
? '같은 국면이 3번 나왔어요. 한 번 더 반복하는 수는 둘 수 없어요.'
|
||||
: v.rules.repetition === 'lose'
|
||||
? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!'
|
||||
: `같은 국면이 3번 나왔어요. 한 번 더 반복하면 ${judge}.`,
|
||||
);
|
||||
if (v.threatened.length) lines.push('주황 점선 동그라미가 쳐진 내 기물은 상대가 잡을 수 있어요. 조심하세요.');
|
||||
lines.push(score);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' };
|
||||
const INK: Record<Side, string> = { cho: '#1d4ed8', han: '#c0262d' };
|
||||
@@ -888,7 +890,7 @@ function Options({ value: o, onChange }: OptionsProps<JanggiOptions>) {
|
||||
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
|
||||
</Field>
|
||||
<Field title="도움 표시">
|
||||
<Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['movesAndThreats', '+ 잡힐 위험'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
<Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
</Field>
|
||||
<Field title="그 밖에">
|
||||
<Seg value={o.allowPass ? 'on' : 'off'} options={[['on', '한수쉼 허용'], ['off', '한수쉼 안 함']]} onChange={(k) => set({ allowPass: k === 'on' })} />
|
||||
@@ -983,6 +985,8 @@ const ui: GameUI<JanggiView, JanggiOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
106
apps/web/src/games/janggi/quick.tsx
Normal file
106
apps/web/src/games/janggi/quick.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/** 장기 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { ReactNode } from 'react';
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
type Side = 'cho' | 'han';
|
||||
const INK: Record<Side, string> = { cho: '#1d4ed8', han: '#c0262d' };
|
||||
|
||||
function octagon(cx: number, cy: number, r: number): string {
|
||||
return Array.from({ length: 8 }, (_, i) => {
|
||||
const a = Math.PI / 8 + (i * Math.PI) / 4;
|
||||
return `${(cx + r * Math.cos(a)).toFixed(3)},${(cy + r * Math.sin(a)).toFixed(3)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
/** Octagonal piece with its hanja, blue for 초 and red for 한. */
|
||||
function Piece({ ch, side, x, y, r = 0.4 }: { ch: string; side: Side; x: number; y: number; r?: number }) {
|
||||
return (
|
||||
<g>
|
||||
<polygon points={octagon(x, y, r)} fill="#fbf3dc" stroke={INK[side]} strokeWidth={0.05} />
|
||||
<text x={x} y={y} fill={INK[side]} fontSize={r * 1.05} fontWeight={700} fontFamily="'Noto Serif KR','Noto Serif CJK KR','Batang',serif" textAnchor="middle" dominantBaseline="central">
|
||||
{ch}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wooden board of w×h line crossings; `palace` draws the X of a 3×3 palace whose top-left crossing is given. */
|
||||
function Board({ w, h, size, palace, children }: { w: number; h: number; size: number; palace?: [number, number]; children: ReactNode }) {
|
||||
const pad = 0.55;
|
||||
const vw = w - 1 + 2 * pad;
|
||||
const vh = h - 1 + 2 * pad;
|
||||
const scale = size / Math.max(vw, vh);
|
||||
return (
|
||||
<svg viewBox={`${-pad} ${-pad} ${vw} ${vh}`} width={vw * scale} height={vh * scale} aria-hidden>
|
||||
<rect x={-pad} y={-pad} width={vw} height={vh} rx={0.2} fill="#e8c48a" />
|
||||
<g stroke="#7a5228" strokeWidth={0.04}>
|
||||
{Array.from({ length: w }, (_, x) => (
|
||||
<line key={`v${x}`} x1={x} y1={0} x2={x} y2={h - 1} />
|
||||
))}
|
||||
{Array.from({ length: h }, (_, y) => (
|
||||
<line key={`h${y}`} x1={0} y1={y} x2={w - 1} y2={y} />
|
||||
))}
|
||||
{palace && (
|
||||
<>
|
||||
<line x1={palace[0]} y1={palace[1]} x2={palace[0] + 2} y2={palace[1] + 2} />
|
||||
<line x1={palace[0] + 2} y1={palace[1]} x2={palace[0]} y2={palace[1] + 2} />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const Dot = ({ x, y }: { x: number; y: number }) => <circle cx={x} cy={y} r={0.15} fill="#16a34a" />;
|
||||
const Cross = ({ x, y }: { x: number; y: number }) => (
|
||||
<g stroke="#dc2626" strokeWidth={0.09}>
|
||||
<line x1={x - 0.17} y1={y - 0.17} x2={x + 0.17} y2={y + 0.17} />
|
||||
<line x1={x + 0.17} y1={y - 0.17} x2={x - 0.17} y2={y + 0.17} />
|
||||
</g>
|
||||
);
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<Board w={3} h={4} size={160} palace={[0, 0]}>
|
||||
<circle cx={1} cy={1} r={0.6} fill="rgba(220,38,38,0.2)" stroke="#dc2626" strokeWidth={0.07} />
|
||||
<Piece ch="漢" side="han" x={1} y={1} r={0.46} />
|
||||
<Piece ch="車" side="cho" x={1} y={3} />
|
||||
</Board>
|
||||
),
|
||||
alt: '파란 차가 궁성 안의 빨간 궁을 곧게 겨누고 있어요(장군)',
|
||||
title: '상대 궁을 몰아요',
|
||||
text: '초(파랑)가 먼저 두고 번갈아 한 수씩 둬요. 상대 궁이 어디로 피해도 잡히게 만들면(외통) 이겨요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Board w={5} h={5} size={160}>
|
||||
{[[0, 1], [4, 1], [0, 3], [4, 3], [1, 4], [3, 4]].map(([x, y]) => (
|
||||
<Dot key={`${x}-${y}`} x={x!} y={y!} />
|
||||
))}
|
||||
<Cross x={1} y={0} />
|
||||
<Cross x={3} y={0} />
|
||||
<Piece ch="馬" side="cho" x={2} y={2} />
|
||||
<Piece ch="卒" side="cho" x={2} y={1} r={0.3} />
|
||||
</Board>
|
||||
),
|
||||
alt: '가운데 마 둘레에 갈 수 있는 곳이 초록 점으로, 바로 위가 막힌 쪽은 빨간 ✕로 보여요',
|
||||
title: '누르면 갈 곳이 보여요',
|
||||
text: '기물을 누르면 갈 수 있는 곳에 초록 점이 찍혀요. 기물은 칸이 아니라 선이 만나는 점에 놓여요. 마·상은 바로 앞(멱)이 막히면 못 가요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<Board w={5} h={2} size={220}>
|
||||
<Dot x={3} y={1} />
|
||||
<Dot x={4} y={1} />
|
||||
<path d="M 0 0.55 Q 1.5 -0.4 3 0.6" fill="none" stroke="#7c3aed" strokeWidth={0.07} strokeDasharray="0.14 0.08" />
|
||||
<Piece ch="包" side="cho" x={0} y={1} />
|
||||
<Piece ch="兵" side="han" x={2} y={1} r={0.3} />
|
||||
</Board>
|
||||
),
|
||||
alt: '포가 기물 하나를 뛰어넘어 그 너머 점으로 가는 모습이에요',
|
||||
title: '포는 넘어서 가요',
|
||||
text: '포는 꼭 기물 하나를 뛰어넘어서만 움직여요. 장군을 받으면 다른 수보다 먼저 궁을 피하거나 막아야 해요.',
|
||||
},
|
||||
];
|
||||
45
apps/web/src/games/las-vegas/help.test.ts
Normal file
45
apps/web/src/games/las-vegas/help.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B', 'C']);
|
||||
const view = (s: unknown, who: string | null) => GAMES['las-vegas']!.view(s, who) as any;
|
||||
|
||||
test('라스베가스: 굴리기 차례 / 기다리기 / 관전', () => {
|
||||
const { state } = setupGame('las-vegas', 3);
|
||||
const cur: string = state.seats[state.current];
|
||||
const other = state.seats.find((p: string) => p !== cur)!;
|
||||
const nick = users[cur]!.nickname;
|
||||
const mine = helpNow(view(state, cur), cur, users);
|
||||
expect(mine).toContain('[🎲 주사위 굴리기]');
|
||||
expect(mine).toContain('남은 주사위 8개');
|
||||
const wait = helpNow(view(state, other), other, users);
|
||||
expect(wait).toContain(`${nick}님이 주사위를 굴릴 차례예요`);
|
||||
expect(wait).toContain('내 주사위는 8개 남았어요');
|
||||
const spec = helpNow(view(state, null), null, users);
|
||||
expect(spec).toContain(`${nick}님이 주사위를 굴릴 차례예요`);
|
||||
expect(spec).toContain('1/4라운드');
|
||||
});
|
||||
|
||||
test('라스베가스: 굴린 뒤 숫자 고르기와 미리 보기', () => {
|
||||
const { state } = setupGame('las-vegas', 2);
|
||||
const cur: string = state.seats[state.current];
|
||||
const other = state.seats.find((p: string) => p !== cur)!;
|
||||
const s = applyAction('las-vegas', state, cur, { type: 'ROLL' });
|
||||
const t = helpNow(view(s, cur), cur, users);
|
||||
expect(t).toContain('개 놓기] 버튼');
|
||||
expect(t).toContain('지금 상황에서 놓으면');
|
||||
expect(t).toContain('일부만 놓을 수는 없어요');
|
||||
expect(helpNow(view(s, other), other, users)).toContain('놓을 숫자를 고르는 중이에요');
|
||||
// 2명: 흰 주사위 4개를 함께 가져요.
|
||||
expect(helpNow(view(state, other), other, users)).toContain('흰 주사위 4개 포함');
|
||||
});
|
||||
|
||||
test('라스베가스: 기권하면 구경', () => {
|
||||
const { state } = setupGame('las-vegas', 3);
|
||||
const cur: string = state.seats[state.current];
|
||||
const other = state.seats.find((p: string) => p !== cur)!;
|
||||
const s = applyAction('las-vegas', state, other, { type: 'CONCEDE' });
|
||||
expect(helpNow(view(s, other), other, users)).toContain('기권해서');
|
||||
});
|
||||
53
apps/web/src/games/las-vegas/help.ts
Normal file
53
apps/web/src/games/las-vegas/help.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** 라스베가스 주사위 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { LasVegasChoice, LasVegasView } from '@bg/games/las-vegas';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const STANDING: Record<LasVegasChoice['standing'], (c: LasVegasChoice) => string> = {
|
||||
first: (c) => `1등${c.note !== null ? `(${c.note}점 지폐)` : ''}`,
|
||||
award: (c) => (c.note !== null ? `${c.note}점 지폐` : '지폐 받음'),
|
||||
tie: () => '동점이라 꽝',
|
||||
none: (c) => (c.own === 0 ? '흰 주사위만 놓임' : '받을 지폐 없음'),
|
||||
};
|
||||
|
||||
const choiceText = (c: LasVegasChoice) => `${c.value}번에 ${c.own + c.neutral}개 → ${STANDING[c.standing](c)}`;
|
||||
|
||||
export function helpNow(v: LasVegasView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished' || !v.current) return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
const round = `${v.round}/${v.rounds}라운드`;
|
||||
const who = `${players[v.current]?.nickname ?? '다른 사람'}님`;
|
||||
const rule = '라운드가 끝나면 카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 꽝이에요.';
|
||||
const seated = !!me && v.seats.includes(me);
|
||||
|
||||
if (!seated) {
|
||||
const doing = v.phase === 'roll' ? '주사위를 굴릴 차례예요.' : '나온 숫자 중 하나를 골라 카지노에 놓는 중이에요.';
|
||||
return `${who}이 ${doing} (${round})\n${rule}`;
|
||||
}
|
||||
if (v.conceded.includes(me!)) return `나는 기권해서 지금은 구경만 할 수 있어요. ${who} 차례예요.`;
|
||||
|
||||
const hand = v.hand[me!];
|
||||
const left = hand ? hand.own + hand.neutral : 0;
|
||||
const mine = v.money[me!];
|
||||
const score = mine && mine.total !== null ? `지금까지 내 점수는 ${mine.total}점(지폐 ${mine.count}장)이에요.` : '';
|
||||
|
||||
if (v.current !== me) {
|
||||
const doing = v.phase === 'roll' ? '주사위를 굴릴 차례예요.' : '놓을 숫자를 고르는 중이에요.';
|
||||
const lines = [`${who}이 ${doing} 기다려 주세요. (${round})`];
|
||||
lines.push(left > 0 ? `내 주사위는 ${left}개 남았어요${hand!.neutral > 0 ? `(흰 주사위 ${hand!.neutral}개 포함)` : ''}.` : '내 주사위는 이번 라운드에 다 놓았어요. 다른 사람이 다 놓을 때까지 기다려요.');
|
||||
if (score) lines.push(score);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// My turn.
|
||||
if (v.phase === 'roll') {
|
||||
const lines = [`내 차례예요(${round}). [🎲 주사위 굴리기]를 누르면 남은 주사위 ${left}개를 모두 굴려요.`];
|
||||
lines.push('굴린 뒤 숫자 하나를 고르면, 그 숫자가 나온 주사위를 모두 같은 번호 카지노에 놓아요.');
|
||||
if (v.houseRoll && v.houseRoll.round === v.round && hand?.own === 8) lines.push(`이번 라운드 시작에 남는 흰 주사위가 ${v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요.`);
|
||||
if (score) lines.push(score);
|
||||
return lines.join('\n');
|
||||
}
|
||||
const lines = [`내 차례예요(${round}). 나온 숫자(또는 같은 번호 카지노)를 누르면 미리 보기가 나와요. 그다음 [… 카지노에 …개 놓기] 버튼을 눌러 확정해요.`];
|
||||
lines.push('고른 숫자의 주사위는 한꺼번에 모두 놓여요. 일부만 놓을 수는 없어요.');
|
||||
if (v.choices.length) lines.push(`지금 상황에서 놓으면: ${v.choices.map(choiceText).join(' · ')}`);
|
||||
if (v.choices.some((c) => c.standing === 'tie')) lines.push('동점이 되는 곳은 그대로 끝나면 같은 개수인 사람 모두 아무것도 못 받아요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const ROLL_MS = 650;
|
||||
const HOUSE = 'house';
|
||||
@@ -811,6 +813,8 @@ const ui: GameUI<LasVegasView, LasVegasOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
131
apps/web/src/games/las-vegas/quick.tsx
Normal file
131
apps/web/src/games/las-vegas/quick.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/** 라스베가스 주사위 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[50, 50]],
|
||||
2: [[28, 28], [72, 72]],
|
||||
3: [[26, 26], [50, 50], [74, 74]],
|
||||
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
|
||||
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
|
||||
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
|
||||
};
|
||||
|
||||
const RED = '#dc2626';
|
||||
const BLUE = '#2563eb';
|
||||
const GREEN = '#16a34a';
|
||||
|
||||
/** A coloured die at (x, y), `s` units wide (pips white on colour). */
|
||||
function Die({ x, y, value, color, s = 30, dim = false }: { x: number; y: number; value: number; color: string; s?: number; dim?: boolean }) {
|
||||
const k = s / 100;
|
||||
return (
|
||||
<g transform={`translate(${x} ${y}) scale(${k})`} opacity={dim ? 0.35 : 1}>
|
||||
<rect x={3} y={3} width={94} height={94} rx={18} fill={color} stroke="#1f2937" strokeWidth={4} />
|
||||
{PIPS[value]!.map(([cx, cy], i) => (
|
||||
<circle key={i} cx={cx} cy={cy} r={10} fill="#fff" />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Bill({ x, y, n }: { x: number; y: number; n: number }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={46} height={26} rx={4} fill="#bbf7d0" stroke="#15803d" strokeWidth={2} />
|
||||
<text x={23} y={18} textAnchor="middle" fontSize={15} fontWeight={800} fill="#14532d">
|
||||
{n}점
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Casino card: number on top, bills inside. */
|
||||
function Casino({ x, y, n, bills }: { x: number; y: number; n: number; bills: number[] }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<rect width={66} height={44 + bills.length * 30} rx={8} fill="#fef3c7" stroke="#b45309" strokeWidth={2.5} />
|
||||
<text x={33} y={26} textAnchor="middle" fontSize={20} fontWeight={900} fill="#78350f">
|
||||
{n}번
|
||||
</text>
|
||||
{bills.map((b, i) => (
|
||||
<Bill key={i} x={10} y={36 + i * 30} n={b} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function PlacePic() {
|
||||
const roll = [4, 2, 4, 6, 1, 4, 5, 2];
|
||||
return (
|
||||
<svg viewBox="0 0 260 150" width={250} height={144} aria-hidden>
|
||||
{roll.map((d, i) => (
|
||||
<Die key={i} x={6 + (i % 4) * 36} y={14 + Math.floor(i / 4) * 40} value={d} color={RED} dim={d !== 4} />
|
||||
))}
|
||||
<text x={158} y={62} fontSize={28} fill="#374151">
|
||||
➜
|
||||
</text>
|
||||
<Casino x={188} y={4} n={4} bills={[6, 3]} />
|
||||
<text x={74} y={122} textAnchor="middle" fontSize={15} fontWeight={800} fill="#111827">
|
||||
4가 3개 → 모두 4번에!
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TiePic() {
|
||||
return (
|
||||
<svg viewBox="0 0 260 150" width={250} height={144} aria-hidden>
|
||||
<Casino x={4} y={4} n={2} bills={[8, 3]} />
|
||||
{[RED, RED].map((c, i) => (
|
||||
<Die key={`r${i}`} x={90 + i * 32} y={14} value={2} color={c} />
|
||||
))}
|
||||
{[BLUE, BLUE].map((c, i) => (
|
||||
<Die key={`b${i}`} x={90 + i * 32} y={56} value={2} color={c} />
|
||||
))}
|
||||
<Die x={90} y={98} value={2} color={GREEN} />
|
||||
<text x={170} y={50} fontSize={20} fontWeight={900} fill="#b91c1c">
|
||||
✕ 동점 꽝
|
||||
</text>
|
||||
<text x={134} y={122} fontSize={17} fontWeight={800} fill="#15803d">
|
||||
→ 8점 받음
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WinPic() {
|
||||
return (
|
||||
<svg viewBox="0 0 260 150" width={250} height={144} aria-hidden>
|
||||
<Bill x={20} y={40} n={9} />
|
||||
<Bill x={34} y={56} n={7} />
|
||||
<Bill x={48} y={72} n={5} />
|
||||
<Bill x={62} y={88} n={4} />
|
||||
<text x={130} y={92} fontSize={22} fontWeight={900} fill="#111827">
|
||||
= 25점
|
||||
</text>
|
||||
<text x={214} y={96} fontSize={44}>
|
||||
🏆
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: <PlacePic />,
|
||||
alt: '빨간 주사위 8개 중 4가 나온 3개를 모두 4번 카지노에 놓아요',
|
||||
title: '굴리고 한 숫자 고르기',
|
||||
text: '내 차례에 남은 주사위를 모두 굴려요. 숫자 하나를 고르면 그 숫자가 나온 주사위를 모두 같은 번호 카지노에 놓아요.',
|
||||
},
|
||||
{
|
||||
pic: <TiePic />,
|
||||
alt: '2번 카지노에 빨강 2개, 파랑 2개가 동점이라 꽝이고, 초록 1개가 8점 지폐를 받아요',
|
||||
title: '같은 개수면 둘 다 꽝',
|
||||
text: '모두 다 놓으면 카지노마다 주사위가 많은 사람부터 큰 지폐를 받아요. 개수가 같은 사람끼리는 모두 아무것도 못 받아요.',
|
||||
},
|
||||
{
|
||||
pic: <WinPic />,
|
||||
alt: '지폐 9, 7, 5, 4점을 더해 25점, 트로피',
|
||||
title: '점수가 높으면 이겨요',
|
||||
text: '정해진 라운드가 모두 끝나면 받은 지폐의 점수를 더해요. 합계가 가장 높은 사람이 이겨요.',
|
||||
},
|
||||
];
|
||||
57
apps/web/src/games/mafia/help.test.ts
Normal file
57
apps/web/src/games/mafia/help.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, seatIds, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const ids = seatIds(7);
|
||||
const users = usersOf(ids);
|
||||
const view = (s: unknown, who: string | null) => GAMES.mafia!.view(s, who) as any;
|
||||
const T = 1_700_000_000_000;
|
||||
|
||||
function game() {
|
||||
const { state } = setupGame('mafia', 7, { mafiaCount: 2 });
|
||||
const byRole = (r: string) => state.players.filter((p: any) => p.role === r).map((p: any) => p.id as string);
|
||||
return { state, mafia: byRole('mafia'), police: byRole('police')[0]!, doctor: byRole('doctor')[0]!, citizen: byRole('citizen')[0]! };
|
||||
}
|
||||
const night = { kind: 'night', startedAt: T, endsAt: T + 30_000, mafiaTarget: null, mafiaPickedBy: null, investigate: null, heal: null, mediumPeek: null };
|
||||
|
||||
test('마피아: 직업 확인 / 관전', () => {
|
||||
const { state, citizen } = game();
|
||||
expect(helpNow(view(state, citizen), citizen, users)).toContain('[내 직업] 카드를 꾹');
|
||||
const spec = helpNow(view(state, null), null, users);
|
||||
expect(spec).toContain('관전 중에는 전체 채팅과 발표만');
|
||||
expect(spec).not.toContain('마피아예요');
|
||||
});
|
||||
|
||||
test('마피아: 밤 안내는 내 직업에 맞게, 남의 직업은 말하지 않아요', () => {
|
||||
const g = game();
|
||||
const s = { ...g.state, day: 1, phase: night };
|
||||
const [m1, m2] = g.mafia;
|
||||
const mt = helpNow(view(s, m1!), m1!, users);
|
||||
expect(mt).toContain('나는 마피아예요');
|
||||
expect(mt).toContain(`동료 마피아는 ${users[m2!]!.nickname}님`);
|
||||
expect(helpNow(view(s, g.police), g.police, users)).toContain('나는 경찰이에요');
|
||||
expect(helpNow(view(s, g.doctor), g.doctor, users)).toContain('나는 의사예요');
|
||||
const c = helpNow(view(s, g.citizen), g.citizen, users);
|
||||
expect(c).toContain('지금은 할 일이 없어요');
|
||||
for (const t of [c, helpNow(view(s, null), null, users)]) {
|
||||
expect(t).not.toContain('마피아예요');
|
||||
expect(t).not.toContain('경찰');
|
||||
expect(t).not.toContain('의사');
|
||||
}
|
||||
// 마피아가 고르면 동료에게 대상과 고른 사람이 보여요.
|
||||
const s2 = applyAction('mafia', s, m1!, { type: 'mafiaTarget', target: g.citizen });
|
||||
expect(helpNow(view(s2, m2!), m2!, users)).toContain(`${users[m1!]!.nickname}님이 골랐어요`);
|
||||
});
|
||||
|
||||
test('마피아: 지목 투표 → 찬반 투표, 탈락한 사람', () => {
|
||||
const g = game();
|
||||
const vote = { ...g.state, day: 2, phase: { kind: 'vote', startedAt: T, endsAt: T + 20_000, votes: {} } };
|
||||
expect(helpNow(view(vote, g.citizen), g.citizen, users)).toContain('[기권]');
|
||||
const confirm = { ...g.state, day: 2, phase: { kind: 'confirm', startedAt: T, endsAt: T + 10_000, accused: g.citizen, ballots: {} } };
|
||||
expect(helpNow(view(confirm, g.police), g.police, users)).toContain('[찬성 (탈락)]');
|
||||
expect(helpNow(view(confirm, g.citizen), g.citizen, users)).toContain('나를 탈락시킬지');
|
||||
expect(helpNow(view(confirm, null), null, users)).toContain(`${users[g.citizen]!.nickname}님을 탈락시킬지`);
|
||||
const dead = { ...confirm, players: g.state.players.map((p: any) => (p.id === g.doctor ? { ...p, alive: false, diedDay: 1, diedBy: 'mafia' } : p)) };
|
||||
expect(helpNow(view(dead, g.doctor), g.doctor, users)).toContain('[죽은 사람] 채팅');
|
||||
});
|
||||
131
apps/web/src/games/mafia/help.ts
Normal file
131
apps/web/src/games/mafia/help.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 마피아 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view.
|
||||
* Only what this viewer may know: the own role is named only where the screen already shows
|
||||
* role-specific things (night actions, after death, after the game).
|
||||
*/
|
||||
import type { MafiaView } from '@bg/games/mafia';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const PHASE_KO: Record<MafiaView['phase']['kind'], string> = {
|
||||
roleReveal: '직업 확인',
|
||||
greeting: '인사 시간',
|
||||
discussion: '낮 토론',
|
||||
vote: '지목 투표',
|
||||
defense: '최후의 반론',
|
||||
confirm: '찬반 투표',
|
||||
night: '밤',
|
||||
ended: '게임 끝',
|
||||
};
|
||||
|
||||
/** "~예요" after a vowel, "~이에요" after a final consonant. */
|
||||
function yeyo(word: string): string {
|
||||
const c = word.charCodeAt(word.length - 1) - 0xac00;
|
||||
return c >= 0 && c < 11172 && c % 28 !== 0 ? `${word}이에요` : `${word}예요`;
|
||||
}
|
||||
|
||||
const WIN = '시민 팀은 마피아를 모두 찾아내면, 마피아 팀은 살아 있는 마피아가 나머지 사람만큼 많아지면 이겨요.';
|
||||
|
||||
export function helpNow(v: MafiaView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const n = (id: string) => `${players[id]?.nickname ?? '다른 사람'}님`;
|
||||
const ph = v.phase;
|
||||
const head = `${v.day}일차 ${yeyo(PHASE_KO[ph.kind])}.`;
|
||||
if (ph.kind === 'ended') {
|
||||
const chats = v.options.revealChatsAtEnd ? ' 모든 채팅방 기록도 이제 볼 수 있어요.' : '';
|
||||
return `게임이 끝났어요. ${v.final?.summary ?? ''}`.trim() + `\n최종 공개 표에서 모든 사람의 직업을 볼 수 있어요.${chats}`;
|
||||
}
|
||||
const my = v.my;
|
||||
|
||||
// Spectators see the day chat and announcements only.
|
||||
if (!my) {
|
||||
const what: Record<string, string> = {
|
||||
roleReveal: '모두 자기 직업을 확인하고 있어요.',
|
||||
greeting: '살아 있는 사람들이 전체 채팅으로 인사하고 있어요. 오늘은 투표가 없어요.',
|
||||
discussion: '살아 있는 사람들이 누가 마피아인지 이야기하고 있어요.',
|
||||
vote: '살아 있는 사람들이 마피아로 의심되는 사람을 지목하고 있어요.',
|
||||
defense: ph.accused ? `${n(ph.accused)}이 지목돼서 마지막으로 변론하고 있어요.` : '지목된 사람이 변론하고 있어요.',
|
||||
confirm: ph.accused ? `${n(ph.accused)}을 탈락시킬지 찬반 투표를 하고 있어요.` : '찬반 투표를 하고 있어요.',
|
||||
night: '각자 직업에 따라 몰래 행동하는 시간이에요. 아침에 발표가 있어요.',
|
||||
};
|
||||
return `${head} ${what[ph.kind] ?? ''}\n관전 중에는 전체 채팅과 발표만 볼 수 있어요.`;
|
||||
}
|
||||
|
||||
const idle = my.idleWarning ? '\n오늘도 아무것도 하지 않으면 자리 비움으로 탈락해요.' : '';
|
||||
|
||||
if (!my.alive) {
|
||||
const see = v.options.deadSeeAll ? ' 이 방에서는 탈락하면 모든 직업과 마피아 채팅도 볼 수 있어요.' : '';
|
||||
return `${head} 나는 탈락했어요. [죽은 사람] 채팅에서만 이야기할 수 있어요.${see}\n살아 있는 사람에게 정보를 알려 주지 말아 주세요(디스코드 같은 다른 대화방도요).`;
|
||||
}
|
||||
|
||||
switch (ph.kind) {
|
||||
case 'roleReveal':
|
||||
return `직업을 확인하는 시간이에요. 위의 [내 직업] 카드를 꾹 누르고 있는 동안만 직업이 보여요(옆 사람에게 보이지 않게).\n곧 ${v.options.firstPhase === 'day' ? '인사 시간' : '첫 밤'}이 시작돼요. ${WIN}`;
|
||||
case 'greeting':
|
||||
return `${head} [전체] 채팅으로 인사해요. 오늘은 투표가 없고, 끝나면 밤이 와요.\n다 됐으면 [인사 끝내기]를 눌러요. 살아 있는 사람이 모두 누르면 바로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`;
|
||||
case 'discussion': {
|
||||
const adj = my.canAdjust ? '\n[+15초]·[−15초]로 토론 시간을 하루 한 번 바꿀 수 있어요.' : '';
|
||||
return `${head} [전체] 채팅으로 누가 마피아 같은지 이야기해요. 직업을 밝힐지는 내 선택이에요.${adj}\n다 이야기했으면 [토론 끝내기]를 눌러요. 모두 누르면 바로 지목 투표로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`;
|
||||
}
|
||||
case 'vote': {
|
||||
const now = my.vote === null ? '아직 고르지 않았어요(안 고르면 기권).' : my.vote === 'abstain' ? '지금은 기권으로 되어 있어요.' : `지금 ${n(my.vote)}을 골랐어요.`;
|
||||
return `${head} '사람들' 목록에서 초록 테두리 사람을 눌러 마피아로 의심되는 사람을 골라요. 시간 안에는 바꿀 수 있고, [기권]도 돼요.\n${now} 표가 가장 많은 한 명이 지목돼요. 같은 표가 나오면 아무도 지목되지 않아요.${idle}`;
|
||||
}
|
||||
case 'defense':
|
||||
if (ph.accused === me) return `${head} 내가 지목됐어요! [전체] 채팅으로 내가 왜 마피아가 아닌지 마지막으로 말해요. 지금은 나만 말할 수 있어요.`;
|
||||
return `${head} ${ph.accused ? n(ph.accused) : '지목된 사람'}의 마지막 변론을 들어요. 지금은 지목된 사람만 채팅할 수 있어요. 곧 찬반 투표를 해요.`;
|
||||
case 'confirm': {
|
||||
const who = ph.accused ? n(ph.accused) : '지목된 사람';
|
||||
if (!my.canConfirm) return `${head} 다른 사람들이 나를 탈락시킬지 찬반 투표를 하고 있어요. 결과를 기다려 주세요.`;
|
||||
const now = my.ballot ? `지금 ${my.ballot === 'yes' ? '찬성' : '반대'}했어요. 시간 안에는 바꿀 수 있어요.` : '안 누르면 집계에서 빠져요.';
|
||||
return `${head} ${who}을 탈락시킬지 정해요. [찬성 (탈락)] 또는 [반대 (살리기)]를 눌러요.\n찬성이 반대보다 많아야 탈락해요. ${now}${idle}`;
|
||||
}
|
||||
case 'night':
|
||||
return nightHelp(v, me, n) + idle;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
function nightHelp(v: MafiaView, me: string | null, n: (id: string) => string): string {
|
||||
const my = v.my!;
|
||||
const nv = my.night;
|
||||
const pick = "'사람들' 목록에서 초록 테두리 사람을 눌러 골라요. 밤이 끝날 때 고른 사람으로 정해지고, 그 전엔 바꿀 수 있어요.";
|
||||
const lines: string[] = [];
|
||||
const isAlive = (id: string) => v.players.some((p) => p.id === id && p.alive);
|
||||
const mates = my.mates.filter(isAlive);
|
||||
switch (nv?.action ?? null) {
|
||||
case null: {
|
||||
lines.push('밤이에요. 지금은 할 일이 없어요. 아침 발표를 기다려 주세요.');
|
||||
if (my.role === 'lover' && mates.length) lines.push(`밤에는 [연인] 채팅으로 ${mates.map(n).join(', ')}과 둘이서만 이야기할 수 있어요.`);
|
||||
break;
|
||||
}
|
||||
case 'mafiaTarget': {
|
||||
lines.push(`밤이에요. 나는 마피아예요. 오늘 밤 노릴 사람을 ${pick}`);
|
||||
if (mates.length) lines.push(`마피아끼리 한 명을 함께 정해요. 누가 고르든 마지막에 고른 사람이 대상이에요. 살아 있는 동료 마피아는 ${mates.map(n).join(', ')}이에요. [마피아] 채팅으로 의논해요.`);
|
||||
const none = v.options.mafiaMustKill === 'random' ? '아무도 안 고르면 무작위로 한 명이 탈락해요.' : '아무도 안 고르면 오늘 밤은 아무도 탈락하지 않아요.';
|
||||
lines.push(nv!.choice ? `지금 대상: ${n(nv!.choice)}${nv!.pickedBy && nv!.pickedBy !== me ? ` (${n(nv!.pickedBy)}이 골랐어요)` : ''}. [선택 취소]로 지울 수 있어요.` : none);
|
||||
break;
|
||||
}
|
||||
case 'investigate': {
|
||||
const what = v.options.policeRevealRole ? '그 사람의 직업이' : '마피아인지 아닌지가';
|
||||
lines.push(`밤이에요. 나는 경찰이에요. 조사할 사람을 ${pick}`);
|
||||
lines.push(`밤이 끝나면 ${what} '조사 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
|
||||
break;
|
||||
}
|
||||
case 'heal': {
|
||||
const self = v.options.doctorSelfHeal ? '나 자신도 고를 수 있어요.' : '나 자신은 고를 수 없어요.';
|
||||
const rep = v.options.doctorRepeat ? '' : ' 어젯밤 지킨 사람은 오늘 다시 고를 수 없어요.';
|
||||
lines.push(`밤이에요. 나는 의사예요. 지켜 줄 사람을 ${pick}`);
|
||||
lines.push(`마피아가 그 사람을 노리면 살아나요. ${self}${rep}${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
|
||||
break;
|
||||
}
|
||||
case 'mediumPeek': {
|
||||
if (nv!.targets.length === 0) lines.push('밤이에요. 나는 영매예요. 아직 탈락한 사람이 없어서 알아볼 사람이 없어요.');
|
||||
else {
|
||||
lines.push(`밤이에요. 나는 영매예요. 직업을 알아볼 탈락한 사람을 ${pick}`);
|
||||
lines.push(`결과는 밤이 끝나면 '영매 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
|
||||
}
|
||||
lines.push('밤에는 [죽은 사람] 채팅을 읽고 쓸 수 있어요.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { Avatar } from '../../components/ui';
|
||||
import { useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
// ---------------------------------------------------------------- names (original names only, docs §12)
|
||||
|
||||
@@ -1024,6 +1026,8 @@ const ui: GameUI<MafiaView, MafiaOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
ownChat: true,
|
||||
};
|
||||
|
||||
|
||||
41
apps/web/src/games/mafia/quick.tsx
Normal file
41
apps/web/src/games/mafia/quick.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/** 마피아 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
/** Big emoji composition on a soft panel (no images). */
|
||||
function Scene({ bg, rows }: { bg: string; rows: { e: string; label?: string }[][] }) {
|
||||
return (
|
||||
<div aria-hidden style={{ width: 240, height: 150, borderRadius: 14, background: bg, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 6, padding: 8, boxSizing: 'border-box' }}>
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'center', gap: 12 }}>
|
||||
{row.map((c, j) => (
|
||||
<div key={j} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 44 }}>
|
||||
<span style={{ fontSize: 34, lineHeight: 1.1 }}>{c.e}</span>
|
||||
{c.label && <span style={{ fontSize: 13, fontWeight: 700, color: '#111827', background: 'rgba(255,255,255,.85)', borderRadius: 6, padding: '0 4px' }}>{c.label}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: <Scene bg="linear-gradient(#fde68a, #fef3c7)" rows={[[{ e: '☀️' }], [{ e: '🙂', label: '토론' }, { e: '🤔', label: '투표' }, { e: '👉', label: '탈락' }]]} />,
|
||||
alt: '해가 뜬 낮에 사람들이 토론하고 투표해서 한 명을 가리켜요',
|
||||
title: '낮: 토론하고 투표',
|
||||
text: '낮에는 [전체] 채팅으로 누가 마피아 같은지 이야기하고, 투표로 한 명을 골라 찬성이 많으면 탈락시켜요.',
|
||||
},
|
||||
{
|
||||
pic: <Scene bg="linear-gradient(#1e1b4b, #312e81)" rows={[[{ e: '🌙' }], [{ e: '🕶️', label: '마피아' }, { e: '🔎', label: '경찰' }, { e: '🩺', label: '의사' }]]} />,
|
||||
alt: '달이 뜬 밤에 마피아, 경찰, 의사가 몰래 행동해요',
|
||||
title: '밤: 직업대로 몰래',
|
||||
text: '밤에는 마피아가 한 명을 노리고, 경찰은 한 명을 조사하고, 의사는 한 명을 지켜요. 내 직업은 [내 직업] 카드를 꾹 눌러 봐요.',
|
||||
},
|
||||
{
|
||||
pic: <Scene bg="linear-gradient(#dcfce7, #fee2e2)" rows={[[{ e: '🙂', label: '시민 팀' }, { e: '⚔️' }, { e: '🕶️', label: '마피아 팀' }]]} />,
|
||||
alt: '시민 팀과 마피아 팀이 마주 보고 있어요',
|
||||
title: '팀이 함께 이겨요',
|
||||
text: '시민 팀은 마피아를 모두 찾으면, 마피아 팀은 시민만큼 많이 남으면 이겨요. 탈락하면 살아 있는 사람에게 힌트를 주면 안 돼요.',
|
||||
},
|
||||
];
|
||||
120
apps/web/src/games/mahjong/help.test.ts
Normal file
120
apps/web/src/games/mahjong/help.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { applyAction, assertCleanText, eachView, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B', 'C', 'D']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.mahjong!.view(s, who) as any;
|
||||
|
||||
/** First view (from random games) that matches `pred`, with its state and viewer. */
|
||||
function findView(n: number, pred: (v: any, who: string | null) => boolean, options: Record<string, unknown> = {}) {
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
let found: { v: any; who: string | null; state: any } | null = null;
|
||||
try {
|
||||
eachView(
|
||||
'mahjong',
|
||||
n,
|
||||
(v, who, state) => {
|
||||
if (pred(v, who)) {
|
||||
found = { v, who, state };
|
||||
throw new Error('found');
|
||||
}
|
||||
},
|
||||
{ steps: 4000, seed: [seed, 7, 3, 9], options },
|
||||
);
|
||||
} catch (e) {
|
||||
if (!found) throw e;
|
||||
}
|
||||
if (found) return found as { v: any; who: string | null; state: any };
|
||||
}
|
||||
throw new Error('no such view');
|
||||
}
|
||||
|
||||
test('마작: 내 차례 / 상대 차례 / 관전', () => {
|
||||
const { state } = setupGame('mahjong', 4);
|
||||
const mine = helpNow(view(state, 'A'), 'A', users);
|
||||
expect(mine).toContain('내 차례예요');
|
||||
expect(mine).toContain('방금 가져온 패는');
|
||||
expect(mine).toContain('[○○ 버리기]');
|
||||
expect(helpNow(view(state, 'B'), 'B', users)).toContain('호랑이님 차례예요. 기다려 주세요');
|
||||
const spec = helpNow(view(state, null), null, users);
|
||||
expect(spec).toContain('호랑이님 차례예요. 1장을 가져오고(쯔모)');
|
||||
expect(spec).toContain('동1국');
|
||||
});
|
||||
|
||||
test('마작: 버린 뒤 응답 창 — 4인은 치·퐁·깡, 3인은 퐁·깡', () => {
|
||||
for (const n of [4, 3]) {
|
||||
const { state } = setupGame('mahjong', n);
|
||||
const v0 = view(state, null);
|
||||
const dealer: string = v0.seats[v0.turn].id;
|
||||
const tile = view(state, dealer).my.discardable[0];
|
||||
const s = applyAction('mahjong', state, dealer, { type: 'discard', tile });
|
||||
const spec = helpNow(view(s, null), null, users);
|
||||
expect(spec).toContain(`${users[dealer]!.nickname}님이`);
|
||||
expect(spec).toContain('버렸어요');
|
||||
if (n === 4) expect(spec).toContain('치·퐁·깡');
|
||||
else expect(spec).not.toContain('치·');
|
||||
expect(helpNow(view(s, dealer), dealer, users)).toContain('내가');
|
||||
}
|
||||
});
|
||||
|
||||
test('마작: 론·치 버튼 안내', () => {
|
||||
const ron = findView(4, (v) => !!v.my?.claim?.ron && !v.my.claim.responded);
|
||||
const t = helpNow(ron.v, ron.who, users);
|
||||
expect(t).toContain('[론!]');
|
||||
expect(t).toContain('[패스]');
|
||||
expect(t).toContain('후리텐');
|
||||
const chi = findView(4, (v) => (v.my?.claim?.chi.length ?? 0) > 0 && !v.my.claim.responded);
|
||||
expect(helpNow(chi.v, chi.who, users)).toContain('[치');
|
||||
});
|
||||
|
||||
test('마작: 리치할 수 있을 때와 리치한 뒤', () => {
|
||||
const f = findView(4, (v) => (v.my?.riichiDiscards.length ?? 0) > 0);
|
||||
const t = helpNow(f.v, f.who, users);
|
||||
expect(t).toContain('[리치]');
|
||||
expect(t).toContain("'텐' 표시");
|
||||
let s = applyAction('mahjong', f.state, f.who!, { type: 'discard', tile: f.v.my.riichiDiscards[0], riichi: true });
|
||||
// Riichi is accepted once the claim window on that discard closes (its timer runs out, nobody calls).
|
||||
const def = GAMES.mahjong!;
|
||||
for (let i = 0; i < 8 && !view(s, f.who).my.riichi; i++) {
|
||||
const at = (def.deadline?.(s) ?? 1_700_000_100_000) + 1;
|
||||
for (const p of def.timeoutPlayers?.(s) ?? []) s = applyAction('mahjong', s, p, def.onTimeout(s, p), undefined, at);
|
||||
}
|
||||
const after = helpNow(view(s, f.who), f.who, users);
|
||||
expect(after).toContain('리치 중');
|
||||
expect(after).toContain('대기패');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('리치한 사람');
|
||||
});
|
||||
|
||||
test('마작: 국 결과와 대국 끝', () => {
|
||||
const r = findView(4, (v, who) => v.phase === 'handResult' && v.result?.kind === 'agari' && who !== null);
|
||||
const t = helpNow(r.v, r.who, users);
|
||||
expect(t).toContain('화료예요');
|
||||
expect(t).toContain('내 점수');
|
||||
expect(t).toMatch(/\[(다음 국으로|최종 결과 보기)\]/);
|
||||
expect(helpNow(view(r.state, null), null, users)).toContain('모두 확인하면');
|
||||
const end = findView(3, (v) => v.phase === 'finished');
|
||||
expect(helpNow(end.v, 'A', users)).toContain('대국이 끝났어요');
|
||||
expect(helpNow(end.v, null, users)).toContain('1위는');
|
||||
});
|
||||
|
||||
describe('마작: 다른 규칙에서도 도움말이 깨지지 않아요', () => {
|
||||
const variants: [number, Record<string, unknown>][] = [
|
||||
[4, {}],
|
||||
[4, { beginnerMode: false, length: 'tonpuu', akaDora: 0, kuitan: false }],
|
||||
[3, { beginnerMode: false, sanmaTsumo: 'northBisection', abortiveDraws: false }],
|
||||
];
|
||||
test.each(variants)('%d인 %o', (n, options) => {
|
||||
let count = 0;
|
||||
eachView(
|
||||
'mahjong',
|
||||
n,
|
||||
(v, who) => {
|
||||
assertCleanText(helpNow(v, who, users), `mahjong ${n}p viewer=${who}`);
|
||||
count++;
|
||||
},
|
||||
{ steps: 1500, seed: [3, 1, 4, 1], options },
|
||||
);
|
||||
expect(count).toBeGreaterThan(n * 10);
|
||||
});
|
||||
});
|
||||
170
apps/web/src/games/mahjong/help.ts
Normal file
170
apps/web/src/games/mahjong/help.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/** 마작 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (my own tiles + public info). */
|
||||
import type { HandResultView, MahjongView, MyView, WaitView } from '@bg/games/mahjong';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const WIND = ['동', '남', '서', '북'];
|
||||
const HONOR = ['동', '남', '서', '북', '백', '발', '중'];
|
||||
const SUIT: Record<string, string> = { m: '만', p: '통', s: '삭' };
|
||||
const ABORT: Record<string, string> = { kyuushu: '구종구패', suufon: '사풍연타', suukan: '사깡유국', suucha: '사가리치', sanchaho: '삼가화' };
|
||||
const LIMIT: Record<string, string> = { mangan: '만관', haneman: '하네만', baiman: '배만', sanbaiman: '삼배만', kazoe: '헤아림 역만', yakuman: '역만' };
|
||||
|
||||
/** "5m0r" / "5m" → "5만", "1z" → "동" (same as Tile.tsx tileShort). */
|
||||
const short = (code: string) => (code[1] === 'z' ? (HONOR[Number(code[0]) - 1] ?? '자패') : `${code[0]}${SUIT[code[1] ?? ''] ?? ''}`);
|
||||
const fmt = (n: number) => n.toLocaleString('ko-KR');
|
||||
const signed = (n: number) => (n > 0 ? `+${fmt(n)}` : n < 0 ? `-${fmt(-n)}` : '0');
|
||||
/** 을/를 by the last syllable. */
|
||||
const eulReul = (w: string) => {
|
||||
const c = w.charCodeAt(w.length - 1);
|
||||
return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 === 0 ? `${w}를` : `${w}을`;
|
||||
};
|
||||
/** Same format as the screen: "3삭(2장), 6삭(4장, 역 없음)". */
|
||||
const waitText = (ws: WaitView[]) => ws.map((w) => `${short(w.kind)}(${w.left}장${w.yaku ? '' : ', 역 없음'})`).join(', ');
|
||||
const roundLabel = (x: { roundWind: number; handNumber: number; honba: number }) => `${WIND[x.roundWind] ?? ''}${x.handNumber + 1}국${x.honba ? ` ${x.honba}본장` : ''}`;
|
||||
|
||||
export function helpNow(v: MahjongView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const nameOf = (seat: number | null | undefined) => {
|
||||
const id = seat === null || seat === undefined ? undefined : v.seats[seat]?.id;
|
||||
const n = id ? players[id]?.nickname : undefined;
|
||||
return n ? `${n}님` : '다른 사람';
|
||||
};
|
||||
const my = me !== null ? v.my : null;
|
||||
const sanma = v.mode === 'sanma';
|
||||
const calls = sanma ? '퐁·깡' : '치·퐁·깡';
|
||||
|
||||
if (v.phase === 'finished') return finished(v, me, players);
|
||||
if (v.phase === 'handResult') return handResult(v, my, nameOf);
|
||||
|
||||
// ---- playing
|
||||
const riichiSeats = v.seats.filter((s) => s.riichi && s.seat !== my?.seat).map((s) => nameOf(s.seat));
|
||||
const riichiLine = riichiSeats.length ? `리치한 사람: ${riichiSeats.join(', ')}.` : '';
|
||||
|
||||
if (v.stage === 'claim' && v.claimOn) {
|
||||
const c = v.claimOn;
|
||||
const tile = `${short(c.tile)} 패`;
|
||||
const mine = !!my && c.from === my.seat;
|
||||
const who = mine ? '내가' : `${nameOf(c.from)}이`;
|
||||
const what =
|
||||
c.source === 'discard' ? `${who} ${eulReul(tile)} 버렸어요.` : c.source === 'kakan' ? `${who} ${eulReul(tile)} 가깡(퐁에 1장 더 붙이기)했어요.` : c.source === 'ankan' ? `${who} ${short(c.tile)} 4장으로 안깡했어요.` : `${who} 북을 옆에 빼 두었어요(북 뽑기).`;
|
||||
if (my?.claim && !my.claim.responded) return claimHelp(v, my, what, calls);
|
||||
if (my?.claim?.responded) return `${what}\n응답했어요. 다른 사람이 고르기를 기다리는 중이에요.`;
|
||||
const how = `${c.source === 'discard' ? `울기(${calls})나 ` : ''}론`;
|
||||
const lines = [`${what} 다른 사람이 이 패로 ${how}을 할지 확인하는 중이에요.`];
|
||||
lines.push('잠깐 기다리면 다음 차례로 넘어가요.');
|
||||
if (my) waitsLine(v, my, lines);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const turn = v.turn;
|
||||
if (turn === null || (v.stage !== 'afterDraw' && v.stage !== 'afterCall')) return '잠시만 기다려 주세요. 곧 다음 차례가 시작돼요.';
|
||||
|
||||
if (!my) {
|
||||
const lines = [v.stage === 'afterCall' ? `${nameOf(turn)} 차례예요. 방금 울어서(${calls}) 패를 가져오지 않고 바로 1장을 버려요.` : `${nameOf(turn)} 차례예요. 1장을 가져오고(쯔모) 1장을 버려요.`];
|
||||
lines.push(`${roundLabel(v)} · 남은 패 ${v.wallCount}장.${riichiLine ? ` ${riichiLine}` : ''}`);
|
||||
lines.push('손패 4면자(같은 패 3장이나 이어진 3장) + 머리(같은 패 2장)를 먼저 완성하는 사람이 이겨요(화료).');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (!my.canDiscard) {
|
||||
const lines = [`${nameOf(turn)} 차례예요. 기다려 주세요.`];
|
||||
if (my.autoPilot) lines.push('지금 자리 비움 상태라 자동으로 쳐요. 아무 패나 누르면 돌아와요.');
|
||||
waitsLine(v, my, lines);
|
||||
if (riichiLine) lines.push(riichiLine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
return myTurn(v, my, riichiSeats.length > 0, calls);
|
||||
}
|
||||
|
||||
/** 리치·텐파이 대기패 / 후리텐 (only what the view already has). */
|
||||
function waitsLine(v: MahjongView, my: MyView, lines: string[]) {
|
||||
if (my.riichi) lines.push('나는 리치 중이에요. 화료패(손패를 완성하는 패)가 나오면 [론!], 내가 가져오면 [쯔모!]가 떠요.');
|
||||
if (my.waits.length) {
|
||||
const noYaku = my.waits.some((w) => !w.yaku);
|
||||
lines.push(`텐파이(1장만 더 오면 완성)예요. 대기패: ${waitText(my.waits)}.${noYaku ? ' "역 없음" 패로는 론할 수 없어요.' : ''}`);
|
||||
}
|
||||
if (my.furiten && (my.waits.length || my.riichi)) lines.push('후리텐이에요: 내 대기패를 전에 버렸거나 론을 넘겨서 론은 못 하고, 내가 직접 가져오는 쯔모로만 화료할 수 있어요.');
|
||||
const beginnerOn = v.options.beginnerMode && my.prefs.beginner;
|
||||
if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`);
|
||||
}
|
||||
|
||||
function myTurn(v: MahjongView, my: MyView, othersRiichi: boolean, calls: string): string {
|
||||
const beginnerOn = v.options.beginnerMode && my.prefs.beginner;
|
||||
const lines: string[] = [];
|
||||
if (my.canTsumo) lines.push('화료할 수 있어요! [쯔모!]를 누르면 가져온 패로 손패 완성(쯔모 화료)이에요.');
|
||||
if (my.riichi) {
|
||||
lines.push(`내 차례예요. 리치 중이라 손패를 바꿀 수 없어서 방금 가져온 패만 버릴 수 있어요(나머지는 흐리게 보여요).`);
|
||||
} else if (v.stage === 'afterCall') {
|
||||
lines.push(`내 차례예요. 방금 울었으니(${calls}) 패를 가져오지 않고 바로 1장을 버려요.`);
|
||||
if (my.locked.length) lines.push('🔒 표시 패는 방금 울은 패와 같은 패(또는 스지)라 지금 버릴 수 없어요.');
|
||||
} else {
|
||||
lines.push(`내 차례예요. ${my.drawn ? `방금 가져온 패는 ${short(my.drawn)} 패예요(맨 오른쪽에 조금 떨어진 패). ` : ''}손패에서 1장을 골라 버려요.`);
|
||||
}
|
||||
lines.push(`${my.riichi ? '가져온 패를' : '버릴 패를'} 누른 뒤 한 번 더 누르거나 [○○ 버리기] 버튼을 누르면 버려요(한 번에 놓기 설정이면 바로 버려져요).`);
|
||||
if (my.riichiDiscards.length) lines.push('[리치]: 텐파이(1장만 더 오면 완성)라 리치할 수 있어요. [리치]를 누르고 밝은 패를 버리면 1,000점을 내고 리치를 걸어요. 리치는 역(점수가 나는 조건)이 되지만, 그다음부터는 가져온 패만 버려요.');
|
||||
const tenpai = Object.keys(my.tenpaiAfter);
|
||||
if (beginnerOn && !my.riichi && tenpai.length) {
|
||||
const kinds = [...new Set(tenpai.map(short))];
|
||||
lines.push(`'텐' 표시가 붙은 패(${kinds.slice(0, 4).join(', ')}${kinds.length > 4 ? ' 등' : ''})를 버리면 텐파이예요. 패를 누르면 기다리는 패와 남은 장수가 아래에 나와요.`);
|
||||
}
|
||||
if (my.ankan.length || my.kakan.length) lines.push('[깡]: 같은 패 4장을 모아 깡을 할 수 있어요. 깡을 하면 패를 1장 더 가져와요.');
|
||||
if (my.canKita) lines.push('[북 뽑기]: 북을 옆에 빼 두고 패를 1장 더 가져와요. 빼 둔 북은 도라(보너스 1판)예요.');
|
||||
if (my.canKyuushu) lines.push('[구종구패(유국)]: 1·9·자패가 9종류 이상이라 이번 국을 무승부로 끝낼 수 있어요(안 해도 돼요).');
|
||||
if (my.furiten && !my.canTsumo) lines.push('후리텐이에요: 론은 못 하고 쯔모로만 화료할 수 있어요.');
|
||||
if (othersRiichi && !my.riichi && !my.canTsumo) lines.push('리치한 사람이 이미 버린 패와 같은 패는 그 사람에게 론 당하지 않아요.');
|
||||
if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`);
|
||||
else if (!beginnerOn && v.options.beginnerMode) lines.push("'내 설정'에서 초보 모드를 켜면 텐파이 표시와 대기패·역 힌트를 볼 수 있어요.");
|
||||
lines.push(v.stage === 'afterDraw' ? '시간이 지나면 방금 가져온 패를 저절로 버려요.' : '시간이 지나면 패 1장이 저절로 버려져요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function claimHelp(v: MahjongView, my: MyView, what: string, calls: string): string {
|
||||
const c = my.claim!;
|
||||
const lines = [what];
|
||||
if (c.ron) lines.push('이 패로 화료할 수 있어요! [론!]을 누르면 남이 낸 패로 손패 완성(론 화료)이에요.');
|
||||
if (c.pon.length) lines.push('[퐁]: 같은 패 2장에 이 패를 더해 3장을 내놓아요.');
|
||||
if (c.kan.length) lines.push('[깡]: 같은 패 3장에 이 패를 더해 4장을 내놓고, 패를 1장 더 가져와요.');
|
||||
if (c.chi.length) lines.push(`[치${c.chi.length > 1 ? ` (${c.chi.length}가지)` : ''}]: 바로 앞 사람이 버린 패로 이어진 3장을 만들어요.${c.chi.length > 1 ? ' 누른 뒤 쓸 조합을 골라요.' : ''}`);
|
||||
lines.push(`[패스]: 그냥 넘겨요. 시간이 지나면 저절로 패스돼요.${c.ron ? ' 론을 넘기면 후리텐이 되어 한동안(리치 중이면 이번 국 끝까지) 론을 못 해요.' : ''}`);
|
||||
if ((c.pon.length || c.kan.length || c.chi.length) && !my.riichi) lines.push(`울면(${calls}) 리치를 못 해요. 역패·탕야오처럼 다른 역(점수가 나는 조건)이 있어야 화료할 수 있어요.`);
|
||||
if (v.mode === 'sanma' && c.chi.length === 0 && (c.pon.length || c.kan.length)) lines.push('3인 마작은 치가 없어요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function handResult(v: MahjongView, my: MyView | null, nameOf: (seat: number | null | undefined) => string): string {
|
||||
const r = v.result as HandResultView | null;
|
||||
const lines: string[] = [];
|
||||
if (!r) lines.push('이번 국이 끝났어요.');
|
||||
else if (r.kind === 'agari') {
|
||||
for (const w of r.winners) {
|
||||
const how = w.from === null ? '쯔모(직접 가져온 패로 완성)' : `론(${nameOf(w.from)}이 버린 패로 완성)`;
|
||||
const yaku = w.yaku.filter((y) => !/^(도라|적도라|우라도라|북) /.test(y.name)).map((y) => y.name);
|
||||
const size = w.limit === 'yakuman' ? (w.yakuman > 1 ? `${w.yakuman}배 역만` : '역만') : `${w.han}판 ${w.fu}부${LIMIT[w.limit] ? ` · ${LIMIT[w.limit]}` : ''}`;
|
||||
lines.push(`${roundLabel(r)}: ${nameOf(w.seat)} ${how} 화료예요. 역: ${yaku.join(', ') || '없음'} · ${size} · ${fmt(w.points)}점.`);
|
||||
}
|
||||
} else if (r.kind === 'exhaustive') {
|
||||
lines.push(`${roundLabel(r)}: 패가 다 떨어져 유국(무승부)이에요. 텐파이(1장만 더 오면 완성)였던 사람: ${r.tenpai.length ? r.tenpai.map(nameOf).join(', ') : '없음'}.`);
|
||||
if (r.nagashi.length) lines.push(`유국만관: ${r.nagashi.map(nameOf).join(', ')}.`);
|
||||
else if (r.tenpai.length && r.tenpai.length < v.n) lines.push('텐파이가 아닌 사람이 텐파이인 사람에게 점수를 줘요.');
|
||||
} else {
|
||||
lines.push(`${roundLabel(r)}: 도중유국(${ABORT[r.abortiveReason ?? ''] ?? '중간에 무효'})이에요. 점수는 그대로예요.`);
|
||||
}
|
||||
if (r && my) {
|
||||
const d = r.deltas[my.seat] ?? 0;
|
||||
lines.push(`내 점수: ${signed(d)} → ${fmt(r.pointsAfter[my.seat] ?? 0)}점.`);
|
||||
}
|
||||
const ready = my ? v.seats[my.seat]?.ready : false;
|
||||
const next = v.gameOver ? '대국이 끝나요.' : v.nextHand ? `다음은 ${roundLabel(v.nextHand)}이에요.` : '';
|
||||
if (!my) lines.push(`${next} 모두 확인하면 넘어가요.`.trim());
|
||||
else if (ready) lines.push(`${next} 다른 사람을 기다리는 중이에요 (${v.seats.filter((s) => s.ready).length}/${v.n}).`.trim());
|
||||
else lines.push(`${next} 표를 확인하고 [${v.gameOver ? '최종 결과 보기' : '다음 국으로'}]를 눌러 주세요. 시간이 지나면 저절로 넘어가요.`.trim());
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function finished(v: MahjongView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const f = v.final;
|
||||
const lines = [`대국이 끝났어요.${f ? ` ${f.summary}` : ''}`];
|
||||
const mine = f?.ranking.find((x) => x.player === me);
|
||||
if (mine) lines.push(`나는 ${mine.rank}위 · ${fmt(mine.points)}점이에요.`);
|
||||
else if (f?.ranking[0]) lines.push(`1위는 ${players[f.ranking[0].player]?.nickname ? `${players[f.ranking[0].player]!.nickname}님` : '다른 사람'}이에요.`);
|
||||
lines.push('최종 결과 표의 "결과(우마·오카)"는 순위 보너스를 더한 놀이용 점수예요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
import { Tile, tileName, tileOrder, tileShort } from './Tile';
|
||||
|
||||
const WIND = ['동', '남', '서', '북'];
|
||||
@@ -1266,6 +1268,8 @@ const ui: GameUI<MahjongView, MahjongOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
98
apps/web/src/games/mahjong/quick.tsx
Normal file
98
apps/web/src/games/mahjong/quick.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/** 마작 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { QuickCard } from '../types';
|
||||
import { Tile } from './Tile';
|
||||
|
||||
const col: CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 };
|
||||
const row: CSSProperties = { display: 'flex', alignItems: 'flex-end', gap: 6 };
|
||||
const label: CSSProperties = { fontSize: 12, fontWeight: 800, color: 'var(--text-sub, #555)', textAlign: 'center' };
|
||||
|
||||
function Group({ codes, w, note }: { codes: string[]; w: number; note?: string }) {
|
||||
return (
|
||||
<span style={col}>
|
||||
<span style={{ display: 'inline-flex', gap: 1 }}>
|
||||
{codes.map((c, i) => (
|
||||
<Tile key={i} code={c} w={w} />
|
||||
))}
|
||||
</span>
|
||||
{note && <span style={label}>{note}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ children, bg, color = '#fff' }: { children: ReactNode; bg: string; color?: string }) {
|
||||
return <span style={{ background: bg, color, borderRadius: 10, padding: '6px 14px', fontWeight: 900, fontSize: 18, lineHeight: 1.2 }}>{children}</span>;
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<div style={col} aria-hidden>
|
||||
<div style={row}>
|
||||
<Group codes={['2m', '3m', '4m']} w={24} note="이어진 3장" />
|
||||
<Group codes={['5p', '5p', '5p']} w={24} note="같은 3장" />
|
||||
</div>
|
||||
<div style={row}>
|
||||
<Group codes={['6s', '7s', '8s']} w={24} />
|
||||
<Group codes={['7z', '7z', '7z']} w={24} />
|
||||
<Group codes={['1z', '1z']} w={24} note="머리 2장" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
alt: '이어진 3장, 같은 3장 묶음 4개와 같은 패 2장으로 완성된 마작 손패',
|
||||
title: '1장 가져오고 1장 버려요',
|
||||
text: '차례마다 1장을 가져오고(쯔모) 1장을 버려요. 3장 묶음 4개 + 같은 패 2장을 먼저 만들면 이겨요(화료).',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<div style={{ ...col, gap: 8 }} aria-hidden>
|
||||
<div style={row}>
|
||||
<span style={label}>리치</span>
|
||||
<Tile code="3s" w={26} />
|
||||
<Tile code="9p" w={26} sideways />
|
||||
<span style={{ width: 34, height: 7, background: '#fff', border: '1px solid #999', borderRadius: 4, position: 'relative', alignSelf: 'center' }}>
|
||||
<span style={{ position: 'absolute', left: 14, top: 1, width: 4, height: 4, borderRadius: 2, background: '#dc2626' }} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={row}>
|
||||
<span style={label}>탕야오</span>
|
||||
<Tile code="2m" w={26} />
|
||||
<Tile code="5p" w={26} />
|
||||
<Tile code="8s" w={26} />
|
||||
</div>
|
||||
<div style={row}>
|
||||
<span style={label}>역패</span>
|
||||
<Tile code="5z" w={26} />
|
||||
<Tile code="5z" w={26} />
|
||||
<Tile code="5z" w={26} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
alt: '쉬운 역 세 가지: 리치 선언, 2~8만 모은 탕야오, 백 3장 역패',
|
||||
title: '역이 꼭 있어야 해요',
|
||||
text: "손패를 완성해도 '역'(점수가 나는 조건)이 1개는 있어야 이겨요. 쉬운 역: 리치, 탕야오(2~8만), 역패(백·발·중 3장).",
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<div style={{ ...col, gap: 10 }} aria-hidden>
|
||||
<div style={{ ...row, gap: 1, paddingTop: 14 }}>
|
||||
{['3m', '4m', '6p', '7p', '8p'].map((c, i) => (
|
||||
<span key={i} style={{ position: 'relative' }}>
|
||||
{i === 4 && (
|
||||
<span style={{ position: 'absolute', top: -15, left: '50%', transform: 'translateX(-50%)', background: '#15803d', color: '#fff', fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '0 4px', lineHeight: 1.4 }}>텐</span>
|
||||
)}
|
||||
<Tile code={c} w={28} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ ...row, gap: 10 }}>
|
||||
<Badge bg="#dc2626">쯔모!</Badge>
|
||||
<Badge bg="#dc2626">론!</Badge>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
alt: "'텐' 표시가 붙은 패와 빨간 쯔모 버튼, 론 버튼",
|
||||
title: '쯔모! 론! 으로 이겨요',
|
||||
text: "필요한 패를 직접 가져오면 [쯔모!], 남이 버리면 [론!]을 눌러요. 초보 모드는 '텐' 표시와 기다리는 패를 알려 줘요.",
|
||||
},
|
||||
];
|
||||
68
apps/web/src/games/marble/help.test.ts
Normal file
68
apps/web/src/games/marble/help.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.marble!.view(s, who) as any;
|
||||
/** Scripted dice: int(6) returns the queued faces (1..6). */
|
||||
const dice = (...faces: number[]) => ({ next: () => 0, int: () => faces.shift()! - 1, shuffle: <T,>(a: readonly T[]) => [...a] }) as any;
|
||||
|
||||
function fresh(opts: Record<string, unknown> = {}) {
|
||||
const { state } = setupGame('marble', 2, opts);
|
||||
state.order = ['A', 'B'];
|
||||
state.current = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
test('부루마불: 굴리기 차례 / 상대 차례 / 관전', () => {
|
||||
const s = fresh();
|
||||
const a = helpNow(view(s, 'A'), 'A', users);
|
||||
expect(a).toContain('[🎲 주사위 굴리기]');
|
||||
expect(a).toContain('지금 내 포인트는 300P');
|
||||
const b = helpNow(view(s, 'B'), 'B', users);
|
||||
expect(b).toContain('호랑이님 차례예요(1/20라운드). 기다려 주세요.');
|
||||
expect(helpNow(view(s, null), null, users)).toContain('호랑이님 차례예요(1/20라운드).');
|
||||
});
|
||||
|
||||
test('부루마불: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기', () => {
|
||||
let s = fresh();
|
||||
s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(2, 4)); // → 6 방콕
|
||||
const t = helpNow(view(s, 'A'), 'A', users);
|
||||
expect(t).toContain('방콕에 왔어요. 주인 없는 땅이라 10P에 살 수 있어요.');
|
||||
expect(t).toContain('[사기]');
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('방콕에서 할 일을 고르는 중이에요');
|
||||
const s2 = fresh();
|
||||
s2.owners[6] = { owner: 'A', level: 0 };
|
||||
const s3 = applyAction('marble', s2, 'A', { type: 'ROLL' }, dice(2, 4));
|
||||
const u = helpNow(view(s3, 'A'), 'A', users);
|
||||
expect(u).toContain('내 땅 방콕에 왔어요. 별장을 5P에 지을 수 있어요. 지으면 통행료가 6P가 돼요.');
|
||||
expect(u).toContain('[짓기]');
|
||||
});
|
||||
|
||||
test('부루마불: 무인도, 더블, 우주여행, 모자란 포인트', () => {
|
||||
let s = fresh();
|
||||
s.players.A.pos = 10;
|
||||
s.players.A.islandLeft = 2;
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('무인도에 갇혔어요(2번 남음)');
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('무인도에 갇혀 있어요(2번 남음)');
|
||||
|
||||
s = fresh();
|
||||
s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(3, 3)); // → 6 방콕, double
|
||||
s = applyAction('marble', s, 'A', { type: 'PASS' });
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('더블이 나와서 한 번 더 굴려요');
|
||||
|
||||
s = fresh();
|
||||
s.players.A.spacePending = true;
|
||||
s.players.A.pos = 30;
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('우주여행이에요');
|
||||
|
||||
s = fresh();
|
||||
s.owners[6] = { owner: 'B', level: 3 };
|
||||
s.owners[39] = { owner: 'A', level: 0 };
|
||||
s.players.A.cash = 5;
|
||||
s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(2, 4)); // 통행료 25 > 5
|
||||
expect(s.phase).toBe('debt');
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('20P가 모자라요');
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('모자란 포인트 20P');
|
||||
});
|
||||
94
apps/web/src/games/marble/help.ts
Normal file
94
apps/web/src/games/marble/help.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/** 부루마불 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (+ the pure board data). */
|
||||
import type { MarbleView } from '@bg/games/marble';
|
||||
import { SPACES } from '@bg/games/marble/board';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const LEVEL_KO = ['땅', '별장', '빌딩', '호텔', '랜드마크'];
|
||||
const P = (n: number) => `${n}P`;
|
||||
const batchim = (w: string) => {
|
||||
const c = w.charCodeAt(w.length - 1);
|
||||
return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0;
|
||||
};
|
||||
const eul = (w: string) => `${w}${batchim(w) ? '을' : '를'}`;
|
||||
const space = (pos: number) => SPACES[pos]?.name ?? `${pos}번 칸`;
|
||||
|
||||
export function helpNow(v: MarbleView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
const name = (id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`;
|
||||
const round = v.maxRounds > 0 ? `${v.round}/${v.maxRounds}라운드` : `${v.round}라운드`;
|
||||
const meP = me ? v.players.find((p) => p.id === me) : undefined;
|
||||
const m = v.my;
|
||||
|
||||
// What is happening, told neutrally (for waiting players and spectators).
|
||||
const situation = (): string => {
|
||||
if (v.phase === 'debt' && v.debt) return `${name(v.debt.debtor)}이 모자란 포인트 ${P(v.debt.short)}를 땅이나 건물을 팔아 채우는 중이에요.`;
|
||||
if (v.phase === 'tradeResponse' && v.trade) return `${name(v.trade.to)}이 ${name(v.trade.from)}의 교환 제안을 보는 중이에요.`;
|
||||
const cur = v.current ? v.players.find((p) => p.id === v.current) : undefined;
|
||||
let s = `${name(v.current)} 차례예요(${round}).`;
|
||||
if (v.phase === 'decide' && v.decision) s += ` ${space(v.decision.pos)}에서 할 일을 고르는 중이에요.`;
|
||||
else if (cur?.spacePending) s += ' 우주여행으로 갈 칸을 고르는 중이에요.';
|
||||
else if (cur && cur.islandLeft > 0) s += ` 무인도에 갇혀 있어요(${cur.islandLeft}번 남음).`;
|
||||
return s;
|
||||
};
|
||||
|
||||
if (!meP) return `${situation()}\n주사위를 굴려 판을 돌며 땅을 사고 건물을 지어요. 남의 땅에 서면 통행료를 내요.`;
|
||||
if (!meP.alive) return `나는 ${meP.conceded ? '기권' : '파산'}해서 지금은 구경만 할 수 있어요.\n${situation()}`;
|
||||
|
||||
const mine = `지금 내 포인트는 ${P(meP.cash)}, 내 땅은 ${meP.lands.length}곳이에요.`;
|
||||
|
||||
if (m?.respond && v.trade) return `${name(v.trade.from)}이 교환을 제안했어요. 주고받을 땅과 포인트를 잘 보고 [받기] 또는 [거절]을 눌러 주세요.`;
|
||||
|
||||
if (m?.sell && v.debt) {
|
||||
const lines = [`내야 할 ${P(v.debt.amount)} 중에 ${P(v.debt.short)}가 모자라요. 땅이나 건물을 은행에 팔아(낸 값의 절반을 받아요) 채워야 해요.`];
|
||||
lines.push(m.autoSell ? '[가장 싼 것부터 팔기] 버튼을 누르거나, ‘내 땅’ 목록에서 직접 골라 팔아요. 다 모이면 자동으로 내요.' : '‘내 땅’ 목록에서 팔 것을 골라 주세요. 다 모이면 자동으로 내요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (m?.decide) {
|
||||
const d = m.decide;
|
||||
const where = space(d.pos);
|
||||
if (d.buy !== null) {
|
||||
return `${where}에 왔어요. 주인 없는 땅이라 ${P(d.buy)}에 살 수 있어요.\n[사기]를 누르면 내 땅이 되고, 다른 사람이 여기 서면 나에게 통행료를 내요. 사지 않으려면 [안 사기]를 눌러요.\n${mine}`;
|
||||
}
|
||||
if (d.build) {
|
||||
const toll = SPACES[d.pos]?.tolls?.[d.build.level];
|
||||
const after = toll !== undefined ? ` 지으면 통행료가 ${P(toll)}가 돼요.` : '';
|
||||
const head = d.boughtThisVisit ? `방금 산 ${where}에 이 방 규칙으로 바로` : `내 땅 ${where}에 왔어요.`;
|
||||
return `${head} ${eul(LEVEL_KO[d.build.level]!)} ${P(d.build.cost)}에 지을 수 있어요.${after}\n[짓기] 또는 [안 짓기]를 눌러 주세요. 건물은 한 번 올 때마다 한 단계씩 지어요.\n${mine}`;
|
||||
}
|
||||
if (d.takeover !== null) {
|
||||
return `${where}에서 통행료를 냈어요. 이 방 규칙에서는 ${P(d.takeover)}를 내고 이 땅을 건물째 가져올(인수) 수 있어요.\n[인수하기] 또는 [안 하기]를 눌러 주세요.\n${mine}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (m?.space) return `🚀 우주여행이에요! 판에서 반짝이는 칸을 누르면 미리 보기가 나오고, 거기서 갈 곳을 정해요.\n출발 칸을 지나가면 월급 ${P(v.options.salary)}를 받아요. [출발로 가기]를 눌러도 돼요.\n${mine}`;
|
||||
|
||||
if (m?.island) {
|
||||
const isl = m.island;
|
||||
const lines = [`🏝️ 무인도에 갇혔어요(${isl.left}번 남음). [🎲 탈출 굴리기]를 눌러 더블(두 주사위가 같은 눈)이 나오면 바로 나가요.`];
|
||||
const ways: string[] = [];
|
||||
if (isl.hasCard) ways.push('[탈출권 쓰기]');
|
||||
if (isl.fee !== null) ways.push(`[탈출비 ${P(isl.fee)} 내기]${isl.canPayFee ? '' : '(포인트가 모자라 지금은 못 눌러요)'}`);
|
||||
if (ways.length) lines.push(`${ways.join(' 또는 ')}로 바로 나갈 수도 있어요.`);
|
||||
lines.push('무인도에 있어도 내 땅의 통행료는 받아요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (m?.canRoll) {
|
||||
const lines: string[] = [];
|
||||
if (v.extraRoll) lines.push(`더블이 나와서 한 번 더 굴려요! [🎲 한 번 더 굴리기]를 눌러 주세요.${v.doublesThisTurn === 2 ? ' 또 더블이 나오면 무인도로 가요.' : ''}`);
|
||||
else lines.push(`내 차례예요(${round}). [🎲 주사위 굴리기]를 누르면 나온 수만큼 앞으로 가요.`);
|
||||
lines.push('주인 없는 땅에 서면 살 수 있고, 남의 땅에 서면 통행료를 내요. 더블이면 한 번 더 굴려요.');
|
||||
if (m.canPropose) lines.push(`굴리기 전에 [🤝 교환 제안하기]로 다른 사람과 땅·포인트를 바꾸자고 할 수 있어요.`);
|
||||
lines.push(mine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Waiting for someone else.
|
||||
const lines = [`${situation()} 기다려 주세요.`];
|
||||
if (v.phase === 'tradeResponse' && v.trade?.from === me) lines[0] = `내 교환 제안에 ${name(v.trade.to)}이 답하기를 기다리는 중이에요.`;
|
||||
if (meP.islandLeft > 0) lines.push(`나는 무인도에 있어요(${meP.islandLeft}번 남음). 그래도 내 땅의 통행료는 받아요.`);
|
||||
else if (meP.spacePending) lines.push('다음 내 차례에 우주여행으로 원하는 칸에 갈 수 있어요.');
|
||||
lines.push(mine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 주사위 땅따먹기 화면 (docs/games/marble.md §9). Board, dice, tokens and buildings are drawn here (SVG/CSS only). */
|
||||
/** 부루마불 화면 (docs/games/marble.md §9). Board, dice, tokens and buildings are drawn here (SVG/CSS only). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MarbleOptions, MarbleOwnedView, MarblePlayerView, MarbleView } from '@bg/games/marble';
|
||||
import { CARDS, SPACES, type CardId, type Level } from '@bg/games/marble/board';
|
||||
@@ -7,6 +7,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
type GameEvent = MarbleView['log'][number];
|
||||
|
||||
@@ -426,7 +428,7 @@ function MarbleBoard({ view: v, me, players, act, pending }: BoardProps<MarbleVi
|
||||
<div className="mb-wrap">
|
||||
<style>{CSS}</style>
|
||||
<div className="mb-box">
|
||||
<svg className="mb-svg" viewBox="0 0 1000 1000" role="group" aria-label={`땅따먹기 판. 출발은 오른쪽 아래이고, 말은 시계 방향으로 돌아요. 방향키로 칸을 옮기고 Enter로 칸 정보를 봐요.${spaceMode ? ' 우주여행으로 갈 칸을 골라 주세요.' : ''}`}>
|
||||
<svg className="mb-svg" viewBox="0 0 1000 1000" role="group" aria-label={`부루마불 판. 출발은 오른쪽 아래이고, 말은 시계 방향으로 돌아요. 방향키로 칸을 옮기고 Enter로 칸 정보를 봐요.${spaceMode ? ' 우주여행으로 갈 칸을 골라 주세요.' : ''}`}>
|
||||
{CELLS.map((c, pos) => {
|
||||
const sp = SPACES[pos]!;
|
||||
const o = ownedAt.get(pos);
|
||||
@@ -581,7 +583,7 @@ function Center({
|
||||
return (
|
||||
<>
|
||||
<div className="mb-title" aria-hidden>
|
||||
주사위 땅따먹기
|
||||
부루마불
|
||||
</div>
|
||||
<div className="mb-meta">
|
||||
<span className="mb-pill">{v.maxRounds > 0 ? `${v.round} / ${v.maxRounds} 라운드` : `${v.round} 라운드`}</span>
|
||||
@@ -1388,6 +1390,8 @@ const ui: GameUI<MarbleView, MarbleOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
87
apps/web/src/games/marble/quick.tsx
Normal file
87
apps/web/src/games/marble/quick.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/** 부루마불 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { ReactNode } from 'react';
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
const PIPS: Record<number, [number, number][]> = {
|
||||
1: [[50, 50]],
|
||||
2: [[28, 28], [72, 72]],
|
||||
3: [[26, 26], [50, 50], [74, 74]],
|
||||
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
|
||||
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
|
||||
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
|
||||
};
|
||||
|
||||
function Die({ value }: { value: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" width={56} height={56} aria-hidden>
|
||||
<rect x={4} y={4} width={92} height={92} rx={18} fill="#fffdf8" stroke="#9ca3af" strokeWidth={5} />
|
||||
{PIPS[value]!.map(([x, y], i) => (
|
||||
<circle key={i} cx={x} cy={y} r={10} fill="#1c1e21" />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A city square like on the board: colour band, name, and a line below. */
|
||||
function Tile({ name, color, children, owner }: { name: string; color: string; children: ReactNode; owner?: string }) {
|
||||
return (
|
||||
<div style={{ width: 96, borderRadius: 10, overflow: 'hidden', border: `3px solid ${owner ?? '#d1d5db'}`, background: '#fff', textAlign: 'center' }}>
|
||||
<div style={{ background: color, height: 16 }} />
|
||||
<div style={{ fontWeight: 800, fontSize: '1rem', padding: '4px 0 0', color: '#111' }}>{name}</div>
|
||||
<div style={{ fontSize: '0.95rem', padding: '2px 4px 6px', color: '#111' }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const row = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10 } as const;
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: (
|
||||
<div style={row} aria-hidden>
|
||||
<Die value={3} />
|
||||
<Die value={5} />
|
||||
<span style={{ fontSize: 28 }}>➜</span>
|
||||
<Tile name="방콕" color="#38bdf8">
|
||||
<b>10P</b>에 사기
|
||||
</Tile>
|
||||
</div>
|
||||
),
|
||||
alt: '주사위 3과 5를 굴려 방콕 칸에 도착하고, 10P에 살 수 있어요',
|
||||
title: '굴리고 땅을 사요',
|
||||
text: '주사위 2개를 굴린 만큼 판을 돌아요. 주인 없는 땅에 서면 [사기]로 내 땅으로 만들 수 있어요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<div style={row} aria-hidden>
|
||||
<Tile name="로마" color="#db2777" owner="#dc2626">
|
||||
<span style={{ fontSize: 22 }}>🏠🏢🏨</span>
|
||||
</Tile>
|
||||
<div style={{ textAlign: 'center', fontWeight: 800, color: '#b91c1c' }}>
|
||||
<div style={{ fontSize: 30 }}>🚶</div>
|
||||
통행료
|
||||
<br />
|
||||
40P
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
alt: '별장·빌딩·호텔을 지은 로마 칸에 다른 사람이 와서 통행료 40P를 내요',
|
||||
title: '짓고 통행료 받기',
|
||||
text: '내 땅에 다시 오면 별장→빌딩→호텔을 한 단계씩 지어요. 남이 내 땅에 서면 통행료를 받고, 건물이 클수록 비싸요.',
|
||||
},
|
||||
{
|
||||
pic: (
|
||||
<div style={{ ...row, gap: 16 }} aria-hidden>
|
||||
<div style={{ textAlign: 'center', fontWeight: 700 }}>
|
||||
<div style={{ fontSize: 44 }}>🏝️</div>무인도
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontWeight: 700 }}>
|
||||
<div style={{ fontSize: 44 }}>🏆</div>총자산 1등
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
alt: '무인도 섬과 총자산 1등 트로피 그림',
|
||||
title: '총자산이 많으면 이겨요',
|
||||
text: '무인도에선 몇 차례 쉬어요(더블이면 바로 탈출). 라운드가 다 끝날 때 총자산(포인트+땅+건물)이 가장 많거나, 남이 모두 파산하면 이겨요.',
|
||||
},
|
||||
];
|
||||
52
apps/web/src/games/ninety-nine/help.test.ts
Normal file
52
apps/web/src/games/ninety-nine/help.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B', 'C']);
|
||||
const view = (s: unknown, who: string | null) => GAMES['ninety-nine']!.view(s, who) as any;
|
||||
const nickOf = (id: string) => users[id]!.nickname;
|
||||
|
||||
test('구십구: 내 차례 / 상대 차례 / 관전', () => {
|
||||
const { state } = setupGame('ninety-nine', 3);
|
||||
const cur = state.current as string;
|
||||
const other = ['A', 'B', 'C'].find((p) => p !== cur)!;
|
||||
// 3 players: the start card says 20.
|
||||
expect(helpNow(view(state, cur), cur, users)).toContain('내 차례예요(지금 합계 20)');
|
||||
const t = helpNow(view(state, other), other, users);
|
||||
expect(t).toContain(`${nickOf(cur)}님 차례예요(지금 합계 20)`);
|
||||
expect(t).toContain('바로 탈락해요');
|
||||
const spec = helpNow(view(state, null), null, users);
|
||||
expect(spec).toContain(`${nickOf(cur)}님 차례예요`);
|
||||
expect(spec).toContain('99를 넘게');
|
||||
const card = view(state, cur).my.hand.find((c: any) => c.kind !== 'joker');
|
||||
const s2 = applyAction('ninety-nine', state, cur, { type: 'play', cardId: card.id, ...(card.choices.length > 1 ? (card.choices[0].sign ? { sign: '+' } : card.choices[0].dir ? { dir: 'keep' } : { aceValue: 1 }) : {}) });
|
||||
expect(helpNow(view(s2, s2.current), s2.current, users)).toContain(`지금 합계 ${s2.total}`);
|
||||
});
|
||||
|
||||
test('구십구: 합계 95에서 낼 수 있는 카드만 밝게, ±카드 안내', () => {
|
||||
const { state } = setupGame('ninety-nine', 3);
|
||||
const cur = state.current as string;
|
||||
state.total = 95;
|
||||
state.hands[cur] = [
|
||||
{ id: 'q1', kind: 'add', v: 8, face: { label: '8' } },
|
||||
{ id: 'q2', kind: 'add', v: 7, face: { label: '7' } },
|
||||
{ id: 'q3', kind: 'plusMinus', v: 9, face: { label: '±9' } },
|
||||
];
|
||||
const t = helpNow(view(state, cur), cur, users);
|
||||
expect(t).toContain('밝게 보이는 카드만 낼 수 있어요(1장)');
|
||||
expect(t).toContain('±카드는');
|
||||
state.hands[cur].push({ id: 'q4', kind: 'zero', face: { label: '0' } });
|
||||
expect(helpNow(view(state, cur), cur, users)).toContain('0 카드는 합계를 그대로 둬요. 누른 뒤 [방향 그대로]와 [방향 바꾸기] 중 하나를 골라요.');
|
||||
});
|
||||
|
||||
test('구십구: 목숨 규칙과 탈락', () => {
|
||||
const { state } = setupGame('ninety-nine', 3, { lives: 3 });
|
||||
const cur = state.current as string;
|
||||
const other = ['A', 'B', 'C'].find((p) => p !== cur)!;
|
||||
const t = helpNow(view(state, other), other, users);
|
||||
expect(t).toContain('목숨 1개를 잃고 합계 20부터 새 판을 해요');
|
||||
expect(t).toContain('내 목숨은 3개예요');
|
||||
state.alive[other] = false;
|
||||
expect(helpNow(view(state, other), other, users)).toContain('탈락했어요');
|
||||
});
|
||||
61
apps/web/src/games/ninety-nine/help.ts
Normal file
61
apps/web/src/games/ninety-nine/help.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/** 구십구 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { NNOptions, NinetyNineView } from '@bg/games/ninety-nine';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
/** Same as the engine's: `auto` deals 5 with the dedicated cards, 3 with trump cards. */
|
||||
export const resolvedHandSize = (o: Pick<NNOptions, 'mode' | 'handSize'>): number => (o.handSize === 'auto' ? (o.mode === 'classic' ? 3 : 5) : o.handSize);
|
||||
/** Same as the engine's: the start card shows 40 for 2 players, 20 for 3, 0 for 4 or more. */
|
||||
export const resolvedStartTotal = (o: Pick<NNOptions, 'mode' | 'startTotal'>, players: number): number =>
|
||||
o.mode === 'kr-product' && o.startTotal === 'auto' ? (players <= 2 ? 40 : players === 3 ? 20 : 0) : 0;
|
||||
|
||||
const nm = (players: Record<string, PublicUser>, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`;
|
||||
|
||||
/** What happens to someone who has no card to play (option dependent). */
|
||||
function loseText(v: NinetyNineView): string {
|
||||
if (v.livesStart === 0) return '바로 탈락해요';
|
||||
return `목숨 1개를 잃고 합계 ${resolvedStartTotal(v.options, v.seats.filter((s) => s.alive).length)}부터 새 판을 해요`;
|
||||
}
|
||||
|
||||
export function helpNow(v: NinetyNineView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
const goal = '카드를 1장씩 내며 합계를 더해요. 합계가 99를 넘게 될 카드밖에 없으면 져요.';
|
||||
if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
|
||||
const seat = me ? v.seats.find((s) => s.id === me) : undefined;
|
||||
const my = seat ? v.my : null;
|
||||
const cur = v.current;
|
||||
const curNick = nm(players, cur);
|
||||
const turnLine = cur ? `${curNick} 차례예요(지금 합계 ${v.total}).` : `지금 합계는 ${v.total}까지 왔어요.`;
|
||||
|
||||
if (!seat || !my) return `${turnLine}\n${goal}`;
|
||||
if (!seat.alive) return `${seat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 사람의 게임을 지켜봐요.'}\n${turnLine}`;
|
||||
|
||||
const lifeLine =
|
||||
v.livesStart > 0
|
||||
? seat.onBoard
|
||||
? '목숨을 다 잃어서 마지막 기회예요. 한 번 더 지면 탈락해요.'
|
||||
: `내 목숨은 ${seat.lives}개예요.`
|
||||
: '';
|
||||
|
||||
if (!my.myTurn) {
|
||||
const lines = [`${turnLine} 기다려 주세요.`];
|
||||
lines.push(`내 차례에 낼 수 있는 카드가 없으면 ${loseText(v)}. 카드를 누르면 낼 때 합계를 미리 볼 수 있어요.`);
|
||||
if (lifeLine) lines.push(lifeLine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const legal = my.hand.filter((c) => c.legal);
|
||||
const lines = [`내 차례예요(지금 합계 ${v.total}). 합계가 99를 넘지 않게 카드 1장을 내요.`];
|
||||
if (legal.length < my.hand.length) lines.push(`초록 테두리로 밝게 보이는 카드만 낼 수 있어요(${legal.length}장). 어두운 카드는 99를 넘거나 0보다 작아져요.`);
|
||||
lines.push('카드를 누르면 낸 뒤 합계가 보여요. 한 번 더 누르면 내요. 패스는 없어요.');
|
||||
if (legal.some((c) => c.kind === 'plusMinus')) lines.push('±카드는 누른 뒤 더하기(+)와 빼기(−) 중 하나를 골라요. 합계가 클 때는 빼기가 좋아요.');
|
||||
if (legal.some((c) => c.kind === 'ace')) lines.push('A는 누른 뒤 더할 값을 골라요.');
|
||||
if (legal.some((c) => c.kind === 'joker')) lines.push(`조커는 합계를 ${v.jokerRange.min}~${v.jokerRange.max} 중 원하는 수로 만들어요. 수를 고르고 [조커 내기]를 눌러요.`);
|
||||
const face = (k: string) => legal.find((c) => c.kind === k)?.face.label;
|
||||
if (face('zero')) lines.push(`${face('zero')} 카드는 합계를 그대로 둬요. 누른 뒤 [방향 그대로]와 [방향 바꾸기] 중 하나를 골라요.`);
|
||||
if (face('reverse')) lines.push(`${face('reverse')} 카드는 합계를 그대로 두고 진행 방향만 바꿔요.`);
|
||||
if (face('hold')) lines.push(`${face('hold')} 카드는 합계를 그대로 두고 다음 사람에게 넘겨요.`);
|
||||
if (face('to99')) lines.push(`${face('to99')} 카드는 합계를 바로 99로 만들어요.`);
|
||||
if (face('minus')) lines.push(`${face('minus')} 카드는 합계에서 10을 빼요.`);
|
||||
if (lifeLine) lines.push(lifeLine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { PlayingCard } from '../../components/cards/PlayingCard';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow, resolvedHandSize } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const MODE_KO: Record<NNOptions['mode'], string> = { 'kr-product': '전용 카드', classic: '트럼프 카드' };
|
||||
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
|
||||
@@ -16,6 +18,10 @@ const TRUMP_RANK: Record<string, string> = { '10': 'T' };
|
||||
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
|
||||
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const resolvedLives = (o: NNOptions) => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
|
||||
/** Rounds start from the start card's number (2명 40 · 3명 20 · 4명 이상 0) rather than always 0. */
|
||||
const startCard = (o: NNOptions) => o.mode === 'kr-product' && o.startTotal === 'auto';
|
||||
/** Where a (new) round starts, in words: "시작 숫자" or "0". */
|
||||
const startWord = (o: NNOptions) => (startCard(o) ? '시작 숫자' : '0');
|
||||
|
||||
/** Short name, e.g. "±9", "조커", "♥K". */
|
||||
function faceName(f: CardFace): string {
|
||||
@@ -96,7 +102,7 @@ const CSS = `
|
||||
.nn-recent .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
|
||||
.nn-mini { display: inline-grid; place-items: center; min-width: 30px; height: 22px; border-radius: 5px; background: #fffef9; color: #1f2937; font-weight: 900; font-size: .8rem; padding: 0 4px; text-shadow: none; }
|
||||
.nn-mini[data-k='plusMinus'] { background: #ede9fe; color: #5b21b6; }
|
||||
.nn-mini[data-k='reverse'] { background: #ccfbf1; color: #115e59; }
|
||||
.nn-mini[data-k='reverse'], .nn-mini[data-k='zero'] { background: #ccfbf1; color: #115e59; }
|
||||
.nn-mini[data-k='joker'], .nn-mini[data-k='to99'] { background: #fef3c7; color: #92400e; }
|
||||
.nn-mini[data-red='true'] { color: #dc2626; }
|
||||
.nn-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
@@ -124,7 +130,7 @@ const CSS = `
|
||||
.nn-art[data-k='add'] { --band: #1d4ed8; }
|
||||
.nn-art[data-k='add'][data-big='true'] { --band: #b91c1c; }
|
||||
.nn-art[data-k='plusMinus'] { --band: #6d28d9; background: linear-gradient(160deg, #fffef9 55%, #ede9fe); }
|
||||
.nn-art[data-k='reverse'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); }
|
||||
.nn-art[data-k='reverse'], .nn-art[data-k='zero'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); }
|
||||
.nn-art[data-k='joker'] { --band: #b45309; background: radial-gradient(circle at 50% 40%, #fef3c7, #fffef9 70%); }
|
||||
.nn-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
|
||||
.nn-pick .btn { flex: 1 1 140px; max-width: 280px; font-size: 1.1rem; font-weight: 800; }
|
||||
@@ -161,7 +167,7 @@ const CSS = `
|
||||
|
||||
// ---------------------------------------------------------------- cards
|
||||
|
||||
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' };
|
||||
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', zero: '방향 고르기', joker: '' };
|
||||
|
||||
function trumpId(f: CardFace): string {
|
||||
return `${TRUMP_RANK[f.label] ?? f.label}${f.suit}`;
|
||||
@@ -307,7 +313,7 @@ function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Recor
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'}</span>
|
||||
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 ${v.total}` : v.total > 0 ? `시작 숫자 ${v.total} · 첫 카드를 기다려요` : '첫 카드를 기다려요'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,9 +332,9 @@ function cardAria(c: HandCardView, myTurn: boolean): string {
|
||||
function previewOf(c: HandCardView): { text: string; cls: string } {
|
||||
if (c.joker) return { text: `${c.joker.min}~${c.joker.max}`, cls: 'ok' };
|
||||
if (c.badge) return { text: c.badge, cls: 'no' };
|
||||
const ok = c.choices.filter((x) => x.legal);
|
||||
if (ok.length === 1) return { text: `→ ${ok[0]!.total}`, cls: 'ok' };
|
||||
return { text: ok.map((x) => `${x.total}`).join(' / '), cls: 'ok' };
|
||||
const totals = [...new Set(c.choices.filter((x) => x.legal).map((x) => x.total))];
|
||||
if (totals.length === 1) return { text: `→ ${totals[0]}`, cls: 'ok' };
|
||||
return { text: totals.join(' / '), cls: 'ok' };
|
||||
}
|
||||
|
||||
function JokerDial({ c, total, pending, onPlay }: { c: HandCardView; total: number; pending: boolean; onPlay: (n: number) => void }) {
|
||||
@@ -476,8 +482,8 @@ function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => v
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!x.legal || pending}
|
||||
aria-label={x.legal ? `${x.label}로 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
|
||||
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) })}
|
||||
aria-label={x.legal ? `${x.dir ? `${x.label}로 두고` : `${x.label}로`} 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
|
||||
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}), ...(x.dir ? { dir: x.dir } : {}) })}
|
||||
>
|
||||
{single(chosen) ? `내기 → ${x.total}` : arrow(x)}
|
||||
</button>
|
||||
@@ -497,7 +503,16 @@ function logText(e: LogEntry, players: Record<string, PublicUser>): string {
|
||||
const n = (id: string) => nick(players, id);
|
||||
switch (e.t) {
|
||||
case 'play': {
|
||||
const how = e.kind === 'joker' ? `${e.total}로 만들었어요` : e.kind === 'reverse' ? `방향을 바꿨어요(${e.total} 그대로)` : e.kind === 'hold' ? `그대로 넘겼어요(${e.total})` : `${e.from} → ${e.total}`;
|
||||
const how =
|
||||
e.kind === 'joker'
|
||||
? `${e.total}로 만들었어요`
|
||||
: e.kind === 'reverse'
|
||||
? `방향을 바꿨어요(${e.total} 그대로)`
|
||||
: e.kind === 'zero'
|
||||
? `합계 ${e.total} 그대로`
|
||||
: e.kind === 'hold'
|
||||
? `그대로 넘겼어요(${e.total})`
|
||||
: `${e.from} → ${e.total}`;
|
||||
return `${n(e.p)}: ${faceName(e.face)}${e.choice && e.kind !== 'joker' ? `(${e.choice})` : ''} ${how}${e.auto ? ' · 자동' : ''}`;
|
||||
}
|
||||
case 'noCard':
|
||||
@@ -507,7 +522,7 @@ function logText(e: LogEntry, players: Record<string, PublicUser>): string {
|
||||
case 'out':
|
||||
return e.why === 'left' ? `${n(e.p)}님이 나가서 탈락했어요` : `${n(e.p)}님이 탈락했어요`;
|
||||
case 'round':
|
||||
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 0`;
|
||||
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 ${e.total ?? 0}`;
|
||||
case 'reshuffle':
|
||||
return `버린 카드 ${e.n}장을 섞어 새 더미를 만들었어요`;
|
||||
case 'timeout':
|
||||
@@ -563,8 +578,13 @@ function Board({ view: v, me, players, act, pending, deadline }: BoardProps<Nine
|
||||
|
||||
function rulesLine(o: NNOptions): string {
|
||||
const lives = resolvedLives(o);
|
||||
const parts = [`손패 ${o.handSize}장`, o.refill ? '내고 1장 보충' : '보충 없음', lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락'];
|
||||
if (lives === 0) parts.push(o.afterElimination === 'reset' ? '탈락 뒤 0부터' : '탈락 뒤 합계 그대로');
|
||||
const parts = [
|
||||
`손패 ${resolvedHandSize(o)}장`,
|
||||
startCard(o) ? '시작 숫자 2명 40 · 3명 20 · 4명 이상 0' : '0부터 시작',
|
||||
o.refill ? '내고 1장 보충' : '보충 없음',
|
||||
lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락',
|
||||
];
|
||||
if (lives === 0) parts.push(o.afterElimination === 'reset' ? `탈락 뒤 ${startWord(o)}부터` : '탈락 뒤 합계 그대로');
|
||||
parts.push(o.floorAtZero === 'forbid' ? '0 미만 빼기 안 됨' : '0 미만은 0');
|
||||
if (o.mode === 'kr-product') parts.push(`조커 ${o.jokerRange === '0-99' ? '0~99' : '60~99'}`);
|
||||
else parts.push(`A는 1 또는 ${o.aceHigh}`);
|
||||
@@ -692,9 +712,14 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
<Seg label="규칙" value={o.mode} options={[['kr-product', '전용 카드'], ['classic', '트럼프 카드']]} onChange={(mode) => set({ mode })} />
|
||||
</Field>
|
||||
<Field label="손패 장수">
|
||||
<Seg label="손패 장수" value={o.handSize} options={[[3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
|
||||
<Seg label="손패 장수" value={o.handSize} options={[['auto', kr ? '5장(기본)' : '3장(기본)'], [3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
|
||||
</Field>
|
||||
<Field label="목숨" help={lives > 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}>
|
||||
{kr && (
|
||||
<Field label="시작 숫자" help={o.startTotal === 'auto' ? '가운데 시작 카드처럼 2명이면 40, 3명이면 20, 4명 이상이면 0에서 시작해요.' : '인원과 상관없이 0에서 시작해요.'}>
|
||||
<Seg label="시작 숫자" value={o.startTotal} options={[['auto', '시작 카드(기본)'], ['zero', '항상 0']]} onChange={(startTotal) => set({ startTotal })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="목숨" help={lives > 0 ? `낼 카드가 없으면 목숨 1개를 잃고 ${startWord(o)}부터 새 판을 해요.` : '낼 카드가 없으면 바로 탈락해요.'}>
|
||||
<Seg
|
||||
label="목숨"
|
||||
value={o.lives}
|
||||
@@ -706,8 +731,8 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
<Seg label="한 턴 제한 시간" value={o.turnSeconds} options={[[10, '10초'], [15, '15초'], [20, '20초'], [30, '30초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
{kr && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 4 })}>
|
||||
어린이 추천 설정(조커 0~99, 손패 4장)
|
||||
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 'auto' })}>
|
||||
어린이 추천 설정(조커 0~99, 손패 5장)
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
@@ -722,7 +747,7 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
<Seg label="목숨을 다 잃어도 한 번 더" value={o.onTheBoard} options={[[false, '끄기'], [true, '켜기']]} onChange={(onTheBoard) => set({ onTheBoard })} />
|
||||
</Field>
|
||||
<Field label="(목숨 없음) 탈락 뒤 합계">
|
||||
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', '0으로 하고 다시 나누기']]} onChange={(afterElimination) => set({ afterElimination })} />
|
||||
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', `${startWord(o)}${startCard(o) ? '로' : '으로'} 하고 다시 나누기`]]} onChange={(afterElimination) => set({ afterElimination })} />
|
||||
</Field>
|
||||
<Field label="빼서 0보다 작아질 때">
|
||||
<Seg label="빼서 0보다 작아질 때" value={o.floorAtZero} options={[['forbid', '못 빼요'], ['clamp', '0으로 맞춰요']]} onChange={(floorAtZero) => set({ floorAtZero })} />
|
||||
@@ -754,7 +779,8 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
|
||||
function optionsSummary(o: NNOptions): string {
|
||||
const lives = resolvedLives(o);
|
||||
const parts = [MODE_KO[o.mode], `손패 ${o.handSize}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
|
||||
const parts = [MODE_KO[o.mode], `손패 ${resolvedHandSize(o)}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
|
||||
if (o.mode === 'kr-product' && o.startTotal === 'zero') parts.push('0부터 시작');
|
||||
if (!o.refill) parts.push('보충 없음');
|
||||
if (o.mode === 'kr-product' && o.jokerRange === '0-99') parts.push('조커 0~99');
|
||||
parts.push(`${o.turnSeconds}초`);
|
||||
@@ -772,7 +798,7 @@ function SpecialTable({ o }: { o: NNOptions }) {
|
||||
[C('7', 'add'), '1~8, 10', '그 숫자만큼 더해요. 9 숫자 카드는 없어요.'],
|
||||
[C('±9', 'plusMinus'), '±9', `9를 더하거나 빼요. ${floor}`],
|
||||
[C('±10', 'plusMinus'), '±10', `10을 더하거나 빼요. ${floor}`],
|
||||
[C('0', 'reverse'), '0', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
|
||||
[C('0', 'zero'), '0', '합계는 그대로예요. 진행 방향을 그대로 둘지 반대로 바꿀지 골라요.'],
|
||||
[C('조커', 'joker'), '조커', `앞 수와 상관없이 ${o.jokerRange === '0-99' ? '0~99' : '60~99'} 중 원하는 수로 만들어요.`],
|
||||
]
|
||||
: [
|
||||
@@ -803,7 +829,8 @@ function SpecialTable({ o }: { o: NNOptions }) {
|
||||
function Rules({ options }: { options?: NNOptions }) {
|
||||
const o: NNOptions = {
|
||||
mode: 'kr-product',
|
||||
handSize: 3,
|
||||
handSize: 'auto',
|
||||
startTotal: 'auto',
|
||||
refill: true,
|
||||
lives: 'auto',
|
||||
onTheBoard: false,
|
||||
@@ -817,26 +844,28 @@ function Rules({ options }: { options?: NNOptions }) {
|
||||
...options,
|
||||
};
|
||||
const lives = resolvedLives(o);
|
||||
const start = startWord(o);
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
<strong>99를 넘기면 안 돼요!</strong> 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요.
|
||||
</p>
|
||||
<p>
|
||||
처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).
|
||||
처음에 {resolvedHandSize(o)}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).{' '}
|
||||
{startCard(o) ? '합계는 가운데 시작 카드의 숫자에서 시작해요. 2명이면 40, 3명이면 20, 4명 이상이면 0이에요.' : '합계는 0에서 시작해요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>낼 카드가 없으면</strong>:{' '}
|
||||
{lives > 0
|
||||
? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
||||
? `목숨 1개를 잃고, ${start}부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
||||
: o.afterElimination === 'reset'
|
||||
? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.'
|
||||
? `바로 탈락하고, 합계를 ${start}${startCard(o) ? '로' : '으로'} 해서 카드를 다시 나눠요.`
|
||||
: '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '}
|
||||
마지막까지 남은 1명이 이겨요.
|
||||
</p>
|
||||
<SpecialTable o={o} />
|
||||
<p className="small muted">
|
||||
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
||||
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99를 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -850,6 +879,8 @@ const ui: GameUI<NinetyNineView, NNOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user