Compare commits

...

89 Commits

Author SHA1 Message Date
EJClaw
0a587a8b98 테스트: 야추·오델로 무작위 대국의 한 수 처리 시간 상한 5ms→30ms (병렬 실행 중 부하로 가끔 실패, 다른 게임과 같은 값)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:21:43 +09:00
EJClaw
39223bacdd 컴퓨터 방 자동 정리 + 게스트 계정 삭제(로그아웃·하루 미접속) + 테스트 10분→1분
- 방 화면을 벗어나면(홈·규칙) detach: 자리·접속 상태는 유지, 방 접속 수에서 빠짐
- 컴퓨터가 있는 방: 사람이 모두 [방 나가기]하면 바로 닫고, 아무도 안 보면 대기실·끝난 방 1분, 게임 중 30분 뒤 정리
- 게스트 로그아웃 = 계정 삭제(자리·전적·세션·소켓), 사이트를 닫고 24시간 지난 게스트도 1분 주기로 삭제
- users.last_seen_at을 요청·소켓 종료 때 갱신(이전엔 갱신 안 됨), 003 마이그레이션에서 기존 게스트 시계 초기화
- 테스트: 무작위 대국 시드를 playoutSeeds()로 평소 1/10, test:full은 전부. bun test --parallel=4, 테스트 서버 종료 대기 1s→50ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:10:52 +09:00
EJClaw
373231b553 위협 가이드를 관리자 전용 옵션으로(기본 꺼짐) + 최고관리자 추가 + 계정 메뉴에 관리자 메뉴
- 엔진: GameDefinition.adminOnlyOptions — 방은 항상 관리자 값 사용(잠금처럼 강제, 잠금 안내는 안 띄움)
- 오목·체스·장기 threatHints, 바둑 atariWarning: 기본 false, 대기실 설정에서 뺌, 관리자 화면 게임 기본 규칙에서만 켬
- 체스·장기 hints는 off / moves만 남김
- 최고관리자 기본값에 1352267557213573160 추가(.env.example·문서 포함)
- 상단 계정 메뉴: 관리자·최고관리자면 [🛠 관리자 메뉴] → /admin
- 테스트: admin.test(관리자 전용 옵션 강제, 두 번째 최고관리자), admin.e2e(계정 메뉴, 위협 가이드 켜기 저장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:59:32 +09:00
EJClaw
4cb71d8cbe 위협 가이드 제거: 오목 위험 자리, 체스·장기 위험 기물 경고, 바둑 단수 경고
- 오목: 상대 3·4를 막으라는 빨간 동그라미·노란 점선(hints 옵션, view.hints, threatHints) 삭제
- 체스·장기: hints 'movesAndThreats'(주황 점선 위험 기물) 삭제 → 'off' / 'moves'만 남김
- 바둑: atariWarning 옵션과 atariWarnings(깜빡이는 단수 돌) 삭제
- 도움말 문구·설정 화면·docs 정리, 테스트는 "어떤 view에도 위협 표시 없음"으로 바꿈
- 갈 수 있는 칸 점, 금수 ×, 둘 수 없는 점 같은 규칙 표시는 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:34:58 +09:00
EJClaw
53fbbc1c9d 헤더: 코드로 참여 입력칸 빼고 테마 버튼(🌙/☀️) 추가 — 누르면 밝게↔어둡게
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:40:26 +09:00
EJClaw
843b18aea1 로그인 페이지: 디스코드 로그인 크게, 아래 게스트로 시작하기 → /login/guest에서 계정 이름 정하기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:12:55 +09:00
EJClaw
bdaaa23af6 디자인: 게임천국 느낌 아이콘, 홈 단순화(큰 버튼 3개 + 탭), 헤더 로그인/계정 메뉴, 로그인 페이지 분리
- 아이콘: 게임패드 + 천사 고리 + 반짝이, scripts/pwa-icons.ts가 favicon.svg와 PNG를 한 디자인에서 생성
- 홈: 방 만들기·코드로 참여(/j)·컴퓨터와 연습 버튼, [게임 고르기 | 지금 열린 방] 탭
- 헤더: 로그인 전 [로그인] → /login, 로그인 뒤 닉네임 → [설정][로그아웃] 메뉴
- /login: 로그인만, /me(설정)는 로그인 안 했으면 /login?next=/me로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:10:11 +09:00
EJClaw
6390e3e64c 게임 4개 원래 이름으로, .5 + NPMplus 배포 구성
- 보석 상인 → 스플렌더, 벽 쌓기 길찾기 → 쿼리도, 숫자 타일 → 루미큐브, 주사위 땅따먹기 → 부루마불
- deploy/docker-compose.proxy.yml: Caddy 없이 앱만(.5:2970), HTTPS·헤더는 NPMplus
- deploy/npmplus-advanced.conf: Caddyfile과 같은 보안 헤더
- 문서: 2026-10-06 결정, 배포 7절 (e2e 구십구 0 카드 방향 버튼 반영 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:32 +09:00
EJClaw
7da06d1b27 구십구: 코리아보드게임즈 공식 규칙으로 맞춤
- 공식 상품 페이지 규칙 설명 이미지 기준: 5장씩, 시작 카드(2명 40·3명 20·4명 이상 0),
  0 카드는 방향 유지/반대 선택, 칩(목숨)은 공식 규칙이 아니라 하우스 룰
- handSize 'auto'(전용 카드 5, 트럼프 3), startTotal 옵션, 카드 종류 'zero'와 dir 액션
- stateVersion 2, 진행 중인 v1 게임은 옛 규칙 그대로 이어서 하도록 migrate
- 특수 카드 장수(±9·±10·0)는 공개 자료가 없어 각 4장 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:32 +09:00
EJClaw
d8b8cdd8f3 M9: 2차 부하·장애 주입 보고서, 로드맵 M9 진행 결과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:06:04 +09:00
EJClaw
6cc3b75975 M9: 게임 E2E 변형 시나리오(체스960)도 요약 카드 본 것으로 처리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:01:07 +09:00
EJClaw
94b813c8b5 M9: 다시 보기·컴퓨터 상대 E2E도 30초 요약 카드를 본 것으로 처리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:49:17 +09:00
EJClaw
c78d0fd1fc M9: 체스 컴퓨터 상대를 새 체스 수 생성기(체스960)에 맞춤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:46:46 +09:00
EJClaw
fde665b13e Merge branch 'm9/bots' into codex/owner/grp_discord_joke-app 2026-10-06 13:35:33 +09:00
EJClaw
6efd7afbc5 Merge branch 'm9/replay' into codex/owner/grp_discord_joke-app 2026-10-06 13:35:23 +09:00
EJClaw
166c89c08b Merge branch 'm9/tutorial' into codex/owner/grp_discord_joke-app 2026-10-06 13:35:23 +09:00
EJClaw
5db651437a Merge branch 'm9/chess960' into codex/owner/grp_discord_joke-app 2026-10-06 13:35:23 +09:00
EJClaw
710ecfe4a5 Merge branch 'm9/pwa' into codex/owner/grp_discord_joke-app 2026-10-06 13:35:23 +09:00
EJClaw
68edd9b434 M9: 게임 26종 30초 요약 카드·[?] 지금 할 수 있는 것 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:35:21 +09:00
EJClaw
bd9272f4ce M9: 컴퓨터와 연습하기 화면과 E2E
- 게임 상세·홈 [🤖 컴퓨터와 연습하기], 대기실 [🤖 컴퓨터 넣기]·[✕], 🤖 아바타·배지,
  연습 게임 안내, 한 판 더는 사람만 세기
- e2e/bots.e2e.ts: 오목 연습(컴퓨터가 여러 번 받음), 원카드 컴퓨터 2명 여러 턴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:32:28 +09:00
EJClaw
a3f81ded37 M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방
- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
  (도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
  게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
  (POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
  재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:32:28 +09:00
EJClaw
5a2d865377 M9: 체스960 화면·E2E·문서
- 방 설정에 규칙(일반/체스960), 옆 패널에 시작 배치 번호와 캐슬링 버튼
- 캐슬링 칸을 노란 테두리 + '캐슬링' 글씨로 표시, 킹 선택 중 내 룩을 누르면 캐슬링
- E2E: chess960 시나리오(시작 번호를 읽어 뒷줄을 비우고 킹→룩으로 캐슬링), Plan.game
- docs: chess.md 5.4·8.0·9, 13-decisions, 02-architecture

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:26:02 +09:00
EJClaw
c84c1ba95e M9: 게임 기록 다시 보기
- 서버: 끝난 게임을 공개된 시드 + room_log 행동으로 다시 돌려 플레이어별 view 장면을 만든다
  (rooms/replay.ts, 장면은 view 차이만 전송: shared/view-patch.ts). 행동 로그의 at = 적용한 정확한 시각,
  start 로그에 stateVersion. GET /api/me/games, GET /api/replays/:gamePk(그 판 플레이어만, no-store),
  RoomView.lastGamePk. 로그가 지워졌거나 규칙 버전이 바뀌었거나 결과가 다르면 한국어 안내(410).
- 웹: 다시 보기 화면(/replay/:gamePk, 게임 Board/Side 읽기 전용, 처음/이전/재생·멈춤/다음/끝, 슬라이더),
  내 정보의 내 게임 기록 목록, 결과 화면의 [처음부터 다시 보기].
- 테스트: 모든 게임을 Room으로 끝까지 둔 뒤 다시 만든 상태·결과·장면이 같은지, 권한, view-patch, e2e/replay.e2e.ts.
- 문서: docs/09 §8, docs/07 §10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:20:05 +09:00
EJClaw
2ed1a7ed33 M9: PWA — 홈 화면에 추가, 오프라인 안내
- 매니페스트·아이콘(192/512/maskable/apple-touch, scripts/pwa-icons.ts로 다시 만들기)
- 손으로 쓴 서비스 워커(apps/web/sw.js): 빌드 때 Vite 플러그인이 빌드 ID를 넣음.
  페이지는 네트워크만(오프라인이면 offline.html), /assets/*만 캐시 먼저(200개까지),
  /api·/ws·/healthz·/internal은 손대지 않음
- 오프라인 안내 페이지, 방 밖에서도 "인터넷 연결이 없어요" 띠
- 홈 아래 "홈 화면에 추가" 카드(설치 버튼, iOS는 공유 메뉴 안내, 닫으면 기억)
- 테스트: apps/web/src/sw.test.ts, e2e/pwa.e2e.ts(E2E_PORT), a11y.e2e.ts도 E2E_PORT 읽기
- 문서: 07 §11, 11 배포(no-cache 대상), README 확인 목록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:12:23 +09:00
EJClaw
61b7c4e326 M9: 체스 자체 수 생성기(체스960 포함), perft·chess.js 비교 테스트
- movegen.ts: 0x88 생성기, 캐슬링 권리를 룩 칸으로 저장(standard·960 공용), Shredder-FEN/X-FEN
- chess.js는 테스트 전용 devDependency로 이동(perft·무작위 대국 비교)
- variant 옵션(일반/체스960), chess960Index, stateVersion 2 + v1 마이그레이션
- 960 캐슬링 규칙·입력(킹→룩 칸, 킹→g/c칸)·무작위 대국 테스트

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:10:51 +09:00
EJClaw
32e3e89d8d M9: [?] 도움 버튼을 차례 안내 옆으로, 튜토리얼 E2E, 기존 E2E는 요약 카드 본 것으로 처리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:10:14 +09:00
EJClaw
57753a037c M9: 초보자 도움 틀 — 30초 규칙 요약 카드, 게임 중 [?] 지금 할 수 있는 것, 오목 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:04:24 +09:00
EJClaw
c27cb0a11a M8: 마피아 E2E 밤 단계 플레키 수정
밤은 행동을 다 해도 일찍 끝나지 않고 타이머로만 넘어가는데, E2E가 밤을
15초로 둬서 부하가 걸리면 채팅+지목 3번이 15초를 넘겨 타이머가 먼저 발동,
진행 중이던 지목의 aria-pressed 대기가 5초에서 실패했다.

- 밤 시간 15초 → 45초(채팅+지목 3번에 충분한 여유)
- 낮 토론 배너 대기 30초 → 60초(길어진 밤 커버)
- 지목 확정·채팅 반영 대기 5초 → 15초(부하 시 왕복 지연 흡수)

검증: 마피아 E2E 단독 3회 + 8코어 전부 포화 상태 1회 모두 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 01:21:59 +09:00
EJClaw
6f462b8cb1 M8: 파티 게임 3종 합치기, 그림 맞히기도 게임 중 방 채팅 닫기
- 도블·그림 맞히기·마피아 브랜치 병합 (validate ctx 타입은 ValidateContext로 통일)
- 그림 맞히기에 ownChat 적용: 출제자 채팅 금지(§4), 정답이 방 채팅으로 새는 문제 차단
- '곧 나와요' 목록이 비면 숨김, 로드맵 M8 결과 기록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:58:59 +09:00
EJClaw
9bfb30a5e7 Merge branch 'm8/mafia' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/server/src/rooms/room.ts
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/engine/src/types.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-06 00:39:35 +09:00
EJClaw
f99784331f Merge branch 'm8/catchmind' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-06 00:39:17 +09:00
EJClaw
16d3c86486 M8: 그림 맞히기 규칙·화면·테스트
- packages/games/src/catchmind: 규칙 모듈(출제 순서·단어 풀 RNG, 고르기/그리기/공개 단계,
  정답 정규화(NFKC·제로폭·끝말·초성 차단·정답 포함 차단·자모 거리 "아까워요"), multi/firstOnly 점수,
  힌트 50%/75%, 신고 과반, 출제자 연결 끊김 10초, 자리 비움 건너뛰기와 "돌아왔어요", 결과 순위),
  단어 740개(words.ko.json, 10개 분야·쉬움/보통, 어린이 안전), 테스트 34개(§10 전부 + 무작위 1,000판)
- 그림 스트리밍: draw 액션은 canvasOp 이벤트만 'stream' 메시지로 중계하고 view에는 개수만,
  (재)접속·sync 때만 syncView로 전체 그림 기록 전송. 스트림 액션 로그는 묶어서(최대 1초) 저장
- 엔진/서버 최소 확장(하위 호환): validate(..., ctx?: {now}), isStreamAction, syncView,
  disconnectGraceMs 선택 훅, ServerMessage 'stream', RoomStore.save 로그 배열 허용
- apps/web/src/games/catchmind: 캔버스 그리기(포인터·coalesced 이벤트, 색 12·굵기 4·지우개·되돌리기·
  전체 지우기), 게이트웨이 한도 아래로 맞춘 배치 전송, 중간점 곡선 렌더링, 힌트 줄·시간 막대·점수판,
  정답/채팅 입력과 "아까워요"·"정답!" 표시, 단어 고르기, 그림 PNG 저장, 옵션·규칙
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘), 방 단위 테스트 4개, E2E 계획(PC+폰 실제 그리기·맞히기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:38:30 +09:00
EJClaw
953ae90546 M8: 도블 규칙·화면·테스트
- packages/games/src/dobble: 7차 사영평면 덱(57장·57그림, 55장 모드), 카드별 레이아웃 6템플릿,
  미니게임 4종(탑 쌓기·우물·뜨거운 감자·독이 든 선물), 서버 도착 순서 판정과 "늦었어요",
  틀림 잠금(serverTs 기준), 지연 보정 light(60ms 창·보정 상한 80ms), 45초 정체 힌트,
  자리 비움 3회 → 우물·뜨거운 감자 기권, 봇 의심(방장에게만), 순위 점수 3·2·1
- 테스트: §10 체크리스트 전부, 시드 1,000판 + 엔진 randomPlayout(카드 보존·비공개 누출·결정성·onTimeout 합법),
  방 단위 테스트(같은 ms 두 정답, 시스템 행동 거절, 지연 보정 창, 끊긴 사람 기권)
- 화면 apps/web/src/games/dobble: 자체 제작 SVG 그림 57종, 두 원형 카드(PC 좌우·휴대폰 위아래),
  누르는 즉시 판정 표시(찾았어요·틀렸어요 흔들림+잠금 원·늦었어요), 카드가 막 바뀐 직후 탭은 늦음 처리,
  상대 고르기(뜨거운 감자·독이 든 선물), 핑 표시, 키보드 조작, 라이트/다크, 움직임 줄이기
- 등록: GAMES, CATALOG(도블), 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구, 탑 쌓기 55장을 실제 탭으로 끝까지(오답 1회 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:33:29 +09:00
EJClaw
c682725968 M8: 마피아 규칙·화면·테스트
- 규칙 모듈 packages/games/src/mafia: 4~12명, 자동/수동 마피아 수, 추가 직업(군인·정치인·영매·연인),
  서버 타이머가 phaseTimeout(시스템 전용)으로 페이즈 진행, 인사 낮/토론(±15초, 토론 끝내기)/지목 투표/
  최후의 반론/찬반 투표/밤(마피아 공용 대상, 경찰·영매 결과는 밤 마감 시 전달, 의사·군인 순서),
  채널(전체/마피아/죽은 사람/연인) 권한을 view에서 필터, 레이트 리밋·고운 말 필터, 자리 비움(2일) 탈락,
  나가기 즉시 탈락, 승리 판정·결과(MVP 포함)
- 테스트: 문서 §10 항목 전부, 시드 1,000판 무작위 대국(불변식·누출·onTimeout 합법·결정성),
  엔진 randomPlayout, 기록 재생, 실제 Room 통과 테스트(방 채팅 닫힘, 연결별 채팅 필터)
- 화면 apps/web/src/games/mafia: 낮/밤 배너와 큰 남은 시간, 채널 탭 채팅, 꾹 눌러 보는 내 직업,
  그리드에서 투표·밤 대상 고르기, 아침 발표·득표 막대, 최종 공개
- 공용 코드(하위 호환): GameDefinition.ownChat, validate의 선택 ctx {now}; Room이 ownChat 게임 진행 중
  방 채팅·반응을 거절하고 validate에 서버 시각 전달; 웹 GameUI.ownChat이면 진행 중 방 채팅 패널 숨김
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 방장 + 휴대폰 3명)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:21:32 +09:00
EJClaw
bc199de10b M7: 보드게임카페 게임 6종 합치기, 뱅 E2E 버튼 찾기 수정
- 달무티·보석 상인·벽 쌓기 길찾기·라스베가스·바퀴벌레 포커·구십구 브랜치 병합(등록 목록·E2E 계획 정리)
- 홈 '곧 나와요' 목록을 M8 게임으로 갱신
- 뱅 E2E: 손에 개틀링('다른 모두에게 뱅!')이 있으면 확인 버튼 찾기가 두 개에 걸리던 문제 → '님에게 뱅!'으로 좁힘
- 로드맵 M7 결과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:14:22 +09:00
EJClaw
8fb4219749 Merge branch 'm7/ninety-nine' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:13 +09:00
EJClaw
77a7492b85 Merge branch 'm7/cockroach-poker' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
7e83d061bc Merge branch 'm7/las-vegas' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
5aad13f8a5 Merge branch 'm7/quoridor' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
df280eb5b0 Merge branch 'm7/splendor' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
467d8ee4c2 Merge branch 'm7/dalmuti' into codex/owner/grp_discord_joke-app 2026-10-05 14:56:12 +09:00
EJClaw
c34497a445 M7: 구십구 규칙·화면·테스트
- 규칙 모듈 packages/games/src/ninety-nine: 전용 카드(1~8·10, ±9·±10·0, 조커)와
  트럼프(전통 99) 두 규칙, 모든 옵션 한국어 메타. 낼 카드가 없으면 같은 apply 안에서
  탈락/목숨 감소/새 판을 연쇄 처리, 시간 초과는 결과 합계가 가장 작은 카드를 자동으로 냄,
  연속 3번 시간 초과면 마감 3초, 나가면 탈락 처리. view는 남의 손패·더미를 숨기고
  카드별 결과 합계 미리보기와 선택지(+/−, A 1/11, 조커 범위)를 계산해 줌.
- 테스트: 문서 10절 시나리오 전부, 무작위 1,000판(보존·유출·onTimeout·결정성)과
  엔진 randomPlayout 18판.
- 화면 apps/web/src/games/ninety-nine: 큰 합계와 게이지 링, 방향 표시, 목숨 하트,
  카드 위 결과 미리보기, ± 두 버튼, 조커 숫자 다이얼, 키보드·스크린리더 지원.
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 1 + 휴대폰 2, 끝까지 진행).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:55:36 +09:00
EJClaw
fa7ceea668 M7: 라스베가스 규칙·화면·테스트
- 규칙 모듈 packages/games/src/las-vegas: 지폐 54장 더미·카지노 채우기(합 5 이상), 굴리기/놓기(같은 눈 전부),
  중립(흰) 주사위 변형(auto/on/off, 3명은 라운드 시작 하우스 주사위 2개 자동 배치), 동점 무효 정산,
  하우스·남은 지폐 더미 아래로(카지노 1→6, 큰 금액부터), 라운드마다 선 이동, 장 수 동점 처리,
  시간 초과(굴리기 자동 / 개수→지폐→작은 수 순 자동 놓기)와 3회 연속 자리 비움, 기권,
  hideMoney 시 남의 금액은 장 수만 보이는 view, 놓기 미리 보기(1등/동점 주의) 계산
- 테스트: 문서 10절 시나리오 전부, 1,000시드 무작위 대국(타임아웃·기권 포함, 지폐·주사위 보존,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 100시드
- 화면 apps/web/src/games/las-vegas: 카지노 6곳(PC 한 줄, 폰 3열×2행), 색깔 주사위 쌓기, 놀이용 지폐,
  굴림 애니메이션, 숫자별 묶음 버튼 → 카지노 강조와 예상 순위 → 확정, 라운드 정산(동점 무효) 표시,
  최종 결과표, 폰에서는 굴리기/놓기 영역이 화면 아래에 고정
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘, 놀이용 점수 문구 검사 대상에 추가
- E2E: PC 방장 + 폰 친구, 3라운드로 바꿔 결과까지 실제 플레이(키보드 선택, 카지노 카드 선택, 동점 무효 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:53:47 +09:00
EJClaw
11ad2be147 M7: 보석 상인 규칙·화면·테스트
- 규칙 모듈 packages/games/src/splendor: 카드 90장·귀족 10장 데이터 파일 분리(cards.ts),
  보석 가져오기(3색/같은 색 2개, allowFewerTokens), 예약(공개/더미, 황금), 구매(자동·수동 지불),
  10개 한도 반납, 귀족 방문·선택, 라운드 끝까지 진행 후 종료, 동점은 카드 적은 쪽,
  넘기기와 모두 넘기면 종료, 기권, 시간 초과 자동 행동(3번 연속이면 자리 비움),
  view는 더미·블라인드 예약·제외 귀족을 숨기고 살 수 있는 카드·지불 미리보기를 계산
- 테스트: 문서 10절 시나리오 42개 + 무작위 대국 1,000판(보존·정보 유출·onTimeout 합법·결정성)
  + 엔진 randomPlayout
- 화면 apps/web/src/games/splendor: 자체 카드·귀족·보석 그림(색+모양+글자), 3단계 시장과 더미,
  보석 은행, 내 보너스·보석·예약, 상대 요약, 살 수 있는 카드 강조, 반납·귀족 선택, PC/휴대폰·다크 모드
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구가 10점 판을 결과까지 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:53:35 +09:00
EJClaw
1fc4d8c7fa M7: 쿼리도(벽 쌓기 길찾기) 규칙·화면·테스트
- 규칙 모듈 packages/games/src/quoridor (id 'quoridor', 표시 이름 '벽 쌓기 길찾기'):
  2인/4인(3인 거부, playerCountError), 직선·대각 점프(wallEdgePawn/wallOnly),
  벽 겹침·판 밖·남은 벽·BFS 길 막기 검사, 도착 즉시 승리, 4인 순위(남은 거리, 공동 순위),
  continueForRanking, 기권·시간패(4인은 말 제거·남은 벽 소멸), 자동 패스,
  무르기(2인 최대 2개 / 4인 다음 사람 전, 전원 동의)·무승부, perMove/fischer/none 시계,
  autoMove(최단 거리 칸, 동률 RNG, 연속 한도 넘으면 시간패).
  view는 차례인 사람에게만 legalPawnMoves·legalWalls(거리 변화량 포함)·myShortestPath.
- 테스트: 문서 §10 시나리오 전부, 1,000판 빠른 무작위 대국(불변식·정보 누출·onTimeout 합법),
  결정성·기록 재생, 엔진 randomPlayout 24판, 합법 벽 목록 성능(약 1.2ms).
- 화면 apps/web/src/games/quoridor: 내 출발 쪽이 아래로 오게 판 회전, 홈 교차점 스냅 →
  미리보기(불가하면 빨간색+이유) → 확인, 가로/세로 전환, 키보드(방향키·R·W·Enter),
  이동 가능 칸·최단 길 점선·목표 줄 색 띠·남은 벽 막대, 무르기/무승부/기권, 규칙 그림.
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON. E2E quoridor 플랜(PC 방장+휴대폰 친구).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:52:20 +09:00
EJClaw
dfe4ce1ca4 M7: 달무티 규칙·화면·테스트
- 규칙 모듈 packages/games/src/dalmuti: 80장(광대 2장) 덱, 첫 신분 뽑기(동점 재뽑기),
  신분 순서 분배, 혁명 확인 단계(혁명/대혁명), 세금 자동 이동과 돌려주기, 트릭 진행
  (광대 와일드·13, passLocksOut), 라운드 종료와 순위 점수/마지막 라운드 순위,
  시간 초과 자동 행동(리드: 가장 큰 숫자 전부, 따라 내기: 패스, 세금: 가장 큰 숫자),
  연속 3회 시간 초과 시 5초 마감, 나가도 탈락 없이 자동 진행, view 정보 숨김
- 테스트: 문서 10절 시나리오 전부 + 1,000 시드 무작위 대국(카드 보존, 유출 검사,
  onTimeout 합법, 결정성) + 엔진 randomPlayout
- 화면 apps/web/src/games/dalmuti: 자체 카드 그림, 숫자별 묶음 선택·장수 조절·광대 섞기,
  내기/패스, 바닥 기준 표시, 신분 자리, 혁명 확인·세금 교환 화면, 규칙·옵션
- 등록: GAMES, catalog, registry, GAME_ICON
- E2E: PC 방장 + 휴대폰 3명, 1라운드 끝까지 진행 후 2라운드 세금 교환 화면 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:49:44 +09:00
EJClaw
3e414e62ca M7: 바퀴벌레 포커 규칙·화면·테스트
- 규칙 모듈(packages/games/src/cockroach-poker): 64장(8종×8) 셔플·좌석순 분배, 2인 10장 비공개 제거,
  offer/call/peek/pass 흐름, seen 규칙, 2인 판정 전용(twoPlayerPass 하우스 룰은 1회 되돌려 주기),
  같은 종류 loseCount장(자동: 2인 5, 3~6인 4)·손패 0장 시작자·eightKindsRule 패배,
  시간 초과 자동 행동(§8.5)과 3연속 시간 초과 시 자리 비움(5초), 나가면 그 사람 패배,
  빠른 말(say, 라운드당 3번), view는 남의 손패·미확인자에게 건네는 카드·제거 카드를 숨김
- 테스트: §10 시나리오 전부, randomPlayout 200판 + 빠른 루프 800판(카드 보존·유출·onTimeout 합법·재생 결정성)
- 화면(apps/web/src/games/cockroach-poker): 자체 SVG 벌레 8종, 앞마당 종류별 배지(경고/위험),
  건네기 3단계(카드→사람→주장), 판정 시트(진짜!/거짓말!/몰래 보고 넘기기), 넘긴 순서·본 사람 표시
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON / E2E 플랜(PC 방장 + 휴대폰 2명, 끝까지 진행)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:46:30 +09:00
EJClaw
559fb8507c 로드맵 M6 문구 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:21:11 +09:00
EJClaw
4de9ca467c M5·M6: 마작·고스톱·뱅·할리갈리·주사위 땅따먹기 합치기
- 다섯 게임 브랜치 병합, 등록 목록·E2E 계획 충돌 정리(E2E 계획은 기존 게임 계획 그대로 + 새 계획 5개)
- 홈 '곧 나와요' 목록을 M7·M8 게임으로 갱신
- 로드맵 M5·M6 진행 결과 기록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:20:18 +09:00
EJClaw
c41803ac45 Merge branch 'm5/marble' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 14:07:23 +09:00
EJClaw
f812789bee Merge branch 'm5/halli-galli' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 14:07:23 +09:00
EJClaw
489c017a96 Merge branch 'm5/bang' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 14:07:23 +09:00
EJClaw
f340f790b5 Merge branch 'm5/gostop' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	packages/games/src/index.ts
2026-10-05 14:07:23 +09:00
EJClaw
c0fb44e395 M5: 마작 규칙·점수 계산·화면·테스트
- 규칙 모듈 packages/games/src/mahjong (tiles·agari·scoring·types·index): 4인 리치 마작(동풍전/반장전)과
  3인 산마, 옵션 전부 한국어 meta, 응답 창(론 > 퐁·깡 > 치, 조기 확정, 최소 400ms, 시간 초과 = 패스),
  리치·일발·더블리치, 후리텐(버림패·동순·리치), 구이가에, 깡 도라 시점, 사깡·사풍·사가리치·구종구패·삼가화,
  유국만관, 책임지불, 북 뽑기, 고점법 점수 계산(역·부·만관~더블 역만, 본장·공탁, 산마 쯔모손/북가절반),
  토비·아가리야메·서입 서든데스, 우마·오카 결과, 초보 모드 힌트, autoPilot·나가기 처리
- 응답 창 타이머는 아직 응답하지 않은 응답 가능자에게만 걸어 연결 끊긴 사람은 바로 패스, 확정 뒤에는 방총자
  타이머로 닫음(끊긴 방총자 때문에 타이머가 헛도는 일 방지). 공개 시계에는 응답 창 남은 시간을 싣지 않음
- 테스트: 점수 계산기 161건(1~4판 × 20~110부 표 전 칸, 만관 이상, 역·부 예시, 산마 지불),
  규칙 시나리오 77건(§10 체크리스트), 무작위 대국 1,000판(4인 반장전 240 · 동풍전 400 · 산마 360,
  옵션 무작위, 패·점수 보존, onTimeout 합법, 재현성) + 엔진 randomPlayout과 매 단계 정보 누출 검사
- 화면 apps/web/src/games/mahjong: 직접 그린 SVG 패, 4방향(3인은 3방향) 버림패 테이블, 탭 두 번 버리기,
  리치·쯔모·깡·북·구종구패, 론·퐁·치(조합 선택)·깡·패스 응답, 국 결과(역·판·부·점수 변화)와 최종 순위,
  초보 모드(텐파이 타패·대기·남은 장수·역 힌트·후리텐 배지), PC 2단 배치·휴대폰 한 줄 손패, 키보드·스크린리더
- 등록: GAMES, 카탈로그(available), 웹 registry, '곧 나와요'에서 마작 제거
- E2E: PC 방장 + 휴대폰 3명 4인 대국으로 한 국을 끝까지(타패·패스·유국 결과 확인·다음 국 시작)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:06:45 +09:00
EJClaw
d6496dc1fa M6: 뱅 규칙·화면·테스트
- packages/games/src/bang: 뱅!(4~7명) 규칙 모듈. 카드 80장·캐릭터 16종 데이터, 거리/사거리,
  효과·반응 스택(뱅!/개틀링/인디언!/결투/잡화점/빈사/킷 칼슨/choose2), 다이너마이트·감옥 판정,
  처치 보상·보안관 벌칙·벌처 샘, 승리 판정, 역할·손패·덱 비공개 view, onTimeout/onLeave,
  legalActions·secretsOf. 하우스 룰 옵션 4종.
- 테스트 62개: 문서 §10 시나리오 전부, 시드 1,000개 4~7명 무작위 대국(카드 총수 보존, 생명 범위,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 실행, 재생 결정성.
- apps/web/src/games/bang: 원형 테이블(생명 탄환·장착·거리·역할 배지), 꾹 눌러 보는 내 역할 카드,
  카드 선택 → 대상 강조 → 확인, 반응 시트와 타이머, 기록·카드 도감, PC/휴대폰, 라이트/다크.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘(UPCOMING에서 '뱅' 제거).
- e2e: bang 플랜(PC 방장 + 휴대폰 3명, 반응 25초, 실제 턴 10번: 뱅!·기타 카드·반응·버리기).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:38:00 +09:00
EJClaw
f5e4a7ce59 M5: 고스톱·맞고 규칙·화면·테스트
- packages/games/src/gostop: 화투 48장 + 보너스패(cards), 족보·국진·최종 공식·박·독박 정산(scoring),
  패 돌리기·바닥 정리·광팔기·총통·먹기 판정(쪽/뻑/따닥/쓸/폭탄/흔들기/보너스패)·피 뺏기·고/스톱·나가리·
  첫뻑/연뻑/삼뻑(round), validate·view(남의 손패·더미 숨김, 매칭/점수 미리 계산)·시간 초과·나가기(index).
  2인 맞고, 3인 고스톱, 4~5인 광팔기, 여러 판 세트(방 점수 제로섬).
- 테스트: 문서 §10 체크리스트 전부, 1,000 시드 무작위 진행(카드·점수 보존, onTimeout 합법),
  engine randomPlayout(누출·JSON·결정성), 정보 누출 속성 테스트.
- 화면 apps/web/src/games/gostop: 월별 바닥, 매칭 하이라이트, 두 장 중 고르기, 흔들기·폭탄·고/스톱·총통·
  국진·광팔기 선택, 먹은 패 종류별 묶음과 족보 진행, 이벤트 알림, 판 결과 계산 과정, PC·휴대폰·다크 모드.
- HwatuCard: 보너스패(2피·3피) 그림 추가. 게임 목록·레지스트리 등록, 홈 "준비 중"에서 고스톱 제거.
- E2E: 맞고 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 고·스톱까지 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:35:21 +09:00
EJClaw
595d3cdf4f M6: 할리갈리 규칙·화면·테스트
- packages/games/src/halli-galli: 56장 과일 카드, 차례 뒤집기(flipGuard 예약), 언제나 칠 수 있는 종
  (tableVersion으로 늦은 종 "늦었어요" 무벌칙, 서버 도착 순서 우선), 오답 벌칙·탈락, official/시간 제한 종료,
  하우스 룰(1대1 오답 패배, 3진 아웃, 벌칙 증가, 벌칙 종 밑), latencyComp light 50ms 창,
  view는 덱 내용을 본인에게도 숨김(장수만), secretsOf/legalActions 제공
- 테스트: §10 체크리스트 전부, 1,000판 시드 랜덤 플레이아웃(카드 보존·누출·onTimeout 합법·결정성) +
  엔진 randomPlayout, 서버 Room 테스트(동시 종 도착 순서, 늦은 종 거절, 자리 비움 자동 뒤집기)
- 화면: 원형 좌석 배치, 큰 종 버튼(스페이스 바), 뒤집기(Enter), SVG 과일 카드, 땡/삐 효과음·진동,
  카드 이동 애니메이션, 어린이 모드 합계 막대, 다크 모드·동작 줄이기 대응
- 공용 변경(하위 호환): GameDefinition.timeoutNotBefore 선택 훅 + Room 타이머 반영(자리 비움 시
  바쁜 반복 방지), feedback.ts에 ding/buzz 소리 추가
- 등록: GAMES, CATALOG, 웹 레지스트리·아이콘, 출시 예정 목록에서 제거, E2E 계획 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:34:43 +09:00
EJClaw
ad7af30daa M6: 주사위 땅따먹기 규칙·화면·테스트
- packages/games/src/marble: 판 데이터(board.ts, 40칸·가격표·행운카드 20장), 상태 기계(rules.ts),
  게임 정의(index.ts). 더블·3더블 무인도, 무인도/탈출권/탈출비, 우주여행·탑승료, 사회복지기금,
  통행료(관광지 2곳 이상 2배·면제권), 빚 갚기·파산(채권자에게 매각 대금), 인수·랜드마크·독점 승리·교환
  옵션, 라운드 제한과 총자산 순위(동점이면 포인트 비교). 카드 더미 순서는 비공개(남은 장 수만),
  시스템 전용 TIMEOUT(굴리기/안 사기/싼 것부터 매각/거절), onLeave = 기권.
- 테스트 80개: 문서 §10 체크리스트 전 항목, 1,000판 시드 무작위 대국(포인트 장부·소유 일관성·
  onTimeout 합법), 엔진 randomPlayout·리플레이 결정성, 시간 초과만으로도 끝나는지.
- 화면: SVG 정사각형 판(도시 이름·소유 색·건물 표시·말), 가운데 주사위·결정 프롬프트, 행운카드 공개,
  우주여행 칸 고르기와 미리 보기, 빚 갚기 막대·매각 목록, 교환 시트, 진행 기록, PC·휴대폰·다크 모드.
  화폐 대신 놀이용 포인트(P) 표기, no-money-wording 검사 대상에 추가.
- 등록: GAMES, CATALOG(주사위 땅따먹기), 웹 registry, GAME_ICON, @bg/games/marble/board export.
- E2E marble 계획: PC 방장 + 휴대폰 손님, 15라운드·200P로 결과 화면까지 실제 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:31:22 +09:00
EJClaw
6321ea8c0d M4: 가족 게임 5종 합치기, E2E 계획 정리, 섯다 E2E 재경기 대응, 테스트 제한 60초
- 윷놀이·원카드·숫자 타일·다빈치 코드·요트 다이스 브랜치 병합(등록 목록·E2E 계획 충돌 정리)
- 섯다 E2E: 동점·구사 재경기가 나오면 결과가 나올 때까지 계속 체크
- bun run test: 무작위 1,000판 테스트가 부하 때 5초 기본 제한을 넘던 문제 → --timeout 60000
- 로드맵 M4 결과, README 확인 명령 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:48:19 +09:00
EJClaw
dfddeec8af Merge branch 'm4/davinci-code' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
dc4e11b64c Merge branch 'm4/rummikub' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
26b1b0c64c Merge branch 'm4/one-card' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
54e3c74854 Merge branch 'm4/yut' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-05 04:40:49 +09:00
EJClaw
4c5c3e5707 M4: 원카드 규칙·화면·테스트
- 규칙 모듈 packages/games/src/one-card: 원카드(트럼프 54장, 6명부터 2벌)와 색깔 카드(108장) 두 모드.
  공격·받아치기·3 방어·7 무늬 지정·J/Q/K·조커 기준 카드·여러 장 내기·마지막 카드 제한, 원카드 선언/잡기,
  파산, 드로우 더미 재구성, +4 의심하기(의심한 사람에게만 손패 공개), +2/+4 받아치기, 목표 점수 여러 판,
  시간 초과(자동 먹기·넘기기·받아들이기, 3번 연속이면 5초), 나가기. 모든 옵션에 한국어 메타.
- 테스트: 문서 10절 시나리오 45개 + 모드별 무작위 대국 1,000판(카드 수 보존, 종료, 결정성, 유출, onTimeout 합법)
  + 엔진 randomPlayout(매 단계 유출·직렬화 검사) 18판.
- 화면 apps/web/src/games/one-card: 상대 좌석, 판(더미·바닥·방향·지정 무늬/색·공격 누적), 손패(정렬·힌트·
  두 번 눌러 내기·끌어 놓기·방향키), 원카드 버튼, 무늬/색 고르기, 의심하기, 기록·특수 카드 표.
  색깔 카드는 직접 그린 SVG(색맹용 무늬 패턴과 색 이름 포함).
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(곧 나와요 목록에서 제외), E2E 계획(PC 1 + 휴대폰 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:40:21 +09:00
EJClaw
96e406b2c9 M4: 숫자 타일 규칙·화면·테스트
- 규칙 모듈(packages/games/src/rummikub): 106타일, 선 정하기·분배, 그룹/런 판정과 조커 해석,
  서버 초안(setDraft/undoDraft/resetDraft) + 보존 검사(C1~C3), 확정 검증(받침대 1장 이상,
  첫 등록 점수, 재배치), 시간 초과(그대로면 1장, 올바르면 확정/되돌림, 틀리면 벌칙 장수),
  풀 소진 교착 종료, 점수 계산, 여러 판 매치, 나가기·자리 비움, 숨김 정보 view, secretsOf
- 테스트: 문서 10절 시나리오 전부 + 엔진 randomPlayout 25회 + 시드 1,000개 무작위 대국
  (타일 106장 보존, 매 단계 정보 유출·onTimeout 합법성 검사, 결정성)
- 화면(apps/web/src/games/rummikub): 탭으로 고르고 놓을 곳 누르기(◀ 앞 / ▶ 뒤 / 새 묶음),
  받침대 색깔순·숫자순 정렬, 되돌리기·처음으로, 실시간 묶음 검사(초록/빨강 + 이유),
  첫 등록 진행 막대, 도와줘 힌트, 한 장 가져오기·확정, 색+모양 표시 타일(조커 자체 디자인)
- 등록: GAMES, CATALOG, 화면 레지스트리, 아이콘(곧 나올 게임 목록에서 제거)
- E2E: PC 방장 + 휴대폰 친구가 화면으로 10차례(힌트로 내기, 되돌리기, 가져오기) 진행
- @bg/games 패키지에 './rummikub/rules' 내보내기 추가(화면 청크에 zod가 안 들어가게)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:34:59 +09:00
EJClaw
2610e37d49 M4: 윷놀이 규칙·화면·테스트
- 규칙(packages/games/yut): 29칸 윷판 그래프(모서리·방은 멈췄다 출발할 때만 꺾음, 방에서는 항상 참먹이 쪽),
  가락 단위 확률(realistic 0.6 / fair 0.5, 빽도 표시 가락, 낙), 윷·모·잡기 추가 던지기, 모아 둔 결과 자유 순서,
  쓸 수 없는 빽도 자동 버림, 빽도는 왔던 길(trail)로, 도 자리 빽도(참먹이/바로 남), pass/reach 날 판정,
  업기·잡기(지나가면 안 잡음), 개인전 2~4명(1등 종료/꼴찌까지), 팀전 4·6·8명(청·홍 번갈아, 팀원 순환),
  기권(팀은 전원 기권 시 패배), 시간 초과 자동 행동(잡기 > 내기 > 업기 > 뒤처진 말 > 올리기), 3연속 초과 시 자리 비움(5초)
- 테스트: 문서 10절 시나리오 + 확률 10만 회(±0.5%p), 무작위 1,000판(불변식·onTimeout 합법·결정성·리플레이),
  자동 진행·무작위 기권 60판, apply p99 < 5ms. 서버 방 테스트 2개(팀전 인원 검사, 연결 끊김 자동 진행·재접속 View)
- 화면(apps/web/games/yut): SVG 윷판·말(편마다 색+모양), 윷가락 던지기 애니메이션, 결과 칩 → 말 → 한 번 더 눌러 확정,
  경로 미리보기와 잡기!/날!/업기 배지, 자동 이동, 칸마다 통통 이동 애니메이션, 진행 기록, 옵션·규칙 설명, 키보드 조작
- 엔진/서버(하위 호환): GameDefinition.playerCountError(옵션별 인원 규칙) 추가, 방 시작 시 검사.
  방 인원이 게임 최대였으면 옵션 변경으로 범위가 바뀔 때 새 최대를 따라감(팀전 8자리)
- 등록: GAMES, 카탈로그(쉬움·15~25분), 화면 레지스트리, 홈의 "준비 중" 목록에서 윷놀이 제거
- E2E: PC 방장 + 휴대폰 친구, 말 2개로 실제 화면 조작(키보드 2단계 확정)으로 끝까지, 다크 모드 화면 저장

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:29:46 +09:00
EJClaw
bd081936e0 M4: 다빈치 코드 규칙·화면·테스트
- 규칙 모듈(packages/games/src/davinci-code): 2~4명, 시작 색 고르기(동시 선택·15초),
  정렬 규칙(같은 숫자 검정/흰색 먼저 옵션), 조커 위치 직접 고르기, 맞힘 뒤 계속/멈춤,
  틀리면 가져온 타일 공개, 더미가 빈 뒤 자기 타일 공개 벌칙(옵션), 탈락 순서로 순위.
  숨은 타일은 view에 색·위치·틀린 추리만 보내고 타일 ID(숫자 포함)는 아는 사람에게만 보냄.
  시간 초과는 시스템 전용 timeout 행동으로 처리하고, 자동 추리는 그 사람의 view만으로
  후보를 계산해 결정적으로 고름. 3턴 연속 시간 초과면 마감 5초. 나가면 탈락·타일 공개.
- 테스트: 문서 10절 시나리오 전부, 시간 초과·연결 끊김·기권, 300판 이벤트/뷰 유출 검사,
  시드 1,000판 무작위 대국(타일 보존, 줄 정렬, 결정성, 내장 유출 검사, onTimeout 합법).
- 화면(apps/web/src/games/davinci-code): 상대 줄·더미·내 줄, 숫자 패드(0~11, 조커),
  한 번 더/멈추기, 조커 자리 고르기, 자기 타일 공개, 시작 색 고르기, 추리 기록, 규칙 그림.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘, '곧 나와요'에서 제외.
- E2E: games.e2e.ts에 davinci-code 계획(PC 방장 + 폰 친구, 5턴 실제 진행).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:25:50 +09:00
EJClaw
41ab2cf187 M4: 요트 다이스 규칙·화면·테스트
- 규칙 모듈(packages/games/src/yacht): 12칸(기본)·13칸 변형, 풀 하우스 요트 인정,
  추가 요트 보너스와 강제/자유 조커, 상단 보너스, 굴림 3회·고정 검증, 기권,
  시간 초과 시 (안 굴렸으면 굴린 뒤) 최고 점수 칸 자동 기록·희생 순서, 1~8명(혼자 가능)
- 점수 계산은 scoreFor 순수 함수로 분리, view에 빈 칸 미리보기·허용 칸 포함
- 테스트: 문서 10절 시나리오 전부, 시드 1,000개 무작위 대국(점수·합계 일관성,
  onTimeout 합법, 결정성), 기권·시간 초과 섞은 200개 대국
- 화면: SVG 주사위(눌러서 고정, 굴림 애니메이션·움직임 줄이기 존중), 남은 굴림 표시,
  전원 점수표(내 열 강조, 미리보기, 0점 확인), 상단 보너스 진행 막대, 기권
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(준비 중 목록에서 제거)
- 결과 화면: 1인 게임은 무승부/승리로 표시하지 않음
- E2E: PC 방장 + 휴대폰 친구가 12라운드 전체를 화면으로 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:17:15 +09:00
EJClaw
5dd8e6ea07 M3: 섯다·포커 규칙과 화면, 카드 게임 E2E
- 섯다(packages/games/seotda): 2장·3장 섯다, 1장씩/한 번에 나눠 주기, 족보(광땡·땡·알리…망통),
  땡잡이·암행어사·구사/멍텅구리구사 재경기, 동점 재경기·나눔, 리바이·탈락, 사이드 팟. 규칙 테스트 54개
- 포커(packages/games/poker): 홀덤·오마하·7포커(초이스·보스)·7포커 하이로우(동시 선언)·바둑이(아침·점심·저녁)·
  5카드 드로우·인디언 포커(이마 카드, 동점 이월, 다이 벌칙). 한국식/노리밋/팟리밋, 블라인드·앤티,
  리바이·레벨 상승·최후 1인. 규칙 테스트 52개 + 족보 판정 24개, 무작위 1,000판(칩 보존·비공개 카드 유출 없음)
- 웹: 섯다·포커 화면(테이블 좌석, 공용 카드, 사이드 팟, 한국식 버튼/레이즈 슬라이더, 초이스·교환·선언,
  체크 가능할 때 포기 확인, 리바이 안내), 섯다 결과 화면에서 가져간 칩 표시
- 엔진 betting.buildPots: 라운드 진행 중에는 아직 콜할 수 있는 플레이어도 각 팟 자격자로 표시
- 서버 테스트: 크래시 흉내 후 소켓 종료로 다시 걸린 타이머가 닫힌 DB를 건드리던 누수 수정
- E2E: 3인(PC + 휴대폰 2) 섯다·7포커 한 판, 6개 게임 모두 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:48:56 +09:00
EJClaw
31aedbfcc9 공정성 확인: 결과 화면에서 브라우저가 SHA-256(씨앗)을 직접 계산해 시작 때 공개한 값과 비교
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:46:30 +09:00
EJClaw
9519b09be8 M3 기반: 공용 베팅 엔진, 카드 정의, 카드·칩 화면 컴포넌트, 돈 표현 금지 검사
- packages/engine/betting: 한국식(체크·삥·콜·따당·쿼터·하프·풀·다이, 라운드당 1회·첫 행동에서만 레이즈),
  노리밋·팟리밋(최소 레이즈, 불완전 올인 레이즈는 재오픈 안 함, BB 옵션), 앤티·블라인드, 올인,
  받지 않은 베팅 반환, 사이드 팟 분할, 동점 분배(홀수 칩 순서), 시간 초과 자동 행동
- 테스트 24개: 포커·섯다 계획서 계산 예시, 행동별 상태 전이, 사이드 팟 예시, 무작위 베팅 1,500판
  (칩 총량 보존·라운드 종료·라운드 끝 금액 일치·팟 합계=판 전체)
- packages/engine/cards: 트럼프 52+조커, 화투 48(+보너스)·섯다 20장 정의와 테스트
- 웹: 직접 그린 SVG 트럼프·화투 카드, 칩 표시("1,250 칩"), 놀이용 칩 안내문
- apps/web/src/no-money-wording.test.ts: 베팅 게임 화면에 통화 기호·"원"·판돈·충전·환전 등 금지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:45:13 +09:00
EJClaw
a10139b51a M2: 바둑 추가, 4게임 E2E 안정화, 로드맵 M2 결과 기록
- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스,
  사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개)
- 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거
- 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화
- 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:34:50 +09:00
EJClaw
62a6caa6a7 M2: 체스·장기 추가
- 체스(chess.js 1.4.0, BSD-2): 캐슬링·앙파상·승진·체크메이트·스테일메이트·3회 반복·50/75수·기물 부족,
  피셔 시계(상대가 메이트 불가면 시간패 대신 무승부), 무르기·무승부·기권, 직접 그린 SVG 기물,
  클릭·드래그 이동, 승진 선택, 판 돌리기 (테스트 40개, perft 포함)
- 장기: 궁성 대각·포다리·멱·장군 합법성·빅장(KJF/KJA/끔)·반복 수 처리·한수쉼·점수 판정(한 덤 1.5),
  상차림 단계(순서/동시 비공개), 초읽기, 팔각 기물(한자+한글), 한 쪽이면 판 뒤집기 (테스트 46개)
- 게임 공통 E2E에 체스·장기 추가(PC 방장 + 휴대폰 친구, 상차림→첫 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:24:11 +09:00
EJClaw
c5c30d4ada M2: 오셀로 추가, 게임 공통 E2E, 접근성 검사(axe) 통과
- 오셀로: 8방향 뒤집기, 자동 패스, 종료·점수(빈칸 승자 몫 옵션), 한 수/피셔 시계·자동 착수,
  무르기·무승부·기권, 놓을 곳 점·뒤집힐 돌 미리 보기·뒤집기 애니메이션 (테스트 31개)
- 카탈로그에 오셀로·체스·장기·바둑 추가(엔진이 등록된 게임만 화면에 나옴)
- e2e/games.e2e.ts: PC 방장 + 휴대폰 친구, 게임 소개→방 만들기→링크 입장→시작→차례대로 첫 수
- e2e/a11y.e2e.ts: 홈·게임 소개·대기실 × PC/휴대폰 × 밝게/어둡게, axe 위반 0건
  (h1, 랜드마크, 제목 순서 정리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:19:05 +09:00
EJClaw
aa7f6bffef M2 안정성: 부하·장애 주입 테스트 통과 + 재시작 후 시간 초과 몰림 수정
- 2,000 연결 / 500 방 / 초당 248 행동: p95 20ms, p99 37ms, CPU 0.2코어, 메모리 141MB, 오류 0
- 부하 중 SIGKILL→재시작: 2,000 연결 자동 복구, 수 유실 0
- 복구된 방 마감을 30초 + 0~30초 무작위로 분산(몰림으로 p99 336ms → 40ms)
- 보고서: docs/reports/load-20261004.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:15:06 +09:00
EJClaw
f72be633f8 부하·장애 주입 테스트 스크립트, 테스트 전용 HTTP 제한 해제 옵션, chess.js 의존성
- scripts/loadtest.ts: 방 N개 × (선수 2 + 관전자) 연결, 방마다 2초에 한 수, 상대 수신까지 지연 p50/p95/p99,
  서버 CPU·메모리, --chaos 시 서버 SIGKILL → 재시작 → 재연결·수 유실 검사
- LOADTEST_NO_HTTP_LIMITS=1(https 주소에서는 무시)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:07:43 +09:00
EJClaw
f81f20a4bf 웹: PC 중심 화면으로 재구성
- 공통 상단 메뉴(사이트 이름·홈·게임 목록·관리자·어디서나 코드 입력·내 정보)
- 홈: 방 만들기 + 코드 입력 카드, 게임 격자, 지금 열린 공개 방 목록(/api/public-rooms)
- 대기실: 두 칸(초대·설정 바로 수정·규칙 요약 / 자리·시작 버튼·채팅 항상 보임)
- 게임 화면: 판(화면 높이에 맞춤, 좌표 표시) + 오른쪽 패널(상태·선수·시계·버튼·기보·채팅)
- 오목: 마우스 올리면 미리 보기 돌, PC는 한 번 클릭으로 착수(두 번 눌러 확정은 터치 기기 기본값,
  기기별 설정), GameUI에 Side 패널 추가
- 채팅: PC는 패널로 항상 표시(Enter로 바로 입력), 휴대폰은 시트
- 관리자: 왼쪽 사이드바, 사용자 표, 규칙 입력 두 칸 격자, 저장 바 고정
- 좁은 화면은 같은 화면을 한 칸으로 접어서 지원(방 상단 줄 줄바꿈 수정)
- E2E: PC 1440×900 기준(방장 PC, 친구 휴대폰), 관리자 E2E도 PC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:04:00 +09:00
EJClaw
847a714077 docs: PC 중심 화면 설계로 변경(07 화면 틀, 상단 메뉴·홈·대기실·게임·관리자 PC 배치)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:54:33 +09:00
EJClaw
242ab3ad71 M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록
- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS),
  어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가
- 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값,
  옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성
- 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격,
  한 수 제한 초)을 옵션으로 꺼냄
- 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값
- 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용),
  관리자 작업 기록(전/후 값)
- 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부
- 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개,
  e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:52:38 +09:00
EJClaw
b2f998fa95 docs: 관리자 사이트 계획(14-admin), 상표명 사용·도메인 game.tkrmagid.kr 확정 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:40:23 +09:00
EJClaw
17b1d0fcda M1 웹 + 배포: 홈·코드 입장·대기실·오목 화면·결과·계정, E2E, Docker/Caddy
- 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장),
  링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정),
  오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내),
  결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동)
- WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리,
  ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리
- @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip
- e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더
- deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example
- 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:37:33 +09:00
EJClaw
b1f3d7c500 M1 서버: 통합 테스트 + 방 단위 테스트, 종료 대기·중도 이탈 자리 처리 수정
- 실제 서버 + WS 클라이언트로 테스트 문서 3절 시나리오 검증(방 만들기·링크 입장·재접속·
  서버 강제 종료 후 복구·차례 위반·중복 cs·탭 교체·방장 이전·도배 제한·잘못된 메시지·
  Origin 검사·디스코드 콜백 state 검증·게스트 합치기)
- 시계를 조작하는 방 단위 테스트: 시간 초과 자동 착수, 연결 끊김 유예, 재시작 후 마감 30초 유예,
  게임 오류 격리(3회 → 일시 정지), 한 판 더 흑백 교대, 게임 중 나가기
- Bun server.stop()이 서버 쪽 WS 종료 뒤 끝나지 않는 문제: 대기 시간 1초로 제한
- 게임 중 나간 사람의 자리가 게임 종료 후 비워지지 않던 버그 수정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:24:49 +09:00
EJClaw
0f4d22f413 Merge remote main (initial README) 2026-10-04 04:18:29 +09:00
EJClaw
6ee8da18f1 M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구
- packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그
- packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개)
- apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:18:29 +09:00
EJClaw
f4d63e1544 docs: 온라인 보드게임 사이트 계획서 (공통 14편 + 게임 26종)
- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜,
  안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트,
  배포·운영, 로드맵(M0~M9), 사용자 결정 항목
- 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:18:29 +09:00
EJClaw
b4a3c51201 Clear joke-app files to start a new app in this workspace
Removes index.html, README.md, PLAN.md. History retained in git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 03:37:22 +09:00
EJClaw
3675a5f62e Add PLAN.md: scope for local party app; remote-trigger client/server out of scope
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 03:35:12 +09:00
EJClaw
7c8c5af4bb Add visible-window party prank app (no tray/hide)
Single-file HTML prank app the friend runs with the window always visible.
Reversible gags only (fake BSOD, fake virus->joke+confetti, screen crack,
glitch, fireworks, beep); any key/ESC/click dismisses. No file writes,
no autostart, no hiding, no network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 03:29:50 +09:00
EJClaw
dae9f588e6 chore: initialize paired workspace 2026-10-03 22:30:42 +09:00
459 changed files with 119506 additions and 1 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
**/node_modules
apps/web/dist
test-results
data
.git
deploy/.env

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
insert_final_newline = true

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
*.tsbuildinfo
data/
.env
*.db
*.db-wal
*.db-shm
test-results/
playwright-report/

View File

@@ -1,2 +1,44 @@
# joke-app # 같이 놀자 보드게임
광고 없이 가볍고, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임 사이트.
계획서는 [docs/](docs/README.md)에 있고, 구현은 계획서를 따른다.
## 개발
```bash
bun install
bun run --filter '@bg/server' dev # 서버 :3000
bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록시)
```
브라우저에서 http://localhost:5173 을 연다. 디스코드 값이 없으면 게스트 로그인만 보인다.
## 확인
```bash
bun run typecheck
bun run test # 규칙·서버 통합·방 단위 테스트 (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 scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
```
## 배포
`deploy/.env.example`을 `deploy/.env`로 복사해 값을 채운 뒤:
```bash
cd deploy && docker compose up -d --build
```
이미 HTTPS 리버스 프록시가 있는 서버(지금 운영 중인 .5 + NPMplus)에서는 `docker compose -f docker-compose.proxy.yml up -d --build`로 앱만 띄운다.
자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
## 관리자
`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`, `1352267557213573160`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md).
## 구조
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구
- `packages/shared` — 프로토콜, 닉네임·방 코드 규칙, 게임 목록 (`/lite`는 브라우저용)
- `packages/games/<id>` — 게임 규칙(순수 함수)
- `apps/server` — Bun 서버(HTTP API, WebSocket, 방 관리, SQLite)
- `apps/web` — React 웹

18
apps/server/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "@bg/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts"
},
"dependencies": {
"@bg/engine": "workspace:*",
"@bg/games": "workspace:*",
"@bg/shared": "workspace:*",
"hono": "4.13.12",
"ulid": "3.0.2",
"zod": "4.6.5"
}
}

View File

@@ -0,0 +1,241 @@
/** 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';
const SUPER = '293719842274541579';
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(undefined, {
discordExchange: async (code) => ({ id: code, username: `user${code.slice(-4)}`, global_name: `디코${code.slice(-4)}`, avatar: null }),
});
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
running.push(s);
return s;
}
/** Logs in through the (mocked) Discord flow; returns the session cookie and user id. */
async function discordLogin(s: Booted, discordId: string): Promise<{ cookie: string; id: string }> {
const st = await api(s.base, '/api/auth/discord/start?next=/');
const state = new URL(st.res.headers.get('location')!).searchParams.get('state')!;
const oauth = st.setCookie!.split(';')[0]!;
const cb = await api(s.base, `/api/auth/discord/callback?code=${discordId}&state=${state}`, { cookie: oauth });
const loc = cb.res.headers.get('location');
if (loc !== '/') throw new Error(`login redirect ${loc}`);
const sid = cb.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
const me = await api(s.base, '/api/me', { cookie: sid });
return { cookie: sid, id: me.body.me.id };
}
describe('roles', () => {
test('guest and plain discord users get 401/403; superadmin by fixed Discord ID', async () => {
const s = start();
const g = await guest(s.base, '손님');
expect((await api(s.base, '/api/admin/me', { cookie: g.cookie })).res.status).toBe(403);
expect((await api(s.base, '/api/admin/me')).res.status).toBe(401);
const plain = await discordLogin(s, '111111111111111111');
expect((await api(s.base, '/api/admin/overview', { cookie: plain.cookie })).res.status).toBe(403);
expect((await api(s.base, '/api/me', { cookie: plain.cookie })).body.role).toBe('user');
const sup = await discordLogin(s, SUPER);
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, '1352267557213573160');
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 () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const adminDiscord = '222222222222222222';
const bad = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: '12ab' }) });
expect(bad.res.status).toBe(400);
// 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, '1352267557213573160', 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);
expect((await api(s.base, '/api/admin/admins', { cookie: adm.cookie })).res.status).toBe(403);
expect((await api(s.base, '/api/admin/games', { cookie: adm.cookie })).res.status).toBe(200);
// Fixed superadmin cannot be removed.
expect((await api(s.base, `/api/admin/admins/${SUPER}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(400);
expect((await api(s.base, `/api/admin/admins/${adminDiscord}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(200);
expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).res.status).toBe(403);
const audit = await api(s.base, '/api/admin/audit', { cookie: sup.cookie });
expect(audit.body.entries.map((e: any) => e.action)).toEqual(['admin.remove', 'admin.add']);
});
test('admin API rejects foreign-origin writes', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const res = await fetch(s.base + '/api/admin/site', {
method: 'PUT',
headers: { origin: 'https://evil.example', cookie: sup.cookie, 'content-type': 'application/json' },
body: JSON.stringify({ maintenance: true }),
});
expect(res.status).toBe(403);
});
});
describe('game settings', () => {
test('schema exposed with Korean titles; defaults apply to new rooms; locked options win; invalid values rejected', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const games = await api(s.base, '/api/admin/games', { cookie: sup.cookie });
const omok = games.body.games.find((g: any) => g.id === 'omok');
expect(omok.schema.properties.ruleSet.title).toBe('규칙');
expect(omok.schema.properties.autoMoveLimit.title).toContain('자동 착수');
const bad = await api(s.base, '/api/admin/games/omok', {
method: 'PUT',
cookie: sup.cookie,
body: JSON.stringify({ defaultOptions: { ruleSet: 'nope' } }),
});
expect(bad.res.status).toBe(400);
const tooMany = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ maxPlayers: 3 }) });
expect(tooMany.res.status).toBe(400);
const put = await api(s.base, '/api/admin/games/omok', {
method: 'PUT',
cookie: sup.cookie,
body: JSON.stringify({
nameKo: '오목(친구룰)',
notice: '렌주룰로만 해요',
defaultOptions: { ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 } },
lockedOptions: ['ruleSet'],
}),
});
expect(put.res.status).toBe(200);
expect(put.body.effective.defaultOptions.timeControl.perMoveSec).toBe(45);
const cfg = await api(s.base, '/api/config');
expect(cfg.body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목(친구룰)', notice: '렌주룰로만 해요' });
// A host creates a room → admin defaults; trying to change the locked rule is overridden.
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).ruleSet).toBe('renju');
expect(first.room.lockedOptions).toEqual(['ruleSet']);
c.send({ t: 'config', options: { ...(first.room.options as any), ruleSet: 'free', boardSize: 19 } });
const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19);
expect((after.room.options as any).ruleSet).toBe('renju');
// Disable the game → creation refused and hidden from catalog.
await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ enabled: false }) });
const refused = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
expect(refused.res.status).toBe(400);
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok').available).toBe(false);
// Reset restores code defaults.
await api(s.base, '/api/admin/games/omok/reset', { method: 'POST', cookie: sup.cookie });
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목', available: true });
});
});
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();
const sup = await discordLogin(s, SUPER);
const site = (await api(s.base, '/api/admin/site', { cookie: sup.cookie })).body.site;
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, maintenance: true, announcement: '점검 중이에요' }) });
const g = await guest(s.base, '방장님');
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
expect(r.res.status).toBe(429);
expect(r.body.error).toBe('점검 중이에요');
expect((await api(s.base, '/api/config')).body).toMatchObject({ maintenance: true, announcement: '점검 중이에요' });
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, bannedWords: ['바보'] }) });
expect((await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: '왕바보' }) })).res.status).toBe(400);
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
const c = await new Client(s.base, g.cookie, g.id).connect();
clients.push(c);
c.send({ t: 'join', code: room.body.code, as: 'player' });
await c.next('room');
const chat = c.next('chat');
c.send({ t: 'chat', text: '너 바보야' });
expect((await chat).text).toBe('너 **야');
});
test('admin closes a room: everyone is told and disconnected from it', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const g = await guest(s.base, '방주인');
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
const c = await new Client(s.base, g.cookie, g.id).connect();
clients.push(c);
c.send({ t: 'join', code: room.body.code, as: 'player' });
await c.next('room');
const list = await api(s.base, '/api/admin/rooms', { cookie: sup.cookie });
expect(list.body.rooms.map((r: any) => r.code)).toContain(room.body.code);
const bye = c.next('bye');
expect((await api(s.base, `/api/admin/rooms/${room.body.code}/close`, { method: 'POST', cookie: sup.cookie })).res.status).toBe(200);
expect((await bye).reason).toBe('closed');
expect((await api(s.base, `/api/rooms/${room.body.code}`)).res.status).toBe(404);
});
test('superadmin: search, rename, ban (sessions revoked, socket closed, discord re-login refused), unban', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const victim = await discordLogin(s, '333333333333333333');
const c = await new Client(s.base, victim.cookie, victim.id).connect();
clients.push(c);
const found = await api(s.base, '/api/admin/users?q=333333333333333333', { cookie: sup.cookie });
expect(found.body.users.map((u: any) => u.id)).toEqual([victim.id]);
const ren = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ nickname: '바뀐 이름' }) });
expect(ren.res.status).toBe(200);
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me.nickname).toBe('바뀐 이름');
const ban = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: '도배' } }) });
expect(ban.res.status).toBe(200);
for (let i = 0; i < 20 && !c.closed; i++) await sleep(25);
expect(c.closed?.code).toBe(4403);
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me).toBeNull();
await expect(discordLogin(s, '333333333333333333')).rejects.toThrow('/?login=banned');
// Superadmin cannot ban themselves.
const self = await api(s.base, `/api/admin/users/${sup.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: 'x' } }) });
expect(self.res.status).toBe(400);
await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: null }) });
const again = await discordLogin(s, '333333333333333333');
expect(again.id).toBe(victim.id);
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']);
});
});

View File

@@ -0,0 +1,90 @@
/** Role resolution by linked Discord ID (docs/14-admin.md §2). Computed per request. */
import type { Database } from 'bun:sqlite';
export type Role = 'user' | 'admin' | 'superadmin';
export class Roles {
constructor(
private db: Database,
private superadminDiscordIds: string[],
) {}
discordIdOf(userId: string): string | null {
return (
this.db
.query<{ provider_user_id: string }, [string]>("SELECT provider_user_id FROM oauth_accounts WHERE user_id = ? AND provider = 'discord'")
.get(userId)?.provider_user_id ?? null
);
}
roleOfDiscord(discordId: string | null): Role {
if (!discordId) return 'user';
if (this.superadminDiscordIds.includes(discordId)) return 'superadmin';
const row = this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM admins WHERE discord_id = ?').get(discordId);
return (row?.n ?? 0) > 0 ? 'admin' : 'user';
}
roleOf(userId: string | null): Role {
return userId ? this.roleOfDiscord(this.discordIdOf(userId)) : 'user';
}
isSuperadminDiscord(discordId: string): boolean {
return this.superadminDiscordIds.includes(discordId);
}
listAdmins(): { discordId: string; note: string | null; addedBy: string | null; addedAt: number; userId: string | null; nickname: string | null; fixed: boolean }[] {
const rows = this.db
.query<{ discord_id: string; note: string | null; added_by: string | null; added_at: number; user_id: string | null; nickname: string | null }, []>(
`SELECT a.discord_id, a.note, a.added_by, a.added_at, o.user_id, u.nickname FROM admins a
LEFT JOIN oauth_accounts o ON o.provider = 'discord' AND o.provider_user_id = a.discord_id
LEFT JOIN users u ON u.id = o.user_id ORDER BY a.added_at`,
)
.all()
.map((r) => ({ discordId: r.discord_id, note: r.note, addedBy: r.added_by, addedAt: r.added_at, userId: r.user_id, nickname: r.nickname, fixed: false }));
const fixed = this.superadminDiscordIds.map((d) => {
const u = this.db
.query<{ user_id: string; nickname: string }, [string]>(
"SELECT o.user_id, u.nickname FROM oauth_accounts o JOIN users u ON u.id = o.user_id WHERE o.provider = 'discord' AND o.provider_user_id = ?",
)
.get(d);
return { discordId: d, note: '슈퍼어드민(설정 고정)', addedBy: null, addedAt: 0, userId: u?.user_id ?? null, nickname: u?.nickname ?? null, fixed: true };
});
return [...fixed, ...rows];
}
addAdmin(discordId: string, note: string | null, by: string, now = Date.now()): boolean {
return this.db.query('INSERT OR IGNORE INTO admins (discord_id, note, added_by, added_at) VALUES (?, ?, ?, ?)').run(discordId, note, by, now).changes > 0;
}
removeAdmin(discordId: string): boolean {
return this.db.query('DELETE FROM admins WHERE discord_id = ?').run(discordId).changes > 0;
}
}
export class Audit {
constructor(private db: Database) {}
log(actorId: string, action: string, target: string | null, before: unknown, after: unknown, now = Date.now()): void {
this.db
.query('INSERT INTO admin_audit (actor_id, action, target, before_json, after_json, at) VALUES (?, ?, ?, ?, ?, ?)')
.run(actorId, action, target, before === undefined ? null : JSON.stringify(before), after === undefined ? null : JSON.stringify(after), now);
}
list(limit: number, offset: number) {
return this.db
.query<{ id: number; actor_id: string; nickname: string | null; action: string; target: string | null; before_json: string | null; after_json: string | null; at: number }, [number, number]>(
'SELECT a.*, u.nickname FROM admin_audit a LEFT JOIN users u ON u.id = a.actor_id ORDER BY a.id DESC LIMIT ? OFFSET ?',
)
.all(limit, offset)
.map((r) => ({
id: r.id,
actorId: r.actor_id,
actor: r.nickname,
action: r.action,
target: r.target,
before: r.before_json ? JSON.parse(r.before_json) : null,
after: r.after_json ? JSON.parse(r.after_json) : null,
at: r.at,
}));
}
}

View File

@@ -0,0 +1,159 @@
/**
* Admin-editable site and game settings with an in-memory cache (docs/14-admin.md §4–§5).
* Changes apply to new rooms / new games only.
*/
import type { Database } from 'bun:sqlite';
import { z } from 'zod';
import type { AnyGameDefinition } from '@bg/engine';
import { CATALOG, type CatalogEntry } from '@bg/shared';
export const SiteSettingsSchema = z.object({
siteName: z.string().trim().min(1).max(40).default('같이 놀자 보드게임'),
announcement: z.string().trim().max(300).default(''),
maintenance: z.boolean().default(false),
bannedWords: z.array(z.string().trim().min(1).max(30)).max(500).default([]),
maxRoomsPerUser: z.number().int().min(1).max(50).default(3),
maxRooms: z.number().int().min(10).max(20000).default(2000),
graceSec: z.number().int().min(10).max(600).default(60),
chatEnabledDefault: z.boolean().default(true),
chatFilterDefault: z.boolean().default(true),
});
export type SiteSettings = z.infer<typeof SiteSettingsSchema>;
export const GameSettingsSchema = z.object({
enabled: z.boolean().optional(),
nameKo: z.string().trim().min(1).max(30).optional(),
blurb: z.string().trim().max(80).optional(),
notice: z.string().trim().max(500).optional(),
minPlayers: z.number().int().min(1).max(32).optional(),
maxPlayers: z.number().int().min(1).max(32).optional(),
defaultOptions: z.unknown().optional(),
lockedOptions: z.array(z.string().max(60)).max(100).optional(),
});
export type GameSettings = z.infer<typeof GameSettingsSchema>;
export interface EffectiveGame {
id: string;
enabled: boolean;
nameKo: string;
blurb: string;
notice: string;
minPlayers: number;
maxPlayers: number;
defaultOptions: unknown;
lockedOptions: string[];
}
export class SettingsStore {
private site: SiteSettings;
private games = new Map<string, GameSettings>();
constructor(
private db: Database,
private defs: Record<string, AnyGameDefinition>,
) {
const row = db.query<{ json: string }, [string]>("SELECT json FROM site_settings WHERE key = ?").get('site');
this.site = SiteSettingsSchema.parse(row ? JSON.parse(row.json) : {});
for (const r of db.query<{ game_id: string; json: string }, []>('SELECT game_id, json FROM game_settings').all()) {
const parsed = GameSettingsSchema.safeParse(JSON.parse(r.json));
if (parsed.success) this.games.set(r.game_id, parsed.data);
}
}
getSite(): SiteSettings {
return this.site;
}
setSite(next: SiteSettings, by: string, now = Date.now()): void {
this.db
.query('INSERT INTO site_settings (key, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
.run('site', JSON.stringify(next), by, now);
this.site = next;
}
rawGame(id: string): GameSettings {
return this.games.get(id) ?? {};
}
/** Validates against the game's own schemas. Returns an error message or null. */
validateGame(id: string, s: GameSettings): string | null {
const def = this.defs[id];
if (!def) return '없는 게임이에요.';
if (s.defaultOptions !== undefined) {
const r = def.optionsSchema.safeParse(s.defaultOptions);
if (!r.success) return `기본 규칙 값이 올바르지 않아요: ${r.error.issues.map((i) => i.path.join('.') + ' ' + i.message).join(', ')}`;
}
const min = s.minPlayers ?? def.minPlayers;
const max = s.maxPlayers ?? def.maxPlayers;
if (min < def.minPlayers || max > def.maxPlayers || min > max) {
return `인원은 원작 범위(${def.minPlayers}~${def.maxPlayers}명) 안에서만 정할 수 있어요.`;
}
return null;
}
setGame(id: string, s: GameSettings, by: string, now = Date.now()): void {
const def = this.defs[id]!;
const clean: GameSettings = { ...s };
if (clean.defaultOptions !== undefined) clean.defaultOptions = def.optionsSchema.parse(clean.defaultOptions);
this.db
.query('INSERT INTO game_settings (game_id, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(game_id) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
.run(id, JSON.stringify(clean), by, now);
this.games.set(id, clean);
}
resetGame(id: string): void {
this.db.query('DELETE FROM game_settings WHERE game_id = ?').run(id);
this.games.delete(id);
}
game(id: string): EffectiveGame | null {
const def = this.defs[id];
if (!def) return null;
const meta = CATALOG.find((c) => c.id === id);
const s = this.rawGame(id);
let defaults: unknown = def.defaultOptions;
if (s.defaultOptions !== undefined) {
const r = def.optionsSchema.safeParse(s.defaultOptions);
if (r.success) defaults = r.data;
}
return {
id,
enabled: s.enabled ?? meta?.available ?? true,
nameKo: s.nameKo ?? meta?.nameKo ?? def.nameKo,
blurb: s.blurb ?? meta?.blurb ?? '',
notice: s.notice ?? '',
minPlayers: s.minPlayers ?? def.minPlayers,
maxPlayers: s.maxPlayers ?? def.maxPlayers,
defaultOptions: defaults,
lockedOptions: s.lockedOptions ?? [],
};
}
/** Catalog for /api/config: code metadata merged with admin overrides. */
catalog(): (CatalogEntry & { notice: string })[] {
return CATALOG.map((c) => {
const g = this.game(c.id);
if (!g) return { ...c, available: false, notice: '' };
return { ...c, nameKo: g.nameKo, blurb: g.blurb, minPlayers: g.minPlayers, maxPlayers: g.maxPlayers, available: g.enabled, notice: g.notice };
});
}
}
/** Forces locked option paths (dot-separated) to the admin default values. */
export function applyLocks(options: unknown, defaults: unknown, locked: string[]): unknown {
if (!locked.length || typeof options !== 'object' || options === null) return options;
const out = structuredClone(options) as Record<string, unknown>;
for (const path of locked) {
const keys = path.split('.');
let src: unknown = defaults;
for (const k of keys) src = (src as Record<string, unknown> | undefined)?.[k];
if (src === undefined) continue;
let dst = out;
for (const k of keys.slice(0, -1)) {
if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {};
dst = dst[k] as Record<string, unknown>;
}
dst[keys[keys.length - 1]!] = structuredClone(src);
}
return out;
}

View File

@@ -0,0 +1,59 @@
/** Discord OAuth2 (authorization code + state), docs/05-accounts-auth.md §3. */
export interface DiscordUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
}
const API = 'https://discord.com/api/v10';
export function authorizeUrl(clientId: string, redirectUri: string, state: string): string {
const p = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: redirectUri,
scope: 'identify',
state,
prompt: 'none',
});
return `https://discord.com/oauth2/authorize?${p}`;
}
export async function exchangeCode(
cfg: { clientId: string; clientSecret: string },
code: string,
redirectUri: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscordUser> {
const tokenRes = await fetchImpl(`${API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}),
signal: AbortSignal.timeout(10_000),
});
if (!tokenRes.ok) throw new Error(`discord token exchange failed: ${tokenRes.status}`);
const token = (await tokenRes.json()) as { access_token: string; token_type: string };
const meRes = await fetchImpl(`${API}/users/@me`, {
headers: { Authorization: `${token.token_type} ${token.access_token}` },
signal: AbortSignal.timeout(10_000),
});
if (!meRes.ok) throw new Error(`discord /users/@me failed: ${meRes.status}`);
return (await meRes.json()) as DiscordUser;
}
export function avatarUrl(u: DiscordUser): string | null {
return u.avatar ? `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.png?size=128` : null;
}
/** Only same-site relative paths are allowed as post-login redirect targets. */
export function safeNext(next: string | null | undefined): string {
if (!next || !next.startsWith('/') || next.startsWith('//') || next.includes('\\')) return '/';
return next;
}

View File

@@ -0,0 +1,90 @@
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);
crypto.getRandomValues(buf);
return Buffer.from(buf).toString('base64url');
}
export function sha256Hex(input: string): string {
return new Bun.CryptoHasher('sha256').update(input).digest('hex');
}
export class Sessions {
constructor(private db: Database) {}
create(userId: string, kind: 'guest' | 'member', userAgent: string | null, now = Date.now()): { token: string; expiresAt: number } {
const token = randomToken();
const expiresAt = now + SESSION_TTL[kind];
this.db
.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at, last_used_at, user_agent) VALUES (?, ?, ?, ?, ?, ?)')
.run(sha256Hex(token), userId, now, expiresAt, now, userAgent?.slice(0, 200) ?? null);
return { token, expiresAt };
}
/** Resolves a token to a user id; slides expiry at most once per day. */
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'; 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);
if (!row) return null;
if (row.expires_at <= now) {
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash);
return null;
}
if (now - row.last_used_at > DAY) {
this.db
.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;
}
revoke(token: string): void {
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(sha256Hex(token));
}
revokeAll(userId: string): void {
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
}
purgeExpired(now = Date.now()): number {
return this.db.query('DELETE FROM sessions WHERE expires_at <= ?').run(now).changes;
}
}
/** Minimal HMAC-signed value for short-lived cookies (OAuth state + PKCE verifier). */
export async function signValue(secret: string, value: string): Promise<string> {
const mac = await hmac(secret, value);
return `${Buffer.from(value).toString('base64url')}.${mac}`;
}
export async function unsignValue(secret: string, signed: string): Promise<string | null> {
const dot = signed.lastIndexOf('.');
if (dot < 0) return null;
const value = Buffer.from(signed.slice(0, dot), 'base64url').toString();
const expected = await hmac(secret, value);
const given = signed.slice(dot + 1);
if (expected.length !== given.length) return null;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
return diff === 0 ? value : null;
}
async function hmac(secret: string, value: string): Promise<string> {
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value));
return Buffer.from(sig).toString('base64url');
}

View File

@@ -0,0 +1,205 @@
import type { Database } from 'bun:sqlite';
import { ulid } from 'ulid';
import type { PublicUser } from '@bg/shared';
export interface UserRow {
id: string;
kind: 'guest' | 'member';
nickname: string;
avatar_url: string | null;
use_avatar: number;
settings_json: string;
nick_changed_at: number | null;
nick_change_count: number;
created_at: number;
last_seen_at: number;
deleted_at: number | null;
banned_at: number | null;
banned_reason: string | null;
}
export const NICK_CHANGES_PER_DAY = 10;
export class Users {
constructor(private db: Database) {}
get(id: string): UserRow | null {
return this.db.query<UserRow, [string]>('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
}
createGuest(nickname: string, now = Date.now()): UserRow {
const id = ulid(now);
this.db
.query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
.run(id, 'guest', nickname, now, now);
return this.get(id)!;
}
findByOauth(provider: string, providerUserId: string): UserRow | null {
return this.db
.query<UserRow, [string, string]>(
`SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id
WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`,
)
.get(provider, providerUserId);
}
createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow {
const id = ulid(now);
this.db
.query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, 'member', nickname, avatarUrl, now, now);
return this.get(id)!;
}
linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void {
this.db
.query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)')
.run(provider, providerUserId, userId, username, now);
}
promoteToMember(userId: string, avatarUrl: string | null): void {
this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId);
}
setAvatar(userId: string, avatarUrl: string | null): void {
this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId);
}
/** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */
mergeGuestInto(guestId: string, memberId: string): void {
this.db.transaction(() => {
const rows = this.db
.query<{ game_id: string; played: number; wins: number; draws: number }, [string]>(
'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?',
)
.all(guestId);
for (const r of rows) {
this.db
.query(
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played,
wins = wins + excluded.wins, draws = draws + excluded.draws`,
)
.run(memberId, r.game_id, r.played, r.wins, r.draws);
}
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId);
this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId);
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId);
this.db.query('DELETE FROM users WHERE id = ?').run(guestId);
})();
}
/** Returns an error message or null. Enforces the daily change limit. */
changeNickname(userId: string, nickname: string, now = Date.now()): string | null {
const u = this.get(userId);
if (!u) return '사용자를 찾을 수 없어요.';
if (u.nickname === nickname) return null;
const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000;
const count = sameDay ? u.nick_change_count : 0;
if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.';
this.db
.query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?')
.run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId);
return null;
}
updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record<string, unknown> }): void {
if (patch.useAvatar !== undefined) {
this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId);
}
if (patch.settings) {
this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId);
}
}
/** Admin rename: ignores the daily limit. */
forceNickname(userId: string, nickname: string): void {
this.db.query('UPDATE users SET nickname = ? WHERE id = ?').run(nickname, userId);
}
setBan(userId: string, reason: string | null, now = Date.now()): void {
if (reason === null) this.db.query('UPDATE users SET banned_at = NULL, banned_reason = NULL WHERE id = ?').run(userId);
else this.db.query('UPDATE users SET banned_at = ?, banned_reason = ? WHERE id = ?').run(now, reason, userId);
}
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
LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord'
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 ?`,
)
.all(q, like, q, q, limit, offset);
const total = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM users WHERE deleted_at IS NULL').get()!.n;
return { rows, total };
}
counts(): { total: number; guests: number; members: number; banned: number } {
return this.db
.query<{ total: number; guests: number; members: number; banned: number }, []>(
`SELECT COUNT(*) AS total, SUM(kind = 'guest') AS guests, SUM(kind = 'member') AS members, SUM(banned_at IS NOT NULL) AS banned
FROM users WHERE deleted_at IS NULL`,
)
.get()!;
}
touch(userId: string, now = Date.now()): void {
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
.query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?")
.run(now, userId);
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
})();
}
stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] {
return this.db
.query<{ gameId: string; played: number; wins: number; draws: number }, [string]>(
'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC',
)
.all(userId);
}
}
export function toPublicUser(u: UserRow): PublicUser {
return {
id: u.id,
nickname: u.nickname,
avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null,
kind: u.kind,
};
}

View 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);
});
});

41
apps/server/src/config.ts Normal file
View File

@@ -0,0 +1,41 @@
export interface Config {
port: number;
publicOrigin: string;
/** Extra origins accepted for WS/CSRF checks (e.g. Vite dev server). */
allowedOrigins: string[];
dbPath: string;
discord: { clientId: string; clientSecret: string } | null;
sessionSecret: string;
secureCookies: boolean;
/** Trust X-Forwarded-For (only when running behind our reverse proxy). */
trustProxy: boolean;
superadminDiscordIds: string[];
/** Load-test only: lift per-IP HTTP limits. Ignored for https origins. */
loadTestNoHttpLimits: boolean;
}
/** Superadmin Discord IDs, comma-separated (docs/14-admin.md §2). */
export const DEFAULT_SUPERADMIN = '293719842274541579,1352267557213573160';
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);
const clientId = env.DISCORD_CLIENT_ID;
const clientSecret = env.DISCORD_CLIENT_SECRET;
const sessionSecret = env.SESSION_SECRET ?? '';
if (publicOrigin.startsWith('https://') && sessionSecret.length < 32) {
throw new Error('SESSION_SECRET must be at least 32 characters in production');
}
return {
port: Number(env.PORT ?? 3000),
publicOrigin,
allowedOrigins: [publicOrigin, ...extra],
dbPath: env.DB_PATH ?? './data/app.db',
discord: clientId && clientSecret ? { clientId, clientSecret } : null,
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
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),
};
}

View File

@@ -0,0 +1,32 @@
import { Database } from 'bun:sqlite';
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const MIGRATIONS_DIR = join(import.meta.dir, 'migrations');
export function openDb(path: string): Database {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
const db = new Database(path, { create: true, strict: true });
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA synchronous = NORMAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec('PRAGMA busy_timeout = 5000;');
migrate(db);
return db;
}
export function migrate(db: Database): void {
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)');
const applied = new Set(
db.query<{ name: string }, []>('SELECT name FROM schema_migrations').all().map((r) => r.name),
);
const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = readFileSync(join(MIGRATIONS_DIR, file), 'utf8');
db.transaction(() => {
db.exec(sql);
db.query('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now());
})();
}
}

View File

@@ -0,0 +1,100 @@
CREATE TABLE users (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('guest','member')),
nickname TEXT NOT NULL,
avatar_url TEXT,
use_avatar INTEGER NOT NULL DEFAULT 1,
settings_json TEXT NOT NULL DEFAULT '{}',
nick_changed_at INTEGER,
nick_change_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE oauth_accounts (
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
username TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (provider, provider_user_id)
);
CREATE INDEX oauth_accounts_user ON oauth_accounts(user_id);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
user_agent TEXT
);
CREATE INDEX sessions_user ON sessions(user_id);
CREATE TABLE rooms (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
host_id TEXT NOT NULL,
game_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('lobby','playing','finished','paused','broken','closed')),
visibility TEXT NOT NULL,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
closed_at INTEGER
);
CREATE UNIQUE INDEX rooms_open_code ON rooms(code) WHERE status <> 'closed';
CREATE INDEX rooms_status ON rooms(status);
CREATE TABLE room_state (
room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE room_log (
room_id TEXT NOT NULL,
seq INTEGER NOT NULL,
game_no INTEGER NOT NULL,
actor_id TEXT,
kind TEXT NOT NULL,
data_json TEXT NOT NULL,
at INTEGER NOT NULL,
PRIMARY KEY (room_id, seq)
);
CREATE TABLE games (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL,
game_no INTEGER NOT NULL,
game_id TEXT NOT NULL,
options_json TEXT NOT NULL,
seed_hash TEXT NOT NULL,
seed TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER,
end_reason TEXT,
result_json TEXT
);
CREATE INDEX games_room ON games(room_id);
CREATE TABLE game_players (
game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
seat INTEGER NOT NULL,
rank INTEGER,
score REAL,
PRIMARY KEY (game_pk, user_id)
);
CREATE INDEX game_players_user ON game_players(user_id);
CREATE TABLE user_stats (
user_id TEXT NOT NULL,
game_id TEXT NOT NULL,
played INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
draws INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, game_id)
);

View File

@@ -0,0 +1,34 @@
CREATE TABLE admins (
discord_id TEXT PRIMARY KEY,
note TEXT,
added_by TEXT,
added_at INTEGER NOT NULL
);
CREATE TABLE game_settings (
game_id TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_by TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE site_settings (
key TEXT PRIMARY KEY,
json TEXT NOT NULL,
updated_by TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE admin_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
before_json TEXT,
after_json TEXT,
at INTEGER NOT NULL
);
CREATE INDEX admin_audit_at ON admin_audit(at);
ALTER TABLE users ADD COLUMN banned_at INTEGER;
ALTER TABLE users ADD COLUMN banned_reason TEXT;

View 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';

View File

@@ -0,0 +1,128 @@
/** 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);
});
});

View File

@@ -0,0 +1,243 @@
/** Admin API (docs/14-admin.md §8). Mounted under /api/admin by createHttpApp. */
import { Hono, type Context } from 'hono';
import { z } from 'zod';
import type { AnyGameDefinition } from '@bg/engine';
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';
export interface AdminDeps {
users: Users;
sessions: Sessions;
rooms: RoomManager;
roles: Roles;
audit: Audit;
settings: SettingsStore;
games: Record<string, AnyGameDefinition>;
disconnectUser: (userId: string) => void;
stats: () => Record<string, unknown>;
}
type Env = { Variables: { userId: string | null; role: Role } };
const DISCORD_ID = /^\d{17,20}$/;
export function createAdminApp(d: AdminDeps) {
const app = new Hono<Env>();
app.use('*', async (c, next) => {
const role = d.roles.roleOf(c.get('userId'));
if (role === 'user') return c.json({ error: '권한이 없어요.' }, c.get('userId') ? 403 : 401);
c.set('role', role);
await next();
});
const superOnly = async (c: Context<Env>, next: () => Promise<void>) => {
if (c.get('role') !== 'superadmin') return c.json({ error: '슈퍼어드민만 할 수 있어요.' }, 403);
await next();
};
const actor = (c: Context<Env>) => c.get('userId')!;
const body = async (c: Context<Env>) => c.req.json().catch(() => null);
app.get('/me', (c) => c.json({ role: c.get('role'), me: toPublicUser(d.users.get(actor(c))!) }));
app.get('/overview', (c) => c.json({ users: d.users.counts(), ...d.stats() }));
// ------------------------------------------------------------ games
app.get('/games', (c) =>
c.json({
games: Object.values(d.games).map((def) => ({
id: def.id,
codeDefaults: { nameKo: def.nameKo, minPlayers: def.minPlayers, maxPlayers: def.maxPlayers, defaultOptions: def.defaultOptions },
raw: d.settings.rawGame(def.id),
effective: d.settings.game(def.id),
schema: z.toJSONSchema(def.optionsSchema, { io: 'input', unrepresentable: 'any' }),
})),
}),
);
app.put('/games/:id', async (c) => {
const id = c.req.param('id');
if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404);
const parsed = GameSettingsSchema.safeParse(await body(c));
if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400);
const err = d.settings.validateGame(id, parsed.data);
if (err) return c.json({ error: err }, 400);
const before = d.settings.rawGame(id);
d.settings.setGame(id, parsed.data, actor(c));
d.audit.log(actor(c), 'game.update', id, before, d.settings.rawGame(id));
return c.json({ effective: d.settings.game(id), raw: d.settings.rawGame(id) });
});
app.post('/games/:id/reset', (c) => {
const id = c.req.param('id');
if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404);
const before = d.settings.rawGame(id);
d.settings.resetGame(id);
d.audit.log(actor(c), 'game.reset', id, before, null);
return c.json({ effective: d.settings.game(id), raw: {} });
});
// ------------------------------------------------------------ site
app.get('/site', (c) => c.json({ site: d.settings.getSite() }));
app.put('/site', async (c) => {
const parsed = SiteSettingsSchema.safeParse(await body(c));
if (!parsed.success) return c.json({ error: `입력 값이 올바르지 않아요: ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` }, 400);
const before = d.settings.getSite();
d.settings.setSite(parsed.data, actor(c));
d.audit.log(actor(c), 'site.update', null, before, parsed.data);
return c.json({ site: parsed.data });
});
// ------------------------------------------------------------ rooms
app.get('/rooms', (c) =>
c.json({
rooms: d.rooms.all().map((r) => ({
code: r.code,
gameId: r.config.gameId,
status: r.status,
host: d.users.get(r.hostId)?.nickname ?? 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,
gameNo: r.gameNo,
lastActiveAt: r.lastActiveAt,
})),
}),
);
app.post('/rooms/:code/close', (c) => {
const code = c.req.param('code');
if (!d.rooms.closeByAdmin(code)) return c.json({ error: '방을 찾을 수 없어요.' }, 404);
d.audit.log(actor(c), 'room.close', code, null, null);
return c.json({ ok: true });
});
// ------------------------------------------------------------ audit
app.get('/audit', (c) => {
const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0);
return c.json({ entries: d.audit.list(50, page * 50) });
});
// ------------------------------------------------------------ users (superadmin)
app.use('/users/*', superOnly);
app.use('/users', superOnly);
app.get('/users', (c) => {
const q = (c.req.query('q') ?? '').trim().slice(0, 60);
const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0);
const { rows, total } = d.users.search(q, 30, page * 30);
return c.json({
total,
users: rows.map((u) => ({
id: u.id,
nickname: u.nickname,
kind: u.kind,
discordId: u.discord_id,
discordName: u.discord_name,
role: d.roles.roleOfDiscord(u.discord_id),
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
createdAt: u.created_at,
lastSeenAt: u.last_seen_at,
})),
});
});
app.get('/users/:id', (c) => {
const u = d.users.get(c.req.param('id'));
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
const discordId = d.roles.discordIdOf(u.id);
return c.json({
user: {
...toPublicUser(u),
discordId,
role: d.roles.roleOfDiscord(discordId),
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
createdAt: u.created_at,
lastSeenAt: u.last_seen_at,
activeRoom: d.rooms.activeRoomFor(u.id),
},
stats: d.users.stats(u.id),
});
});
app.patch('/users/:id', async (c) => {
const id = c.req.param('id');
const u = d.users.get(id);
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
const parsed = z
.object({ nickname: z.string().max(100).optional(), ban: z.object({ reason: z.string().trim().min(1).max(200) }).nullable().optional() })
.safeParse(await body(c));
if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400);
const discordId = d.roles.discordIdOf(id);
if (parsed.data.ban && discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 제한할 수 없어요.' }, 400);
if (parsed.data.nickname !== undefined) {
const nick = normalizeNickname(parsed.data.nickname);
const e = nicknameError(nick, d.settings.getSite().bannedWords);
if (e) return c.json({ error: e }, 400);
d.users.forceNickname(id, nick);
d.audit.log(actor(c), 'user.rename', id, u.nickname, nick);
}
if (parsed.data.ban !== undefined) {
if (parsed.data.ban) {
d.users.setBan(id, parsed.data.ban.reason);
d.sessions.revokeAll(id);
d.disconnectUser(id);
d.audit.log(actor(c), 'user.ban', id, null, parsed.data.ban);
} else {
d.users.setBan(id, null);
d.audit.log(actor(c), 'user.unban', id, { reason: u.banned_reason }, null);
}
}
return c.json({ ok: true });
});
app.post('/users/:id/logout', (c) => {
const id = c.req.param('id');
if (!d.users.get(id)) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
d.sessions.revokeAll(id);
d.disconnectUser(id);
d.audit.log(actor(c), 'user.logout', id, null, null);
return c.json({ ok: true });
});
app.delete('/users/:id', (c) => {
const id = c.req.param('id');
const u = d.users.get(id);
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
const discordId = d.roles.discordIdOf(id);
if (discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 삭제할 수 없어요.' }, 400);
d.disconnectUser(id);
d.users.softDelete(id);
d.audit.log(actor(c), 'user.delete', id, { nickname: u.nickname, discordId }, null);
return c.json({ ok: true });
});
// ------------------------------------------------------------ admins (superadmin)
app.use('/admins/*', superOnly);
app.use('/admins', superOnly);
app.get('/admins', (c) => c.json({ admins: d.roles.listAdmins() }));
app.post('/admins', async (c) => {
const parsed = z.object({ discordId: z.string().trim(), note: z.string().trim().max(100).optional() }).safeParse(await body(c));
if (!parsed.success || !DISCORD_ID.test(parsed.data.discordId)) return c.json({ error: '디스코드 ID는 숫자 17~20자리예요.' }, 400);
if (d.roles.isSuperadminDiscord(parsed.data.discordId)) return c.json({ error: '이미 슈퍼어드민이에요.' }, 400);
if (!d.roles.addAdmin(parsed.data.discordId, parsed.data.note ?? null, actor(c))) return c.json({ error: '이미 등록된 어드민이에요.' }, 400);
d.audit.log(actor(c), 'admin.add', parsed.data.discordId, null, { note: parsed.data.note ?? null });
return c.json({ admins: d.roles.listAdmins() });
});
app.delete('/admins/:discordId', (c) => {
const id = c.req.param('discordId');
if (d.roles.isSuperadminDiscord(id)) return c.json({ error: '설정 파일의 슈퍼어드민은 지울 수 없어요.' }, 400);
if (!d.roles.removeAdmin(id)) return c.json({ error: '등록되지 않은 디스코드 ID예요.' }, 404);
d.audit.log(actor(c), 'admin.remove', id, null, null);
return c.json({ admins: d.roles.listAdmins() });
});
return app;
}

322
apps/server/src/http/app.ts Normal file
View File

@@ -0,0 +1,322 @@
/** HTTP API (docs/05 §6, docs/06). */
import { Hono, type Context } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { z } from 'zod';
import { nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
import type { Config } from '../config';
import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord';
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';
import type { SettingsStore } from '../admin/settings';
export interface HttpDeps {
config: Config;
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'>;
/** Deletes a guest account everywhere (rooms, sockets, DB). */
removeGuest: (userId: string) => void;
}
type Env = { Variables: { userId: string | null } };
const OAUTH_COOKIE = 'oauth';
export function createHttpApp(d: HttpDeps) {
const app = new Hono<Env>();
const lift = d.config.loadTestNoHttpLimits ? 1_000_000 : 1;
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 redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
const cookieOpts = (maxAgeMs: number) => ({
httpOnly: true,
secure: d.config.secureCookies,
sameSite: 'Lax' as const,
path: '/',
maxAge: Math.floor(maxAgeMs / 1000),
});
// Session resolution.
app.use('/api/*', async (c, next) => {
const token = getCookie(c, SESSION_COOKIE);
c.set('userId', token ? d.sessions.resolve(token) : null);
await next();
});
// CSRF: state-changing requests must come from our origin (docs/04 §7).
app.use('/api/*', async (c, next) => {
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
const origin = c.req.header('origin');
if (!origin || !d.config.allowedOrigins.includes(origin)) return c.json({ error: '잘못된 요청이에요.' }, 403);
}
await next();
});
const requireUser = (c: Context<Env>) => c.get('userId');
const startSession = (c: Context<Env>, userId: string, kind: 'guest' | 'member') => {
const { token } = d.sessions.create(userId, kind, c.req.header('user-agent') ?? null);
setCookie(c, SESSION_COOKIE, token, cookieOpts(SESSION_TTL[kind]));
};
app.get('/healthz', (c) => {
const h = d.health();
return c.json(h, h.ok ? 200 : 503);
});
app.get('/api/config', (c) => {
const site = d.settings.getSite();
return c.json({
discord: d.config.discord !== null,
catalog: d.settings.catalog(),
siteName: site.siteName,
announcement: site.announcement,
maintenance: site.maintenance,
});
});
app.post('/api/auth/guest', async (c) => {
if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400);
const nick = normalizeNickname(body.data.nickname);
const e = nicknameError(nick, d.settings.getSite().bannedWords);
if (e) return c.json({ error: e }, 400);
const current = requireUser(c);
if (current) {
// Already signed in: treat as a nickname change instead of creating another account.
const err = d.users.changeNickname(current, nick);
if (err) return c.json({ error: err }, 400);
return c.json({ me: toPublicUser(d.users.get(current)!) });
}
const u = d.users.createGuest(nick);
startSession(c, u.id, 'guest');
return c.json({ me: toPublicUser(u) });
});
app.get('/api/auth/discord/start', async (c) => {
if (!d.config.discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
const state = randomToken(24);
const next = safeNext(c.req.query('next'));
setCookie(c, OAUTH_COOKIE, await signValue(d.config.sessionSecret, JSON.stringify({ state, next, at: Date.now() })), {
...cookieOpts(10 * 60_000),
path: '/api/auth/discord',
});
return c.redirect(authorizeUrl(d.config.discord.clientId, redirectUri, state));
});
app.get('/api/auth/discord/callback', async (c) => {
const discord = d.config.discord;
if (!discord) return c.text('디스코드 로그인이 설정되지 않았어요.', 404);
const raw = getCookie(c, OAUTH_COOKIE);
deleteCookie(c, OAUTH_COOKIE, { path: '/api/auth/discord' });
const payload = raw ? await unsignValue(d.config.sessionSecret, raw) : null;
const parsed = payload ? (JSON.parse(payload) as { state: string; next: string; at: number }) : null;
const code = c.req.query('code');
if (!parsed || !code || parsed.state !== c.req.query('state') || Date.now() - parsed.at > 10 * 60_000) {
return c.redirect('/?login=failed');
}
let du: DiscordUser;
try {
du = d.discordExchange ? await d.discordExchange(code, redirectUri) : await exchangeCode(discord, code, redirectUri);
} catch {
return c.redirect('/?login=failed');
}
const avatar = avatarUrl(du);
const existing = d.users.findByOauth('discord', du.id);
if (existing?.banned_at) return c.redirect('/?login=banned');
const current = requireUser(c) ? d.users.get(requireUser(c)!) : null;
let userId: string;
let merged = false;
if (existing) {
userId = existing.id;
d.users.setAvatar(userId, avatar);
if (current && current.kind === 'guest' && current.id !== existing.id) {
d.users.mergeGuestInto(current.id, existing.id);
merged = true;
}
} else if (current && current.kind === 'guest') {
d.users.linkOauth(current.id, 'discord', du.id, du.username);
d.users.promoteToMember(current.id, avatar);
userId = current.id;
merged = true;
} else {
const nick = normalizeNickname(du.global_name ?? du.username).slice(0, 12);
const u = d.users.createMember(nicknameError(nick) ? '디스코드 사용자' : nick, avatar);
d.users.linkOauth(u.id, 'discord', du.id, du.username);
userId = u.id;
}
const old = getCookie(c, SESSION_COOKIE);
if (old) d.sessions.revoke(old);
startSession(c, userId, 'member');
const next = safeNext(parsed.next);
return c.redirect(merged ? `${next}${next.includes('?') ? '&' : '?'}linked=1` : next);
});
app.post('/api/auth/logout', (c) => {
const token = getCookie(c, SESSION_COOKIE);
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 });
});
app.post('/api/auth/logout-all', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
d.sessions.revokeAll(u);
deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.json({ ok: true });
});
app.get('/api/me', (c) => {
const u = requireUser(c);
const row = u ? d.users.get(u) : null;
if (!row) return c.json({ me: null });
return c.json({
me: toPublicUser(row),
settings: JSON.parse(row.settings_json),
useAvatar: !!row.use_avatar,
role: d.roles.roleOf(row.id),
hasDiscordAvatar: !!row.avatar_url,
activeRoom: d.rooms.activeRoomFor(row.id),
});
});
app.patch('/api/me', async (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
const body = z
.object({
nickname: z.string().max(100).optional(),
useAvatar: z.boolean().optional(),
settings: z
.object({
fontScale: z.enum(['normal', 'large', 'xlarge']).optional(),
theme: z.enum(['auto', 'light', 'dark']).optional(),
sound: z.boolean().optional(),
vibrate: z.boolean().optional(),
colorBlind: z.boolean().optional(),
tapConfirm: z.boolean().optional(),
})
.optional(),
})
.safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400);
if (body.data.nickname !== undefined) {
const nick = normalizeNickname(body.data.nickname);
const e = nicknameError(nick, d.settings.getSite().bannedWords) ?? d.users.changeNickname(u, nick);
if (e) return c.json({ error: e }, 400);
}
d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings });
return c.json({ me: toPublicUser(d.users.get(u)!) });
});
app.delete('/api/me', async (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
const body = z.object({ confirm: z.literal('탈퇴') }).safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: '확인 문구를 정확히 입력해 주세요.' }, 400);
d.users.softDelete(u);
deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.json({ ok: true });
});
app.get('/api/me/stats', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
return c.json({ stats: d.users.stats(u) });
});
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(), 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 = 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 });
});
app.post('/api/quick/:gameId', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
const gameId = c.req.param('gameId');
if (!d.settings.game(gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const r = d.rooms.quickPlay(u, gameId);
if (typeof r === 'string') return c.json({ error: r }, 429);
return c.json({ code: r.code });
});
/** Room preview for the join screen (no auth needed). */
app.get('/api/rooms/:code', (c) => {
if (!lookupLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const code = normalizeRoomCode(c.req.param('code'));
const r = code ? d.rooms.get(code) : undefined;
if (!r || r.status === 'broken') return c.json({ error: '방을 찾을 수 없어요. 코드를 다시 확인해 주세요.' }, 404);
const host = d.users.get(r.hostId);
return c.json({
code: r.code,
codeLabel: formatRoomCode(r.code),
gameId: r.config.gameId,
host: host?.nickname ?? '',
seated: r.seatedIds().length,
maxPlayers: r.config.maxPlayers,
status: r.status,
});
});
app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) }));
app.get('/api/public-rooms', (c) => c.json({ rooms: d.rooms.publicRooms(null).slice(0, 50) }));
app.route(
'/api/admin',
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }),
);
return app;
}

27
apps/server/src/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import { loadConfig } from './config';
import { jsonLog, startServer } from './server';
const config = loadConfig();
const app = startServer({ config });
jsonLog('info', 'server started', { port: app.server.port, origin: config.publicOrigin, discord: config.discord !== null });
let stopping = false;
const shutdown = async (signal: string) => {
if (stopping) return;
stopping = true;
jsonLog('info', 'shutting down', { signal });
const force = setTimeout(() => process.exit(0), 10_000);
await app.stop();
clearTimeout(force);
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('uncaughtException', (err) => {
jsonLog('error', 'uncaughtException', { err: String(err), stack: err.stack });
process.exit(1);
});
process.on('unhandledRejection', (err) => {
jsonLog('error', 'unhandledRejection', { err: String(err) });
process.exit(1);
});

View 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);
});
});

View File

@@ -0,0 +1,371 @@
/** 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) {
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 });
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 with nobody connected 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.disconnect(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('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('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();
});
});

View 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();
}
}

View File

@@ -0,0 +1,191 @@
/** 그림 맞히기 through a real Room: stroke streaming, resync on reconnect, drawer disconnect, rate limit (docs/games/catchmind.md §5.5, §8.5, §10). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import type { CanvasOp, CatchmindState, CatchmindView } from '@bg/games/catchmind';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
type Msgs = ServerMessage[];
type StateMsg = Extract<ServerMessage, { t: 'state' }>;
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const games = { catchmind: GAMES.catchmind! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'catchmind',
options: games.catchmind.defaultOptions,
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'catchmind', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conn = (p: string) => {
const msgs: Msgs = [];
const c: Conn & { msgs: Msgs } = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
return c;
};
const conns: Record<string, Conn & { msgs: Msgs }> = {};
for (const p of players) {
conns[p] = conn(p);
room.join(conns[p], 'player');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
const state = () => room.game!.state as CatchmindState;
let cs = 100;
const act = (p: string, a: unknown) => {
room.act(p, ++cs, a);
room.clearTimer();
return cs;
};
return { room, db, clock, conns, conn, state, act };
}
const drawOps = (stroke: number): CanvasOp[] => [
{ k: 'b', s: stroke, c: 3, w: 1, e: 0 },
{ k: 'p', s: stroke, q: 0, d: [100, 100, 10, 0, 10, 5] },
{ k: 'p', s: stroke, q: 1, d: [130, 120, 4, 4] },
{ k: 'e', s: stroke },
];
const lastState = (m: Msgs) => m.filter((x): x is StateMsg => x.t === 'state').at(-1)!;
const pointCount = (ops: CanvasOp[]) => ops.reduce((n, o) => n + (o.k === 'p' ? o.d.length / 2 : 0), 0);
describe('그림 맞히기 room flow', () => {
test('그림은 stream 메시지로만 중계(전체 view 재전송 없음), 재접속한 사람은 그림 기록 전체를 받음', () => {
const { room, db, clock, conns, conn, state, act } = harness(['A', 'B', 'C']);
const d = state().turn.drawer;
const [g1, g2] = state().order.filter((p) => p !== d) as [string, string];
act(d, { type: 'chooseWord', index: 0 });
expect(state().turn.phase).toBe('drawing');
const before = conns[g1]!.msgs.length;
const ops = [...drawOps(1), ...drawOps(2)];
for (const op of ops) {
clock.advance(50);
act(d, { type: 'draw', op });
}
const got = conns[g1]!.msgs.slice(before);
expect(got.every((m) => m.t === 'stream')).toBe(true);
expect(got).toHaveLength(ops.length);
const relayed = got.flatMap((m) => (m.t === 'stream' ? m.events : [])) as { type: string; n: number; op: CanvasOp }[];
expect(relayed.map((e) => e.n)).toEqual(ops.map((_, i) => i));
expect(relayed.map((e) => e.op)).toEqual(ops);
// The drawer's echo carries its cs (ack).
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'stream', cs: expect.any(Number) });
// Regular views carry only the count.
act(g1, { type: 'guess', text: '모르겠어요' });
const v = lastState(conns[g2]!.msgs).view as CatchmindView;
expect(v.canvas).toBeNull();
expect(v.canvasCount).toBe(ops.length);
// g2 drops, more strokes are drawn, g2 comes back → the join view has the whole drawing.
room.disconnect(conns[g2]!);
room.clearTimer();
for (const op of drawOps(3)) {
clock.advance(50);
act(d, { type: 'draw', op });
}
const back = conn(g2);
room.join(back, 'player');
room.clearTimer();
const sv = lastState(back.msgs).view as CatchmindView;
expect(sv.canvas).toEqual(state().turn.canvas);
expect(sv.canvas!.filter((o) => o.k === 'b')).toHaveLength(3);
expect(pointCount(sv.canvas!)).toBe(pointCount(state().turn.canvas));
expect(sv.canvasCount).toBe(12);
// Explicit resync too (client gap detection).
room.sendSync(back);
expect((lastState(back.msgs).view as CatchmindView).canvas).toHaveLength(12);
// Streamed actions are logged in batches with the next snapshot.
const drawLogs = () => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM room_log WHERE data_json LIKE '%\"draw\"%'").get()!.n;
expect(drawLogs()).toBe(12); // the join wrote the pending ones
clock.advance(50);
act(d, { type: 'draw', op: { k: 'b', s: 4, c: 0, w: 0, e: 0 } });
expect(drawLogs()).toBe(12); // not yet written
room.flushStream();
expect(drawLogs()).toBe(13);
const seqs = db.query<{ seq: number }, []>('SELECT seq FROM room_log ORDER BY seq').all().map((r) => r.seq);
expect(new Set(seqs).size).toBe(seqs.length);
const snap = db.query<{ state_json: string }, []>('SELECT state_json FROM room_state').get()!;
expect(JSON.parse(snap.state_json).game.state.turn.canvas).toHaveLength(13);
});
test('초당 31번째 그림 메시지는 거절, 출제자가 아니면 거절', () => {
const { clock, conns, state, act } = harness(['A', 'B']);
const d = state().turn.drawer;
const g = state().order.find((p) => p !== d)!;
act(d, { type: 'chooseWord', index: 1 });
const cs = act(g, { type: 'draw', op: { k: 'b', s: 1, c: 0, w: 0, e: 0 } });
expect(conns[g]!.msgs.at(-1)).toEqual({ t: 'reject', cs, reason: '그림은 출제자만 그릴 수 있어요.' });
for (let s = 1; s <= 30; s++) {
clock.advance(10);
act(d, { type: 'draw', op: { k: 'b', s, c: 0, w: 0, e: 0 } });
}
expect(state().turn.canvas).toHaveLength(30);
const c31 = act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'reject', cs: c31, reason: expect.stringContaining('너무 빨리') });
clock.advance(1000);
act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
expect(state().turn.canvas).toHaveLength(31);
});
test('출제자 연결 끊김: 고르기 중이면 바로 자동 선택, 그리기 10초 뒤 턴 종료·정답 공개·다음 출제자', () => {
const { room, clock, conns, state } = harness(['A', 'B', 'C']);
const d = state().turn.drawer;
const g = state().order.find((p) => p !== d)!;
room.disconnect(conns[d]!);
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now());
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('drawing');
const word = state().turn.word!.word;
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
clock.advance(9_999);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('drawing');
clock.advance(1);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('reveal');
expect(state().turn.endReason).toBe('drawerAway');
expect(state().scores[d]).toBe(0);
expect((lastState(conns[g]!.msgs).view as CatchmindView).word).toBe(word);
clock.advance(5_000);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('choosing');
expect(state().turn.drawer).not.toBe(d);
expect(state().away).toEqual([d]);
});
test('연결된 출제자는 힌트·시간 만료 때만 tick(10초 지나도 끝나지 않음)', () => {
const { room, clock, state, act } = harness(['A', 'B']);
const d = state().turn.drawer;
act(d, { type: 'chooseWord', index: 0 });
const t0 = state().turn.startedAt;
const ends = state().turn.endsAt;
expect(room.effectiveDeadline()).toBe(t0 + (ends - t0) / 2);
clock.advance(20_000);
room.onTimer();
room.clearTimer();
expect(state().turn.phase).toBe('drawing');
clock.advance(ends - t0);
for (let i = 0; i < 3 && state().turn.phase === 'drawing'; i++) {
room.onTimer();
room.clearTimer();
}
expect(state().turn.endReason).toBe('time');
});
});

View File

@@ -0,0 +1,120 @@
/** 도블 through a real Room: arrival order, "늦었어요" rejects, the latency window and away players (docs/games/dobble.md §5.3, §8.5). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { answerArt, pairsFor, type DobbleOptions, type DobbleState } from '@bg/games/dobble';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[], opts: Partial<DobbleOptions> = {}) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
const games = { dobble: GAMES.dobble! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'dobble',
options: games.dobble.optionsSchema.parse(opts),
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'dobble', 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');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
// 3s countdown → playing.
clock.advance(3000);
room.onTimer();
room.clearTimer();
const state = () => room.game!.state as DobbleState;
/** The correct claim of `p` (first pair), as the client would send it. */
const right = (p: string) => {
const pair = pairsFor(state(), p)[0]!;
return { type: 'claim', base: pair.base, symbol: answerArt(state(), p, pair)!, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) };
};
return { room, clock, conns, state, right };
}
describe('도블 room flow', () => {
test('같은 가운데 카드에 같은 ms 두 정답: 큐에서 먼저 처리된 사람만 가져가고, 나중 사람은 "늦었어요" 거절(잠금 없음)', () => {
const { room, conns, state, right } = harness(['A', 'B', 'C']);
const a = right('A');
const b = right('B');
const towerB = state().piles.B!.length;
room.act('B', 1, b);
room.act('A', 1, a);
room.clearTimer();
expect(state().piles.B).toHaveLength(towerB + 1);
expect(state().piles.A).toHaveLength(1);
expect(state().lockedUntil.A).toBe(0);
expect(conns.A!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 찾았어요.' }]);
// Nobody's view carries the answer or hidden cards; the hit is public.
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
expect((last.view as { lastHit: { player: string } }).lastHit).toMatchObject({ player: 'B' });
});
test('클라이언트가 tick/resolve 같은 시스템 행동을 보내면 거절', () => {
const { room, conns } = harness(['A', 'B']);
room.act('A', 1, { type: 'tick' });
room.act('A', 2, { type: 'leave' });
expect(conns.A!.msgs.filter((m) => m.t === 'reject').map((m) => (m as { reason: string }).reason)).toEqual(['잘못된 요청이에요.', '잘못된 요청이에요.']);
});
test('지연 보정 light: 창(60ms)은 방 타이머가 닫고, 보정 시각이 빠른 사람이 이겨요', () => {
const { room, clock, state, right } = harness(['A', 'B'], { latencyComp: 'light' });
// RTT averages (normally fed by the claims): A 20ms → 10ms credit, B 200ms → 80ms credit (cap).
const s = state();
s.rtt.A = 20;
s.rtt.B = 200;
const a = right('A');
const b = right('B');
room.act('A', 1, a);
clock.advance(40);
room.act('B', 1, b);
expect(state().pendingWindow?.claims).toHaveLength(2);
expect(room.effectiveDeadline()).toBe(clock.now() - 40 + 60);
clock.set(room.effectiveDeadline()!);
room.onTimer();
room.clearTimer();
expect(state().pendingWindow).toBeNull();
// B arrived 40ms later but its adjusted time is 30ms earlier (credit 80 vs 10).
expect(state().lastHit).toMatchObject({ player: 'B' });
});
test('우물: 연결이 끊긴 사람은 유예(60초) 뒤 10초 간격으로 세 번 시간 초과 → 기권, 남은 사람끼리 계속(바쁜 반복 없음)', () => {
const { room, clock, conns, state, right } = harness(['A', 'B', 'C'], { miniGame: 'well' });
const t0 = clock.now();
room.disconnect(conns.C!);
room.clearTimer();
let fired = 0;
while (state().status.C === 'playing' && fired < 50) {
const d = room.effectiveDeadline()!;
if (d > clock.now()) clock.set(d);
room.onTimer();
room.clearTimer();
fired++;
// The others keep finding pictures now and then.
if (fired % 3 === 0 && pairsFor(state(), 'A').length) room.act('A', fired, right('A'));
room.clearTimer();
}
expect(state().status.C).toBe('forfeit');
const took = clock.now() - t0;
expect(took).toBeGreaterThanOrEqual(60_000 + 20_000);
expect(took).toBeLessThanOrEqual(60_000 + 45_000);
expect(fired).toBeLessThan(20);
expect(room.status).toBe('playing');
expect(state().phase).toBe('playing');
});
});

View File

@@ -0,0 +1,123 @@
/** 할리갈리 through a real Room: bell arrival order, "늦었어요" rejects, away players' auto flips (docs/games/halli-galli.md §5.4, §8.5). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { CARDS, type HalliGalliState } from '@bg/games/halli-galli';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
const games = { 'halli-galli': GAMES['halli-galli']! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'halli-galli',
options: games['halli-galli'].defaultOptions,
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'halli-galli', 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');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
// 3s countdown → playing.
clock.advance(3000);
room.onTimer();
room.clearTimer();
const state = () => room.game!.state as HalliGalliState;
return { room, clock, conns, state };
}
/** Moves the named cards (fruit + count) from wherever they are onto the players' face-up piles. */
function placeTops(s: HalliGalliState, tops: Record<string, [string, number]>): void {
for (const p of s.order) {
s.decks[p]!.push(...s.faceUp[p]!);
s.faceUp[p] = [];
}
for (const [p, [fruit, count]] of Object.entries(tops)) {
const c = CARDS.find((x) => x.fruit === fruit && x.count === count && !Object.values(s.faceUp).flat().includes(x.id))!;
for (const q of s.order) s.decks[q] = s.decks[q]!.filter((id) => id !== c.id);
s.pot = s.pot.filter((id) => id !== c.id);
s.faceUp[p]!.push(c.id);
}
}
describe('할리갈리 room flow', () => {
test('거의 동시에 친 두 종: 서버 도착 순서로 먼저 온 사람만 수거, 나중 사람은 "늦었어요" 거절(벌칙 없음)', () => {
const { room, conns, state } = harness(['A', 'B', 'C']);
// §5.5: banana 3 + strawberry 2 + banana 2 → banana 5.
const [x, y, z] = state().order as [string, string, string];
placeTops(state(), { [x]: ['banana', 3], [y]: ['strawberry', 2], [z]: ['banana', 2] });
const v = state().tableVersion;
const decks = Object.fromEntries(state().order.map((p) => [p, state().decks[p]!.length]));
const pot = state().pot.length; // 3 players: 2 leftover cards under the bell
// Same millisecond: the room's single-threaded queue decides — B's message is handled first.
room.act('B', 1, { type: 'ring', version: v });
room.act('C', 1, { type: 'ring', version: v });
room.clearTimer();
expect(state().decks.B).toHaveLength(decks.B! + 3 + pot);
expect(state().decks.C).toHaveLength(decks.C!);
expect(state().wrongCount.C).toBe(0);
expect(state().turn).toBe('B');
expect(conns.C!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' }]);
// B got the success echo (state with its cs) and everyone sees who rang.
expect(conns.B!.msgs.some((m) => m.t === 'state' && m.cs === 1)).toBe(true);
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
expect((last.view as { lastBell: { player: string; correct: boolean } }).lastBell).toMatchObject({ player: 'B', correct: true });
});
test('틀린 종은 바로 벌칙, 버전이 지난 종은 무시', () => {
const { room, clock, conns, state } = harness(['A', 'B']);
placeTops(state(), { A: ['lime', 1] });
const v = state().tableVersion;
const before = state().decks.B!.length;
room.act('B', 1, { type: 'ring', version: v });
room.clearTimer();
expect(state().decks.B).toHaveLength(before - 1);
// The turn player flips (after the guard) — a bell for the old table is ignored as late.
clock.advance(1000);
room.act(state().turn, 2, { type: 'flip' });
room.clearTimer();
expect(state().tableVersion).toBe(v + 1);
room.act('A', 3, { type: 'ring', version: v });
expect(conns.A!.msgs.find((m) => m.t === 'reject')).toMatchObject({ reason: expect.stringContaining('늦었어요') });
});
test('연결이 끊겨 자리 비움이 된 사람도 자동 뒤집기로 계속 진행(1초 간격, 바쁜 반복 없음)', () => {
const { room, clock, conns, state } = harness(['A', 'B']);
const away = state().turn;
const other = away === 'A' ? 'B' : 'A';
room.disconnect(conns[away]!);
room.clearTimer();
clock.advance(61_000); // grace period (60s) over
const gaps: [string, number][] = [];
for (let i = 0; i < 6; i++) {
const s = state();
const d = room.effectiveDeadline()!;
gaps.push([s.turn, d - s.turnStartedAt]);
if (d > clock.now()) clock.set(d);
const flips = s.faceUp[s.turn]!.length;
room.onTimer();
room.clearTimer();
expect(state().faceUp[s.turn]!.length).toBe(flips + 1);
}
// The first (overdue) flip happens at once; afterwards the away player flips 1s into each turn,
// the connected player gets the normal 3 seconds.
for (const [who, gap] of gaps.slice(1)) expect(gap).toBe(who === other ? 3000 : 1000);
expect(room.status).toBe('playing');
});
});

View File

@@ -0,0 +1,70 @@
/** 마피아 through a real Room: room chat closed while playing, filtered game chat per connection, server-run phases. */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import type { MafiaState, MafiaView } from '@bg/games/mafia';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const games = { mafia: GAMES.mafia! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'mafia',
options: { ...games.mafia.defaultOptions, firstPhase: 'night' },
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'mafia', 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 state = () => room.game!.state as MafiaState;
const lastView = (p: string) => (conns[p]!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>).view as MafiaView;
return { room, clock, conns, state, lastView };
}
describe('마피아 room flow', () => {
test('게임 중 방 채팅·반응은 닫히고, 게임 채팅은 연결마다 채널 권한대로만 간다', () => {
const { room, clock, conns, state, lastView } = harness(['A', 'B', 'C', 'D']);
expect(room.chat('A', '대기실 인사')).toBeNull();
expect(room.start('A')).toBeNull();
expect(room.chat('A', '몰래 말하기')).toBe('게임 중에는 게임 화면의 채팅을 써 주세요.');
// Role reveal → night on the server clock.
clock.advance(8_000);
room.onTimer();
expect(state().phase.kind).toBe('night');
const mafia = state().players.find((p) => p.role === 'mafia')!.id;
const town = state().players.filter((p) => p.role !== 'mafia').map((p) => p.id);
room.act(mafia, 1, { type: 'chat', channel: 'mafia', text: 'ROOM-MAFIA-SECRET' });
expect(lastView(mafia).chat.map((c) => c.text)).toContain('ROOM-MAFIA-SECRET');
for (const t of town) {
const all = JSON.stringify(conns[t]!.msgs);
expect(all).not.toContain('ROOM-MAFIA-SECRET');
expect(all).not.toContain(`"role":"mafia"`);
}
// Rate limit (validate gets the server clock), and the system-only phase timer cannot be sent by clients.
room.act(mafia, 2, { type: 'chat', channel: 'mafia', text: '또' });
expect(conns[mafia]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 2, reason: '조금 천천히 보내 주세요.' });
room.act(town[0]!, 3, { type: 'phaseTimeout' });
expect(conns[town[0]!]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 3, reason: '잘못된 요청이에요.' });
expect(state().phase.kind).toBe('night');
clock.advance(30_000);
room.onTimer();
expect(state().phase.kind).toBe('discussion');
expect(state().day).toBe(2);
});
});

View File

@@ -0,0 +1,246 @@
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
import { ulid } from 'ulid';
import type { AnyGameDefinition } from '@bg/engine';
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';
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;
export interface ManagerDeps {
store: RoomStore;
games: Record<string, AnyGameDefinition>;
users: { publicUser(id: string): PublicUser | null };
now?: () => number;
log?: RoomDeps['log'];
random?: () => number;
settings?: SettingsStore;
botDelay?: RoomDeps['botDelay'];
}
export class RoomManager {
private byCode = new Map<string, Room>();
/** userId → room code where the user holds a seat. */
private seatIndex = new Map<string, Set<string>>();
private createdBy = new Map<string, Set<string>>();
private readonly now: () => number;
constructor(private deps: ManagerDeps) {
this.now = deps.now ?? Date.now;
}
private roomDeps(): RoomDeps {
return {
store: this.deps.store,
games: this.deps.games,
users: this.deps.users,
now: this.now,
log: this.deps.log,
settings: this.deps.settings,
botDelay: this.deps.botDelay,
onEmpty: (room, why) => {
// Everyone left a room with computer players on purpose: nobody can come back to it, close it now.
if (why === 'leave' && this.byCode.get(room.code) === room && room.hasBots() && !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);
if (set.size) this.seatIndex.set(userId, set);
else this.seatIndex.delete(userId);
},
};
}
get(code: string): Room | undefined {
return this.byCode.get(code);
}
all(): Room[] {
return [...this.byCode.values()];
}
private newCode(): string {
const rand = this.deps.random ?? Math.random;
for (let i = 0; i < 200; i++) {
const code = String(Math.floor(rand() * 1_000_000)).padStart(6, '0');
if (!isAllowedRoomCode(code) || this.byCode.has(code)) continue;
if (this.deps.store.codeInUse(code, this.now())) continue;
return code;
}
throw new Error('could not allocate room code');
}
/** Returns the room or a Korean error message. */
create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string {
const def = this.deps.games[gameId];
const eff = this.deps.settings?.game(gameId);
const site = this.deps.settings?.getSite();
if (!def || (eff && !eff.enabled)) return '없는 게임이에요.';
if (site?.maintenance) return site.announcement || '지금은 점검 중이라 새 방을 만들 수 없어요.';
const maxRooms = site?.maxRooms ?? MAX_ROOMS;
const perUser = site?.maxRoomsPerUser ?? MAX_ROOMS_PER_USER;
if (this.byCode.size >= maxRooms) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c));
if (mine.length >= perUser) return `방은 한 번에 ${perUser}개까지 만들 수 있어요.`;
const options = eff?.defaultOptions ?? def.defaultOptions;
const base = def.playersFor ? def.playersFor(options).max : def.maxPlayers;
const config: RoomConfig = {
gameId,
options,
maxPlayers: Math.min(base, eff?.maxPlayers ?? base),
visibility,
allowSpectators: true,
chatEnabled: site?.chatEnabledDefault ?? true,
chatFilter: site?.chatFilterDefault ?? true,
graceSec: site?.graceSec ?? 60,
};
const code = this.newCode();
const id = ulid(this.now());
const room = new Room(id, code, config, hostId, this.roomDeps());
this.deps.store.insertRoom({
id,
code,
hostId,
gameId,
status: 'lobby',
visibility,
config,
stateJson: JSON.stringify(room.snapshot()),
now: this.now(),
});
this.byCode.set(code, room);
this.createdBy.set(hostId, new Set([...mine, code]));
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) ?? []) {
const r = this.byCode.get(code);
if (r && (r.inGame(userId) || r.status === 'lobby' || r.status === 'finished')) return code;
}
return null;
}
/** When a user takes a seat in `code`, free lobby seats they hold elsewhere (one room at a time). */
releaseOtherLobbySeats(userId: string, code: string): void {
for (const other of [...(this.seatIndex.get(userId) ?? [])]) {
if (other === code) continue;
const r = this.byCode.get(other);
if (r && !r.inGame(userId)) r.leave(userId);
}
}
/** 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()
// 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,
host: this.deps.users.publicUser(r.hostId)?.nickname ?? '',
seated: r.seatedIds().length,
max: r.config.maxPlayers,
options: r.config.options,
}))
.filter((r) => r.seated < r.max)
.sort((a, b) => a.max - a.seated - (b.max - b.seated));
}
/** Quick play: fullest open public lobby, else a new public room. */
quickPlay(userId: string, gameId: string): Room | string {
const best = this.publicRooms(gameId)[0];
if (best) return this.byCode.get(best.code)!;
return this.create(userId, gameId, 'public');
}
restoreAll(): { restored: number; broken: number } {
let restored = 0;
let broken = 0;
for (const { row, seq, stateJson } of this.deps.store.openRooms()) {
try {
const snap = JSON.parse(stateJson) as RoomSnapshot;
const room = Room.restore(row.id, row.code, seq, snap, this.roomDeps());
this.byCode.set(row.code, room);
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, true);
restored++;
} catch (err) {
this.deps.log?.('error', 'room restore failed', { code: row.code, err: String(err) });
this.deps.store.markBroken(row.id);
broken++;
}
}
return { restored, broken };
}
/** Periodic cleanup (call every minute). */
sweep(): void {
const now = this.now();
for (const room of this.all()) {
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) ||
((room.status === 'paused' || room.status === 'broken') && idle > FINISHED_TTL);
if (closable) this.close(room);
}
}
closeByAdmin(code: string): boolean {
const room = this.byCode.get(code);
if (!room) return false;
room.closeByAdmin();
this.close(room);
return true;
}
close(room: Room): void {
room.shutdown();
this.deps.store.close(room.id, this.now());
this.byCode.delete(room.code);
for (const u of room.seatedIds()) this.roomDeps().onSeatChange!(room, u, false);
}
shutdown(): void {
for (const r of this.all()) r.shutdown();
}
}

View 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);
});
});

View 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;
}
}

View File

@@ -0,0 +1,228 @@
/** Room-level tests with a controllable clock: timers, grace period, restore, error isolation. */
import { describe, expect, test } from 'bun:test';
import { z } from 'zod';
import type { AnyGameDefinition } from '@bg/engine';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig, type RoomSnapshot } from './room';
import { RoomStore } from './store';
function harness(games: Record<string, AnyGameDefinition> = GAMES) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const deps = {
store,
games,
users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) },
now: clock.now,
};
const config: RoomConfig = {
gameId: Object.keys(games)[0]!,
options: Object.values(games)[0]!.defaultOptions,
maxPlayers: 2,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, 'A', deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: config.gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now: now });
const conn = (userId: string) => {
const msgs: ServerMessage[] = [];
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
return c;
};
return { room, store, clock, deps, conn, db };
}
describe('Room timers', () => {
test('8. turn timeout applies onTimeout (omok perMove autoMove)', () => {
const { room, clock, conn } = harness();
const a = conn('A');
const b = conn('B');
room.join(a, 'player');
room.join(b, 'player');
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
expect(room.start('A')).toBeNull();
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
clock.advance(9_999);
room.onTimer();
room.clearTimer();
expect((room.game!.state as any).moves).toHaveLength(0);
clock.advance(1);
room.onTimer();
room.clearTimer();
expect((room.game!.state as any).moves).toHaveLength(1);
expect((room.game!.state as any).moves[0].auto).toBe(true);
});
test('disconnected player is auto-acted after the grace period, not before the turn deadline rules', () => {
const { room, clock, conn } = harness();
const a = conn('A');
const b = conn('B');
room.join(a, 'player');
room.join(b, 'player');
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
room.start('A');
room.clearTimer();
// No time control: idle limit 300s. Black disconnects → grace 60s wins.
room.disconnect(a);
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true);
// Reconnect before the grace period ends → deadline goes back to the idle limit.
clock.advance(30_000);
room.join(conn('A'), 'player');
room.clearTimer();
expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000);
});
test('restore postpones overdue deadlines by 30s and keeps game state', () => {
const { room, clock, conn, deps, store } = harness();
room.join(conn('A'), 'player');
room.join(conn('B'), 'player');
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
room.start('A');
room.clearTimer();
room.act('A', 1, { type: 'place', x: 7, y: 7 });
room.clearTimer();
const { seq, stateJson } = store.openRooms()[0]!;
clock.advance(600_000); // server was down for 10 minutes
const restored = Room.restore('room1', '482913', seq, JSON.parse(stateJson) as RoomSnapshot, deps);
restored.clearTimer();
expect((restored.game!.state as any).moves).toHaveLength(1);
const d = restored.effectiveDeadline()!;
expect(d).toBeGreaterThanOrEqual(clock.now() + 30_000);
expect(d).toBeLessThan(clock.now() + 60_000);
expect(restored.roomView().seats.every((s) => s && !s.connected)).toBe(true);
});
});
describe('Room isolation and lobby', () => {
const boom: AnyGameDefinition = {
id: 'boom',
nameKo: '폭탄',
minPlayers: 2,
maxPlayers: 2,
stateVersion: 1,
defaultOptions: {},
optionsSchema: z.object({}),
actionSchema: z.object({ type: z.literal('go') }),
setup: ({ players }) => ({ players, n: 0 }),
validate: () => ({ ok: true }),
apply: () => {
throw new Error('bug');
},
view: (s) => s,
activePlayers: (s: any) => s.players,
onTimeout: () => ({ type: 'go' }),
result: () => null,
};
test('a throwing game rejects the action, keeps state, pauses after 3 errors', () => {
const { room, conn } = harness({ boom });
const a = conn('A');
room.join(a, 'player');
room.join(conn('B'), 'player');
room.start('A');
for (let i = 1; i <= 3; i++) room.act('A', i, { type: 'go' });
expect(a.msgs.filter((m) => m.t === 'reject')).toHaveLength(3);
expect(room.status).toBe('paused');
expect((room.game!.state as any).n).toBe(0);
expect(room.voidGame('A')).toBeNull();
expect(room.status).toBe('finished');
});
test('seat rules: start needs min players, only host starts, kicked users cannot rejoin', () => {
const { room, conn } = harness();
room.join(conn('A'), 'player');
expect(room.start('A')).toBe('1명 더 필요해요.');
room.join(conn('B'), 'player');
expect(room.start('B')).toBe('방장만 시작할 수 있어요.');
room.join(conn('C'), 'player');
expect(room.spectators.has('C')).toBe(true);
expect(room.kick('A', 'C')).toBeNull();
expect(room.join(conn('C'), 'player')).toBe('방장이 내보낸 방에는 다시 들어갈 수 없어요.');
});
test('rematch starts when all seated players vote; colours alternate with gameNo', () => {
const { room, conn } = harness();
room.join(conn('A'), 'player');
room.join(conn('B'), 'player');
room.configure('A', { options: { colorAssignment: 'alternate' } });
room.start('A');
room.clearTimer();
expect((room.game!.state as any).players.black).toBe('A');
room.act('B', 1, { type: 'resign' });
expect(room.status).toBe('finished');
expect(room.sessionStats).toEqual({ A: { played: 1, wins: 1 }, B: { played: 1, wins: 0 } });
room.rematch('A');
expect(room.status).toBe('finished');
room.rematch('B');
room.clearTimer();
expect(room.status).toBe('playing');
expect(room.gameNo).toBe(2);
expect((room.game!.state as any).players.black).toBe('B');
});
test('leaving mid-game resigns via onLeave', () => {
const { room, conn } = harness();
room.join(conn('A'), 'player');
room.join(conn('B'), 'player');
room.start('A');
room.clearTimer();
room.leave('B');
expect(room.status).toBe('finished');
expect(room.lastResult?.ranking).toEqual([['A'], ['B']]);
expect(room.seatOf('B')).toBe(-1);
});
});
describe('윷놀이 room rules', () => {
const yutGames = () => ({ yut: GAMES.yut! });
test('team mode follows the game range to 8 seats and refuses odd counts via playerCountError', () => {
const { room, conn } = harness(yutGames());
room.join(conn('A'), 'player');
expect(room.configure('A', { maxPlayers: 4 })).toBeNull();
expect(room.configure('A', { options: { ...GAMES.yut!.defaultOptions, mode: 'team' } })).toBeNull();
expect(room.seats).toHaveLength(8);
for (const u of ['B', 'C', 'D', 'E']) room.join(conn(u), 'player');
expect(room.start('A')).toBe('팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.');
room.join(conn('F'), 'player');
expect(room.start('A')).toBeNull();
room.clearTimer();
expect((room.game!.state as any).sides.map((s: any) => s.members)).toEqual([
['A', 'C', 'E'],
['B', 'D', 'F'],
]);
});
test('a disconnected player is auto-played after the grace period; reconnecting gets the current view', () => {
const { room, clock, conn } = harness(yutGames());
const conns = { A: conn('A'), B: conn('B') };
room.join(conns.A, 'player');
room.join(conns.B, 'player');
room.configure('A', { options: { ...GAMES.yut!.defaultOptions, turnSeconds: null } });
expect(room.start('A')).toBeNull();
room.clearTimer();
const cur = (room.game!.state as any).current as 'A' | 'B';
room.disconnect(conns[cur]);
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
clock.advance(60_000);
room.onTimer();
room.clearTimer();
expect((room.game!.state as any).turnSeq).toBe(1);
const back = conn(cur);
room.join(back, 'player');
room.clearTimer();
const st = back.msgs.find((m) => m.t === 'state') as any;
expect(st.view.turnSeq).toBe((room.game!.state as any).turnSeq);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
/** Room persistence (docs/04 §2, docs/08 §3). All writes are synchronous SQLite transactions. */
import type { Database } from 'bun:sqlite';
export interface RoomRow {
id: string;
code: string;
host_id: string;
game_id: string;
status: string;
visibility: string;
config_json: string;
created_at: number;
updated_at: number;
}
export interface LogEntry {
seq: number;
gameNo: number;
actorId: string | null;
kind: string;
data: unknown;
at: number;
}
export class RoomStore {
constructor(private db: Database) {}
codeInUse(code: string, now = Date.now()): boolean {
// Open rooms, plus rooms closed within the last 24h (docs/06 §3).
const row = this.db
.query<{ n: number }, [string, number]>(
"SELECT COUNT(*) AS n FROM rooms WHERE code = ? AND (status <> 'closed' OR closed_at > ?)",
)
.get(code, now - 24 * 3600_000);
return (row?.n ?? 0) > 0;
}
insertRoom(r: { id: string; code: string; hostId: string; gameId: string; status: string; visibility: string; config: unknown; stateJson: string; now: number }): void {
this.db.transaction(() => {
this.db
.query('INSERT INTO rooms (id, code, host_id, game_id, status, visibility, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(r.id, r.code, r.hostId, r.gameId, r.status, r.visibility, JSON.stringify(r.config), r.now, r.now);
this.db.query('INSERT INTO room_state (room_id, seq, state_json, updated_at) VALUES (?, 0, ?, ?)').run(r.id, r.stateJson, r.now);
})();
}
/** Writes the log entries (optional), latest room snapshot and room metadata in one transaction. */
save(
roomId: string,
seq: number,
stateJson: string,
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
log: LogEntry | LogEntry[] | null,
now = Date.now(),
): void {
this.db.transaction(() => {
for (const e of log === null ? [] : Array.isArray(log) ? log : [log]) {
this.db
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(roomId, e.seq, e.gameNo, e.actorId, e.kind, JSON.stringify(e.data ?? null), e.at);
}
this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
this.db
.query('UPDATE rooms SET host_id = ?, game_id = ?, status = ?, visibility = ?, config_json = ?, updated_at = ? WHERE id = ?')
.run(meta.hostId, meta.gameId, meta.status, meta.visibility, JSON.stringify(meta.config), now, roomId);
})();
}
close(roomId: string, now = Date.now()): void {
this.db.transaction(() => {
this.db.query("UPDATE rooms SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?").run(now, now, roomId);
this.db.query('DELETE FROM room_state WHERE room_id = ?').run(roomId);
})();
}
markBroken(roomId: string, now = Date.now()): void {
this.db.query("UPDATE rooms SET status = 'broken', updated_at = ? WHERE id = ?").run(now, roomId);
}
openRooms(): { row: RoomRow; seq: number; stateJson: string }[] {
return this.db
.query<RoomRow & { seq: number; state_json: string }, []>(
"SELECT r.*, s.seq, s.state_json FROM rooms r JOIN room_state s ON s.room_id = r.id WHERE r.status IN ('lobby','playing','finished','paused')",
)
.all()
.map((r) => ({ row: r, seq: r.seq, stateJson: r.state_json }));
}
insertGame(g: { id: string; roomId: string; gameNo: number; gameId: string; options: unknown; seedHash: string; startedAt: number }): void {
this.db
.query('INSERT INTO games (id, room_id, game_no, game_id, options_json, seed_hash, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(g.id, g.roomId, g.gameNo, g.gameId, JSON.stringify(g.options), g.seedHash, g.startedAt);
}
finishGame(g: {
id: string;
gameId: string;
seed: string;
endedAt: number;
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(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) {
this.db
.query('INSERT OR REPLACE INTO game_players (game_pk, user_id, seat, rank, score) VALUES (?, ?, ?, ?, ?)')
.run(g.id, p.userId, p.seat, p.rank, p.score);
if (!counted) continue;
const draw = p.rank === 1 && firstCount === g.players.length && g.players.length > 1;
const win = p.rank === 1 && !draw;
this.db
.query(
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, 1, ?, ?)
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + 1, wins = wins + excluded.wins, draws = draws + excluded.draws`,
)
.run(p.userId, g.gameId, win ? 1 : 0, draw ? 1 : 0);
}
})();
}
log(roomId: string): LogEntry[] {
return this.db
.query<{ seq: number; game_no: number; actor_id: string | null; kind: string; data_json: string; at: number }, [string]>(
'SELECT seq, game_no, actor_id, kind, data_json, at FROM room_log WHERE room_id = ? ORDER BY seq',
)
.all(roomId)
.map((r) => ({ seq: r.seq, gameNo: r.game_no, actorId: r.actor_id, kind: r.kind, data: JSON.parse(r.data_json), at: r.at }));
}
purgeOldLogs(now = Date.now()): void {
this.db.query('DELETE FROM room_log WHERE at < ?').run(now - 90 * 24 * 3600_000);
}
}

View File

@@ -0,0 +1,302 @@
/** Integration scenarios from docs/10-testing.md §3. */
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 {}
}
});
function start(dbPath?: string): Booted {
const s = boot(dbPath);
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 createRoom(s: Booted, c: Client, gameId = 'omok'): Promise<string> {
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId }) });
expect(r.res.status).toBe(200);
return r.body.code;
}
async function join(c: Client, code: string, as: 'player' | 'spectator' = 'player') {
c.send({ t: 'join', code, as });
return c.next('room');
}
/** A hosts (black), B joins; game started. */
async function twoPlayerGame(s: Booted) {
const a = await client(s, '호랑이');
const b = await client(s, '고양이');
const code = await createRoom(s, a);
await join(a, code);
await join(b, code);
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
const sa = a.next('state');
const sb = b.next('state');
a.send({ t: 'start' });
await Promise.all([sa, sb]);
return { a, b, code };
}
const place = (c: Client, x: number, y: number) => c.act({ type: 'place', x, y });
describe('M1 integration scenarios', () => {
test('1. create room, join by code, auto-seat, play omok to the end, result stored', async () => {
const s = start();
const { a, b, code } = await twoPlayerGame(s);
expect(code).toMatch(/^\d{6}$/);
const room = a.lastOf('room')!.room;
expect(room.seats.filter(Boolean).map((x) => x!.user.nickname)).toEqual(['호랑이', '고양이']);
// Black (A) plays a horizontal five, White (B) answers elsewhere.
for (let i = 0; i < 4; i++) {
let p = b.next('state', (m) => (m.view as any).turn === 'white');
place(a, 3 + i, 7);
await p;
p = a.next('state', (m) => (m.view as any).turn === 'black');
place(b, 3 + i, 0);
await p;
}
const res = a.next('result');
place(a, 7, 7);
const r = await res;
expect(r.result.ranking).toEqual([[a.id], [b.id]]);
const stats = await api(s.base, '/api/me/stats', { cookie: a.cookie });
expect(stats.body.stats).toEqual([{ gameId: 'omok', played: 1, wins: 1, draws: 0 }]);
const room2 = (await a.next('room', (m) => m.room.status === 'finished').catch(() => a.lastOf('room')!)).room;
expect(room2.lastSeed).toMatch(/^[0-9a-f]{32}$/);
expect(room2.sessionStats[a.id]).toEqual({ played: 1, wins: 1 });
});
test('2. joining via link preview without login, then guest + auto seat', async () => {
const s = start();
const a = await client(s, '방장님');
const code = await createRoom(s, a);
await join(a, code);
const preview = await api(s.base, `/api/rooms/${code}`);
expect(preview.body).toMatchObject({ code, gameId: 'omok', host: '방장님', seated: 1, maxPlayers: 2 });
const b = await client(s, '손님');
const r = await join(b, code);
expect(r.room.seats[1]?.user.id).toBe(b.id);
// A third person becomes a spectator.
const c = await client(s, '구경꾼');
const r3 = await join(c, code);
expect(r3.room.spectators.map((u) => u.id)).toContain(c.id);
});
test('3. disconnect and reconnect keeps seat and state', async () => {
const s = start();
const { a, b, code } = await twoPlayerGame(s);
const p = b.next('state', (m) => (m.view as any).moveCount === 1);
place(a, 7, 7);
await p;
const notice = a.next('notice', (m) => m.code === 'disconnected');
b.close();
await notice;
const roomMsg = await a.next('room', (m) => m.room.seats[1]?.connected === false).catch(() => a.lastOf('room')!);
expect(roomMsg.room.seats[1]?.connected).toBe(false);
const b2 = await client(s, '', { cookie: b.cookie, id: b.id });
expect(b2.lastOf('welcome')!.activeRoom).toBe(code);
b2.send({ t: 'join', code, as: 'player' });
const st = await b2.next('state');
expect((st.view as any).board[7 * 15 + 7]).toBe(1);
expect((st.view as any).turn).toBe('white');
const after = b2.lastOf('room') ?? (await b2.next('room'));
expect(after.room.seats[1]?.user.id).toBe(b.id);
});
test('4. server crash (no graceful stop) → restart restores the game', async () => {
const s1 = boot();
const { a, b, code } = await twoPlayerGame(s1);
let p = b.next('state', (m) => (m.view as any).moveCount === 1);
place(a, 7, 7);
await p;
p = a.next('state', (m) => (m.view as any).moveCount === 2);
place(b, 0, 0);
await p;
// Simulate a crash: drop the listener and timers without the graceful bye/cleanup.
for (const r of s1.rooms.all()) r.clearTimer();
await Promise.race([s1.server.stop(true), sleep(300)]);
// Closing the sockets re-arms grace timers; a crashed process has none, and they must not touch the closed DB later.
for (const r of s1.rooms.all()) r.clearTimer();
s1.db.close();
const s2 = start(s1.dbPath);
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
const b2 = await client(s2, '', { cookie: b.cookie, id: b.id });
expect(a2.lastOf('welcome')!.activeRoom).toBe(code);
a2.send({ t: 'join', code, as: 'player' });
b2.send({ t: 'join', code, as: 'player' });
const st = await a2.next('state');
const v = st.view as any;
expect(v.moveCount).toBe(2);
expect(v.turn).toBe('black');
const p2 = b2.next('state', (m) => (m.view as any).moveCount === 3);
place(a2, 8, 8);
await p2;
});
test('5. acting out of turn is rejected without state change', async () => {
const s = start();
const { a, b } = await twoPlayerGame(s);
const before = b.lastOf('state')!.seq;
const rej = b.next('reject');
const cs = place(b, 1, 1);
const r = await rej;
expect(r.cs).toBe(cs);
expect(r.reason).toBe('지금은 내 차례가 아니에요.');
await sleep(50);
expect(a.lastOf('state')!.seq).toBe(before);
});
test('6. duplicate cs is processed once', async () => {
const s = start();
const { a, b } = await twoPlayerGame(s);
const cs = place(a, 7, 7);
a.send({ t: 'act', cs, a: { type: 'place', x: 8, y: 8 } });
await b.next('state', (m) => (m.view as any).moveCount >= 1);
await sleep(100);
const states = b.messages.filter((m) => m.t === 'state');
expect((states[states.length - 1]!.view as any).moveCount).toBe(1);
const mine = a.messages.filter((m) => m.t === 'state' && m.cs === cs);
expect(mine).toHaveLength(1);
});
test('7. second tab replaces the first', async () => {
const s = start();
const a = await client(s, '탭주인');
const code = await createRoom(s, a);
await join(a, code);
const a2 = await client(s, '', { cookie: a.cookie, id: a.id });
const bye = a.next('bye');
await join(a2, code);
expect((await bye).reason).toBe('replaced');
});
test('9. host leaves → host transfers', async () => {
const s = start();
const a = await client(s, '첫방장');
const b = await client(s, '다음방장');
const code = await createRoom(s, a);
await join(a, code);
await join(b, code);
const r = b.next('room', (m) => m.room.hostId === b.id);
a.send({ t: 'leave' });
expect((await r).room.seats.filter(Boolean)).toHaveLength(1);
});
test('10. flooding is rate limited and the server stays healthy', async () => {
const s = start();
const a = await client(s, '도배왕');
for (let i = 0; i < 100; i++) a.send({ t: 'ping', ts: i });
await sleep(200);
const pongs = a.messages.filter((m) => m.t === 'pong').length;
expect(pongs).toBeLessThanOrEqual(45);
expect(pongs).toBeGreaterThan(0);
const h = await api(s.base, '/healthz');
expect(h.res.status).toBe(200);
});
test('11. malformed / oversized / schema-invalid messages', async () => {
const s = start();
const a = await client(s, '이상한값');
a.ws.send('{not json');
await a.next('error', (m) => m.code === 'bad-json');
a.send({ t: 'act', cs: 'x' });
await a.next('error', (m) => m.code === 'bad-message');
a.ws.send('x'.repeat(20 * 1024));
for (let i = 0; i < 20 && !a.closed; i++) await sleep(25);
expect(a.closed?.code).toBe(1009);
const h = await api(s.base, '/healthz');
expect(h.res.status).toBe(200);
});
test('12a. discord login is hidden when not configured', async () => {
const s = boot(undefined, {
discordExchange: async () => ({ id: '123', username: 'disco', global_name: '디코친구', avatar: null }),
});
running.push(s);
// Discord disabled in this config → 404.
const off = await api(s.base, '/api/auth/discord/start');
expect(off.res.status).toBe(404);
});
test('origin check: foreign origin cannot open WS or POST', async () => {
const s = start();
const g = await guest(s.base, '보안확인');
const res = await fetch(s.base + '/ws', { headers: { origin: 'https://evil.example', cookie: g.cookie, upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13' } });
expect(res.status).toBe(403);
const post = await fetch(s.base + '/api/rooms', { method: 'POST', headers: { origin: 'https://evil.example', cookie: g.cookie, 'content-type': 'application/json' }, body: '{"gameId":"omok"}' });
expect(post.status).toBe(403);
});
test('nickname rules and change', async () => {
const s = start();
const bad = await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: 'a' }) });
expect(bad.res.status).toBe(400);
const g = await guest(s.base, ' 처음 이름 ');
const me = await api(s.base, '/api/me', { cookie: g.cookie });
expect(me.body.me.nickname).toBe('처음 이름');
const ch = await api(s.base, '/api/me', { method: 'PATCH', cookie: g.cookie, body: JSON.stringify({ nickname: '바뀐이름' }) });
expect(ch.body.me.nickname).toBe('바뀐이름');
});
test('chat is filtered and spectators receive public view only', async () => {
const s = start();
const { a, code } = await twoPlayerGame(s);
const c = await client(s, '관전자');
c.send({ t: 'join', code, as: 'spectator' });
const st = await c.next('state');
expect((st.view as any).myColor).toBeNull();
const chat = c.next('chat');
a.send({ t: 'chat', text: '안녕 씨발' });
expect((await chat).text).toBe('안녕 **');
});
});
describe('Discord login flow', () => {
test('state mismatch rejected, valid flow merges guest into new member', async () => {
const s = boot(undefined, {
discordExchange: async () => ({ id: '42', username: 'disco', global_name: '디코친구', avatar: 'abc' }),
});
running.push(s);
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
const g = await guest(s.base, '게스트였음');
const startRes = await api(s.base, '/api/auth/discord/start?next=/r/123456', { cookie: g.cookie });
expect(startRes.res.status).toBe(302);
const loc = new URL(startRes.res.headers.get('location')!);
expect(loc.host).toBe('discord.com');
const state = loc.searchParams.get('state')!;
const oauthCookie = startRes.setCookie!.split(';')[0]!;
const bad = await api(s.base, `/api/auth/discord/callback?code=x&state=wrong`, { cookie: `${g.cookie}; ${oauthCookie}` });
expect(bad.res.headers.get('location')).toBe('/?login=failed');
const good = await api(s.base, `/api/auth/discord/callback?code=x&state=${state}`, { cookie: `${g.cookie}; ${oauthCookie}` });
expect(good.res.headers.get('location')).toBe('/r/123456?linked=1');
const sid = good.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
const me = await api(s.base, '/api/me', { cookie: sid });
expect(me.body.me).toMatchObject({ id: g.id, nickname: '게스트였음', kind: 'member' });
expect(me.body.me.avatar).toBe('https://cdn.discordapp.com/avatars/42/abc.png?size=128');
// Old guest session no longer valid.
const old = await api(s.base, '/api/me', { cookie: g.cookie });
expect(old.body.me).toBeNull();
});
});

183
apps/server/src/server.ts Normal file
View File

@@ -0,0 +1,183 @@
/** Wires DB, auth, rooms, HTTP and WebSocket into one Bun server (docs/02 §2). */
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';
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) => {
const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
if (level === 'error') console.error(line);
else console.log(line);
};
export interface StartOptions {
config: Config;
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) {
const { config } = opts;
const log = opts.log ?? jsonLog;
const db = openDb(config.dbPath);
const users = new Users(db);
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;
};
const settings = new SettingsStore(db, GAMES);
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, botDelay: opts.botDelay });
const restored = rooms.restoreAll();
log('info', 'rooms restored', restored);
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 });
};
/** 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)) {
// Still on the site (socket open) or still holding a seat: not idle.
if (gateway.isOnline(id) || rooms.activeRoomFor(id)) continue;
removeGuest(id);
}
};
let ready = true;
const ipOf = (req: Request) =>
(config.trustProxy ? req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() : undefined) ||
server?.requestIP(req)?.address ||
'unknown';
const app = createHttpApp({
config,
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),
stats: () => {
const all = rooms.all();
const day = Date.now() - 24 * 3600_000;
return {
connections: gateway.connectionCount,
rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length },
games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day),
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
memoryMb: Math.round(process.memoryUsage().rss / 1048576),
};
},
},
});
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
const serveStatic = staticDir && existsSync(staticDir);
const server: Server<WsData> = Bun.serve<WsData>({
port: config.port,
async fetch(req, srv) {
const url = new URL(req.url);
if (url.pathname === '/ws') {
const origin = req.headers.get('origin');
if (!origin || !config.allowedOrigins.includes(origin)) return new Response('forbidden origin', { status: 403 });
const cookie = req.headers.get('cookie') ?? '';
const token = cookie
.split(';')
.map((s) => s.trim())
.find((s) => s.startsWith(`${SESSION_COOKIE}=`))
?.slice(SESSION_COOKIE.length + 1);
const userId = token ? sessions.resolve(decodeURIComponent(token)) : null;
if (!userId) return new Response('unauthorized', { status: 401 });
return gateway.upgrade(req, srv, userId);
}
if (url.pathname.startsWith('/api/') || url.pathname === '/healthz') return app.fetch(req);
if (url.pathname.startsWith('/internal/')) return new Response('not found', { status: 404 });
if (serveStatic) return serveFile(staticDir!, url.pathname);
return new Response('not found', { status: 404 });
},
websocket: {
...gateway.handlers,
idleTimeout: 40,
sendPings: true,
perMessageDeflate: true,
maxPayloadLength: 64 * 1024,
},
});
const sweeper = setInterval(() => {
try {
rooms.sweep();
sessions.purgeExpired();
purgeIdleGuests();
store.purgeOldLogs();
} catch (err) {
log('error', 'sweep failed', { err: String(err) });
}
}, 60_000);
const stop = async () => {
ready = false;
clearInterval(sweeper);
gateway.shutdown();
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(opts.stopWaitMs ?? 1000)]);
db.close();
};
return { server, stop, rooms, users, sessions, db, gateway, settings, roles, purgeIdleGuests };
}
async function serveFile(root: string, pathname: string): Promise<Response> {
const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
const candidate = join(root, safe);
if (candidate.startsWith(root) && !candidate.endsWith('/')) {
const f = Bun.file(candidate);
if (await f.exists()) {
const immutable = safe.startsWith('/assets/');
return new Response(f, { headers: { 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' } });
}
}
// SPA fallback.
return new Response(Bun.file(join(root, 'index.html')), { headers: { 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8' } });
}

View File

@@ -0,0 +1,128 @@
/** Helpers for integration tests: boot a real server, act as browser clients. */
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ServerMessage } from '@bg/shared';
import { loadConfig } from './config';
import { startServer, type StartOptions } from './server';
export const ORIGIN = 'http://localhost:5173';
export function tempDbPath(): string {
const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-'));
return join(dir, 'app.db');
}
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, stopWaitMs: 50, ...extra });
const base = `http://localhost:${app.server.port}`;
return { ...app, base, dbPath, config };
}
export async function api(base: string, path: string, init: RequestInit & { cookie?: string } = {}) {
const headers = new Headers(init.headers);
headers.set('origin', ORIGIN);
if (init.cookie) headers.set('cookie', init.cookie);
if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
const res = await fetch(base + path, { ...init, headers, redirect: 'manual' });
const setCookie = res.headers.get('set-cookie');
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = await res.text();
}
return { res, body, setCookie };
}
export async function guest(base: string, nickname: string): Promise<{ cookie: string; id: string }> {
const r = await api(base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname }) });
if (r.res.status !== 200) throw new Error(`guest failed: ${JSON.stringify(r.body)}`);
const cookie = r.setCookie!.split(';')[0]!;
return { cookie, id: r.body.me.id };
}
export class Client {
ws!: WebSocket;
messages: ServerMessage[] = [];
private waiters: { pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void }[] = [];
closed: { code: number; reason: string } | null = null;
cs = 0;
constructor(
private base: string,
public cookie: string,
public id: string,
) {}
async connect(): Promise<this> {
this.closed = null;
const url = this.base.replace('http', 'ws') + '/ws';
// Bun's WebSocket client accepts custom headers.
this.ws = new WebSocket(url, { headers: { cookie: this.cookie, origin: ORIGIN } } as unknown as string[]);
this.ws.onmessage = (e) => {
const m = JSON.parse(String(e.data)) as ServerMessage;
this.messages.push(m);
for (const w of [...this.waiters]) {
if (w.pred(m)) {
this.waiters.splice(this.waiters.indexOf(w), 1);
w.resolve(m);
}
}
};
this.ws.onclose = (e) => {
this.closed = { code: e.code, reason: e.reason };
};
await this.waitFor((m) => m.t === 'welcome');
return this;
}
send(m: unknown): void {
this.ws.send(JSON.stringify(m));
}
/** Resolves with the first message (already received after `since`, or future) matching pred. */
waitFor<T extends ServerMessage['t']>(
pred: (m: ServerMessage) => boolean,
timeout = 3000,
since = 0,
): Promise<Extract<ServerMessage, { t: T }>> {
const existing = this.messages.slice(since).find(pred);
if (existing) return Promise.resolve(existing as any);
return new Promise((resolve, reject) => {
const w = { pred, resolve: resolve as (m: ServerMessage) => void };
this.waiters.push(w);
setTimeout(() => {
const i = this.waiters.indexOf(w);
if (i >= 0) {
this.waiters.splice(i, 1);
reject(new Error(`timeout waiting; last: ${JSON.stringify(this.messages.slice(-3))}`));
}
}, timeout);
});
}
/** Waits for the next message of type t received after now. */
next<T extends ServerMessage['t']>(t: T, pred: (m: Extract<ServerMessage, { t: T }>) => boolean = () => true, timeout = 3000) {
const since = this.messages.length;
return this.waitFor<T>((m) => m.t === t && pred(m as any), timeout, since);
}
lastOf<T extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) if (this.messages[i]!.t === t) return this.messages[i] as any;
return undefined;
}
act(a: unknown): number {
const cs = ++this.cs;
this.send({ t: 'act', cs, a });
return cs;
}
close(): void {
this.ws.close();
}
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

View File

@@ -0,0 +1,242 @@
/** WebSocket gateway: auth on upgrade, validation, rate limits, dispatch (docs/03). */
import type { Server, ServerWebSocket } from 'bun';
import { ClientMessage, MAX_CLIENT_MESSAGE_BYTES, PROTOCOL, type PublicUser, type ServerMessage } from '@bg/shared';
import type { RoomManager } from '../rooms/manager';
import type { Conn, Room } from '../rooms/room';
import { TokenBucket } from './rate-limit';
export interface WsData {
userId: string;
connId: number;
bucket: TokenBucket;
strikes: number;
room: string | null;
conn: Conn | null;
}
export interface GatewayDeps {
rooms: RoomManager;
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;
const MAX_BUFFERED = 1024 * 1024;
export class Gateway {
private nextId = 1;
private byUser = new Map<string, Set<ServerWebSocket<WsData>>>();
private closing = false;
constructor(private deps: GatewayDeps) {}
get connectionCount(): number {
let n = 0;
for (const s of this.byUser.values()) n += s.size;
return n;
}
/** Called from fetch() after the session was resolved. Returns a Response on refusal. */
upgrade(req: Request, server: Server<WsData>, userId: string): Response | undefined {
if (this.closing) return new Response('restarting', { status: 503 });
if ((this.byUser.get(userId)?.size ?? 0) >= MAX_CONNS_PER_USER) return new Response('too many connections', { status: 429 });
const data: WsData = { userId, connId: this.nextId++, bucket: new TokenBucket(20, 40), strikes: 0, room: null, conn: null };
if (server.upgrade(req, { data })) return undefined;
return new Response('upgrade failed', { status: 400 });
}
private send(ws: ServerWebSocket<WsData>, msg: ServerMessage): void {
if (ws.getBufferedAmount() > MAX_BUFFERED) {
ws.close(1013, 'slow consumer');
return;
}
ws.send(JSON.stringify(msg));
}
private connFor(ws: ServerWebSocket<WsData>): Conn {
ws.data.conn ??= {
userId: ws.data.userId,
send: (m) => this.send(ws, m),
close: (code, reason) => ws.close(code, reason),
};
return ws.data.conn;
}
readonly handlers = {
open: (ws: ServerWebSocket<WsData>) => {
const set = this.byUser.get(ws.data.userId) ?? new Set();
set.add(ws);
this.byUser.set(ws.data.userId, set);
const me = this.deps.me(ws.data.userId);
if (!me) {
ws.close(4401, 'unauthorized');
return;
}
this.send(ws, {
t: 'welcome',
me,
serverTime: (this.deps.now ?? Date.now)(),
protocol: PROTOCOL,
activeRoom: this.deps.rooms.activeRoomFor(ws.data.userId),
});
},
message: (ws: ServerWebSocket<WsData>, raw: string | Buffer) => {
const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.byteLength;
if (size > MAX_CLIENT_MESSAGE_BYTES) {
ws.close(1009, 'message too big');
return;
}
if (!ws.data.bucket.take()) {
if (++ws.data.strikes > 50) ws.close(4429, 'rate limited');
return;
}
let parsed: ClientMessage;
try {
const json = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
const r = ClientMessage.safeParse(json);
if (!r.success) {
this.send(ws, { t: 'error', code: 'bad-message', message: '잘못된 요청이에요.' });
return;
}
parsed = r.data;
} catch {
this.send(ws, { t: 'error', code: 'bad-json', message: '잘못된 요청이에요.' });
return;
}
try {
this.dispatch(ws, parsed);
} catch (err) {
this.deps.log?.('error', 'dispatch failed', { user: ws.data.userId, t: parsed.t, err: String(err), stack: (err as Error).stack });
this.send(ws, { t: 'error', code: 'internal', message: '알 수 없는 오류가 발생했어요.' });
}
},
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);
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);
},
};
private dispatch(ws: ServerWebSocket<WsData>, m: ClientMessage): void {
const u = ws.data.userId;
const err = (message: string, code = 'rejected') => this.send(ws, { t: 'error', code, message });
if (m.t === 'ping') {
this.send(ws, { t: 'pong', ts: m.ts, serverTime: (this.deps.now ?? Date.now)() });
return;
}
if (m.t === 'join') {
const room = this.deps.rooms.get(m.code);
if (!room) return err('방을 찾을 수 없어요. 코드를 다시 확인해 주세요.', 'room-not-found');
// Leaving a previous room on this socket.
if (ws.data.room && ws.data.room !== m.code) {
const prev = this.deps.rooms.get(ws.data.room);
if (prev && ws.data.conn) prev.disconnect(ws.data.conn);
}
if (m.as === 'player') this.deps.rooms.releaseOtherLobbySeats(u, m.code);
const e = room.join(this.connFor(ws), m.as);
if (e) return err(e, 'join-failed');
ws.data.room = m.code;
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) {
case 'leave':
room.leave(u);
ws.data.room = null;
return;
case 'seat':
e = room.seat(u, m.seat);
break;
case 'unseat':
e = room.unseat(u);
break;
case 'ready':
e = room.ready(u, m.ready);
break;
case 'config': {
const { t: _t, ...patch } = m;
e = room.configure(u, patch);
break;
}
case 'start':
e = room.start(u);
break;
case 'kick':
e = room.kick(u, m.userId);
break;
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;
case 'chat':
e = room.chat(u, m.text);
break;
case 'emote':
room.emote(u, m.id);
return;
case 'rematch':
e = room.rematch(u);
break;
case 'void':
e = room.voidGame(u);
break;
case 'sync':
room.sendSync(this.connFor(ws));
return;
}
if (e) err(e);
}
/** Drop every connection of a user (ban / forced logout). */
disconnectUser(userId: string): void {
for (const ws of [...(this.byUser.get(userId) ?? [])]) {
this.send(ws, { t: 'bye', reason: 'kicked' });
ws.close(4403, 'banned');
}
}
isOnline(userId: string): boolean {
return (this.byUser.get(userId)?.size ?? 0) > 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;
for (const set of this.byUser.values()) {
for (const ws of set) {
this.send(ws, { t: 'bye', reason: 'restart' });
ws.close(1012, 'restart');
}
}
}
}

View File

@@ -0,0 +1,45 @@
/** Token bucket (docs/03 §8). */
export class TokenBucket {
private tokens: number;
private last: number;
constructor(
private ratePerSec: number,
private burst: number,
now = Date.now(),
) {
this.tokens = burst;
this.last = now;
}
take(now = Date.now()): boolean {
this.tokens = Math.min(this.burst, this.tokens + ((now - this.last) / 1000) * this.ratePerSec);
this.last = now;
if (this.tokens < 1) return false;
this.tokens -= 1;
return true;
}
}
/** Fixed-window counter per key (HTTP endpoints). */
export class WindowLimiter {
private hits = new Map<string, { start: number; n: number }>();
constructor(
private limit: number,
private windowMs: number,
) {}
allow(key: string, now = Date.now()): boolean {
const h = this.hits.get(key);
if (!h || now - h.start >= this.windowMs) {
this.hits.set(key, { start: now, n: 1 });
if (this.hits.size > 50_000) this.prune(now);
return true;
}
h.n++;
return h.n <= this.limit;
}
private prune(now: number): void {
for (const [k, v] of this.hits) if (now - v.start >= this.windowMs) this.hits.delete(k);
}
}

13
apps/server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": [
"bun"
]
},
"include": [
"src"
]
}

25
apps/web/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!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" />
<meta name="description" content="광고 없이 가볍게, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임" />
<meta property="og:title" content="같이 놀자 보드게임" />
<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>
<div id="root"></div>
<noscript>이 사이트를 쓰려면 자바스크립트를 켜 주세요.</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
apps/web/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@bg/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@bg/shared": "workspace:*",
"pretendard": "1.3.9",
"qrcode-generator": "2.0.4",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0",
"zustand": "5.0.15",
"@bg/games": "workspace:*"
},
"devDependencies": {
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"vite": "8.3.2"
}
}

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View 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" }
]
}

View 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>

View File

@@ -0,0 +1,106 @@
/** Chat list + quick reactions + input. Inline on PC, inside a sheet on phones. */
import { useEffect, useRef, useState } from 'react';
import { CHAT_MAX, EMOTE_LABEL, EMOTES, type PublicUser, type RoomView } from '@bg/shared/lite';
import { create } from 'zustand';
import { socket, useRoom } from '../net/socket';
export const useChatSeen = create<{ seen: number }>(() => ({ seen: 0 }));
export function ChatPanel({ room, me, title = true }: { room: RoomView; me: PublicUser; title?: boolean }) {
const chat = useRoom((s) => s.chat);
const [text, setText] = useState('');
const [hidden, setHidden] = useState<Set<string>>(new Set());
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
useChatSeen.setState({ seen: chat.length });
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [chat.length]);
// Enter anywhere (outside inputs) focuses chat — PC shortcut (docs/07 §3.5).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.key === 'Enter' && !['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(t.tagName) && t.getAttribute('role') !== 'grid') {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<section className="chat-panel" aria-label="채팅">
{title && <h2 style={{ fontSize: '1.1rem' }}>채팅</h2>}
<div ref={listRef} className="chat-list" aria-live="polite">
{chat.length === 0 && <p className="muted small">아직 메시지가 없어요. Enter를 누르면 바로 입력할 수 있어요.</p>}
{chat
.filter((m) => !m.from || !hidden.has(m.from.id))
.map((m) => (
<div key={m.id} className="chat-line">
<strong>{m.from?.nickname ?? '알림'}</strong>
{m.text}
{m.from && m.from.id !== me.id && (
<button
className="muted small"
style={{ border: 0, background: 'none', cursor: 'pointer' }}
title="이 사람의 채팅 가리기"
onClick={() => setHidden(new Set([...hidden, m.from!.id]))}
>
가리기
</button>
)}
</div>
))}
</div>
<div className="emote-row">
{EMOTES.map((id) => (
<button key={id} type="button" onClick={() => socket.send({ t: 'emote', id })}>
{EMOTE_LABEL[id]}
</button>
))}
</div>
{room.chatEnabled ? (
<form
className="row"
style={{ flexWrap: 'nowrap' }}
onSubmit={(e) => {
e.preventDefault();
if (text.trim()) socket.send({ t: 'chat', text: text.trim().slice(0, CHAT_MAX) });
setText('');
}}
>
<input
ref={inputRef}
className="input"
style={{ flex: 1, minHeight: 44 }}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && (e.currentTarget as HTMLInputElement).blur()}
maxLength={CHAT_MAX}
placeholder="메시지 입력 (Enter)"
aria-label="메시지"
enterKeyHint="send"
/>
<button className="btn btn-primary btn-sm">보내기</button>
</form>
) : (
<p className="muted small">이 방은 채팅을 쓰지 않아요. 반응 버튼은 쓸 수 있어요.</p>
)}
</section>
);
}
/** Floating emote bubbles (both layouts). */
export function EmoteBubbles({ room }: { room: RoomView }) {
const emotes = useRoom((s) => s.emotes);
const nick = (id: string) => room.seats.find((s) => s?.user.id === id)?.user.nickname ?? room.spectators.find((u) => u.id === id)?.nickname ?? '';
return (
<div style={{ position: 'fixed', left: '50%', transform: 'translateX(-50%)', bottom: 24, zIndex: 35, display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center', pointerEvents: 'none' }}>
{emotes.map((e) => (
<div key={e.id} className="toast">
{nick(e.from)}: {EMOTE_LABEL[e.emote as keyof typeof EMOTE_LABEL] ?? ''}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,83 @@
/** 6-digit code entry: big boxes, numeric keypad, paste detection, auto-submit (docs/06 §3). */
import { useEffect, useRef, useState } from 'react';
import { normalizeRoomCode } from '@bg/shared/lite';
export function CodeInput({ onComplete, busy, autoFocus = true }: { onComplete: (code: string) => void; busy?: boolean; autoFocus?: boolean }) {
const [digits, setDigits] = useState('');
const [clip, setClip] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
// Offer a big paste button if the clipboard already holds a code or invite link.
if (!autoFocus) return; // reading the clipboard can prompt; only on the dedicated join page
navigator.clipboard
?.readText?.()
.then((t) => setClip(normalizeRoomCode(t)))
.catch(() => {});
}, [autoFocus]);
const set = (v: string) => {
const code = normalizeRoomCode(v);
const next = code ?? v.replace(/\D/g, '').slice(0, 6);
setDigits(next);
if (next.length === 6) onComplete(next);
};
return (
<div className="stack">
<label htmlFor="code-input" className="sr-only">
방 코드 6자리
</label>
<div style={{ position: 'relative' }} onClick={() => inputRef.current?.focus()}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 6 }} aria-hidden>
{[0, 1, 2, -1, 3, 4, 5].map((i, k) =>
i < 0 ? (
<span key={k} style={{ alignSelf: 'center', textAlign: 'center', fontSize: '1.4rem', color: 'var(--text-sub)' }}>
·
</span>
) : (
<span
key={k}
style={{
height: 64,
borderRadius: 12,
border: `2px solid ${i === digits.length ? 'var(--primary)' : 'var(--border)'}`,
background: 'var(--surface)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
}}
>
{digits[i] ?? ''}
</span>
),
)}
</div>
<input
id="code-input"
ref={inputRef}
value={digits}
onChange={(e) => set(e.target.value)}
onPaste={(e) => {
e.preventDefault();
set(e.clipboardData.getData('text'));
}}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={40}
disabled={busy}
style={{ position: 'absolute', inset: 0, opacity: 0, fontSize: 16 }}
/>
</div>
{clip && digits.length < 6 && (
<button className="btn btn-soft btn-block" onClick={() => set(clip)}>
📋 복사한 코드 {clip.slice(0, 3)} {clip.slice(3)} 붙여넣기
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Link } from 'react-router';
import type { CatalogEntry } from '@bg/shared/lite';
import { GAME_ICON } from '../lib/catalog-ui';
export function GameGrid({ games, columns = 'repeat(auto-fill, minmax(200px, 1fr))' }: { games: CatalogEntry[]; columns?: string }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: columns, gap: 16 }}>
{games.map((g) => (
<Link key={g.id} to={`/g/${g.id}`} className="card game-card" style={{ textDecoration: 'none', color: 'inherit', display: 'flex', flexDirection: 'column', gap: 8, padding: 20 }}>
<span style={{ fontSize: '3rem', lineHeight: 1 }} aria-hidden>
{GAME_ICON[g.id] ?? '🎲'}
</span>
<strong style={{ fontSize: '1.25rem' }}>{g.nameKo}</strong>
<span className="muted">{g.blurb}</span>
<span className="row small" style={{ gap: 6, marginTop: 'auto' }}>
<span className="badge">{g.minPlayers === g.maxPlayers ? `${g.minPlayers}명` : `${g.minPlayers}~${g.maxPlayers}명`}</span>
<span className="badge">{g.minutes}분</span>
<span className="badge">{g.level}</span>
</span>
</Link>
))}
</div>
);
}

View 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)}
</>
);
}

View File

@@ -0,0 +1,18 @@
import { catalogById } from '@bg/shared/lite';
import { useSession } from '../store/session';
/** Display name with admin overrides applied. */
export function gameName(id: string): string {
return useSession.getState().catalog.find((c) => c.id === id)?.nameKo ?? catalogById(id)?.nameKo ?? '게임';
}
/** Admin notice for a game (docs/14-admin.md §4). */
export function GameNotice({ gameId }: { gameId: string }) {
const notice = useSession((s) => s.catalog.find((c) => c.id === gameId)?.notice);
if (!notice) return null;
return (
<p className="small" style={{ background: 'var(--primary-soft)', borderRadius: 10, padding: '8px 12px', whiteSpace: 'pre-wrap' }}>
📢 {notice}
</p>
);
}

View 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>
);
}

View File

@@ -0,0 +1,71 @@
/** One-field start: nickname (pre-filled suggestion) + start button; Discord as secondary (docs/05 §1). */
import { useState } from 'react';
import { nicknameError, normalizeNickname, suggestNickname } from '@bg/shared/lite';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
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);
const discord = useSession((s) => s.discordEnabled);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const n = normalizeNickname(nick);
const err = nicknameError(n);
if (err) return setError(err);
setBusy(true);
try {
const r = await api.guest(n);
useSession.getState().setMe(r.me);
await useSession.getState().refresh();
onDone?.();
} catch (ex) {
setError((ex as Error).message);
} finally {
setBusy(false);
}
};
return (
<form className="stack" onSubmit={submit}>
<label className="field">
내 이름(닉네임)
<input
className="input"
value={nick}
onChange={(e) => {
setNick(e.target.value);
setError(null);
}}
onFocus={(e) => e.target.select()}
maxLength={20}
autoComplete="nickname"
enterKeyHint="go"
/>
</label>
{error && <p className="field-error">{error}</p>}
<button className="btn btn-primary btn-block" disabled={busy}>
{busy ? '잠시만요…' : submitLabel}
</button>
{discord && showDiscord && (
<a className="btn btn-secondary btn-block" href={discordLoginUrl(next)}>
디스코드로 로그인
</a>
)}
<p className="muted small">이름은 나중에 언제든 바꿀 수 있어요.</p>
</form>
);
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { Sheet, CodeLabel } from './ui';
/** QR is generated lazily so the library is not in the first-page bundle. */
export function QrModal({ url, code, onClose }: { url: string; code: string; onClose: () => void }) {
const [cells, setCells] = useState<{ n: number; path: string } | null>(null);
useEffect(() => {
let alive = true;
import('qrcode-generator').then(({ default: qrcode }) => {
const qr = qrcode(0, 'M');
qr.addData(url);
qr.make();
const n = qr.getModuleCount();
let path = '';
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (qr.isDark(y, x)) path += `M${x} ${y}h1v1h-1z`;
if (alive) setCells({ n, path });
});
return () => {
alive = false;
};
}, [url]);
return (
<Sheet onClose={onClose} label="QR 코드">
<h2>휴대폰 카메라로 찍으면 들어와요</h2>
<div style={{ background: '#fff', borderRadius: 16, padding: 12, width: 'min(80vw, 320px)', aspectRatio: '1', alignSelf: 'center' }}>
{cells ? (
<svg viewBox={`-2 -2 ${cells.n + 4} ${cells.n + 4}`} width="100%" height="100%" role="img" aria-label="초대 QR 코드" shapeRendering="crispEdges">
<path d={cells.path} fill="#000" />
</svg>
) : (
<p className="muted">만드는 중…</p>
)}
</div>
<p style={{ textAlign: 'center', fontSize: '1.6rem', fontWeight: 700 }}>
코드 <CodeLabel code={code} />
</p>
<button className="btn btn-secondary btn-block" onClick={onClose}>
닫기
</button>
</Sheet>
);
}

View File

@@ -0,0 +1,120 @@
/** PC top menu (docs/07-ui-ux.md §3.2): 로고·메뉴, 오른쪽에 테마 버튼과 로그인/계정 메뉴. */
import { useEffect, useRef, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { Avatar, ConfirmSheet } from './ui';
export function SiteHeader() {
const { me, siteName, role } = useSession();
return (
<header className="site-header">
<div className="site-header-inner">
<Link to="/" className="site-logo" aria-label={`${siteName} 홈`}>
<img src="/favicon.svg" alt="" width={32} height={32} />
<span className="full">{siteName}</span>
</Link>
<nav className="site-nav" aria-label="주 메뉴">
<NavLink to="/" end>
홈
</NavLink>
<NavLink to="/games">게임 목록</NavLink>
{role !== 'user' && <NavLink to="/admin">관리자</NavLink>}
</nav>
<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>
);
}

View File

@@ -0,0 +1,44 @@
/** Ring countdown to a server deadline (docs/07 §4). */
import { useEffect, useRef, useState } from 'react';
import { socket } from '../net/socket';
import { haptic, sounds } from '../lib/feedback';
export function useServerNow(intervalMs = 250): number {
const [now, setNow] = useState(() => socket.now());
useEffect(() => {
const t = setInterval(() => setNow(socket.now()), intervalMs);
return () => clearInterval(t);
}, [intervalMs]);
return now;
}
export function TurnTimer({ deadline, totalMs, mine, size = 44 }: { deadline: number | null; totalMs: number; mine: boolean; size?: number }) {
const now = useServerNow();
const warned = useRef<number | null>(null);
const left = deadline ? Math.max(0, deadline - now) : null;
const sec = left === null ? null : Math.ceil(left / 1000);
useEffect(() => {
if (!mine || sec === null) return;
if ((sec === 10 || sec === 5) && warned.current !== sec) {
warned.current = sec;
sounds.warn();
if (sec === 5) haptic([60, 40, 60]);
}
}, [sec, mine]);
if (left === null) return null;
const frac = Math.min(1, left / Math.max(1, totalMs));
const color = sec! <= 5 ? 'var(--danger)' : sec! <= 10 ? 'var(--warning)' : 'var(--success)';
const r = size / 2 - 4;
const c = 2 * Math.PI * r;
return (
<span style={{ position: 'relative', width: size, height: size, display: 'inline-flex' }} role="timer" aria-label={`남은 시간 ${sec}초`}>
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }} aria-hidden>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--surface-2)" strokeWidth={4} />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={4} strokeDasharray={c} strokeDashoffset={c * (1 - frac)} strokeLinecap="round" />
</svg>
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: size > 40 ? '0.95rem' : '0.8rem', color }}>
{sec! >= 600 ? `${Math.floor(sec! / 60)}분` : sec! >= 100 ? `${Math.floor(sec! / 60)}:${String(sec! % 60).padStart(2, '0')}` : sec}
</span>
</span>
);
}

View File

@@ -0,0 +1,35 @@
/**
* Play-chip display (docs/04 §6): chips are room-only play points.
* Never shown as money: no currency symbols, no "원/돈/충전/환전" wording.
*/
export function formatChips(n: number): string {
return `${Math.max(0, Math.round(n)).toLocaleString('ko-KR')} 칩`;
}
export function Chips({ n, strong, className }: { n: number; strong?: boolean; className?: string }) {
return (
<span className={className} style={{ fontVariantNumeric: 'tabular-nums', fontWeight: strong ? 800 : 600, whiteSpace: 'nowrap' }}>
<ChipIcon /> {formatChips(n)}
</span>
);
}
export function ChipIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 20 20" aria-hidden style={{ verticalAlign: '-3px' }}>
<circle cx="10" cy="10" r="9" fill="#2563eb" />
<circle cx="10" cy="10" r="5.5" fill="none" stroke="#fff" strokeWidth="2" strokeDasharray="3 2.4" />
</svg>
);
}
/** Shown once per room in betting games. */
export const PLAY_CHIP_NOTICE = '칩은 이 방에서만 쓰는 놀이용 점수예요. 실제 가치가 없고 바꾸거나 주고받을 수 없으며, 방이 끝나면 사라져요.';
export function PlayChipNotice() {
return (
<p className="small" style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '8px 12px', margin: 0 }}>
<ChipIcon /> {PLAY_CHIP_NOTICE}
</p>
);
}

View File

@@ -0,0 +1,184 @@
/**
* Hwatu card drawn in SVG (our own simple artwork, no commercial hwatu images — docs/games/seotda.md §12).
* Large month number + kind badge so the card is readable without knowing the pictures.
*/
import type { CSSProperties, ReactNode } from 'react';
export interface HwatuInfo {
id: number;
month: number; // 0 for bonus
kind: 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus';
tti?: 'hong' | 'cheong' | 'cho' | 'plain';
name: string;
piValue?: number;
}
const MONTH_COLOR = ['#6b7280', '#15803d', '#be185d', '#db2777', '#1f2937', '#7c3aed', '#dc2626', '#b91c1c', '#111827', '#ca8a04', '#c2410c', '#7c2d12', '#1e40af'];
const MONTH_NAME = ['보너스', '송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비'];
const KIND_BADGE: Record<HwatuInfo['kind'], string> = { gwang: '光', yeol: '열', tti: '띠', pi: '피', ssangpi: '쌍피', bonus: '+' };
const TTI_COLOR = { hong: '#dc2626', cheong: '#2563eb', cho: '#15803d', plain: '#dc2626' };
const TTI_TEXT = { hong: '홍단', cheong: '청단', cho: '', plain: '' };
/** A small motif per month (flowers/plants), drawn with basic shapes. */
function Motif({ month, color }: { month: number; color: string }): ReactNode {
const c = color;
switch (month) {
case 1: // 송학: pine
return <path d="M50 52 L28 92 H72 Z M50 72 L22 116 H78 Z" fill={c} opacity="0.85" />;
case 2: // 매조: plum blossoms
case 3: // 벚꽃
return (
<g fill={c} opacity="0.85">
{[[34, 70], [62, 62], [50, 92], [70, 100], [30, 108]].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r="10" />
))}
</g>
);
case 4: // 흑싸리
case 7: // 홍싸리
return (
<g stroke={c} strokeWidth="5" strokeLinecap="round" opacity="0.85">
{[30, 45, 60, 75].map((x, i) => (
<line key={i} x1={x} y1="52" x2={x - 6} y2={100 + (i % 2) * 14} />
))}
</g>
);
case 5: // 난초: iris leaves
return (
<g fill="none" stroke={c} strokeWidth="5" opacity="0.85">
<path d="M40 120 Q34 80 44 52" />
<path d="M55 120 Q62 84 52 54" />
<path d="M70 120 Q76 92 68 64" />
</g>
);
case 6: // 모란: peony
return (
<g fill={c} opacity="0.85">
<circle cx="50" cy="86" r="22" />
<circle cx="50" cy="86" r="10" fill="#fde68a" />
</g>
);
case 8: // 공산: hill
return <path d="M8 120 Q50 52 92 120 Z" fill={c} opacity="0.85" />;
case 9: // 국화
return (
<g fill={c} opacity="0.9">
{Array.from({ length: 10 }, (_, i) => {
const a = (i / 10) * Math.PI * 2;
return <ellipse key={i} cx={50 + Math.cos(a) * 14} cy={88 + Math.sin(a) * 14} rx="8" ry="4" transform={`rotate(${(a * 180) / Math.PI} ${50 + Math.cos(a) * 14} ${88 + Math.sin(a) * 14})`} />;
})}
<circle cx="50" cy="88" r="7" fill="#78350f" />
</g>
);
case 10: // 단풍: maple
return <path d="M50 58 L58 78 L78 74 L64 90 L74 108 L50 98 L26 108 L36 90 L22 74 L42 78 Z" fill={c} opacity="0.9" />;
case 11: // 오동
return (
<g fill={c} opacity="0.85">
<ellipse cx="38" cy="82" rx="16" ry="22" />
<ellipse cx="64" cy="94" rx="16" ry="22" />
</g>
);
case 12: // 비: rain lines + umbrella
return (
<g stroke={c} strokeWidth="3" opacity="0.85">
{[20, 35, 50, 65, 80].map((x) => (
<line key={x} x1={x} y1="54" x2={x - 8} y2="118" />
))}
</g>
);
default:
return <text x="50" y="98" fontSize="36" textAnchor="middle" fill={c}>★</text>;
}
}
export type HwatuSize = 'sm' | 'md' | 'lg';
const WIDTH: Record<HwatuSize, number> = { sm: 40, md: 60, lg: 84 };
export function HwatuCard({
card,
size = 'md',
selected,
dimmed,
highlight,
onClick,
style,
}: {
card: HwatuInfo | null;
size?: HwatuSize;
selected?: boolean;
dimmed?: boolean;
highlight?: boolean;
onClick?: () => void;
style?: CSSProperties;
}) {
const w = WIDTH[size];
const h = Math.round(w * 1.6);
const Tag = onClick ? 'button' : 'span';
const s: CSSProperties = {
width: w,
height: h,
flex: 'none',
display: 'inline-block',
padding: 0,
border: 0,
background: 'none',
cursor: onClick ? 'pointer' : 'default',
opacity: dimmed ? 0.45 : 1,
transform: selected ? `translateY(${-Math.round(w * 0.2)}px)` : undefined,
transition: 'transform 0.12s ease',
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
...style,
};
if (!card) {
return (
<Tag style={s} onClick={onClick} aria-label="뒤집힌 화투">
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#991b1b" stroke="#450a0a" strokeWidth="3" />
<rect x="10" y="10" width="80" height="140" rx="5" fill="#b91c1c" />
</svg>
</Tag>
);
}
const color = MONTH_COLOR[card.month] ?? '#374151';
return (
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
{card.kind === 'bonus' ? (
<text x="50" y="102" fontSize="34" fontWeight="900" fill="#b45309" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{card.piValue ?? 2}피
</text>
) : (
<Motif month={card.month} color={color} />
)}
{card.kind === 'tti' && (
<g>
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
{TTI_TEXT[card.tti ?? 'plain'] && (
<text x="67" y="62" fontSize="13" fontWeight="800" fill="#fff" textAnchor="middle" style={{ writingMode: 'vertical-rl' } as CSSProperties}>
{TTI_TEXT[card.tti ?? 'plain']}
</text>
)}
</g>
)}
{(card.kind === 'gwang' || card.kind === 'yeol') && (
<g>
<circle cx="78" cy="120" r="15" fill={card.kind === 'gwang' ? '#dc2626' : '#a16207'} />
<text x="78" y="127" fontSize={card.kind === 'gwang' ? 20 : 16} fontWeight="800" fill="#fff" textAnchor="middle">
{card.kind === 'gwang' ? '光' : '열'}
</text>
</g>
)}
{/* Month number: large and always readable */}
<rect x="6" y="6" width="38" height="38" rx="8" fill={color} />
<text x="25" y="35" fontSize="28" fontWeight="900" fill="#fff" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{card.month || '+'}
</text>
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{MONTH_NAME[card.month]} · {card.kind === 'bonus' ? `${card.piValue ?? 2}피` : card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
</text>
</svg>
</Tag>
);
}

View File

@@ -0,0 +1,102 @@
/** Trump card drawn in SVG (no image assets). Big corner index for readability on PC and phones. */
import type { CSSProperties } from 'react';
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
const RANK_LABEL: Record<string, string> = { T: '10', J: 'J', Q: 'Q', K: 'K', A: 'A' };
const FACE_KO: Record<string, string> = { J: '잭', Q: '퀸', K: '킹', A: '에이스' };
export type CardSize = 'sm' | 'md' | 'lg';
const WIDTH: Record<CardSize, number> = { sm: 44, md: 64, lg: 88 };
export interface PlayingCardProps {
/** e.g. "AS", "TD", "X1" (joker). null = face down. */
card: string | null;
size?: CardSize;
selected?: boolean;
dimmed?: boolean;
highlight?: boolean;
onClick?: () => void;
title?: string;
style?: CSSProperties;
/** Colour-blind friendly: 4-colour suits. */
fourColor?: boolean;
}
export function cardAria(card: string | null): string {
if (!card) return '뒤집힌 카드';
if (card.startsWith('X')) return '조커';
const r = card[0]!;
return `${SUIT_KO[card[1]!]} ${FACE_KO[r] ?? RANK_LABEL[r] ?? r}`;
}
export function PlayingCard({ card, size = 'md', selected, dimmed, highlight, onClick, title, style, fourColor }: PlayingCardProps) {
const w = WIDTH[size];
const h = Math.round(w * 1.4);
const lift = selected ? -Math.round(w * 0.22) : 0;
const common: CSSProperties = {
width: w,
height: h,
flex: 'none',
transform: `translateY(${lift}px)`,
transition: 'transform 0.12s ease',
cursor: onClick ? 'pointer' : 'default',
opacity: dimmed ? 0.45 : 1,
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))',
...style,
};
const Tag = onClick ? 'button' : 'span';
const btnReset: CSSProperties = onClick ? { padding: 0, border: 0, background: 'none' } : {};
if (!card) {
return (
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? '뒤집힌 카드'} title={title}>
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="138" rx="9" fill="#1e3a8a" stroke="#fff" strokeWidth="3" />
<rect x="9" y="9" width="82" height="122" rx="6" fill="none" stroke="#93c5fd" strokeWidth="2" />
<path d="M50 30 L70 70 L50 110 L30 70 Z" fill="#3b82f6" opacity="0.7" />
</svg>
</Tag>
);
}
const joker = card.startsWith('X');
const suit = card[1]!;
const rank = card[0]!;
const color = joker
? card === 'X1' ? '#111827' : '#dc2626'
: fourColor
? { S: '#111827', H: '#dc2626', D: '#2563eb', C: '#15803d' }[suit]!
: suit === 'H' || suit === 'D'
? '#dc2626'
: '#111827';
const label = joker ? '★' : (RANK_LABEL[rank] ?? rank);
const sym = joker ? '' : SUIT_SYMBOL[suit];
return (
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? cardAria(card)} aria-pressed={onClick ? !!selected : undefined} title={title}>
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="138" rx="9" fill="#fffef9" stroke={selected ? '#2563eb' : '#9ca3af'} strokeWidth={selected ? 5 : 2} />
<text x="9" y="33" fontSize="30" fontWeight="800" fill={color} fontFamily="Pretendard Variable, sans-serif">
{label}
</text>
<text x="11" y="60" fontSize="26" fill={color}>
{sym}
</text>
{joker ? (
<text x="50" y="98" fontSize="22" fontWeight="800" textAnchor="middle" fill={color}>
JOKER
</text>
) : 'JQK'.includes(rank) ? (
<>
<rect x="30" y="44" width="56" height="78" rx="6" fill={color} opacity="0.1" />
<text x="58" y="100" fontSize="44" fontWeight="800" textAnchor="middle" fill={color}>
{rank}
</text>
</>
) : (
<text x="60" y="108" fontSize="54" textAnchor="middle" fill={color}>
{sym}
</text>
)}
</svg>
</Tag>
);
}

View File

@@ -0,0 +1,165 @@
/** Shared UI primitives (docs/07 §4). */
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { PublicUser } 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'];
export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nickname' | 'avatar'>; size?: number }) {
let h = 0;
for (const ch of user.id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
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" /> : 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 }) {
const [copied, setCopied] = useState(false);
const onClick = async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
// Fallback for old browsers / insecure contexts.
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
if (!ok) {
toast(`복사가 안 돼요. 직접 적어 주세요: ${text}`, 'error', 6000);
return;
}
}
haptic(30);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button type="button" className={className} onClick={onClick} aria-live="polite">
{copied ? done : label}
</button>
);
}
export function ShareButton({ title, text, url, className = 'btn btn-secondary' }: { title: string; text: string; url: string; className?: string }) {
const canShare = typeof navigator !== 'undefined' && 'share' in navigator;
if (!canShare) return <CopyButton text={`${text}\n${url}`} label="💬 초대글 복사" className={className} />;
return (
<button
type="button"
className={className}
onClick={() => navigator.share({ title, text, url }).catch(() => {})}
>
📤 공유하기
</button>
);
}
export function Sheet({ onClose, children, label }: { onClose: () => void; children: ReactNode; label: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
ref.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
prev?.focus?.();
};
}, [onClose]);
return (
<div className="sheet-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="sheet" role="dialog" aria-modal="true" aria-label={label} tabIndex={-1} ref={ref}>
{children}
</div>
</div>
);
}
export function ConfirmSheet({
title,
body,
confirm,
cancel = '취소',
danger,
onConfirm,
onClose,
}: {
title: string;
body?: ReactNode;
confirm: string;
cancel?: string;
danger?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<Sheet onClose={onClose} label={title}>
<h2>{title}</h2>
{body && <div className="muted">{body}</div>}
<div className="stack">
<button
className={`btn btn-block ${danger ? 'btn-danger' : 'btn-primary'}`}
onClick={() => {
onConfirm();
onClose();
}}
>
{confirm}
</button>
<button className="btn btn-block btn-secondary" onClick={onClose}>
{cancel}
</button>
</div>
</Sheet>
);
}
export function Toasts() {
const toasts = useToasts((s) => s.toasts);
return (
<div className="toasts" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.kind === 'error' ? 'error' : ''}`}>
{t.text}
</div>
))}
</div>
);
}
export function ConnectionBanner() {
const status = useRoom((s) => s.status);
const code = useRoom((s) => s.code);
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;
}
export function CodeLabel({ code }: { code: string }) {
return <span style={{ fontVariantNumeric: 'tabular-nums', letterSpacing: '0.08em' }}>{formatRoomCode(code)}</span>;
}
export function TopBar({ title, onBack, right }: { title: ReactNode; onBack?: () => void; right?: ReactNode }) {
return (
<nav className="topbar" aria-label="페이지 메뉴">
{onBack && (
<button className="icon-btn" onClick={onBack} aria-label="뒤로">
←<span className="lbl">뒤로</span>
</button>
)}
<div className="topbar-title">{title}</div>
{right}
</nav>
);
}

View 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('[요청 취소]');
});

View 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');
}

View File

@@ -0,0 +1,862 @@
/** 바둑 화면 (docs/games/baduk.md §9). */
import { useEffect, useMemo, useRef, useState } from 'react';
import type { BadukOptions, BadukView, Pt, ScoreBreakdown } from '@bg/games/baduk';
import type { PublicUser } from '@bg/shared/lite';
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: '백' };
const COLS = 'ABCDEFGHJKLMNOPQRST';
const label = (size: number, p: Pt) => `${COLS[p.x]}${size - p.y}`;
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
const ILLEGAL_KO = {
suicide: '둘 곳의 활로가 없어 둘 수 없어요(자충수).',
ko: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.',
superko: '같은 모양이 반복되어 둘 수 없어요.',
} as const;
function stars(size: number): Pt[] {
if (size === 9) return [{ x: 2, y: 2 }, { x: 6, y: 2 }, { x: 2, y: 6 }, { x: 6, y: 6 }, { x: 4, y: 4 }];
if (size === 13) return [{ x: 3, y: 3 }, { x: 9, y: 3 }, { x: 3, y: 9 }, { x: 9, y: 9 }, { x: 6, y: 6 }];
const s = [3, 9, 15];
return s.flatMap((x) => s.map((y) => ({ x, y })));
}
/** Points like "흑 2집 반" (territory) or "1.5점" (area). */
function fmtScore(n: number, method: 'territory' | 'area'): string {
if (method === 'area') return String(n);
const whole = Math.floor(n);
const half = n - whole >= 0.5;
return half ? (whole === 0 ? '반' : `${whole}.5`) : String(whole);
}
function Stone({ color, cx, cy, r, ghost }: { color: Color; cx: number; cy: number; r: number; ghost?: boolean }) {
return (
<g opacity={ghost ? 0.5 : 1}>
<circle cx={cx} cy={cy} r={r} fill={color === 'black' ? '#151515' : '#fbfbf8'} stroke={color === 'black' ? '#000' : '#666'} strokeWidth={0.05} />
{color === 'black' ? (
<circle cx={cx - r * 0.3} cy={cy - r * 0.3} r={r * 0.28} fill="#fff" opacity={0.15} />
) : (
<circle cx={cx + r * 0.25} cy={cy + r * 0.25} r={r * 0.6} fill="#000" opacity={0.05} />
)}
</g>
);
}
type MarkMode = 'dead' | 'seki';
function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps<BadukView>) {
const n = v.size;
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
const marking = v.phase === 'scoring' && v.myColor !== null;
const [mode, setMode] = useState<MarkMode>('dead');
const [preview, setPreview] = useState<Pt | null>(null);
const [hover, setHover] = useState<Pt | null>(null);
const [cursor, setCursor] = useState<Pt>({ x: (n - 1) >> 1, y: (n - 1) >> 1 });
const [announce, setAnnounce] = useState('');
const [bubble, setBubble] = useState<{ p: Pt; text: string } | null>(null);
const [kbd, setKbd] = useState(false);
const [changed, setChanged] = useState<Set<number>>(new Set());
const svgRef = useRef<SVGSVGElement>(null);
const prevCount = useRef(v.moveCount);
const prevDead = useRef<Set<number> | null>(null);
const illegal = useMemo(() => new Map(v.illegalPoints.map((p) => [p.y * n + p.x, p.reason])), [v.illegalPoints, n]);
// Dead stones: the live marks while scoring, or (after the game) stones owned by the other colour.
const dead = useMemo(() => {
if (v.scoring) return new Set(v.scoring.dead.map((p) => p.y * n + p.x));
const own = v.result?.score?.ownership;
const out = new Set<number>();
if (own) v.board.forEach((c, i) => c !== 0 && own[i] !== c && out.add(i));
return out;
}, [v.scoring, v.result, v.board, n]);
const seki = useMemo(() => new Set((v.scoring?.seki ?? []).map((p) => p.y * n + p.x)), [v.scoring?.seki, n]);
// Move sounds and announcements.
useEffect(() => {
if (v.moveCount > prevCount.current) {
const m = v.moves[v.moves.length - 1]!;
if (m.pt) {
sounds.stone();
if (m.captured > 0) haptic(30);
setAnnounce(`${COLOR_KO[m.color]}이 ${label(n, m.pt)}에 두었어요.${m.captured ? ` 돌 ${m.captured}개를 따냈어요.` : ''}`);
} else setAnnounce(`${COLOR_KO[m.color]}이 패스했어요.`);
if (myTurn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
}
prevCount.current = v.moveCount;
setPreview(null);
setBubble(null);
}, [v.moveCount]); // eslint-disable-line react-hooks/exhaustive-deps
// Highlight groups whose dead/alive mark just changed (e.g. by the opponent).
useEffect(() => {
if (!v.scoring) {
prevDead.current = null;
return;
}
const before = prevDead.current;
prevDead.current = dead;
if (!before) return;
const diff = new Set<number>();
for (const i of dead) if (!before.has(i)) diff.add(i);
for (const i of before) if (!dead.has(i)) diff.add(i);
if (diff.size === 0) return;
setChanged(diff);
const t = setTimeout(() => setChanged(new Set()), 2500);
return () => clearTimeout(t);
}, [dead]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), 2500);
return () => clearTimeout(t);
}, [bubble]);
const at = (p: Pt) => v.board[p.y * n + p.x];
const canPlace = (p: Pt) => myTurn && !pending && at(p) === 0 && !illegal.has(p.y * n + p.x);
const canMark = (p: Pt) => marking && !pending && (mode === 'dead' ? at(p) !== 0 : at(p) === 0);
const place = (p: Pt) => {
act({ type: 'place', x: p.x, y: p.y });
setPreview(null);
setHover(null);
};
const mark = (p: Pt) => act(mode === 'dead' ? { type: 'toggleDead', x: p.x, y: p.y } : { type: 'toggleSeki', x: p.x, y: p.y });
const tap = (p: Pt, mouse: boolean) => {
setCursor(p);
if (marking) {
if (canMark(p)) mark(p);
else if (mode === 'dead' && at(p) === 0) setBubble({ p, text: '돌을 눌러 사석/생석을 바꿔요. 빈 곳은 [빅 표시]에서 눌러요.' });
return;
}
if (!myTurn || at(p) !== 0) return;
const why = illegal.get(p.y * n + p.x);
if (why) {
haptic([30, 30, 30]);
setBubble({ p, text: ILLEGAL_KO[why] });
setAnnounce(ILLEGAL_KO[why]);
return;
}
if (mouse || !tapConfirm) {
if (canPlace(p)) place(p);
return;
}
if (preview && preview.x === p.x && preview.y === p.y) place(p);
else setPreview(p);
};
const pointToCell = (e: React.PointerEvent<SVGSVGElement>): Pt | null => {
const svg = svgRef.current;
if (!svg) return null;
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const loc = pt.matrixTransform(svg.getScreenCTM()!.inverse());
const x = Math.round(loc.x);
const y = Math.round(loc.y);
return x >= 0 && y >= 0 && x < n && y < n ? { x, y } : null;
};
const onKey = (e: React.KeyboardEvent) => {
setKbd(true);
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
if (d[e.key]) {
e.preventDefault();
setCursor((c) => ({ x: Math.min(n - 1, Math.max(0, c.x + d[e.key]![0])), y: Math.min(n - 1, Math.max(0, c.y + d[e.key]![1])) }));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tap(cursor, true);
}
};
// Ownership marks: live preview while marking, final result after the game.
const score: ScoreBreakdown | undefined = v.scoring?.preview ?? v.result?.score;
const ghost = preview ?? (hover && canPlace(hover) ? hover : null);
const hoverMark = marking && hover && canMark(hover) ? hover : null;
const lastColor: Color | null = v.lastMove ? (at(v.lastMove) === 1 ? 'black' : 'white') : null;
const pad = 0.95;
return (
<div className="board-wrap stack" style={{ gap: 10 }}>
<svg
ref={svgRef}
viewBox={`${-pad} ${-pad} ${n - 1 + 2 * pad} ${n - 1 + 2 * pad}`}
style={{
width: '100%',
display: 'block',
background: 'var(--wood)',
borderRadius: 12,
touchAction: 'manipulation',
cursor: myTurn || marking ? 'pointer' : 'default',
boxShadow: 'var(--shadow)',
}}
onPointerMove={(e) => {
if (e.pointerType !== 'mouse' || !(myTurn || marking)) return;
const p = pointToCell(e);
if (p?.x !== hover?.x || p?.y !== hover?.y) setHover(p);
}}
onPointerLeave={() => setHover(null)}
onPointerUp={(e) => {
setKbd(false);
const p = pointToCell(e);
if (p) tap(p, e.pointerType === 'mouse');
}}
tabIndex={0}
role="grid"
aria-label={`바둑판 ${n}줄. 방향키로 움직이고 Enter로 ${marking ? '표시를 바꿉니다' : '둡니다'}. 현재 위치 ${label(n, cursor)}`}
onKeyDown={onKey}
>
{Array.from({ length: n }, (_, i) => (
<g key={i} stroke="var(--wood-line)" strokeWidth={i === 0 || i === n - 1 ? 0.06 : 0.035}>
<line x1={0} y1={i} x2={n - 1} y2={i} />
<line x1={i} y1={0} x2={i} y2={n - 1} />
</g>
))}
{Array.from({ length: n }, (_, i) => (
<g key={`c${i}`} fill="var(--wood-line)" fontSize={0.36} fontWeight={700} textAnchor="middle" dominantBaseline="central" opacity={0.75}>
<text x={i} y={-0.62}>{COLS[i]}</text>
<text x={i} y={n - 1 + 0.62}>{COLS[i]}</text>
<text x={-0.62} y={i}>{n - i}</text>
<text x={n - 1 + 0.62} y={i}>{n - i}</text>
</g>
))}
{stars(n).map((p) => (
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
))}
{/* Ko / superko points for the mover (suicide points are explained on click only). */}
{myTurn &&
v.illegalPoints
.filter((p) => p.reason !== 'suicide')
.map((p) => <rect key={`k${p.x}-${p.y}`} x={p.x - 0.18} y={p.y - 0.18} width={0.36} height={0.36} fill="none" stroke="#b91c1c" strokeWidth={0.07} />)}
{v.board.map((c, i) => {
if (c === 0) return null;
const color: Color = c === 1 ? 'black' : 'white';
const x = i % n;
const y = Math.floor(i / n);
const isDead = dead.has(i);
return (
<g key={i} opacity={isDead ? 0.45 : 1}>
<Stone color={color} cx={x} cy={y} r={0.47} />
</g>
);
})}
{/* Stones just captured fade out. */}
{v.phase === 'playing' &&
v.lastCaptured.map((p) => (
<g key={`cap${v.moveCount}-${p.x}-${p.y}`} opacity={0}>
<animate attributeName="opacity" from={0.7} to={0} dur="0.6s" fill="freeze" />
<Stone color={lastColor ? other(lastColor) : 'white'} cx={p.x} cy={p.y} r={0.47} />
</g>
))}
{/* Territory / area marks (small squares) and seki marks. */}
{score &&
score.ownership.map((o, i) => {
if (o === 0 || (v.board[i] !== 0 && !dead.has(i))) return null;
return <rect key={`o${i}`} x={(i % n) - 0.17} y={Math.floor(i / n) - 0.17} width={0.34} height={0.34} fill={o === 1 ? '#111' : '#fafafa'} stroke={o === 1 ? '#000' : '#555'} strokeWidth={0.03} />;
})}
{[...seki].map((i) => (
<path key={`seki${i}`} d={`M ${i % n} ${Math.floor(i / n) - 0.2} l 0.2 0.2 l -0.2 0.2 l -0.2 -0.2 z`} fill="#f59e0b" opacity={0.9} />
))}
{[...dead].map((i) => (
<g key={`x${i}`} stroke={v.board[i] === 1 ? '#fafafa' : '#111'} strokeWidth={0.08}>
<line x1={(i % n) - 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) + 0.2} y2={Math.floor(i / n) + 0.2} />
<line x1={(i % n) + 0.2} y1={Math.floor(i / n) - 0.2} x2={(i % n) - 0.2} y2={Math.floor(i / n) + 0.2} />
</g>
))}
{[...changed].map((i) => (
<circle key={`ch${i}`} cx={i % n} cy={Math.floor(i / n)} r={0.52} fill="none" stroke="#3b82f6" strokeWidth={0.09}>
<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="3" />
</circle>
))}
{v.lastMove && lastColor && (
<circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} />
)}
{myTurn &&
v.atariWarnings.map((p) => (
<circle key={`a${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08}>
<animate attributeName="opacity" values="1;0.25;1" dur="1.2s" repeatCount="indefinite" />
</circle>
))}
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />}
{hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />}
{(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
{bubble && <circle cx={bubble.p.x} cy={bubble.p.y} r={0.48} fill="none" stroke="#dc2626" strokeWidth={0.09} />}
</svg>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{bubble && (
<div className="card" role="status" style={{ padding: '8px 12px', border: '2px solid var(--danger)', fontWeight: 600 }}>
{label(n, bubble.p)}: {bubble.text}
</div>
)}
{marking && (
<div className="card stack" style={{ padding: 12, gap: 8 }}>
<div className="segmented" role="group" aria-label="표시 종류">
<button type="button" aria-pressed={mode === 'dead'} onClick={() => setMode('dead')}>
✕ 사석 표시
</button>
<button type="button" aria-pressed={mode === 'seki'} onClick={() => setMode('seki')}>
◆ 빅 표시
</button>
</div>
<p className="small" style={{ margin: 0 }}>
{mode === 'dead'
? '죽은 돌 묶음을 누르면 사석(✕)으로 바뀌고, 다시 누르면 살아 있는 돌로 돌아와요.'
: '빅(세키) 안의 빈 곳을 누르면 ◆로 표시돼요. 한국·일본식 계산에서는 빅 안의 집을 세지 않아요.'}
</p>
</div>
)}
{preview && myTurn && (
<div className="row" style={{ justifyContent: 'center' }}>
<button className="btn btn-primary" style={{ flex: 2 }} disabled={!canPlace(preview)} onClick={() => place(preview)}>
{`${label(n, preview)}에 두기`}
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => setPreview(null)}>
취소
</button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------- side panel
function ScoreTable({ score, heading }: { score: ScoreBreakdown; heading?: boolean }) {
const t = score.method === 'territory';
const rows: [string, string | number, string | number][] = t
? [
['집', score.black.territory, score.white.territory],
['따낸 돌(사석 포함)', score.black.captures, score.white.captures],
['덤', '-', score.white.komi],
]
: [
['판 위 살아 있는 돌', score.black.stones, score.white.stones],
['영역(빈 곳)', score.black.territory, score.white.territory],
['덤', '-', score.white.komi],
...(score.white.handicapComp ? [['치석 보정', '-', score.white.handicapComp] as [string, string, number]] : []),
];
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontVariantNumeric: 'tabular-nums' }}>
<thead>
<tr className="small muted">
<th style={{ textAlign: 'left', fontWeight: 600 }}>{heading ? (t ? '집 계산' : '영역 계산') : ''}</th>
<th style={{ textAlign: 'right' }}>● 흑</th>
<th style={{ textAlign: 'right' }}>○ 백</th>
</tr>
</thead>
<tbody>
{rows.map(([k, b, w]) => (
<tr key={k} className="small">
<td>{k}</td>
<td style={{ textAlign: 'right' }}>{b}</td>
<td style={{ textAlign: 'right' }}>{w}</td>
</tr>
))}
<tr style={{ fontWeight: 800, borderTop: '2px solid var(--border)' }}>
<td>합계</td>
<td style={{ textAlign: 'right' }}>{score.black.total}</td>
<td style={{ textAlign: 'right' }}>{score.white.total}</td>
</tr>
</tbody>
</table>
);
}
function ScoringPanel({ view: v, act, pending }: { view: BadukView; act: (a: unknown) => void; pending: boolean }) {
const [confirmResume, setConfirmResume] = useState(false);
const sc = v.scoring!;
const p = sc.preview;
const lead = p.diff === 0 ? '동점' : `${p.diff > 0 ? '흑' : '백'} ${fmtScore(Math.abs(p.diff), p.method)}${p.method === 'territory' ? '집' : '점'} 앞섬`;
const mine = v.myColor;
return (
<div className="card stack" style={{ padding: 14, gap: 10 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong style={{ fontSize: '1.1rem' }}>사석 확인</strong>
<TurnTimer deadline={sc.deadlineAt} totalMs={sc.durationMs} mine={!!mine && !sc.agreed[mine]} size={40} />
</div>
<p className="small" style={{ margin: 0 }}>
대국이 멈췄어요. 판 위의 <strong>죽은 돌</strong>을 눌러 ✕ 표시를 맞춘 뒤 두 사람 모두 <strong>동의</strong>하면 계가가 끝나요. 처음 표시는 컴퓨터의 추정이라 틀릴 수 있어요.
</p>
<div style={{ fontSize: '1.5rem', fontWeight: 800, textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
흑 {p.black.total} : 백 {p.white.total}
</div>
<div className="muted small" style={{ textAlign: 'center' }}>{lead}</div>
<ScoreTable score={p} heading />
<div className="row small" style={{ justifyContent: 'space-around' }}>
<span>● 흑 {sc.agreed.black ? '✅ 동의함' : '⏳ 확인 중'}</span>
<span>○ 백 {sc.agreed.white ? '✅ 동의함' : '⏳ 확인 중'}</span>
</div>
{mine && (
<div className="stack" style={{ gap: 8 }}>
<button className="btn btn-primary btn-block" style={{ fontSize: '1.1rem' }} disabled={pending || sc.agreed[mine]} onClick={() => act({ type: 'agreeScore' })}>
{sc.agreed[mine] ? '동의했어요 · 상대를 기다려요' : '✔ 이 표시대로 동의'}
</button>
<button className="btn btn-secondary btn-block" disabled={pending} onClick={() => setConfirmResume(true)}>
이의 – 대국 재개
</button>
</div>
)}
{confirmResume && (
<ConfirmSheet
title="대국을 다시 둘까요?"
body="표시를 모두 지우고 대국으로 돌아가요. 다시 시작하면 상대가 먼저 둬요."
confirm="대국 재개"
onConfirm={() => act({ type: 'resumePlay' })}
onClose={() => setConfirmResume(false)}
/>
)}
</div>
);
}
function BadukSide({ view: v, me, players, act, pending, deadline }: BoardProps<BadukView>) {
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'pass'>(null);
const incoming =
v.phase === 'playing' && v.myColor
? v.undo.pending && v.undo.pending.by !== v.myColor
? 'undo'
: v.draw.pendingBy && v.draw.pendingBy !== v.myColor
? 'draw'
: null
: null;
const outgoing = v.myColor && (v.undo.pending?.by === v.myColor || v.draw.pendingBy === v.myColor);
const undoLeft = v.myColor ? v.undo.remaining[v.myColor] : null;
const opp: Color = v.myColor === 'white' ? 'black' : 'white';
const mine: Color = v.myColor ?? 'black';
const myTurn = v.phase === 'playing' && v.myColor === v.turn;
const last = v.moves[v.moves.length - 1];
const oppPassed = myTurn && last && !last.pt && last.color !== v.myColor;
return (
<>
<PlayerBar view={v} color={v.myColor ? opp : 'black'} players={players} deadline={deadline} me={me} />
<PlayerBar view={v} color={v.myColor ? mine : 'white'} players={players} deadline={deadline} me={me} />
{oppPassed && (
<div className="card" style={{ padding: 10, border: '2px solid var(--warning)' }}>
<strong>상대가 패스했어요.</strong> 끝내려면 나도 패스하세요. 더 둘 곳이 있으면 계속 둬도 돼요.
</div>
)}
{v.phase === 'scoring' && <ScoringPanel view={v} act={act} pending={pending} />}
{v.phase === 'finished' && v.result?.score && (
<div className="card stack" style={{ padding: 14, gap: 8 }}>
<strong style={{ fontSize: '1.15rem' }}>{v.result.summaryKo}</strong>
<ScoreTable score={v.result.score} heading />
</div>
)}
{v.phase === 'playing' && v.myColor && (
<div className="stack" style={{ gap: 8 }}>
<button className="btn btn-secondary btn-block" disabled={!myTurn || pending} onClick={() => setConfirm('pass')}>
✋ 패스
</button>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{v.undo.allowed && (
<button
className="btn btn-secondary btn-sm"
disabled={!!outgoing || undoLeft === 0 || !v.moves.some((m) => m.color === v.myColor)}
onClick={() => act({ type: 'requestUndo' })}
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
>
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={!!outgoing} onClick={() => setConfirm('draw')}>
🤝 무승부
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
🏳 기권
</button>
</div>
</div>
)}
{v.phase === 'scoring' && v.myColor && (
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
🏳 기권
</button>
)}
{outgoing && (
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
<span className="small">상대의 답을 기다리는 중…</span>
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
</div>
)}
<MoveList view={v} />
{incoming && (
<Sheet onClose={() => {}} label="상대의 요청">
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
<p className="muted">{incoming === 'undo' ? '수락하면 돌과 따낸 돌이 그 전으로 돌아가요.' : '수락하면 이 판은 무승부로 끝나요.'}</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
수락
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
거절
</button>
</div>
</Sheet>
)}
{confirm === 'pass' && (
<ConfirmSheet
title="패스할까요?"
body={v.consecutivePasses > 0 ? '상대도 패스했으니, 패스하면 대국이 멈추고 사석 확인으로 넘어가요.' : '이번 차례에 돌을 두지 않고 넘겨요.'}
confirm="패스하기"
onConfirm={() => act({ type: 'pass' })}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'resign' && (
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대의 불계승이에요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
)}
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
</>
);
}
function MoveList({ view: v }: { view: BadukView }) {
const ref = useRef<HTMLOListElement>(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}, [v.moveCount]);
if (v.moveCount === 0) return null;
return (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({v.moveCount}수)</summary>
<ol ref={ref} style={{ margin: '8px 0 0', paddingLeft: 0, listStyle: 'none', maxHeight: 150, overflow: 'auto', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
{v.moves.map((m, i) => (
<li key={i} className="small">
<span className="muted">{i + 1}.</span> {m.color === 'black' ? '●' : '○'} {m.pt ? label(v.size, m.pt) : '패스'}
{m.captured ? <span className="muted"> ×{m.captured}</span> : null}
</li>
))}
</ol>
</details>
);
}
const mmss = (ms: number) => {
const s = Math.max(0, Math.ceil(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
function PlayerBar({ view: v, color, players, deadline, me }: { view: BadukView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
const id = v.players[color];
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
const active = v.phase === 'playing' && v.turn === color;
const now = useServerNow(250);
const c = v.clock;
const elapsed = active ? Math.max(0, now - c.turnStartedAt) : 0;
const mine = id === me;
let clock: React.ReactNode = null;
let periodSecLeft: number | null = null;
if (c.kind === 'byoyomi') {
const main = c.mainMs[color];
if (elapsed < main) {
clock = <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem' }}>{mmss(main - elapsed)}</span>;
} else {
const periodMs = c.periodSec * 1000;
const over = elapsed - main;
const used = Math.floor(over / periodMs);
const left = Math.max(0, c.periodsLeft[color] - used);
periodSecLeft = active ? Math.ceil((periodMs - (over % periodMs)) / 1000) : c.periodSec;
const urgent = active && periodSecLeft <= 10;
clock = (
<span className="stack" style={{ alignItems: 'flex-end', gap: 2 }}>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 800, fontSize: urgent ? '1.6rem' : '1.15rem', color: urgent ? 'var(--danger)' : undefined }}>
{periodSecLeft}초
</span>
<span aria-label={`초읽기 ${left}번 남음`} style={{ letterSpacing: 2 }}>
{Array.from({ length: c.periods }, (_, i) => (
<span key={i} style={{ color: i < left ? 'var(--primary)' : 'var(--border)' }}>●</span>
))}
</span>
</span>
);
}
} else if (c.kind === 'fischer') {
const ms = c.mainMs[color] - elapsed;
clock = (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && ms <= 10_000 ? 'var(--danger)' : undefined }}>{mmss(ms)}</span>
);
} else if (active) {
clock = <TurnTimer deadline={deadline} totalMs={c.idleLimitSec * 1000} mine={mine} />;
}
// Byoyomi countdown beeps for my own clock (last 5 seconds of a period).
const beeped = useRef<number | null>(null);
useEffect(() => {
if (!active || !mine || periodSecLeft === null || periodSecLeft > 5 || beeped.current === periodSecLeft) return;
beeped.current = periodSecLeft;
sounds.warn();
}, [periodSecLeft, active, mine]);
const urgentBorder = active && periodSecLeft !== null && periodSecLeft <= 10;
return (
<div
className="card row"
style={{ padding: '8px 12px', gap: 10, border: `3px solid ${urgentBorder ? 'var(--danger)' : active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}
>
<svg width={28} height={28} viewBox="-1 -1 2 2" aria-hidden>
<Stone color={color} cx={0} cy={0} r={0.9} />
</svg>
<Avatar user={u} size={32} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{mine && <span className="muted small"> (나)</span>}
</div>
<div className="muted small">
{COLOR_KO[color]} · 따낸 돌 {v.captures[color]}
{color === 'white' && v.rules.komi ? ` · 덤 ${v.rules.komi}` : ''}
{active ? ' · 두는 중' : ''}
{v.phase === 'scoring' && v.scoring?.agreed[color] ? ' · 동의함' : ''}
</div>
</div>
{clock}
</div>
);
}
function statusText(v: BadukView, _me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summaryKo ?? '대국이 끝났어요';
if (v.phase === 'scoring') {
if (v.myColor && !v.scoring!.agreed[v.myColor]) return '사석을 확인하고 [동의]를 눌러 주세요';
if (v.myColor) return '상대가 사석을 확인하고 있어요…';
return '사석 확인 중이에요';
}
if (v.myColor && v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 요청했어요';
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
if (v.myColor === v.turn) {
if (v.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요';
if (v.atariWarnings.length) return `내 차례예요 (${COLOR_KO[v.turn]}). 단수에 몰린 돌이 있어요!`;
return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`;
}
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
return v.myColor ? `${nick}님이 생각 중이에요…` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
type TC = BadukOptions['timeControl'];
const TIME_PRESETS: [string, string, Partial<TC>][] = [
['auto', '판 크기에 맞춤 + 초읽기 30초×3', { kind: 'byoyomi', mainSecAuto: true, periodSec: 30, periods: 3 }],
['b5', '5분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 300, periodSec: 30, periods: 3 }],
['b10', '10분 + 30초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 600, periodSec: 30, periods: 3 }],
['b30', '30분 + 60초×3', { kind: 'byoyomi', mainSecAuto: false, mainSec: 1800, periodSec: 60, periods: 3 }],
['f', '피셔 10분 + 10초', { kind: 'fischer', mainSecAuto: false, mainSec: 600, incSec: 10 }],
['none', '시간 제한 없음', { kind: 'none' }],
];
function timeKey(tc: TC): string {
if (tc.kind === 'none') return 'none';
if (tc.kind === 'fischer') return tc.mainSec === 600 && tc.incSec === 10 && !tc.mainSecAuto ? 'f' : 'custom';
if (tc.mainSecAuto && tc.periodSec === 30 && tc.periods === 3) return 'auto';
const hit = TIME_PRESETS.find(([k, , p]) => k.startsWith('b') && !tc.mainSecAuto && p.mainSec === tc.mainSec && p.periodSec === tc.periodSec && p.periods === tc.periods);
return hit ? hit[0] : 'custom';
}
const DEFAULT_MAIN: Record<number, number> = { 9: 180, 13: 600, 19: 1200 };
function timeLabel(o: BadukOptions): string {
const tc = o.timeControl;
if (tc.kind === 'none') return '시간 제한 없음';
const main = tc.mainSecAuto ? DEFAULT_MAIN[o.boardSize]! : tc.mainSec;
const m = Math.floor(main / 60);
const mainLabel = main === 0 ? '' : `${m ? `${m}분` : ''}${main % 60 ? ` ${main % 60}초` : ''}`.trim();
if (tc.kind === 'fischer') return `${mainLabel} + 수당 ${tc.incSec}초`;
return `${mainLabel ? `${mainLabel} + ` : ''}${tc.periodSec}초×${tc.periods}`;
}
function komiOf(o: BadukOptions): number {
if (!o.komiAuto) return o.komi;
if (o.handicap > 0) return 0.5;
return o.scoring === 'area' ? 7.5 : 6.5;
}
function Options({ value: o, onChange }: OptionsProps<BadukOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<BadukOptions>) => onChange({ ...o, ...patch });
const setTc = (patch: Partial<TC>) => set({ timeControl: { ...o.timeControl, ...patch } });
const maxHandicap = o.boardSize === 9 ? 5 : 9;
const handicaps = ([0, 2, 3, 4, 5, 6, 7, 8, 9] as const).filter((h) => h <= maxHandicap);
const tk = timeKey(o.timeControl);
return (
<div className="stack" style={{ gap: 16 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>판 크기</strong>
<Seg
value={o.boardSize}
options={[[9, '9줄 (처음이면 추천)'], [13, '13줄'], [19, '19줄']]}
onChange={(boardSize) => set({ boardSize, handicap: boardSize === 9 && o.handicap > 5 ? 5 : o.handicap })}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>계가 방식</strong>
<Seg value={o.scoring} options={[['territory', '집 계산 (한국·일본식)'], ['area', '영역 계산 (중국식)']]} onChange={(scoring) => set({ scoring })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>접바둑</strong>
<Seg value={o.handicap} options={handicaps.map((h) => [h, h === 0 ? '맞바둑' : `${h}점`] as [BadukOptions['handicap'], string])} onChange={(handicap) => set({ handicap })} />
{o.handicap > 0 && <span className="muted small">흑(하수)이 치석 {o.handicap}개를 미리 놓고, 백이 먼저 둬요. 덤은 0.5집이에요.</span>}
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>시간</strong>
<Seg
value={tk}
options={[
...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]),
...(tk === 'custom' ? [['custom', `${timeLabel(o)}(관리자 설정)`] as [string, string]] : []),
]}
onChange={(k) => {
const preset = TIME_PRESETS.find((p) => p[0] === k);
if (preset) setTc(preset[2]);
}}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>흑백 정하기</strong>
<Seg
value={o.colorAssignment}
options={[['nigiri', '무작위'], ['hostBlack', o.handicap ? '방장이 흑(하수)' : '방장이 흑'], ['hostWhite', o.handicap ? '방장이 백(상수)' : '방장이 백']]}
onChange={(colorAssignment) => set({ colorAssignment })}
/>
</div>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<div className="stack" style={{ gap: 6 }}>
<strong>덤 (지금 {komiOf(o)})</strong>
<Seg value={o.komiAuto ? 'auto' : 'custom'} options={[['auto', '자동'], ['custom', '직접 정하기']]} onChange={(k) => set({ komiAuto: k === 'auto' })} />
{!o.komiAuto && (
<input
className="input"
style={{ maxWidth: 160 }}
type="number"
min={-10}
max={10}
step={0.5}
value={o.komi}
onChange={(e) => {
const n = Math.round(Number(e.target.value) * 2) / 2;
if (Number.isFinite(n)) set({ komi: Math.max(-10, Math.min(10, n)) });
}}
aria-label="덤"
/>
)}
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>패 규칙</strong>
<Seg value={o.koRule} options={[['positionalSuperko', '같은 모양 반복 금지'], ['korean', '한국식(삼패 등은 무승부)']]} onChange={(koRule) => set({ koRule })} />
</div>
{o.timeControl.kind === 'byoyomi' && (
<div className="stack" style={{ gap: 6 }}>
<strong>초읽기</strong>
<Seg value={o.timeControl.periodSec} options={[[10, '10초'], [20, '20초'], [30, '30초'], [60, '60초']]} onChange={(periodSec) => setTc({ periodSec })} />
<Seg value={o.timeControl.periods} options={[[1, '1번'], [2, '2번'], [3, '3번'], [5, '5번']]} onChange={(periods) => setTc({ periods })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}>
<strong>무르기</strong>
<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.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>사석 확인 제한 시간</strong>
<Seg value={o.scoringDeadlineSec} options={[[120, '2분'], [180, '3분'], [300, '5분']]} onChange={(scoringDeadlineSec) => set({ scoringDeadlineSec })} />
</div>
</>
)}
</div>
);
}
function optionsSummary(o: BadukOptions): string {
return [
o.scoring === 'territory' ? '집 계산' : '영역 계산',
`${o.boardSize}줄`,
o.handicap ? `${o.handicap}점 접바둑` : `덤 ${komiOf(o)}`,
timeLabel(o),
].join(' · ');
}
function Rules({ options }: { options?: BadukOptions }) {
const scoring = options?.scoring ?? 'territory';
const koRule = options?.koRule ?? 'positionalSuperko';
return (
<div className="stack">
<p>흑과 백이 번갈아 판의 <strong>교차점</strong>에 돌을 하나씩 놓아요. 흑이 먼저 둬요(접바둑이면 흑이 치석을 미리 놓고 백이 먼저 둬요). 한 번 놓은 돌은 움직이지 않아요.</p>
<p>
<strong>따내기</strong>: 돌에 상하좌우로 맞닿은 빈 점을 <strong>활로</strong>라고 해요. 상하좌우로 붙은 같은 색 돌은 한 덩어리로 활로를 함께 써요. 상대 돌 덩어리의 활로를 모두 막으면 그 돌을 판에서 들어내고, 따낸 돌로 세어요. 활로가 하나만 남은 상태를 <strong>단수</strong>라고 해요.
</p>
<p>
<strong>자충수 금지</strong>: 놓는 순간 내 돌의 활로가 없어지는 자리에는 둘 수 없어요. 단, 그 수로 상대 돌을 따내서 활로가 생기면 둘 수 있어요.
</p>
<p>
<strong>패</strong>: 돌 하나를 서로 번갈아 따낼 수 있는 모양에서는, 따인 쪽이 <strong>바로</strong> 되따낼 수 없어요. 다른 곳에 한 수 둔 뒤에 되따낼 수 있어요.
{koRule === 'positionalSuperko'
? ' 이 방은 판 전체 모양이 이전과 똑같아지는 수를 모두 금지해요(동형 반복 금지).'
: ' 이 방은 한국식이라 같은 국면이 세 번 나오면(삼패 등) 무승부로 끝나요.'}
{' '}둘 수 없는 패 자리는 빨간 네모로 보여 줘요.
</p>
<p>
<strong>끝내기</strong>: 더 둘 곳이 없으면 <strong>패스</strong>해요. 두 사람이 연달아 패스하면 대국이 멈추고 <strong>사석 확인</strong>을 해요. 판에서 죽은 돌을 눌러 ✕ 표시를 맞추고, 두 사람 모두 [동의]하면 점수가 나와요. 의견이 다르면 [이의 – 대국 재개]로 돌아가 직접 둬서 결론을 내요.
</p>
{scoring === 'territory' ? (
<p>
<strong>집 계산(한국·일본식)</strong>: 내 돌로만 둘러싼 빈 점(집) + 따낸 돌(사석 포함)이 내 점수예요. 백은 먼저 둔 흑의 이득을 메우려고 <strong>덤</strong>을 더 받아요. 빅(서로 잡을 수 없이 공배를 나눠 사는 모양) 안의 집은 세지 않아요.
</p>
) : (
<p>
<strong>영역 계산(중국식)</strong>: 판 위의 살아 있는 내 돌 + 내 돌로만 둘러싼 빈 점이 내 점수예요. 따낸 돌은 세지 않으니 끝내기 전에 공배(양쪽 사이의 빈 점)를 메운 뒤 패스하세요. 백은 덤을 더 받고, 접바둑이면 치석 수만큼 더 받아요.
</p>
)}
<p>점수가 높은 쪽이 이겨요. 덤이 0.5 단위면 비기는 일이 없어요. 언제든 기권(불계패)할 수 있어요. 처음이라면 <strong>9줄</strong> 판으로 시작해 보세요.</p>
</div>
);
}
const ui: GameUI<BadukView, BadukOptions> = {
Board: BadukBoard,
Side: BadukSide,
statusText,
isMyTurn: (v, me) => {
if (!v.myColor || v.players[v.myColor] !== me) return false;
if (v.phase === 'playing') return v.turn === v.myColor;
if (v.phase === 'scoring') return !v.scoring!.agreed[v.myColor];
return false;
},
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View 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줄 판을 추천해요.',
},
];

View 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('모두 캐릭터를 고르는 중');
});

View 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}`;
}

File diff suppressed because it is too large Load Diff

View 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: '⭐보안관만 공개돼요. 보안관·부관은 무법자와 배신자를, 무법자는 보안관을 쓰러뜨리면 이겨요. 배신자는 끝까지 혼자 남아야 해요.',
},
];

View 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('호랑이님 우승!');
});

View 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;
}

File diff suppressed because it is too large Load Diff

View 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: '먼저 맞힐수록 점수를 많이 받고, 누가 맞히면 그린 사람도 점수를 받아요. 모두 다 그린 뒤 점수가 가장 높으면 우승!',
},
];

View 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('게임이 끝났어요. 무승부');
});

View 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');
}

View File

@@ -0,0 +1,918 @@
/** 체스 화면 (docs/games/chess.md §9). Legal moves come from the server view; the client has no rule code. */
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ChessOptions, ChessView, LegalMove } from '@bg/games/chess';
import type { PublicUser } from '@bg/shared/lite';
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';
type Promo = 'q' | 'r' | 'b' | 'n';
const COLOR_KO: Record<Color, string> = { white: '백', black: '흑' };
const PIECE_KO: Record<PieceType, string> = { k: '킹', q: '퀸', r: '룩', b: '비숍', n: '나이트', p: '폰' };
const FILES = 'abcdefgh';
const other = (c: Color): Color => (c === 'white' ? 'black' : 'white');
// ---------------------------------------------------------------- squares
/** Board array index (a8 = 0 … h1 = 63) for a square name. */
const idxOf = (sq: string) => (8 - Number(sq[1])) * 8 + FILES.indexOf(sq[0]!);
const sqOf = (file: number, rank: number) => `${FILES[file]}${rank + 1}`;
/** Display cell (0..7 from the top-left) for a square, given which colour sits at the bottom. */
function cellOf(sq: string, bottom: Color): { x: number; y: number } {
const f = FILES.indexOf(sq[0]!);
const r = Number(sq[1]) - 1;
return bottom === 'white' ? { x: f, y: 7 - r } : { x: 7 - f, y: r };
}
function squareAt(x: number, y: number, bottom: Color): string {
return bottom === 'white' ? sqOf(x, 7 - y) : sqOf(7 - x, y);
}
const pieceAt = (v: ChessView, sq: string) => v.board[idxOf(sq)] ?? null;
const colorOfPiece = (p: string): Color => (p === p.toUpperCase() ? 'white' : 'black');
// ---------------------------------------------------------------- pieces (hand-drawn SVG, 100×100 box)
const BASE = 'M20 91 H80 Q84 91 84 87 V84 Q84 79 79 79 H21 Q16 79 16 84 V87 Q16 91 20 91 Z';
const ROYAL_BODY = 'M30 79 L36 57 H64 L70 79 Z';
const SHAPES: Record<PieceType, { fill: string[]; balls?: [number, number, number][]; detail?: string[] }> = {
p: {
fill: [
'M33 79 C35 68 39 62 43 58 H57 C61 62 65 68 67 79 Z',
'M38 59 H62 Q65 59 65 56 Q65 52 61 52 H39 Q35 52 35 56 Q35 59 38 59 Z',
],
balls: [[50, 38, 14]],
},
r: {
fill: ['M30 79 L34 47 H66 L70 79 Z', 'M28 47 L27 19 H37 V26 H45 V19 H55 V26 H63 V19 H73 L72 47 Z'],
detail: ['M33 39 H67', 'M33 66 H67'],
},
b: {
fill: [
'M35 79 C36 72 40 68 42 64 H58 C60 68 64 72 65 79 Z',
'M37 65 H63 Q66 65 66 62 Q66 58 62 58 H38 Q34 58 34 62 Q34 65 37 65 Z',
'M50 17 C62 26 68 38 65 49 Q63 56 58 58 H42 Q37 56 35 49 C32 38 38 26 50 17 Z',
],
balls: [[50, 13, 5.5]],
detail: ['M57 29 L47 42'],
},
n: {
fill: [
'M33 79 C32 66 37 58 46 51 C41 51 36 53 31 57 C27 60 21 58 20 52 C19 47 23 43 28 39 L43 25 C45 20 48 16 52 12 L56 19 C71 22 80 40 77 79 Z',
],
detail: ['M61 26 C69 36 72 52 70 70'],
balls: [
[44, 32, 2.8],
[26, 51, 1.6],
],
},
q: {
fill: [ROYAL_BODY, 'M32 57 L19 30 L33 44 L35 24 L44 41 L50 19 L56 41 L65 24 L67 44 L81 30 L68 57 Z'],
balls: [
[19, 29, 4.5],
[35, 23, 4.5],
[50, 17, 4.5],
[65, 23, 4.5],
[81, 29, 4.5],
],
detail: ['M34 63 H66'],
},
k: {
fill: [
ROYAL_BODY,
'M32 57 C23 48 25 33 36 33 C43 33 47 38 50 44 C53 38 57 33 64 33 C75 33 77 48 68 57 Z',
'M47 8 H53 V16 H60 V22 H53 V36 H47 V22 H40 V16 H47 Z',
],
detail: ['M34 63 H66', 'M50 44 V57'],
},
};
function PieceShape({ type, color }: { type: PieceType; color: Color }) {
const shape = SHAPES[type];
const fill = color === 'white' ? '#fbfaf5' : '#2c2a28';
const stroke = color === 'white' ? '#1d1b19' : '#0c0b0a';
const detail = color === 'white' ? '#1d1b19' : '#ece7dc';
return (
<g strokeLinejoin="round" strokeLinecap="round">
<path d={BASE} fill={fill} stroke={stroke} strokeWidth={3.2} />
{shape.fill.map((d, i) => (
<path key={i} d={d} fill={fill} stroke={stroke} strokeWidth={3.2} />
))}
{shape.balls?.map(([cx, cy, r], i) =>
type === 'n' ? (
<circle key={i} cx={cx} cy={cy} r={r} fill={detail} />
) : (
<circle key={i} cx={cx} cy={cy} r={r} fill={fill} stroke={stroke} strokeWidth={3.2} />
),
)}
{shape.detail?.map((d, i) => (
<path key={`d${i}`} d={d} fill="none" stroke={detail} strokeWidth={2.6} />
))}
</g>
);
}
/** A piece as a standalone inline icon (player bars, picker, rules). */
function PieceIcon({ type, color, size = 22 }: { type: PieceType; color: Color; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 100 100" aria-hidden style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<PieceShape type={type} color={color} />
</svg>
);
}
function letterPiece(p: string): { type: PieceType; color: Color } {
return { type: p.toLowerCase() as PieceType, color: colorOfPiece(p) };
}
// ---------------------------------------------------------------- board
const LIGHT = '#efd9b4';
const DARK = '#b38660';
function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
const myTurn = v.phase === 'playing' && v.myColor !== null && v.myColor === v.turn;
const [flip, setFlip] = useState(false);
const bottom: Color = v.myColor ?? (flip ? 'black' : 'white');
const [selected, setSelected] = useState<string | null>(null);
const [hover, setHover] = useState<string | null>(null);
const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 4, y: 6 });
const [kbd, setKbd] = useState(false);
const [promo, setPromo] = useState<{ from: string; to: string } | null>(null);
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
const drag = useRef<{ from: string; wasSelected: boolean; sx: number; sy: number; moved: boolean } | null>(null);
const [announce, setAnnounce] = useState('');
const svgRef = useRef<SVGSVGElement>(null);
const prevCount = useRef(v.moves.length);
const movedRef = useRef<SVGGElement>(null);
const dropped = useRef(false);
const byFrom = useMemo(() => {
const m = new Map<string, LegalMove[]>();
for (const mv of v.legalMoves ?? []) m.set(mv.from, [...(m.get(mv.from) ?? []), mv]);
return m;
}, [v.legalMoves]);
const targets = selected ? (byFrom.get(selected) ?? []) : [];
const showDots = v.hints !== 'off';
useEffect(() => {
if (v.moves.length !== prevCount.current) {
const last = v.moves[v.moves.length - 1];
if (v.moves.length > prevCount.current && last) {
// Slide the moved piece in (~150ms) unless I just dropped it there myself.
const el = movedRef.current;
if (el && v.lastMove && !dropped.current && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
const a = cellOf(v.lastMove.from, bottom);
const b = cellOf(v.lastMove.to, bottom);
el.animate([{ transform: `translate(${a.x - b.x}px, ${a.y - b.y}px)` }, { transform: 'translate(0px, 0px)' }], { duration: 150, easing: 'ease-out' });
}
sounds.stone();
setAnnounce(`${COLOR_KO[last.color]}: ${last.san}${v.checkSquare ? ', 체크!' : ''}`);
if (myTurn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
} else setAnnounce('수를 물렀어요.');
}
prevCount.current = v.moves.length;
dropped.current = false;
setSelected(null);
setPromo(null);
}, [v.moves.length]); // eslint-disable-line react-hooks/exhaustive-deps
const send = (from: string, to: string, promotion?: Promo) => {
act({ type: 'move', from, to, ...(promotion ? { promotion } : {}) });
setSelected(null);
setPromo(null);
setHover(null);
};
const attempt = (from: string, to: string) => {
const mv = (byFrom.get(from) ?? []).find((m) => m.to === to);
if (!mv || pending) return;
if (mv.promotion && !v.autoQueen) setPromo({ from, to });
else send(from, to);
};
const ownMovable = (sq: string) => myTurn && byFrom.has(sq);
const click = (sq: string, wasSelected: boolean) => {
if (!myTurn) return;
if (selected && targets.some((m) => m.to === sq)) return attempt(selected, sq);
const p = pieceAt(v, sq);
if (p && colorOfPiece(p) === v.myColor) {
if (!byFrom.has(sq)) {
setSelected(null);
setAnnounce(`${PIECE_KO[p.toLowerCase() as PieceType]}은 지금 움직일 수 없어요.`);
haptic([30, 30, 30]);
return;
}
setSelected(wasSelected ? null : sq);
return;
}
if (selected) {
setAnnounce('그 칸으로는 갈 수 없어요.');
haptic([30, 30, 30]);
}
setSelected(null);
};
const toSvg = (e: React.PointerEvent<SVGSVGElement>) => {
const svg = svgRef.current;
if (!svg) return null;
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
return pt.matrixTransform(svg.getScreenCTM()!.inverse());
};
const cellAt = (e: React.PointerEvent<SVGSVGElement>): string | null => {
const p = toSvg(e);
if (!p) return null;
const x = Math.floor(p.x);
const y = Math.floor(p.y);
return x >= 0 && y >= 0 && x < 8 && y < 8 ? squareAt(x, y, bottom) : null;
};
const onKey = (e: React.KeyboardEvent) => {
setKbd(true);
const d: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const step = d[e.key];
if (step) {
e.preventDefault();
setCursor((c) => ({ x: Math.min(7, Math.max(0, c.x + step[0])), y: Math.min(7, Math.max(0, c.y + step[1])) }));
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const sq = squareAt(cursor.x, cursor.y, bottom);
click(sq, selected === sq);
} else if (e.key === 'Escape') setSelected(null);
};
const cell = (sq: string) => cellOf(sq, bottom);
const lastFrom = v.lastMove ? cell(v.lastMove.from) : null;
const lastTo = v.lastMove ? cell(v.lastMove.to) : null;
const check = v.checkSquare ? cell(v.checkSquare) : null;
const hoverTarget = hover && selected && targets.some((m) => m.to === hover) ? cell(hover) : null;
const dragging = drag.current?.moved && dragPos ? drag.current.from : null;
const cursorSq = squareAt(cursor.x, cursor.y, bottom);
return (
<div className="board-wrap stack" style={{ gap: 10 }}>
<svg
ref={svgRef}
viewBox="-0.02 -0.02 8.04 8.04"
style={{
width: '100%',
display: 'block',
borderRadius: 8,
boxShadow: 'var(--shadow)',
touchAction: 'none',
userSelect: 'none',
cursor: dragging ? 'grabbing' : hover && (ownMovable(hover) || hoverTarget) ? 'pointer' : 'default',
}}
tabIndex={0}
role="grid"
aria-label={`체스판. ${v.myColor ? `나는 ${COLOR_KO[v.myColor]}이에요. ` : ''}방향키로 칸을 고르고 Enter로 기물을 집거나 놓아요. 현재 칸 ${cursorSq}${pieceAt(v, cursorSq) ? ` ${COLOR_KO[colorOfPiece(pieceAt(v, cursorSq)!)]} ${PIECE_KO[pieceAt(v, cursorSq)!.toLowerCase() as PieceType]}` : ''}`}
onKeyDown={onKey}
onPointerDown={(e) => {
setKbd(false);
const sq = cellAt(e);
// 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);
}}
onPointerMove={(e) => {
const sq = cellAt(e);
if (sq !== hover) setHover(sq);
const d = drag.current;
if (!d) return;
if (!d.moved && Math.hypot(e.clientX - d.sx, e.clientY - d.sy) > 6) d.moved = true;
if (d.moved) setDragPos(toSvg(e));
}}
onPointerLeave={() => setHover(null)}
onPointerUp={(e) => {
const sq = cellAt(e);
const d = drag.current;
drag.current = null;
setDragPos(null);
if (d?.moved) {
if (sq && sq !== d.from) {
dropped.current = true;
attempt(d.from, sq);
}
return;
}
if (sq) click(sq, d ? d.wasSelected : false);
}}
onPointerCancel={() => {
drag.current = null;
setDragPos(null);
}}
>
<defs>
<radialGradient id="chess-check">
<stop offset="0%" stopColor="#ff2a2a" stopOpacity={0.95} />
<stop offset="45%" stopColor="#e11d1d" stopOpacity={0.75} />
<stop offset="100%" stopColor="#a10000" stopOpacity={0} />
</radialGradient>
</defs>
{Array.from({ length: 64 }, (_, i) => {
const x = i % 8;
const y = Math.floor(i / 8);
return <rect key={i} x={x} y={y} width={1} height={1} fill={(x + y) % 2 === 0 ? LIGHT : DARK} />;
})}
{lastFrom && <rect x={lastFrom.x} y={lastFrom.y} width={1} height={1} fill="#cdd26a" opacity={0.75} />}
{lastTo && <rect x={lastTo.x} y={lastTo.y} width={1} height={1} fill="#aaa23a" opacity={0.6} />}
{selected && <rect x={cell(selected).x} y={cell(selected).y} width={1} height={1} fill="#4f7a28" opacity={0.55} />}
{v.threatened?.map((sq) => {
const c = cell(sq);
return <rect key={`t${sq}`} x={c.x + 0.04} y={c.y + 0.04} width={0.92} height={0.92} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.14 0.08" />;
})}
{check && <rect x={check.x} y={check.y} width={1} height={1} fill="url(#chess-check)" />}
{/* Coordinates: files on the bottom row, ranks on the left column. */}
{Array.from({ length: 8 }, (_, i) => (
<g key={`c${i}`} fontSize={0.2} fontWeight={700} style={{ pointerEvents: 'none' }}>
<text x={i + 0.94} y={7.94} textAnchor="end" fill={(i + 7) % 2 === 0 ? DARK : LIGHT}>
{squareAt(i, 7, bottom)[0]}
</text>
<text x={0.06} y={i + 0.22} fill={i % 2 === 0 ? DARK : LIGHT}>
{squareAt(0, i, bottom)[1]}
</text>
</g>
))}
{hoverTarget && <rect x={hoverTarget.x} y={hoverTarget.y} width={1} height={1} fill="#4f7a28" opacity={0.35} />}
{v.board.map((p, i) => {
if (!p) return null;
const sq = sqOf(i % 8, 7 - Math.floor(i / 8));
if (sq === dragging) return null;
const c = cell(sq);
const { type, color } = letterPiece(p);
return (
<g key={sq} transform={`translate(${c.x} ${c.y})`} style={{ pointerEvents: 'none' }}>
<g ref={v.lastMove?.to === sq ? movedRef : undefined}>
<g transform="translate(0.04 0.04) scale(0.0092)">
<PieceShape type={type} color={color} />
</g>
</g>
</g>
);
})}
{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' }} />
) : (
<circle key={`m${m.to}`} cx={c.x + 0.5} cy={c.y + 0.5} r={0.16} fill="rgba(20,40,10,0.38)" style={{ pointerEvents: 'none' }} />
);
})}
{check && (
<g transform={`translate(${check.x + 0.5} ${check.y < 1 ? check.y + 1.22 : check.y - 0.22})`} style={{ pointerEvents: 'none' }}>
<rect x={-0.42} y={-0.17} width={0.84} height={0.34} rx={0.12} fill="#b91c1c" />
<text textAnchor="middle" dominantBaseline="central" fontSize={0.2} fontWeight={800} fill="#fff">
체크!
</text>
</g>
)}
{dragging && dragPos && (
<g transform={`translate(${dragPos.x - 0.55} ${dragPos.y - 0.6}) scale(0.011)`} style={{ pointerEvents: 'none' }}>
<PieceShape {...letterPiece(pieceAt(v, dragging)!)} />
</g>
)}
{kbd && <rect x={cursor.x + 0.03} y={cursor.y + 0.03} width={0.94} height={0.94} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
</svg>
<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)}>
⇅ 판 돌리기
</button>
</div>
)}
{promo && v.myColor && (
<Sheet onClose={() => setPromo(null)} label="승진할 기물 고르기">
<h2>어떤 기물로 바꿀까요?</h2>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr', gap: 8 }}>
{(['q', 'r', 'b', 'n'] as const).map((t) => (
<button
key={t}
className={`btn ${t === 'q' ? 'btn-primary' : 'btn-secondary'}`}
style={{ flexDirection: 'column', padding: 8, minHeight: 96 }}
autoFocus={t === 'q'}
onClick={() => send(promo.from, promo.to, t)}
>
<PieceIcon type={t} color={v.myColor!} size={t === 'q' ? 60 : 44} />
<span>{PIECE_KO[t]}</span>
</button>
))}
</div>
<button className="btn btn-ghost btn-block" onClick={() => setPromo(null)}>
취소
</button>
</Sheet>
)}
</div>
);
}
// ---------------------------------------------------------------- side panel
const REASON_HELP: Partial<Record<NonNullable<ChessView['result']>['reason'], string>> = {
checkmate: '킹이 공격받고 있는데 피할 곳도, 막을 방법도 없어요.',
stalemate: '체크는 아니지만 둘 수 있는 수가 하나도 없어서 비겼어요.',
insufficientMaterial: '남은 기물로는 어느 쪽도 체크메이트할 수 없어서 비겼어요.',
threefold: '같은 국면이 세 번 나와서 비겼어요.',
fivefold: '같은 국면이 다섯 번 나와서 비겼어요.',
fiftyMove: '50수 동안 폰 이동도 잡기도 없어서 비겼어요.',
seventyFiveMove: '75수 동안 폰 이동도 잡기도 없어서 비겼어요.',
timeoutVsInsufficient: '시간이 다 됐지만 상대도 체크메이트할 기물이 없어서 비겼어요.',
aborted: '첫 수를 두지 않아 대국이 취소됐어요. 승패는 기록되지 않아요.',
};
function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<ChessView>) {
const [confirm, setConfirm] = useState<null | 'resign' | 'draw' | 'claim'>(null);
const mine = v.myColor;
const incoming =
v.phase === 'playing' && mine
? v.undo.pending && v.undo.pending.by !== mine
? 'undo'
: v.draw.pendingBy && v.draw.pendingBy !== mine
? 'draw'
: null
: null;
const outgoing = !!mine && (v.undo.pending?.by === mine || v.draw.pendingBy === mine);
const undoLeft = mine ? v.undo.remaining[mine] : null;
const top: Color = mine ? other(mine) : 'black';
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 && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{v.undo.allowed && (
<button
className="btn btn-secondary btn-sm"
disabled={outgoing || !!v.draw.pendingBy || undoLeft === 0 || !v.moves.some((m) => m.color === mine)}
onClick={() => act({ type: 'requestUndo' })}
title={undoLeft !== null ? `${undoLeft}번 남음` : '무제한'}
>
↩ 무르기{undoLeft !== null ? ` ${undoLeft}` : ''}
</button>
)}
{v.draw.allowed && (
<button className="btn btn-secondary btn-sm" disabled={outgoing || !!v.undo.pending} onClick={() => setConfirm('draw')}>
🤝 무승부
</button>
)}
<button className="btn btn-danger btn-sm" onClick={() => setConfirm('resign')}>
{beforeFirst ? '✕ 대국 취소' : '🏳 기권'}
</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수 규칙'})
</button>
)}
{outgoing && (
<div className="card row" style={{ justifyContent: 'space-between', padding: 10 }}>
<span className="small">상대의 답을 기다리는 중…</span>
<button className="btn btn-ghost btn-sm" onClick={() => act({ type: 'cancelOffer' })}>
요청 취소
</button>
</div>
)}
{v.result && REASON_HELP[v.result.reason] && (
<div className="card" style={{ padding: 12 }}>
<strong>{v.result.summary}</strong>
<div className="muted small">{REASON_HELP[v.result.reason]}</div>
</div>
)}
<MoveList view={v} />
{incoming && (
<Sheet onClose={() => {}} label="상대의 요청">
<h2>{incoming === 'undo' ? `상대가 ${v.undo.pending!.plies}수 무르기를 요청했어요` : '상대가 무승부를 제안했어요'}</h2>
<p className="muted">{incoming === 'undo' ? '수락하면 기물이 되돌아가요. 시계는 되돌아가지 않아요.' : '수락하면 이 판은 무승부로 끝나요. 거절하거나 내가 수를 두면 제안이 사라져요.'}</p>
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: true } : { type: 'respondDraw', accept: true })}>
수락
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} disabled={pending} onClick={() => act(incoming === 'undo' ? { type: 'respondUndo', accept: false } : { type: 'respondDraw', accept: false })}>
거절
</button>
</div>
</Sheet>
)}
{confirm === 'resign' &&
(beforeFirst ? (
<ConfirmSheet title="대국을 취소할까요?" body="아직 첫 수를 두기 전이라 승패 없이 끝나요." confirm="대국 취소" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
) : (
<ConfirmSheet title="기권할까요?" body="기권하면 이 판은 상대가 이겨요." confirm="기권하기" danger onConfirm={() => act({ type: 'resign' })} onClose={() => setConfirm(null)} />
))}
{confirm === 'draw' && <ConfirmSheet title="무승부를 제안할까요?" body="상대가 수락하면 비겨요." confirm="제안하기" onConfirm={() => act({ type: 'offerDraw' })} onClose={() => setConfirm(null)} />}
{confirm === 'claim' && (
<ConfirmSheet title="무승부를 주장할까요?" body="규칙에 따라 상대 동의 없이 바로 비겨요." confirm="무승부 주장" onConfirm={() => act({ type: 'claimDraw' })} onClose={() => setConfirm(null)} />
)}
</>
);
}
function MoveList({ view: v }: { view: ChessView }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight });
}, [v.moves.length]);
if (v.moves.length === 0) return null;
// Rows of [white, black]; a game started by black would show "…" first (not possible in standard chess).
const rows: { no: number; w?: string; b?: string }[] = [];
for (const m of v.moves) {
if (m.color === 'white' || rows.length === 0) rows.push({ no: rows.length + 1 });
rows[rows.length - 1]![m.color === 'white' ? 'w' : 'b'] = m.san;
}
const lastIdx = v.moves.length - 1;
return (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기보 ({Math.ceil(v.moves.length / 2)}수)</summary>
<div ref={ref} style={{ marginTop: 8, maxHeight: 180, overflow: 'auto', display: 'grid', gridTemplateColumns: '2.5em 1fr 1fr', gap: '2px 8px', fontVariantNumeric: 'tabular-nums' }}>
{rows.map((r, i) => (
<MoveRow key={i} no={r.no} w={r.w} b={r.b} lastW={i * 2 === lastIdx} lastB={i * 2 + 1 === lastIdx} />
))}
</div>
</details>
);
}
function MoveRow({ no, w, b, lastW, lastB }: { no: number; w?: string; b?: string; lastW: boolean; lastB: boolean }) {
const cell = (san: string | undefined, last: boolean) => (
<span className="small" style={{ fontWeight: last ? 800 : 500, background: last ? 'var(--surface-2)' : undefined, borderRadius: 4, padding: '0 4px' }}>
{san ?? ''}
</span>
);
return (
<>
<span className="small muted">{no}.</span>
{cell(w, lastW)}
{cell(b, lastB)}
</>
);
}
function fmtClock(ms: number): string {
const clamped = Math.max(0, ms);
if (clamped < 20_000) return `${Math.floor(clamped / 1000)}.${Math.floor((clamped % 1000) / 100)}`;
const s = Math.ceil(clamped / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return h ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`;
}
function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView; color: Color; players: Record<string, PublicUser>; deadline: number | null; me: string | null }) {
const id = v.players[color];
const u = players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' as const };
const active = v.phase === 'playing' && v.turn === color;
const now = useServerNow(100);
const tc = v.timeControl;
let clock: React.ReactNode = null;
if (active && !v.started[color]) {
clock = (
<span className="row" style={{ gap: 6 }}>
<span className="small muted">첫 수</span>
<TurnTimer deadline={deadline} totalMs={v.firstMoveSec * 1000} mine={id === me} />
</span>
);
} else if (tc.kind === 'fischer') {
const ms = v.remainingMs[color] - (active && v.started[color] ? now - v.turnStartedAt : 0);
const low = ms <= 10_000;
clock = (
<span
style={{
fontVariantNumeric: 'tabular-nums',
fontWeight: 800,
fontSize: '1.35rem',
padding: '2px 10px',
borderRadius: 8,
background: active ? (low ? 'var(--danger)' : 'var(--text)') : 'var(--surface-2)',
color: active ? 'var(--surface)' : low ? 'var(--danger)' : undefined,
}}
role="timer"
aria-label={`${COLOR_KO[color]} 남은 시간 ${fmtClock(ms)}`}
>
{fmtClock(ms)}
</span>
);
} else if (active) {
clock = <TurnTimer deadline={deadline} totalMs={v.idleLimitSec * 1000} mine={id === me} />;
}
const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b));
const adv = color === 'white' ? v.materialDiff : -v.materialDiff;
return (
<div className="card" style={{ padding: '8px 12px', border: `3px solid ${active ? 'var(--primary)' : 'transparent'}`, transition: 'border-color 0.2s' }}>
<div className="row" style={{ gap: 10 }}>
<PieceIcon type="k" color={color} size={30} />
<Avatar user={u} size={32} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{u.nickname}
{id === me && <span className="muted small"> (나)</span>}
</div>
<div className="muted small">
{COLOR_KO[color]}
{active ? ' · 두는 중' : ''}
</div>
</div>
{clock}
</div>
{(caps.length > 0 || adv > 0) && (
<div className="row" style={{ gap: 0, marginTop: 4, minHeight: 20, flexWrap: 'wrap' }} aria-label={`잡은 기물 ${caps.map((c) => PIECE_KO[c as PieceType]).join(', ')}`}>
{caps.map((c, i) => (
<PieceIcon key={i} type={c as PieceType} color={other(color)} size={20} />
))}
{adv > 0 && <span className="small muted" style={{ marginLeft: 6, fontWeight: 700 }}>+{adv}</span>}
</div>
)}
</div>
);
}
function statusText(v: ChessView, _me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
const mine = v.myColor;
if (mine && v.undo.pending && v.undo.pending.by !== mine) return '상대가 무르기를 요청했어요';
if (mine && v.draw.pendingBy && v.draw.pendingBy !== mine) return '상대가 무승부를 제안했어요';
if (mine === v.turn) {
if (v.checkSquare) return '체크! 킹을 지켜야 해요';
if (!v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`;
if (v.claimable) return '내 차례예요. 무승부를 주장할 수 있어요';
return `내 차례예요 (${COLOR_KO[mine]}). 움직일 기물을 눌러 주세요`;
}
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
const check = v.checkSquare ? ' (체크)' : '';
return mine ? `${nick}님이 생각 중이에요…${check}` : `${nick}님(${COLOR_KO[v.turn]}) 차례예요${check}`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
const TIME_PRESETS: [string, string, ChessOptions['timeControl']][] = [
['3+2', '3+2 블리츠', { kind: 'fischer', baseMin: 3, incSec: 2 }],
['5+0', '5+0', { kind: 'fischer', baseMin: 5, incSec: 0 }],
['5+3', '5+3', { kind: 'fischer', baseMin: 5, incSec: 3 }],
['10+0', '10+0 래피드', { kind: 'fischer', baseMin: 10, incSec: 0 }],
['10+5', '10+5', { kind: 'fischer', baseMin: 10, incSec: 5 }],
['15+10', '15+10', { kind: 'fischer', baseMin: 15, incSec: 10 }],
['30+0', '30+0 클래식', { kind: 'fischer', baseMin: 30, incSec: 0 }],
['none', '시간 제한 없음', { kind: 'none', baseMin: 10, incSec: 5 }],
];
const timeKey = (tc: ChessOptions['timeControl']) => (tc.kind === 'none' ? 'none' : `${tc.baseMin}+${tc.incSec}`);
function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<ChessOptions>) => onChange({ ...o, ...patch });
const key = timeKey(o.timeControl);
const isPreset = TIME_PRESETS.some((p) => p[0] === key);
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
value={key}
options={[...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), ...(isPreset ? [] : [[key, `${timeLabel(o.timeControl)} (직접 설정)`] as [string, string]])]}
onChange={(k) => {
const preset = TIME_PRESETS.find((p) => p[0] === k);
if (preset) set({ timeControl: preset[2] });
}}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>백(먼저 두는 쪽)</strong>
<Seg
value={o.colorAssignment}
options={[['random', '무작위'], ['hostWhite', '방장이 백'], ['hostBlack', '방장이 흑'], ['alternate', '판마다 번갈아']]}
onChange={(colorAssignment) => set({ colorAssignment })}
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>초보 도움말</strong>
<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 ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
{o.timeControl.kind === 'fischer' && (
<div className="stack" style={{ gap: 6 }}>
<strong>시간 직접 정하기</strong>
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
<label className="row" style={{ gap: 6 }}>
기본
<input
className="input"
type="number"
min={1}
max={90}
value={o.timeControl.baseMin}
onChange={(e) => set({ timeControl: { ...o.timeControl, baseMin: num(Number(e.target.value), 1, 90) } })}
style={{ width: 80 }}
/>
분
</label>
<label className="row" style={{ gap: 6 }}>
+ 한 수마다
<input
className="input"
type="number"
min={0}
max={60}
value={o.timeControl.incSec}
onChange={(e) => set({ timeControl: { ...o.timeControl, incSec: num(Number(e.target.value), 0, 60) } })}
style={{ width: 80 }}
/>
초
</label>
</div>
</div>
)}
{o.timeControl.kind === 'none' && (
<div className="stack" style={{ gap: 6 }}>
<strong>응답이 없으면 시간패</strong>
<Seg value={o.idleLimitSec} options={[[300, '5분'], [600, '10분']]} onChange={(idleLimitSec) => set({ idleLimitSec })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}>
<strong>첫 수 제한 (넘기면 대국 취소)</strong>
<Seg value={o.firstMoveSec} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(firstMoveSec) => set({ firstMoveSec })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>3회 반복 · 50수 무승부</strong>
<Seg value={o.drawMode} options={[['auto', '자동으로 무승부'], ['fide', 'FIDE (주장해야 무승부)']]} onChange={(drawMode) => set({ drawMode })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>무르기 (상대 동의)</strong>
<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.autoQueen ? 'on' : 'off'} options={[['off', '승진할 때 기물 고르기'], ['on', '승진하면 바로 퀸']]} onChange={(k) => set({ autoQueen: k === 'on' })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div>
</>
)}
</div>
);
}
function timeLabel(tc: ChessOptions['timeControl']): string {
if (tc.kind === 'none') return '시간 제한 없음';
return `${tc.baseMin}분 + ${tc.incSec}초`;
}
function optionsSummary(o: ChessOptions): string {
const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`;
return [o.variant === 'chess960' ? '체스960' : '표준', timeLabel(o.timeControl), undo].join(' · ');
}
// ---------------------------------------------------------------- rules
const PIECE_RULES: [PieceType, string][] = [
['k', '가로·세로·대각선으로 한 칸. 공격받는 칸으로는 갈 수 없어요. 잡히면 안 되는 가장 중요한 기물이에요.'],
['q', '가로·세로·대각선으로 몇 칸이든. 가장 강한 기물이에요(9점).'],
['r', '가로·세로로 몇 칸이든(5점).'],
['b', '대각선으로 몇 칸이든. 처음 칸과 같은 색 칸만 다녀요(3점).'],
['n', "'ㄱ'자(두 칸 + 옆 한 칸)로 움직이고, 다른 기물을 뛰어넘을 수 있어요(3점)."],
['p', '앞으로 한 칸(처음 자리에서는 두 칸까지). 잡을 때만 대각선 앞으로 한 칸 가요(1점).'],
];
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>) 이겨요.
내 킹이 공격받게 되는 수는 둘 수 없어요.
</p>
<div className="stack" style={{ gap: 6 }}>
{PIECE_RULES.map(([t, text]) => (
<div key={t} className="row" style={{ gap: 10, alignItems: 'flex-start' }}>
<PieceIcon type={t} color="white" size={34} />
<div>
<strong>{PIECE_KO[t]}</strong> <span className="muted">{text}</span>
</div>
</div>
))}
</div>
{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>
<p>
<strong>승진</strong>: 폰이 끝 줄에 닿으면 퀸·룩·비숍·나이트 중 하나로 바꿔요. 보통은 퀸을 골라요.
</p>
<p>
<strong>비기는 경우</strong>: 체크가 아닌데 둘 수가 없을 때(스테일메이트), 남은 기물로 체크메이트가 불가능할 때(예: 킹 대 킹), 같은 국면이 3번 나오거나 50수 동안 폰 이동과 잡기가
없을 때{fide ? '(이 방은 차례인 사람이 [무승부 주장]을 눌러야 해요. 5번 반복·75수는 자동)' : '(이 방은 자동으로 비겨요)'}, 그리고 서로 합의했을 때예요.
</p>
<p>
<strong>시간</strong>: 수를 둘 때마다 정해진 초가 더해져요. 시간이 다 되면 져요. 단 상대에게 체크메이트할 기물이 없으면 비겨요. 각자 첫 수는 시계가 줄지 않지만 정해진 시간 안에 두지
않으면 대국이 취소돼요.
</p>
<p>조작: 기물을 누르면 갈 수 있는 칸에 점이 찍혀요(잡을 수 있는 칸은 동그라미). 그 칸을 누르거나 기물을 끌어다 놓으면 둬요.</p>
</div>
);
}
const ui: GameUI<ChessView, ChessOptions> = {
Board: ChessBoard,
Side: ChessSide,
statusText,
isMyTurn: (v, me) => v.phase === 'playing' && !!v.myColor && v.players[v.turn] === me,
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View 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: '폰이 끝 줄에 닿으면 퀸 같은 강한 기물로 바뀌어요(승진). 내 킹이 공격받으면(체크) 꼭 먼저 피하거나 막아야 해요.',
},
];

View 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)}님이 졌고`);
});

View 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');
}

File diff suppressed because it is too large Load Diff

View 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장, 방 설정에 따라 다름) 모인 사람 한 명이 지고, 나머지는 모두 이겨요.',
},
];

View 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('[패스]');
});

View 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');
}

File diff suppressed because it is too large Load Diff

View 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: '카드를 먼저 다 낸 순서로 다음 신분이 정해져요. 농노는 가장 좋은 카드를 달무티에게 세금으로 주고, 같은 장수를 돌려받아요.',
},
];

View 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('내 숨은 타일 하나를 공개해야 해요');
});

View 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}`;
}
}

View File

@@ -0,0 +1,960 @@
/** 다빈치 코드 화면 (docs/games/davinci-code.md §9). */
import { useEffect, useRef, useState } from 'react';
import type { DavinciGuess, DavinciOptions, DavinciTileView, DavinciView, PhaseKind, TColor, TileValue } from '@bg/games/davinci-code';
import type { PublicUser } from '@bg/shared/lite';
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: '흰색' };
const NUMBERS = Array.from({ length: 12 }, (_, i) => i);
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 valueKo = (v: TileValue) => (v === 'joker' ? '조커' : String(v));
const faceOf = (v: TileValue) => (v === 'joker' ? '−' : String(v));
const hiddenLeft = (row: DavinciTileView[]) => row.filter((t) => !t.revealed).length;
function totalMsOf(v: DavinciView): number {
if (v.phase === 'chooseStart') return 15_000;
if ((v.idle[v.current] ?? 0) >= 3) return 5_000;
return v.phase === 'guess' ? v.options.turnSeconds * 1000 : 10_000;
}
/** Seats starting with the one after `me` (clockwise); spectators see everyone. */
function opponentsOf(v: DavinciView, me: string | null): string[] {
const i = me ? v.seats.indexOf(me) : -1;
if (i < 0) return v.seats;
return v.seats.map((_, k) => v.seats[(i + 1 + k) % v.seats.length]!).filter((p) => p !== me);
}
/** Colour+number combinations the viewer can already see (for the "impossible guess" warning). */
function visibleCombos(v: DavinciView): Set<string> {
const out = new Set<string>();
for (const row of Object.values(v.rows)) for (const t of row) if (t.n !== undefined) out.add(`${t.color}:${t.n}`);
if (v.pending?.n !== undefined) out.add(`${v.pending.color}:${v.pending.n}`);
return out;
}
function guessLine(g: DavinciGuess, players: Record<string, PublicUser>): string {
return `${nick(players, g.by)} → ${nick(players, g.target)} ${g.index + 1}번째 ${COLOR_SHORT[g.color]} = ${valueKo(g.said)} (${g.correct ? '맞힘' : '틀림'}${g.auto ? ', 시간 초과' : ''})`;
}
const CSS = `
.dv-board { width: 100%; max-width: 980px; max-height: 100%; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px; }
.dv-opps { display: grid; gap: 10px; }
.dv-player { background: var(--surface); border-radius: var(--radius-lg); padding: 10px 12px; box-shadow: var(--shadow); border: 3px solid transparent; }
.dv-player.turn { border-color: var(--focus); }
.dv-player.out { opacity: .7; }
.dv-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 32px; }
.dv-name { font-weight: 800; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dv-badge { display: inline-block; border-radius: 999px; padding: 1px 9px; font-weight: 800; font-size: .8rem; background: var(--surface-2); color: var(--text); }
.dv-badge.turn { background: var(--focus); color: #1c1e21; }
.dv-badge.out { background: var(--danger-soft); color: var(--danger); }
.dv-badge.ok { background: var(--success-soft); color: var(--success); }
.dv-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-end; padding-top: 8px; }
.dv-slot { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.dv-pos { font-size: .75rem; color: var(--text-sub); font-variant-numeric: tabular-nums; line-height: 1; }
.dv-tile { --w: 46px; width: var(--w); height: calc(var(--w) * 1.42); border-radius: 8px; display: flex; align-items: center; justify-content: center;
font-weight: 900; font-size: calc(var(--w) * .56); line-height: 1; position: relative; user-select: none; font-variant-numeric: tabular-nums;
box-shadow: 0 3px 0 rgba(0,0,0,.35); transition: transform .15s ease; }
.dv-tile.black { background: linear-gradient(160deg, #42454d, #141519); color: #fff; border: 2px solid color-mix(in srgb, var(--text) 30%, #050505); }
.dv-tile.white { background: linear-gradient(160deg, #ffffff, #e2ded3); color: #15161a; border: 2px solid #9d978a; }
.dv-tile.back.black { background: repeating-linear-gradient(45deg, #2a2c33 0 5px, #1b1c21 5px 10px); }
.dv-tile.back.white { background: repeating-linear-gradient(45deg, #fbf9f4 0 5px, #e6e1d6 5px 10px); }
.dv-tile .q { opacity: .45; font-size: .62em; }
.dv-tile.open::after { content: ''; position: absolute; left: 18%; right: 18%; bottom: 4px; height: 4px; border-radius: 2px; background: #f59e0b; }
.dv-tile.secret { outline: 3px dashed var(--primary); outline-offset: 2px; }
.dv-tile.sm { --w: 30px; }
.dv-tile.lg { --w: 54px; }
.dv-tile.hit { animation: dv-flip .5s ease-out, dv-hit 1.2s ease-out; }
.dv-tile.miss { animation: dv-flip .5s ease-out, dv-miss 1.2s ease-out; }
.dv-tile.new { animation: dv-drop .35s ease-out; }
@keyframes dv-flip { 0% { transform: rotateY(90deg); } 100% { transform: rotateY(0); } }
@keyframes dv-hit { 0%, 60% { box-shadow: 0 0 0 4px #22c55e, 0 0 18px 6px rgba(34,197,94,.7); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
@keyframes dv-miss { 0%, 60% { box-shadow: 0 0 0 4px #ef4444, 0 0 18px 6px rgba(239,68,68,.6); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
@keyframes dv-drop { 0% { transform: translateY(-14px); opacity: .3; } 100% { transform: translateY(0); opacity: 1; } }
.dv-pick { background: none; border: 0; padding: 3px; border-radius: 11px; cursor: pointer; color: inherit; }
.dv-pick:disabled { cursor: default; }
.dv-pick.can { box-shadow: 0 0 0 3px var(--primary); }
.dv-pick.can:hover .dv-tile { transform: translateY(-3px); }
.dv-pick.sel { box-shadow: 0 0 0 4px var(--focus); }
.dv-pick.sel .dv-tile { transform: translateY(-6px); }
.dv-pick:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-gap { align-self: stretch; min-width: 28px; min-height: 48px; border: 2px dashed var(--primary); background: var(--primary-soft); color: var(--text);
border-radius: 8px; font-weight: 800; cursor: pointer; padding: 0 2px; font-size: .8rem; }
.dv-gap:focus-visible { outline: 3px solid var(--focus); }
.dv-mid { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: 12px; align-items: start; }
.dv-center { background: var(--felt); color: #fff; border-radius: var(--radius-lg); padding: 12px; display: flex; flex-direction: column; gap: 10px; align-items: center; }
.dv-piles { display: flex; gap: 12px; justify-content: center; }
.dv-pile { display: flex; flex-direction: column; align-items: center; gap: 4px; background: rgba(0,0,0,.18); border: 2px solid transparent; border-radius: 12px;
padding: 8px 10px; color: #fff; font-weight: 800; min-width: 92px; min-height: 48px; cursor: pointer; font-size: .9rem; }
.dv-pile:disabled { cursor: default; opacity: .8; }
.dv-pile.can { border-color: #fde047; background: rgba(253,224,71,.15); }
.dv-pile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-stack { position: relative; width: 44px; height: 50px; }
.dv-stack .dv-tile { position: absolute; left: 0; }
.dv-pending { display: flex; align-items: center; gap: 10px; font-size: .9rem; }
.dv-panel { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.dv-pad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.dv-pad button { min-height: 56px; font-size: 1.35rem; font-weight: 900; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-variant-numeric: tabular-nums; }
.dv-pad button.unlikely { opacity: .4; }
.dv-pad button[aria-pressed='true'] { background: var(--primary); color: var(--primary-text); border-color: var(--primary); opacity: 1; }
.dv-pad button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-pad .joker { grid-column: 1 / -1; font-size: 1.1rem; }
.dv-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.dv-big .btn { min-height: 64px; font-size: 1.15rem; }
.dv-split { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.dv-split button { min-height: 56px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 700; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 8px; }
.dv-split button[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.dv-split button:disabled { opacity: .4; cursor: not-allowed; }
.dv-split button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.dv-mini { display: inline-block; width: 12px; height: 17px; border-radius: 3px; vertical-align: middle; }
.dv-mini.black { background: #1b1c21; border: 1px solid #000; }
.dv-mini.white { background: #fff; border: 1px solid #8f8a7e; }
.dv-me { border-color: var(--primary); }
.dv-legend { font-size: .85rem; color: var(--text-sub); display: flex; gap: 6px 16px; flex-wrap: wrap; align-items: center; }
.dv-legend > span { display: inline-flex; align-items: center; gap: 7px; }
.dv-me .dv-slot { gap: 6px; }
.dv-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; font-size: .9rem; }
.dv-log li.hit::before { content: '✓ '; color: var(--success); font-weight: 900; }
.dv-log li.miss::before { content: '✗ '; color: var(--danger); font-weight: 900; }
@media (max-width: 719px) {
.dv-tile { --w: 34px; border-radius: 6px; }
.dv-tile.lg { --w: 37px; }
.dv-row { gap: 4px; }
.dv-mid { grid-template-columns: minmax(0, 1fr); }
.dv-board { max-height: none; overflow: visible; }
}
@media (prefers-reduced-motion: reduce) {
.dv-tile.hit, .dv-tile.miss, .dv-tile.new { animation: none; }
}
`;
// ---------------------------------------------------------------- tiles
type Fx = 'hit' | 'miss' | 'new';
function Tile({ color, n, revealed, mine, size, fx }: { color: TColor; n?: TileValue; revealed: boolean; mine?: boolean; size?: 'sm' | 'lg'; fx?: Fx }) {
const known = n !== undefined;
const cls = ['dv-tile', color, known ? '' : 'back', revealed ? 'open' : '', mine && !revealed ? 'secret' : '', size ?? '', fx ?? ''].filter(Boolean).join(' ');
return (
<span className={cls} aria-hidden>
{known ? faceOf(n) : <span className="q">?</span>}
</span>
);
}
function tileLabel(t: DavinciTileView, pos: number, mine: boolean): string {
const what = t.n === undefined ? '숨김' : t.n === 'joker' ? '조커' : `${t.n}`;
return `${pos + 1}번째, ${COLOR_KO[t.color]} ${what}${t.revealed ? ', 공개됨' : mine ? ', 나만 보임' : ''}${t.wrong.length ? `, 틀린 추리 ${t.wrong.map(valueKo).join('·')}` : ''}`;
}
// ---------------------------------------------------------------- board
interface Sel {
target: string;
index: number;
}
function DavinciBoard({ view: v, me, players, act, pending, deadline }: BoardProps<DavinciView>) {
const amPlayer = !!me && v.seats.includes(me);
const myTurn = amPlayer && v.current === me && !!v.alive[me!] && v.phase !== 'chooseStart' && v.phase !== 'finished';
const [sel, setSel] = useState<Sel | null>(null);
const [val, setVal] = useState<TileValue | null>(null);
const [fx, setFx] = useState<Map<string, Fx>>(new Map());
const [announce, setAnnounce] = useState('');
const prevHist = useRef(v.history.length);
const prevPlaced = useRef(JSON.stringify(v.lastPlaced));
const prevTurn = useRef(`${v.current}|${v.phase === 'chooseStart'}`);
const resetKey = `${v.phase}|${v.current}|${v.history.length}`;
useEffect(() => {
setSel(null);
setVal(null);
}, [resetKey]);
// Effects + screen-reader text for new guesses and newly placed tiles.
useEffect(() => {
const placedNow = JSON.stringify(v.lastPlaced);
const next = new Map<string, Fx>();
if (v.history.length > prevHist.current) {
const g = v.history[v.history.length - 1]!;
if (g.correct) next.set(`${g.target}:${g.index}`, 'hit');
else if (v.lastPlaced && v.lastPlaced.player === g.by && v.lastPlaced.revealed) next.set(`${g.by}:${v.lastPlaced.index}`, 'miss');
setAnnounce(
`${nick(players, g.by)}님이 ${nick(players, g.target)}님 ${g.index + 1}번째 타일을 ${valueKo(g.said)}(으)로 추리해서 ${g.correct ? '맞혔어요' : '틀렸어요'}.`,
);
if (g.correct && g.by === me) haptic(30);
} else if (placedNow !== prevPlaced.current && v.lastPlaced) {
next.set(`${v.lastPlaced.player}:${v.lastPlaced.index}`, v.lastPlaced.revealed ? 'miss' : 'new');
}
prevHist.current = v.history.length;
prevPlaced.current = placedNow;
if (next.size) {
setFx(next);
const t = setTimeout(() => setFx(new Map()), 1300);
return () => clearTimeout(t);
}
return undefined;
}, [v.history.length, JSON.stringify(v.lastPlaced)]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const key = `${v.current}|${v.phase === 'chooseStart'}`;
if (key !== prevTurn.current && v.phase !== 'chooseStart' && v.phase !== 'finished') {
if (v.current === me) {
haptic(40);
sounds.myTurn();
setAnnounce('내 차례예요.');
} else setAnnounce(`${nick(players, v.current)}님 차례예요.`);
}
prevTurn.current = key;
}, [v.current, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
const canGuess = myTurn && v.phase === 'guess' && !pending;
const pick = (target: string, index: number) => {
if (sel && sel.target === target && sel.index === index) {
setSel(null);
setVal(null);
} else {
setSel({ target, index });
setVal(null);
}
};
return (
<div className="dv-board" role="region" aria-label="다빈치 코드 판">
<style>{CSS}</style>
<div className="dv-opps">
{opponentsOf(v, me).map((p) => (
<PlayerRow key={p} id={p} view={v} me={me} players={players} deadline={deadline} fx={fx} canPick={canGuess && !!v.alive[p]} sel={sel} onPick={pick} />
))}
</div>
<div className="dv-mid">
<Center view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} />
<ActionPanel view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} sel={sel} val={val} setVal={setVal} onCancel={() => (setSel(null), setVal(null))} />
</div>
{amPlayer && <MyRow view={v} me={me!} players={players} act={act} pending={pending} myTurn={myTurn} deadline={deadline} fx={fx} />}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function PlayerHead({ id, view: v, me, players, deadline }: { id: string; view: DavinciView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
const u = userOf(players, id);
const row = v.rows[id] ?? [];
const st = v.stats[id] ?? { guesses: 0, correct: 0 };
const outRank = v.eliminationOrder.indexOf(id);
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
const choosing = v.phase === 'chooseStart' && v.alive[id] && v.picked && !v.picked[id];
return (
<div className="dv-head">
<Avatar user={u} size={26} />
<span className="dv-name">
{u.nickname}
{id === me && <span className="muted"> (나)</span>}
</span>
{isTurn && <span className="dv-badge turn">차례</span>}
{v.final?.winner === id && <span className="dv-badge ok">승리</span>}
{!v.alive[id] && <span className="dv-badge out">{v.left[id] ? '나감' : '탈락'}{outRank >= 0 ? ` · ${v.seats.length - outRank}위` : ''}</span>}
{v.alive[id] && (v.idle[id] ?? 0) >= 3 && <span className="dv-badge">자리 비움</span>}
{v.phase === 'chooseStart' && v.alive[id] && <span className={`dv-badge${choosing ? '' : ' ok'}`}>{choosing ? '고르는 중' : '다 골랐어요'}</span>}
<span className="small muted" style={{ marginLeft: 'auto' }}>
{v.phase !== 'chooseStart' && `숨은 타일 ${hiddenLeft(row)}개 · `}맞힘 {st.correct}/{st.guesses}
</span>
{(isTurn || choosing) && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={id === me} size={30} />}
</div>
);
}
function PlayerRow({
id,
view: v,
me,
players,
deadline,
fx,
canPick,
sel,
onPick,
}: {
id: string;
view: DavinciView;
me: string | null;
players: Record<string, PublicUser>;
deadline: number | null;
fx: Map<string, Fx>;
canPick: boolean;
sel: Sel | null;
onPick: (target: string, index: number) => void;
}) {
const row = v.rows[id] ?? [];
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
const name = nick(players, id);
return (
<section className={`dv-player${isTurn ? ' turn' : ''}${v.alive[id] || v.phase === 'finished' ? '' : ' out'}`} aria-label={`${name}님 타일`}>
<PlayerHead id={id} view={v} me={me} players={players} deadline={deadline} />
<div className="dv-row" role="group" aria-label={`${name}님 타일 줄`}>
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일을 기다리는 중이에요' : '타일이 없어요'}</span>}
{row.map((t, i) => {
const can = canPick && !t.revealed;
const selected = sel?.target === id && sel.index === i;
return (
<div className="dv-slot" key={i}>
<button
type="button"
className={`dv-pick${can ? ' can' : ''}${selected ? ' sel' : ''}`}
disabled={!can}
aria-pressed={can ? selected : undefined}
aria-label={`${name}님 ${tileLabel(t, i, false)}${can ? ', 눌러서 추리' : ''}`}
onClick={() => onPick(id, i)}
>
<Tile color={t.color} n={t.n} revealed={t.revealed} fx={fx.get(`${id}:${i}`)} />
</button>
<span className="dv-pos" aria-hidden>
{i + 1}
</span>
</div>
);
})}
</div>
</section>
);
}
function MyRow({
view: v,
me,
players,
act,
pending,
myTurn,
deadline,
fx,
}: {
view: DavinciView;
me: string;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
myTurn: boolean;
deadline: number | null;
fx: Map<string, Fx>;
}) {
const row = v.rows[me] ?? [];
const placing = myTurn && v.phase === 'placeJoker' && !pending;
const revealing = myTurn && v.phase === 'revealOwn' && !pending;
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === me;
const gap = (index: number) =>
placing && (
<button key={`gap${index}`} type="button" className="dv-gap" aria-label={`${index + 1}번째 자리에 조커 넣기`} onClick={() => act({ type: 'placeJoker', index })}>
여기
</button>
);
return (
<section className={`dv-player dv-me${isTurn ? ' turn' : ''}${v.alive[me] || v.phase === 'finished' ? '' : ' out'}`} aria-label="내 타일">
<PlayerHead id={me} view={v} me={me} players={players} deadline={deadline} />
<div className="dv-row" role="group" aria-label="내 타일 줄">
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일 색을 고르면 여기에 놓여요' : '타일이 없어요'}</span>}
{row.map((t, i) => {
const can = revealing && !t.revealed;
return [
gap(i),
<div className="dv-slot" key={i}>
{can ? (
<button type="button" className="dv-pick can" aria-label={`내 ${tileLabel(t, i, true)}, 눌러서 공개`} onClick={() => act({ type: 'revealOwn', index: i })}>
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
</button>
) : (
<span className="dv-pick" role="img" aria-label={`내 ${tileLabel(t, i, true)}`}>
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
</span>
)}
<span className="dv-pos" aria-hidden>
{i + 1}
</span>
</div>,
];
})}
{gap(row.length)}
</div>
{row.length > 0 && (
<div className="dv-legend" style={{ marginTop: 6 }}>
<span>
<span className="dv-mini white" style={{ outline: '2px dashed var(--primary)', outlineOffset: 1 }} /> 점선: 상대에게 숨김
</span>
<span>
<span className="dv-mini white" style={{ boxShadow: 'inset 0 -4px 0 #f59e0b' }} /> 아래 노란 줄: 모두에게 공개됨
</span>
</div>
)}
</section>
);
}
function Center({ view: v, me, players, act, pending, myTurn }: { view: DavinciView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; myTurn: boolean }) {
const drawing = myTurn && v.phase === 'draw' && !pending;
const total = v.pool.black + v.pool.white;
const last = v.history[v.history.length - 1];
return (
<div className="dv-center" role="group" aria-label="더미">
{v.options.chooseColors ? (
<div className="dv-piles">
{(['black', 'white'] as const).map((c) => {
const can = drawing && v.pool[c] > 0;
return (
<button
key={c}
type="button"
className={`dv-pile${can ? ' can' : ''}`}
disabled={!can}
aria-label={`${COLOR_KO[c]} 타일 가져오기, ${v.pool[c]}개 남음`}
onClick={() => act({ type: 'draw', color: c })}
>
<span className="dv-stack" aria-hidden>
{v.pool[c] > 0 ? (
[0, 1, 2].slice(0, Math.min(3, v.pool[c])).map((k) => (
<span key={k} style={{ position: 'absolute', left: 7 + k * 3, top: 6 - k * 3 }}>
<Tile color={c} revealed={false} size="sm" />
</span>
))
) : (
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>없음</span>
)}
</span>
<span>
{COLOR_SHORT[c]} {v.pool[c]}개
</span>
</button>
);
})}
</div>
) : (
<button type="button" className={`dv-pile${drawing ? ' can' : ''}`} disabled={!drawing} aria-label={`타일 가져오기, ${total}개 남음`} onClick={() => act({ type: 'draw' })}>
<span>타일 가져오기</span>
<span className="small">
남은 타일 {total}개 (검정 {v.pool.black} · 흰색 {v.pool.white})
</span>
</button>
)}
{v.pending ? (
<div className="dv-pending">
<Tile color={v.pending.color} n={v.pending.n} revealed={false} mine={v.current === me} />
<span>
{v.current === me ? '내가 가져온 타일' : `${nick(players, v.current)}님이 가져온 타일`}
<br />
<span className="small" style={{ opacity: 0.85 }}>
{v.current === me ? '나만 보여요' : '색만 보여요'}
</span>
</span>
</div>
) : (
v.phase !== 'chooseStart' &&
v.phase !== 'finished' &&
total === 0 && <span className="small">더미가 비었어요. 가져오지 않고 바로 추리해요.</span>
)}
{last && (
<span className="small" style={{ textAlign: 'center' }} aria-hidden>
최근: {guessLine(last, players)}
</span>
)}
</div>
);
}
function ActionPanel({
view: v,
me,
players,
act,
pending,
myTurn,
sel,
val,
setVal,
onCancel,
}: {
view: DavinciView;
me: string | null;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
myTurn: boolean;
sel: Sel | null;
val: TileValue | null;
setVal: (v: TileValue | null) => void;
onCancel: () => void;
}) {
if (v.phase === 'finished') {
return (
<div className="dv-panel" aria-live="polite">
<strong>게임이 끝났어요</strong>
<span>{v.final?.summary}</span>
<span className="small muted">모든 타일을 공개했어요.</span>
</div>
);
}
if (v.phase === 'chooseStart') return <StartChooser view={v} me={me} act={act} pending={pending} />;
if (!myTurn) {
const who = nick(players, v.current);
const doing: Record<PhaseKind, string> = {
chooseStart: '',
draw: '타일을 가져오는 중이에요',
guess: '추리하는 중이에요',
afterCorrect: '맞혔어요! 계속할지 고르는 중이에요',
placeJoker: '조커를 넣을 자리를 고르는 중이에요',
revealOwn: '틀려서 자기 타일을 공개하는 중이에요',
finished: '',
};
return (
<div className="dv-panel">
<strong>
{who}님이 {doing[v.phase]}
</strong>
{me && v.seats.includes(me) && !v.alive[me] && <span className="small muted">나는 탈락했어요. 끝까지 구경할 수 있어요.</span>}
<span className="small muted">공개된 숫자와 타일 위치, 추리 기록을 보고 상대의 숫자를 짐작해 보세요.</span>
</div>
);
}
switch (v.phase) {
case 'draw':
return (
<div className="dv-panel">
<strong>더미에서 타일을 하나 가져와 주세요.</strong>
<span className="small muted">
{v.options.chooseColors ? '검정과 흰색 중 원하는 색 더미를 누르세요. ' : ''}가져온 타일의 숫자는 나만 봐요.
</span>
</div>
);
case 'guess':
return sel ? <NumberPad view={v} players={players} act={act} pending={pending} sel={sel} val={val} setVal={setVal} onCancel={onCancel} /> : (
<div className="dv-panel">
<strong>맞힐 상대 타일을 눌러 주세요.</strong>
<span className="small muted">파란 테두리가 있는 숨은 타일만 고를 수 있어요. 틀리면 {v.pending ? '가져온 타일이 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 타일 하나를 공개해야 해요' : '차례가 넘어가요'}.</span>
</div>
);
case 'afterCorrect':
return (
<div className="dv-panel" aria-live="polite">
<strong style={{ color: 'var(--success)', fontSize: '1.15rem' }}>맞혔어요!</strong>
<div className="dv-big">
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'continue' })}>
한 번 더 맞히기
</button>
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'stop' })}>
여기서 멈추기
</button>
</div>
<span className="small muted">
{v.pending
? '멈추면 가져온 타일이 숨겨진 채로 들어가요. 계속하다 틀리면 가져온 타일이 공개돼요.'
: '멈추면 차례가 끝나요. 계속하다 틀리면 벌칙이 있을 수 있어요.'}
</span>
</div>
);
case 'placeJoker':
return (
<div className="dv-panel">
<strong>조커를 넣을 자리를 골라 주세요.</strong>
<span className="small muted">내 줄의 "여기" 버튼 중 하나를 누르세요. 조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요.</span>
</div>
);
case 'revealOwn':
return (
<div className="dv-panel">
<strong>틀렸어요. 공개할 내 타일을 하나 골라 주세요.</strong>
<span className="small muted">더미가 비어서 가져온 타일 대신 내 숨은 타일 하나를 공개해요. 내 줄에서 파란 테두리 타일을 누르세요.</span>
</div>
);
default:
return null;
}
}
function NumberPad({
view: v,
players,
act,
pending,
sel,
val,
setVal,
onCancel,
}: {
view: DavinciView;
players: Record<string, PublicUser>;
act: (a: unknown) => void;
pending: boolean;
sel: Sel;
val: TileValue | null;
setVal: (v: TileValue | null) => void;
onCancel: () => void;
}) {
const tile = v.rows[sel.target]?.[sel.index];
const padRef = useRef<HTMLDivElement>(null);
useEffect(() => {
padRef.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
}, [sel.target, sel.index]);
if (!tile) return null;
const seen = visibleCombos(v);
const cands = tile.candidates ? new Set(tile.candidates.map(String)) : null;
const values: TileValue[] = [...NUMBERS, ...(v.options.jokers ? ['joker' as const] : [])];
const impossible = (x: TileValue) => seen.has(`${tile.color}:${x}`) || tile.wrong.includes(x) || (!!cands && !cands.has(String(x)));
const warn = val === null ? null : seen.has(`${tile.color}:${val}`) ? `${COLOR_SHORT[tile.color]} ${valueKo(val)}은(는) 이미 보이는 곳에 있어요.` : tile.wrong.includes(val) ? '이 타일에 이미 틀린 숫자예요.' : cands && !cands.has(String(val)) ? '공개된 정보로 보면 어려운 숫자예요.' : null;
return (
<div
className="dv-panel"
role="group"
aria-label="숫자 고르기"
onKeyDown={(e) => {
if (e.key === 'Escape') onCancel();
}}
>
<strong>
{nick(players, sel.target)}님 {sel.index + 1}번째 타일({COLOR_SHORT[tile.color]})은 몇일까요?
</strong>
{tile.wrong.length > 0 && <span className="small muted">이미 틀린 추리: {tile.wrong.map(valueKo).join(', ')}</span>}
<div className="dv-pad" ref={padRef}>
{values.map((x) => (
<button
key={String(x)}
type="button"
className={`${x === 'joker' ? 'joker' : ''}${(v.options.deductionHelper || seen.has(`${tile.color}:${x}`)) && impossible(x) ? ' unlikely' : ''}`}
aria-pressed={val === x}
aria-label={x === 'joker' ? '조커' : String(x)}
onClick={() => setVal(x)}
>
{x === 'joker' ? '조커 (−)' : x}
</button>
))}
</div>
<span className="small" style={{ minHeight: '1.4em', color: 'var(--warning)' }}>
{warn ? `${warn} 그래도 말할 수 있어요.` : ''}
</span>
<div className="row" style={{ flexWrap: 'nowrap' }}>
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
취소
</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 2 }}
disabled={val === null || pending}
onClick={() => act({ type: 'guess', target: sel.target, index: sel.index, value: val })}
>
{val === null ? '숫자를 고르세요' : `${valueKo(val)}, 이 숫자!`}
</button>
</div>
</div>
);
}
function StartChooser({ view: v, me, act, pending }: { view: DavinciView; me: string | null; act: (a: unknown) => void; pending: boolean }) {
const n = v.startCount;
const lim = v.pickLimits ?? { black: n, white: n };
const fallbackBlack = Math.min(Math.ceil(n / 2), lim.black);
const [black, setBlack] = useState<number>(fallbackBlack);
const done = v.picked ? Object.values(v.picked).filter(Boolean).length : 0;
const total = v.picked ? v.seats.filter((p) => v.alive[p]).length : 0;
if (!me || !v.seats.includes(me) || !v.alive[me]) {
return (
<div className="dv-panel">
<strong>모두 시작 타일 색을 고르는 중이에요 ({done}/{total})</strong>
</div>
);
}
if (v.myPick) {
return (
<div className="dv-panel">
<strong>
검정 {v.myPick.black}개 · 흰색 {v.myPick.white}개를 골랐어요.
</strong>
<span className="small muted">
다른 사람을 기다리는 중이에요 ({done}/{total}). 다 고르면 숫자가 정해져 내 줄에 놓여요.
</span>
</div>
);
}
const okPick = (b: number) => b <= lim.black && n - b <= lim.white;
const chosen = okPick(black) ? black : null;
return (
<div className="dv-panel">
<strong>시작 타일 {n}개의 색을 골라 주세요.</strong>
<span className="small muted">숫자는 고른 색 안에서 무작위로 정해져요. 15초 안에 고르지 않으면 반반으로 정해져요.</span>
<div className="dv-split" role="group" aria-label="시작 타일 색">
{Array.from({ length: n + 1 }, (_, i) => n - i).map((b) => (
<button key={b} type="button" aria-pressed={chosen === b} disabled={!okPick(b)} onClick={() => setBlack(b)} aria-label={`검정 ${b}개 · 흰색 ${n - b}개`}>
<span aria-hidden>
{Array.from({ length: n }, (_, k) => (
<span key={k} className={`dv-mini ${k < b ? 'black' : 'white'}`} style={{ marginRight: 2 }} />
))}
</span>
<span>
검 {b} · 흰 {n - b}
</span>
</button>
))}
</div>
{(lim.black < n || lim.white < n) && (
<span className="small muted">
먼저 고른 사람 때문에 검정은 {lim.black}개, 흰색은 {lim.white}개까지 고를 수 있어요.
</span>
)}
<button type="button" className="btn btn-primary" disabled={chosen === null || pending} onClick={() => act({ type: 'chooseStart', black: chosen, white: n - chosen! })}>
이렇게 받기
</button>
</div>
);
}
// ---------------------------------------------------------------- side
function DavinciSide({ view: v, me, players, deadline }: BoardProps<DavinciView>) {
const running = v.phase !== 'finished';
const ranking = v.final?.ranking ?? null;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 8, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>{running ? (v.phase === 'chooseStart' ? '시작 타일 고르기' : `${nick(players, v.current)}님 차례`) : '게임 끝'}</strong>
{running && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
</div>
<span className="small muted">{optionsSummary(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }} aria-label="참가자">
{v.seats.map((p) => {
const st = v.stats[p] ?? { guesses: 0, correct: 0 };
const out = v.eliminationOrder.indexOf(p);
return (
<li key={p} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: v.alive[p] || !running ? 1 : 0.6 }}>
<Avatar user={userOf(players, p)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: p === me ? 800 : 600 }}>
{nick(players, p)}
{running && v.phase !== 'chooseStart' && v.current === p ? ' · 차례' : ''}
{v.final?.winner === p ? ' · 1위' : out >= 0 ? ` · ${v.left[p] ? '나감' : '탈락'} ${v.seats.length - out}위` : ''}
</span>
<span className="small muted" style={{ whiteSpace: 'nowrap' }}>
숨김 {hiddenLeft(v.rows[p] ?? [])} · 맞힘 {st.correct}/{st.guesses}
</span>
</li>
);
})}
</ol>
</div>
{ranking && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{ranking.map((g, i) => (
<span key={i}>
{i + 1}위 {g.map((id) => `${nick(players, id)} (맞힘 ${v.stats[id]?.correct ?? 0} / 추리 ${v.stats[id]?.guesses ?? 0})`).join(', ')}
</span>
))}
</div>
)}
<details className="card" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>추리 기록 ({v.history.length})</summary>
{v.history.length === 0 ? (
<p className="small muted" style={{ margin: '8px 0 0' }}>
아직 추리가 없어요.
</p>
) : (
<ol className="dv-log" style={{ marginTop: 8 }} tabIndex={0} aria-label="추리 기록 목록">
{[...v.history].reverse().map((g, i) => (
<li key={v.history.length - i} className={g.correct ? 'hit' : 'miss'}>
{guessLine(g, players)}
</li>
))}
</ol>
)}
</details>
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>정렬 규칙</summary>
<SortPicture tieOrder={v.options.tieOrder} />
</details>
</>
);
}
function SortPicture({ tieOrder }: { tieOrder: DavinciOptions['tieOrder'] }) {
const tiles: [TColor, number][] = tieOrder === 'black-left' ? [['black', 2], ['black', 5], ['white', 5], ['white', 9]] : [['black', 2], ['white', 5], ['black', 5], ['white', 9]];
return (
<div className="stack" style={{ gap: 6, marginTop: 8 }}>
<div className="row" style={{ gap: 6 }} role="img" aria-label={`정렬 예: ${tiles.map(([c, n]) => `${COLOR_SHORT[c]} ${n}`).join(', ')}`}>
{tiles.map(([c, n], i) => (
<Tile key={i} color={c} n={n} revealed={false} />
))}
</div>
<span className="small muted">작은 수가 왼쪽이에요. 같은 숫자면 {tieOrder === 'black-left' ? '검정' : '흰색'}이 왼쪽이에요. 새 타일은 서버가 알맞은 자리에 끼워 줘요.</span>
</div>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: DavinciView, me: string | null): boolean {
if (!me || !v.seats.includes(me) || v.phase === 'finished' || !v.alive[me]) return false;
if (v.phase === 'chooseStart') return !!v.picked && !v.picked[me];
return v.current === me;
}
function statusText(v: DavinciView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.final?.summary ?? '게임이 끝났어요';
if (v.phase === 'chooseStart') return isMyTurn(v, me) ? '내 차례예요. 시작 타일 색을 골라 주세요' : '다른 사람이 시작 타일 색을 고르는 중이에요';
if (isMyTurn(v, me)) {
switch (v.phase) {
case 'draw':
return '내 차례예요. 더미에서 타일을 가져와 주세요';
case 'guess':
return '내 차례예요. 상대 타일을 골라 숫자를 맞혀 보세요';
case 'afterCorrect':
return '내 차례예요. 맞혔어요! 한 번 더 할지 멈출지 골라 주세요';
case 'placeJoker':
return '내 차례예요. 조커를 넣을 자리를 골라 주세요';
case 'revealOwn':
return '내 차례예요. 공개할 내 타일을 골라 주세요';
}
}
const out = !!me && v.seats.includes(me) && !v.alive[me] ? '탈락했어요 · ' : '';
return `${out}${nick(players, v.current)}님 차례예요`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<DavinciOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<DavinciOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="조커">
<Seg label="조커" value={o.jokers} options={[[false, '안 씀'], [true, '씀(검정·흰색 각 1개)']]} onChange={(jokers) => set({ jokers })} />
</Field>
<Field label="시작 타일 수">
<Seg label="시작 타일 수" value={o.startTiles} options={[['auto', '자동(2·3명 4개, 4명 3개)'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(startTiles) => set({ startTiles })} />
</Field>
<Field label="추리 제한 시간">
<Seg label="추리 제한 시간" value={o.turnSeconds} options={[[20, '20초'], [30, '30초'], [45, '45초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="가능한 숫자 후보 표시(초보 도움)">
<Seg label="후보 표시" value={o.deductionHelper} options={[[false, '끄기'], [true, '켜기']]} onChange={(deductionHelper) => set({ deductionHelper })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="같은 숫자일 때 색 순서">
<Seg label="같은 숫자일 때 색 순서" value={o.tieOrder} options={[['black-left', '검정이 왼쪽'], ['white-left', '흰색이 왼쪽']]} onChange={(tieOrder) => set({ tieOrder })} />
</Field>
<Field label="타일을 가져올 때">
<Seg label="타일을 가져올 때" value={o.chooseColors} options={[[true, '색을 골라서'], [false, '무작위로']]} onChange={(chooseColors) => set({ chooseColors })} />
</Field>
<Field label="더미가 빈 뒤 틀렸을 때">
<Seg label="더미가 빈 뒤 틀렸을 때" value={o.emptyPoolPenalty} options={[['reveal-own', '내 숨은 타일 1개 공개'], ['none', '벌칙 없음']]} onChange={(emptyPoolPenalty) => set({ emptyPoolPenalty })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: DavinciOptions): string {
return [
o.jokers ? '조커 사용' : '조커 없음',
o.startTiles === 'auto' ? '시작 타일 자동' : `시작 타일 ${o.startTiles}개`,
o.chooseColors ? '색 골라 가져오기' : '무작위로 가져오기',
`추리 ${o.turnSeconds}초`,
...(o.tieOrder === 'white-left' ? ['같은 숫자는 흰색 먼저'] : []),
...(o.deductionHelper ? ['후보 표시'] : []),
].join(' · ');
}
function Rules({ options }: { options?: DavinciOptions }) {
const o = options;
return (
<div className="stack">
<p>
검은색과 흰색 숫자 타일(0~11)을 작은 수부터 큰 수 순서로 세워 두고, <strong>상대 타일의 숫자를 추리해 맞히는</strong> 게임이에요. 2~4명이 할 수 있어요. 숨은 타일이 모두 드러나면 탈락하고, 마지막까지 숨은
타일이 남은 사람이 이겨요.
</p>
<p>
<strong>준비</strong>: 각자 시작 타일 {o && o.startTiles !== 'auto' ? `${o.startTiles}개` : '(2·3명은 4개, 4명은 3개)'}를 받아요.
{o?.chooseColors === false ? ' 색은 무작위로 정해져요.' : ' 검정과 흰색을 몇 개씩 받을지 직접 골라요.'} 타일 뒷면이 그 타일의 색이라서, 숨은 타일도 색은 모두에게 보이고 숫자만 숨겨져요.
</p>
<p>
<strong>정렬</strong>: 내 줄은 왼쪽에서 오른쪽으로 숫자가 커져요. 같은 숫자면 {o?.tieOrder === 'white-left' ? '흰색' : '검정'}이 왼쪽이에요. 새 타일은 알맞은 자리에 저절로 끼워지고, 이미 놓인 타일의 순서는 바뀌지
않아요.
</p>
<SortPicture tieOrder={o?.tieOrder ?? 'black-left'} />
<p>
<strong>내 차례</strong>: ① 더미에서 타일을 하나 가져와요(숫자는 나만 봐요). ② 상대의 숨은 타일 하나를 눌러 숫자를 말해요.
</p>
<ul>
<li>
<strong>맞히면</strong> 그 타일이 공개돼요. "한 번 더 맞히기"로 계속하거나 "여기서 멈추기"로 끝낼 수 있어요. 멈추면 가져온 타일이 숨겨진 채로 내 줄에 들어가요.
</li>
<li>
<strong>틀리면</strong> 가져온 타일이 공개된 채로 내 줄에 들어가고 차례가 끝나요.
</li>
</ul>
<p>
<strong>더미가 비면</strong> 가져오지 않고 바로 추리해요. 이때 틀리면{' '}
{o?.emptyPoolPenalty === 'none' ? '벌칙 없이 차례만 넘어가요.' : '내 숨은 타일 하나를 골라 공개해야 해요. 마지막 숨은 타일을 공개하면 탈락해요.'}
</p>
{o?.jokers && (
<p>
<strong>조커</strong>: 숫자 대신 "−"가 그려진 타일이에요(검정·흰색 각 1개). 시작 타일에는 들어가지 않아요. 조커를 가져오면 내 줄의 원하는 틈에 직접 넣고, 한 번 놓으면 옮길 수 없어요. 추리할 때
"조커"라고 말할 수도 있어요.
</p>
)}
<p>
이미 공개된 같은 색·같은 숫자를 말하는 것처럼 말이 안 되는 추리도 할 수는 있어요(화면에서 알려 줘요). 오른쪽 <strong>추리 기록</strong>에서 누가 어떤 숫자를 말했는지 다시 볼 수 있어요.
</p>
<p className="muted">
시간 안에 추리하지 않으면 공개된 정보로 가장 가능성 있는 작은 숫자를 자동으로 말해요. 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 결과 화면에는 각자 맞힌 횟수와 추리 횟수가 나와요.
</p>
</div>
);
}
const ui: GameUI<DavinciView, DavinciOptions> = {
Board: DavinciBoard,
Side: DavinciSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View 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: '맞히면 그 타일이 공개되고 더 맞힐지 멈출지 골라요. 틀리면 가져온 타일이 공개돼요. 마지막까지 숨은 타일이 남은 사람이 이겨요.',
},
];

View File

@@ -0,0 +1,8 @@
import { expect, test } from 'bun:test';
import { SYMBOL_NAMES } from '@bg/games/dobble';
import { ICONS, ICON_NAMES } from './art';
test('도블 그림 57종: 그림과 이름이 규칙 모듈과 같은 순서', () => {
expect(ICONS).toHaveLength(57);
expect(ICON_NAMES).toEqual([...SYMBOL_NAMES]);
});

View File

@@ -0,0 +1,556 @@
/**
* 도블 그림 57종 (docs/games/dobble.md §3.2) — all drawn here as simple SVG in a 100×100 box.
* Every picture has its own silhouette with a dark outline, so colour is only a secondary cue (colour-blind friendly).
* Index = icon id (same order as SYMBOL_NAMES in @bg/games/dobble).
*/
import type { ReactNode } from 'react';
const O = '#1f2937';
/** Outline props for filled shapes. */
const L = { stroke: O, strokeWidth: 3.5, strokeLinejoin: 'round', strokeLinecap: 'round' } as const;
/** Thick stroked lines (no fill). */
const W = (color: string, w = 6) => ({ stroke: color, strokeWidth: w, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' }) as const;
const rays = (n: number, r1: number, r2: number, cx = 50, cy = 50, a0 = 0) =>
Array.from({ length: n }, (_, i) => {
const a = a0 + (i * 2 * Math.PI) / n;
return `M${(cx + Math.cos(a) * r1).toFixed(1)} ${(cy + Math.sin(a) * r1).toFixed(1)} L${(cx + Math.cos(a) * r2).toFixed(1)} ${(cy + Math.sin(a) * r2).toFixed(1)}`;
}).join(' ');
const starPts = (n: number, ro: number, ri: number, cx = 50, cy = 52) =>
Array.from({ length: n * 2 }, (_, i) => {
const a = -Math.PI / 2 + (i * Math.PI) / n;
const r = i % 2 ? ri : ro;
return `${(cx + Math.cos(a) * r).toFixed(1)},${(cy + Math.sin(a) * r).toFixed(1)}`;
}).join(' ');
/** Korean names, same order as SYMBOL_NAMES in @bg/games/dobble (kept here so the screen does not pull in the rules module; a test checks they match). */
export const ICON_NAMES = [
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
];
export const ICONS: (() => ReactNode)[] = [
// 0 사과
() => (
<>
<path d="M50 30 C36 18 13 26 14 52 C15 76 33 93 50 84 C67 93 85 76 86 52 C87 26 64 18 50 30 Z" fill="#ef4444" {...L} />
<path d="M50 30 L55 10" {...W('#78350f', 5)} />
<path d="M57 18 C64 6 78 8 80 12 C74 22 64 22 57 18 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<path d="M28 44 C30 38 34 35 38 34" {...W('#fecaca', 4)} />
</>
),
// 1 해
() => (
<>
<path d={rays(8, 28, 44)} {...W('#f59e0b', 7)} />
<circle cx="50" cy="50" r="22" fill="#fbbf24" {...L} />
</>
),
// 2 달
() => <path d="M60 10 A40 40 0 1 0 90 64 A31 31 0 1 1 60 10 Z" fill="#fde047" {...L} />,
// 3 별
() => <polygon points={starPts(5, 44, 19)} fill="#a78bfa" {...L} />,
// 4 하트
() => <path d="M50 86 C20 64 8 48 10 32 C12 16 32 8 50 26 C68 8 88 16 90 32 C92 48 80 64 50 86 Z" fill="#ec4899" {...L} />,
// 5 물방울
() => (
<>
<path d="M50 8 C44 20 22 44 22 64 A28 28 0 0 0 78 64 C78 44 56 20 50 8 Z" fill="#38bdf8" {...L} />
<path d="M36 62 C36 70 40 76 46 78" {...W('#e0f2fe', 5)} />
</>
),
// 6 불꽃
() => (
<>
<path d="M50 92 C26 92 16 74 22 56 C26 44 34 40 34 26 C44 32 46 40 46 46 C52 36 54 22 50 8 C70 20 84 42 80 64 C78 82 66 92 50 92 Z" fill="#f97316" {...L} />
<path d="M50 88 C38 88 34 78 38 70 C41 64 46 62 46 54 C54 60 62 68 60 78 C59 84 55 88 50 88 Z" fill="#fde047" />
</>
),
// 7 눈송이
() => (
<>
<path d={rays(6, 0, 42, 50, 50, Math.PI / 2)} {...W('#0284c7', 6)} />
<path d={[0, 1, 2, 3, 4, 5].map((i) => {
const a = Math.PI / 2 + (i * Math.PI) / 3;
const bx = 50 + Math.cos(a) * 26;
const by = 50 + Math.sin(a) * 26;
const p = (d: number) => `${(bx + Math.cos(a + d) * 12).toFixed(1)} ${(by + Math.sin(a + d) * 12).toFixed(1)}`;
return `M${p(0.9)} L${bx.toFixed(1)} ${by.toFixed(1)} L${p(-0.9)}`;
}).join(' ')} {...W('#0284c7', 5)} />
</>
),
// 8 번개
() => <polygon points="60,6 20,56 46,56 36,94 82,40 56,40 70,6" fill="#eab308" {...L} />,
// 9 구름
() => <path d="M24 76 C10 76 6 60 18 54 C14 40 30 30 40 38 C44 22 66 20 72 36 C86 32 96 48 88 58 C96 66 90 78 78 76 Z" fill="#cbd5e1" {...L} />,
// 10 나무 (pine)
() => (
<>
<rect x="44" y="76" width="12" height="18" fill="#92400e" {...L} />
<polygon points="50,6 24,40 38,40 18,62 34,62 12,80 88,80 66,62 82,62 62,40 76,40" fill="#16a34a" {...L} />
</>
),
// 11 꽃
() => (
<>
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 24} cy={50 + Math.sin(a) * 24} r="17" fill="#f472b6" stroke={O} strokeWidth="3" />;
})}
<circle cx="50" cy="50" r="13" fill="#fde047" stroke={O} strokeWidth="3" />
</>
),
// 12 나뭇잎
() => (
<>
<path d="M14 86 C14 40 40 12 88 12 C88 60 62 86 14 86 Z" fill="#4ade80" {...L} />
<path d="M14 86 L70 30 M40 60 L40 44 M54 46 L54 32 M40 60 L56 60 M54 46 L68 46" {...W('#166534', 3.5)} />
</>
),
// 13 선인장
() => (
<>
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W(O, 22)} />
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W('#22c55e', 15)} />
<path d="M44 30 L40 28 M56 40 L60 38 M44 72 L40 70 M56 78 L60 76" {...W(O, 2.5)} />
<rect x="30" y="86" width="40" height="10" rx="3" fill="#c2410c" {...L} />
</>
),
// 14 버섯
() => (
<>
<path d="M40 54 L36 88 C36 92 64 92 64 88 L60 54 Z" fill="#fef3c7" {...L} />
<path d="M8 56 C8 26 28 12 50 12 C72 12 92 26 92 56 Z" fill="#dc2626" {...L} />
<circle cx="32" cy="34" r="6" fill="#fff" />
<circle cx="56" cy="24" r="5" fill="#fff" />
<circle cx="70" cy="42" r="6" fill="#fff" />
<circle cx="46" cy="44" r="4" fill="#fff" />
</>
),
// 15 당근
() => (
<>
<path d="M50 30 C44 18 34 10 26 10 M50 30 C50 16 52 8 56 4 M50 30 C58 18 70 12 78 14" {...W('#16a34a', 7)} />
<path d="M32 32 C40 26 60 26 68 32 L52 94 C51 97 49 97 48 94 Z" fill="#f97316" {...L} />
<path d="M40 46 L48 46 M54 60 L60 60 M44 72 L50 72" {...W('#9a3412', 3)} />
</>
),
// 16 레몬
() => (
<>
<path d="M6 50 C14 46 18 22 50 20 C82 22 86 46 94 50 C86 54 82 78 50 80 C18 78 14 54 6 50 Z" fill="#fde047" {...L} />
<path d="M30 40 C34 34 40 32 46 31" {...W('#fefce8', 5)} />
</>
),
// 17 체리
() => (
<>
<path d="M30 66 C34 40 46 22 62 10 M70 62 C66 40 64 22 62 10" {...W('#15803d', 5)} />
<path d="M62 10 C72 4 86 8 88 14 C78 20 68 18 62 10 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<circle cx="28" cy="72" r="18" fill="#dc2626" stroke={O} strokeWidth="3.5" />
<circle cx="72" cy="70" r="18" fill="#b91c1c" stroke={O} strokeWidth="3.5" />
<circle cx="22" cy="66" r="4" fill="#fecaca" />
<circle cx="66" cy="64" r="4" fill="#fecaca" />
</>
),
// 18 고양이
() => (
<>
<path d="M16 40 L18 8 L40 26 C46 24 54 24 60 26 L82 8 L84 40 C90 52 88 82 50 86 C12 82 10 52 16 40 Z" fill="#fb923c" {...L} />
<ellipse cx="36" cy="52" rx="5" ry="7" fill={O} />
<ellipse cx="64" cy="52" rx="5" ry="7" fill={O} />
<path d="M46 64 L54 64 L50 69 Z" fill="#be185d" />
<path d="M8 60 L32 64 M8 72 L32 70 M92 60 L68 64 M92 72 L68 70" {...W(O, 2.5)} />
</>
),
// 19 강아지
() => (
<>
<ellipse cx="50" cy="54" rx="28" ry="32" fill="#d6a46b" {...L} />
<path d="M26 30 C10 30 6 52 12 70 C20 72 26 60 28 46 Z M74 30 C90 30 94 52 88 70 C80 72 74 60 72 46 Z" fill="#78350f" {...L} />
<circle cx="40" cy="50" r="4.5" fill={O} />
<circle cx="60" cy="50" r="4.5" fill={O} />
<ellipse cx="50" cy="66" rx="8" ry="6" fill={O} />
<path d="M50 72 L50 78 M42 80 C46 84 54 84 58 80" {...W(O, 3)} />
</>
),
// 20 물고기
() => (
<>
<path d="M8 50 C24 24 58 22 74 50 C58 78 24 76 8 50 Z" fill="#14b8a6" {...L} />
<path d="M72 50 L94 30 L94 70 Z" fill="#0f766e" {...L} />
<circle cx="26" cy="46" r="5" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d="M40 36 C46 44 46 56 40 64" {...W('#99f6e4', 3.5)} />
</>
),
// 21 새
() => (
<>
<path d="M20 62 C20 36 40 24 58 30 C64 20 76 18 84 26 L96 30 L84 36 C84 64 66 80 44 80 L14 86 L26 72 C22 70 20 66 20 62 Z" fill="#60a5fa" {...L} />
<circle cx="74" cy="30" r="3.5" fill={O} />
<path d="M34 54 C44 46 58 50 62 60 C52 66 40 64 34 54 Z" fill="#1d4ed8" {...L} strokeWidth={2.5} />
</>
),
// 22 거북이
() => (
<>
<circle cx="86" cy="58" r="9" fill="#86efac" stroke={O} strokeWidth="3" />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W(O, 11)} />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W('#86efac', 6)} />
<path d="M10 70 C10 36 30 22 48 22 C68 22 84 36 84 70 Z" fill="#15803d" {...L} />
<path d="M48 32 L60 42 L56 58 L40 58 L36 42 Z M48 32 L48 24 M60 42 L76 40 M56 58 L64 70 M40 58 L32 70 M36 42 L20 42" {...W('#bbf7d0', 3)} />
</>
),
// 23 무당벌레
() => (
<>
<path d="M28 22 L20 10 M72 22 L80 10" {...W(O, 3.5)} />
<circle cx="50" cy="28" r="16" fill={O} />
<circle cx="50" cy="58" r="34" fill="#ef4444" stroke={O} strokeWidth="3.5" />
<path d="M50 26 L50 92" {...W(O, 3.5)} />
<circle cx="34" cy="48" r="6" fill={O} />
<circle cx="66" cy="48" r="6" fill={O} />
<circle cx="30" cy="70" r="5" fill={O} />
<circle cx="70" cy="70" r="5" fill={O} />
</>
),
// 24 거미
() => (
<>
<path d="M50 4 L50 30" {...W('#9ca3af', 2)} />
<path d="M40 50 L22 34 L12 44 M40 56 L16 52 L8 64 M42 62 L22 72 L18 88 M44 66 L36 82 L36 94 M60 50 L78 34 L88 44 M60 56 L84 52 L92 64 M58 62 L78 72 L82 88 M56 66 L64 82 L64 94" {...W(O, 4)} />
<circle cx="50" cy="38" r="10" fill="#374151" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="62" rx="15" ry="18" fill="#4b5563" stroke={O} strokeWidth="3" />
<circle cx="46" cy="36" r="2.5" fill="#fff" />
<circle cx="54" cy="36" r="2.5" fill="#fff" />
</>
),
// 25 공룡
() => (
<path
d="M88 16 C80 8 70 10 68 18 L64 46 C50 40 32 42 22 54 C14 62 8 64 2 62 C8 72 18 74 26 70 L26 88 L36 88 L38 74 L54 74 L56 88 L66 88 L66 66 C72 58 74 46 76 26 L86 26 C92 24 92 20 88 16 Z"
fill="#65a30d"
{...L}
/>
),
// 26 토끼
() => (
<>
<path d="M38 46 C28 30 26 6 34 4 C42 2 46 26 46 44 Z M62 46 C72 30 74 6 66 4 C58 2 54 26 54 44 Z" fill="#f5f5f4" {...L} />
<path d="M38 40 C33 28 32 14 35 10 M62 40 C67 28 68 14 65 10" {...W('#f9a8d4', 4)} />
<circle cx="50" cy="64" r="26" fill="#f5f5f4" stroke={O} strokeWidth="3.5" />
<circle cx="40" cy="60" r="4" fill={O} />
<circle cx="60" cy="60" r="4" fill={O} />
<path d="M46 70 L54 70 L50 75 Z" fill="#ec4899" />
</>
),
// 27 펭귄
() => (
<>
<ellipse cx="50" cy="54" rx="30" ry="40" fill="#111827" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="60" rx="20" ry="30" fill="#f8fafc" />
<circle cx="42" cy="34" r="4" fill="#fff" />
<circle cx="58" cy="34" r="4" fill="#fff" />
<circle cx="42" cy="34" r="2" fill={O} />
<circle cx="58" cy="34" r="2" fill={O} />
<path d="M44 42 L56 42 L50 50 Z" fill="#f97316" {...L} strokeWidth={2} />
<path d="M32 94 L44 94 L40 88 Z M56 94 L68 94 L60 88 Z" fill="#f97316" {...L} strokeWidth={2} />
</>
),
// 28 열쇠
() => (
<>
<circle cx="28" cy="50" r="18" fill="#f59e0b" stroke={O} strokeWidth="3.5" />
<circle cx="28" cy="50" r="7" fill="#fffbeb" stroke={O} strokeWidth="3" />
<path d="M46 44 L92 44 L92 56 L84 56 L84 66 L76 66 L76 56 L68 56 L68 64 L60 64 L60 56 L46 56 Z" fill="#f59e0b" {...L} />
</>
),
// 29 자물쇠
() => (
<>
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W(O, 14)} />
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W('#94a3b8', 8)} />
<rect x="18" y="44" width="64" height="48" rx="8" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill={O} />
<path d="M50 64 L50 80" {...W(O, 6)} />
</>
),
// 30 시계
() => (
<>
<circle cx="50" cy="50" r="42" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="50" r="33" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d={rays(12, 27, 31)} {...W(O, 3)} />
<path d="M50 50 L50 28 M50 50 L66 58" {...W(O, 5)} />
<circle cx="50" cy="50" r="4" fill="#dc2626" />
</>
),
// 31 전구
() => (
<>
<path d="M38 70 C38 58 18 50 20 32 C22 14 36 6 50 6 C64 6 78 14 80 32 C82 50 62 58 62 70 Z" fill="#fde047" {...L} />
<path d="M44 66 L44 50 L50 56 L56 50 L56 66" {...W('#ca8a04', 3)} />
<rect x="36" y="70" width="28" height="18" rx="3" fill="#9ca3af" {...L} />
<path d="M36 76 L64 76 M36 82 L64 82" {...W(O, 2.5)} />
<path d="M44 88 L56 88 L52 96 L48 96 Z" fill={O} />
</>
),
// 32 연필
() => (
<g transform="rotate(-45 50 50)">
<rect x="14" y="38" width="16" height="24" rx="4" fill="#f9a8d4" {...L} />
<rect x="28" y="38" width="8" height="24" fill="#9ca3af" {...L} />
<rect x="36" y="38" width="36" height="24" fill="#facc15" {...L} />
<path d="M72 38 L94 50 L72 62 Z" fill="#fde68a" {...L} />
<path d="M86 46 L94 50 L86 54 Z" fill={O} />
</g>
),
// 33 가위
() => (
<>
<path d="M36 62 L82 12 M64 62 L18 12" {...W(O, 10)} />
<path d="M36 62 L82 12 M64 62 L18 12" {...W('#cbd5e1', 5)} />
<circle cx="30" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="30" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="70" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="70" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="50" cy="45" r="4" fill={O} />
</>
),
// 34 안경
() => (
<>
<path d="M8 40 L4 30 M92 40 L96 30 M42 50 C46 44 54 44 58 50" {...W(O, 4)} />
<circle cx="26" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<circle cx="74" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<path d="M18 46 L24 40 M66 46 L72 40" {...W('#fff', 3.5)} />
</>
),
// 35 우산
() => (
<>
<path d="M50 50 L50 82 C50 92 36 92 36 84" {...W(O, 6)} />
<path d="M6 52 C6 26 28 10 50 10 C72 10 94 26 94 52 C88 46 80 46 72 52 C66 46 56 46 50 52 C44 46 34 46 28 52 C20 46 12 46 6 52 Z" fill="#8b5cf6" {...L} />
<path d="M50 10 L50 50 M28 52 C30 30 40 16 50 10 M72 52 C70 30 60 16 50 10" {...W('#ddd6fe', 2.5)} />
</>
),
// 36 모자 (top hat)
() => (
<>
<path d="M28 70 L32 14 L68 14 L72 70 Z" fill="#1f2937" {...L} />
<path d="M30 56 L70 56 L71 68 L29 68 Z" fill="#dc2626" />
<ellipse cx="50" cy="74" rx="44" ry="10" fill="#374151" {...L} />
</>
),
// 37 왕관
() => (
<>
<path d="M10 30 L28 54 L50 18 L72 54 L90 30 L82 80 L18 80 Z" fill="#facc15" {...L} />
<rect x="18" y="80" width="64" height="10" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill="#dc2626" stroke={O} strokeWidth="2.5" />
<circle cx="30" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
<circle cx="70" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
</>
),
// 38 종
() => (
<>
<circle cx="50" cy="86" r="8" fill="#92400e" stroke={O} strokeWidth="3" />
<path d="M14 80 C22 72 22 60 24 44 C26 24 38 14 50 14 C62 14 74 24 76 44 C78 60 78 72 86 80 Z" fill="#f59e0b" {...L} />
<path d="M50 14 L50 6" {...W(O, 6)} />
<path d="M34 66 C34 50 36 36 44 28" {...W('#fde68a', 4)} />
</>
),
// 39 닻
() => (
<>
<circle cx="50" cy="16" r="9" fill="none" stroke="#1e3a8a" strokeWidth="6" />
<path d="M50 25 L50 88 M32 38 L68 38 M14 58 C16 78 32 88 50 88 C68 88 84 78 86 58 M14 58 L8 66 M14 58 L22 64 M86 58 L92 66 M86 58 L78 64" {...W('#1e3a8a', 7)} />
</>
),
// 40 자전거
() => (
<>
<circle cx="22" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<circle cx="78" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<path d="M22 66 L40 40 L66 40 L78 66 M40 40 L50 66 L66 40 M50 66 L22 66 M36 30 L48 30 M40 40 L37 30 M66 40 L62 26 L72 24" {...W('#e11d48', 5)} />
<circle cx="50" cy="66" r="4" fill={O} />
</>
),
// 41 자동차
() => (
<>
<path d="M6 66 L8 50 L24 46 L34 28 L68 28 L80 46 L94 50 L94 66 Z" fill="#2563eb" {...L} />
<path d="M38 34 L62 34 L72 46 L30 46 Z" fill="#bae6fd" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 46" {...W(O, 2.5)} />
<circle cx="26" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="74" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="26" cy="68" r="4" fill="#d1d5db" />
<circle cx="74" cy="68" r="4" fill="#d1d5db" />
</>
),
// 42 로켓
() => (
<>
<path d="M36 66 L18 84 L20 62 Z M64 66 L82 84 L80 62 Z" fill="#dc2626" {...L} />
<path d="M42 80 L50 96 L58 80 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 4 C66 18 70 40 66 80 L34 80 C30 40 34 18 50 4 Z" fill="#e5e7eb" {...L} />
<path d="M50 4 C58 10 62 18 64 26 L36 26 C38 18 42 10 50 4 Z" fill="#dc2626" {...L} />
<circle cx="50" cy="46" r="9" fill="#38bdf8" stroke={O} strokeWidth="3" />
</>
),
// 43 배 (sailboat)
() => (
<>
<path d="M50 8 L50 66" {...W(O, 4)} />
<path d="M54 12 L84 58 L54 58 Z" fill="#f8fafc" {...L} />
<path d="M46 22 L22 58 L46 58 Z" fill="#fbbf24" {...L} />
<path d="M6 66 L94 66 L80 86 L20 86 Z" fill="#b45309" {...L} />
</>
),
// 44 공 (soccer ball)
() => (
<>
<circle cx="50" cy="50" r="42" fill="#fff" stroke={O} strokeWidth="3.5" />
<polygon points={starPts(5, 14, 14, 50, 50).split(' ').filter((_, i) => i % 2 === 0).join(' ')} fill={O} />
<path d={rays(5, 14, 30, 50, 50, -Math.PI / 2)} {...W(O, 3)} />
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 36} cy={50 + Math.sin(a) * 36} r="7" fill={O} />;
})}
</>
),
// 45 주사위
() => (
<>
<rect x="12" y="12" width="76" height="76" rx="14" fill="#fff" {...L} />
{[
[30, 30],
[70, 30],
[50, 50],
[30, 70],
[70, 70],
].map(([x, y]) => (
<circle key={`${x}${y}`} cx={x} cy={y} r="7.5" fill="#dc2626" />
))}
</>
),
// 46 음표
() => (
<>
<path d="M62 72 L62 10 C70 22 86 26 84 46 C80 34 72 32 62 30" {...W(O, 7)} />
<ellipse cx="46" cy="74" rx="18" ry="13" transform="rotate(-24 46 74)" fill={O} />
</>
),
// 47 카메라
() => (
<>
<path d="M34 24 L40 14 L60 14 L66 24 Z" fill="#4b5563" {...L} />
<rect x="8" y="24" width="84" height="58" rx="10" fill="#6b7280" {...L} />
<circle cx="50" cy="53" r="20" fill="#e5e7eb" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="53" r="11" fill="#1e3a8a" />
<circle cx="46" cy="49" r="3" fill="#fff" />
<rect x="72" y="32" width="12" height="7" rx="2" fill="#fde047" stroke={O} strokeWidth="2" />
</>
),
// 48 집
() => (
<>
<rect x="20" y="46" width="60" height="44" fill="#fde68a" {...L} />
<path d="M8 50 L50 12 L92 50 Z" fill="#dc2626" {...L} />
<rect x="42" y="64" width="16" height="26" fill="#92400e" {...L} />
<rect x="26" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
<rect x="62" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
</>
),
// 49 성
() => (
<>
<path d="M8 92 L8 30 L14 30 L14 22 L22 22 L22 30 L28 30 L28 22 L36 22 L36 46 L64 46 L64 22 L72 22 L72 30 L78 30 L78 22 L86 22 L86 30 L92 30 L92 92 Z" fill="#a8a29e" {...L} />
<path d="M40 92 L40 72 C40 62 60 62 60 72 L60 92 Z" fill="#57534e" {...L} />
<path d="M50 46 L50 8 M50 8 L66 14 L50 20" fill="#ef4444" {...L} strokeWidth={3} />
<rect x="18" y="44" width="8" height="12" fill={O} />
<rect x="74" y="44" width="8" height="12" fill={O} />
</>
),
// 50 선물상자
() => (
<>
<rect x="14" y="40" width="72" height="50" fill="#16a34a" {...L} />
<rect x="8" y="28" width="84" height="16" fill="#22c55e" {...L} />
<path d="M44 28 L56 28 L56 90 L44 90 Z" fill="#facc15" {...L} strokeWidth={2.5} />
<path d="M50 28 C38 8 18 14 26 26 C30 30 40 30 50 28 C60 30 70 30 74 26 C82 14 62 8 50 28 Z" fill="#facc15" {...L} />
</>
),
// 51 촛불
() => (
<>
<path d="M50 8 C40 20 40 32 50 34 C60 32 60 20 50 8 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 40" {...W(O, 3)} />
<rect x="36" y="40" width="28" height="48" rx="3" fill="#f8fafc" {...L} />
<path d="M36 50 C42 50 42 60 46 60 C50 60 50 50 54 50" {...W('#e0e7ff', 4)} />
<ellipse cx="50" cy="90" rx="30" ry="6" fill="#64748b" {...L} />
</>
),
// 52 망치
() => (
<g transform="rotate(-35 50 50)">
<rect x="44" y="32" width="12" height="62" rx="4" fill="#b45309" {...L} />
<path d="M18 14 L76 14 L82 20 L82 32 L18 32 C12 32 10 26 12 22 Z" fill="#6b7280" {...L} />
</g>
),
// 53 느낌표
() => (
<>
<path d="M38 8 L62 8 L56 64 L44 64 Z" fill="#ef4444" {...L} />
<circle cx="50" cy="82" r="10" fill="#ef4444" stroke={O} strokeWidth="3.5" />
</>
),
// 54 물음표
() => (
<>
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W(O, 18)} />
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W('#3b82f6', 11)} />
<circle cx="56" cy="84" r="10" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
</>
),
// 55 눈사람
() => (
<>
<path d="M24 60 L6 48 M76 60 L94 48 M10 51 L8 42 M90 51 L92 42" {...W('#78350f', 4)} />
<circle cx="50" cy="70" r="24" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="30" r="17" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="44" cy="26" r="2.6" fill={O} />
<circle cx="56" cy="26" r="2.6" fill={O} />
<path d="M50 32 L64 36 L50 37 Z" fill="#f97316" />
<circle cx="50" cy="62" r="3" fill={O} />
<circle cx="50" cy="74" r="3" fill={O} />
<path d="M36 44 C44 50 56 50 64 44" {...W('#dc2626', 6)} />
</>
),
// 56 무지개
() => (
<>
{['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#8b5cf6'].map((c, i) => (
<path key={c} d={`M${8 + i * 6} 78 A${42 - i * 6} ${42 - i * 6} 0 0 1 ${92 - i * 6} 78`} {...W(c, 6.5)} strokeLinecap="butt" />
))}
<path d="M2 80 C2 70 18 68 22 76 C30 74 34 84 26 88 L6 88 C0 88 0 82 2 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
<path d="M98 80 C98 70 82 68 78 76 C70 74 66 84 74 88 L94 88 C100 88 100 82 98 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
</>
),
];
/** One icon in its own 100×100 SVG. */
export function SymbolArt({ art, className }: { art: number; className?: string }) {
const draw = ICONS[art];
return (
<svg viewBox="0 0 100 100" className={className} aria-hidden focusable="false">
{draw ? draw() : null}
</svg>
);
}

View 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등 호랑이님');
});

View 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');
}

View File

@@ -0,0 +1,858 @@
/** 도블 화면 (docs/games/dobble.md §9): two round cards, tap the one picture they share. */
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { CardView, ClaimPair, DobbleOptions, DobbleView, LogEntry, MiniGame, SeatView } from '@bg/games/dobble';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
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). */
const TAP_GRACE_MS = 250;
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? ''));
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 who = (players: Record<string, PublicUser>, me: string | null, id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
const CSS = `
.db { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; --cd: min(440px, 50vh, calc(50% - 14px)); }
.db-top { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: space-between; font-weight: 700; }
.db-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.db-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-pill.warn { background: var(--warning-soft); color: var(--warning); }
.db-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
.db-strip { display: flex; gap: 8px; list-style: none; margin: 0; padding: 2px 2px 4px; overflow-x: auto; justify-content: center; }
.db-seat { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); border: 3px solid transparent; color: var(--text); font: inherit; text-align: left; min-height: 48px; }
button.db-seat { cursor: pointer; }
.db-seat[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.db-seat[data-out='true'] { opacity: .55; }
.db-seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.db-seat .who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.db-seat .nm { font-weight: 800; font-size: .88rem; max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; }
.db-seat .meta { font-size: .78rem; font-weight: 700; color: var(--text-sub); font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-seat .meta.rtt { display: inline-flex; gap: 3px; align-items: center; }
.db-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; color: #fff; background: #6b7280; }
.db-tag.ok { background: #15803d; }
.db-tag.warn { background: #f59e0b; color: #111827; }
.db-table { position: relative; display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 14px; border-radius: 26px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); border: 6px solid var(--wood); box-shadow: var(--shadow); touch-action: manipulation; }
.db-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; width: var(--cd); }
.db-slot .cap { color: #fff; font-weight: 800; font-size: .95rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); display: flex; gap: 6px; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.db-card { position: relative; width: 100%; aspect-ratio: 1; border-radius: 50%; background: #fffdf6; border: 4px solid #d6c7a1; box-shadow: 0 6px 0 rgba(0,0,0,.18), 0 10px 22px rgba(0,0,0,.28); }
.db-card.empty { background: rgba(255,255,255,.1); border: 3px dashed rgba(255,255,255,.55); box-shadow: none; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; text-align: center; padding: 18%; }
.db-card[data-shake='true'] { animation: db-shake .4s ease; }
.db-sym { position: absolute; border-radius: 50%; border: 0; padding: 0; margin: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.db-sym:disabled { cursor: default; }
@media (hover: hover) { .db-sym:hover:not(:disabled) { background: rgba(37, 99, 235, .08); } }
.db-sym:focus-visible { outline: 4px solid var(--focus); outline-offset: -2px; z-index: 2; }
.db-sym .ic { width: 80%; height: 80%; display: block; pointer-events: none; transform: rotate(var(--rot)) scale(var(--sc)); }
.db-sym .ic svg { width: 100%; height: 100%; display: block; }
.db-sym .ic.in { animation: db-in .25s ease-out; }
.db-sym[data-hint='true'] { box-shadow: 0 0 0 4px #f59e0b; animation: db-pulse .8s ease-in-out infinite; }
.db-sym[data-hit='true'] { box-shadow: 0 0 0 5px #16a34a; background: rgba(22,163,74,.14); }
.db-sym[data-hit='true'] .ic { transform: rotate(var(--rot)) scale(calc(var(--sc) * 1.25)); transition: transform .15s ease; }
.db-thumb { width: 44px; height: 44px; flex: none; position: relative; border-radius: 50%; background: #fffdf6; border: 2px solid #d6c7a1; }
.db-thumb .db-sym { cursor: inherit; }
.db-lock { position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; display: flex; align-items: center; justify-content: center; z-index: 3; }
.db-lock b { background: rgba(17,24,39,.82); color: #fff; border-radius: 999px; padding: 6px 14px; font-size: 1.05rem; }
.db-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.4); color: #fff; border-radius: 20px;
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; text-align: center; }
.db-count small { font-size: 1.1rem; padding: 0 12px; }
.db-bubble { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
font-weight: 900; font-size: 1.15rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: db-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; display: flex; align-items: center; gap: 8px; }
.db-bubble .bi { width: 34px; height: 34px; flex: none; }
.db-bubble.ok { border: 4px solid #16a34a; }
.db-bubble.bad { border: 4px solid #dc2626; }
.db-bubble.late { border: 4px solid #6b7280; color: #374151; font-size: .98rem; }
.db-bubble.info { border: 4px solid #2563eb; }
.db-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 14px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 700; text-align: center; }
.db-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
.db-sus { color: var(--warning); font-weight: 800; }
@keyframes db-shake { 0%,100% { transform: none; } 20% { transform: translateX(-10px); } 40% { transform: translateX(9px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(4px); } }
@keyframes db-in { from { opacity: 0; transform: rotate(var(--rot)) scale(calc(var(--sc) * .5)); } to { opacity: 1; transform: rotate(var(--rot)) scale(var(--sc)); } }
@keyframes db-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
@keyframes db-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(245,158,11,.9); } 50% { box-shadow: 0 0 0 8px rgba(245,158,11,.35); } }
@media (max-width: 719px) {
.db { --cd: min(80vw, calc(50vh - 158px), 330px); gap: 6px; }
.db-slot { position: relative; }
.db-slot .cap { position: absolute; top: -2px; left: 50%; transform: translateX(-50%); z-index: 2; background: rgba(0,0,0,.6); border-radius: 999px; padding: 1px 10px; font-size: .78rem; }
.db-table { flex-direction: column; align-items: center; gap: 8px; padding: 8px; border-width: 5px; border-radius: 22px; }
.db-pill { font-size: .8rem; padding: 2px 8px; }
.db-strip { justify-content: flex-start; }
.db-seat { padding: 3px 8px; gap: 6px; }
.db-seat .meta.rtt, .db-pill.mode { display: none; }
.db-seat .who { flex-direction: row; align-items: center; gap: 5px; }
.db-seat .nm { max-width: 72px; font-size: .8rem; }
.db-thumb { width: 38px; height: 38px; }
.db-bubble { font-size: .92rem; padding: 4px 12px; top: 50%; transform: translate(-50%, -50%); animation: none; width: max-content; }
.db-bubble .bi { width: 26px; height: 26px; }
.db-count { font-size: 4rem; }
}
@media (prefers-reduced-motion: reduce) {
.db-card[data-shake='true'], .db-sym .ic.in, .db-bubble, .db-sym[data-hint='true'] { animation: none; }
.db-sym[data-hit='true'] .ic { transition: none; }
}
`;
// ---------------------------------------------------------------- card
function usePhone(): boolean {
const q = '(max-width: 719px)';
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
useEffect(() => {
const mq = window.matchMedia(q);
const on = () => setM(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return m;
}
/**
* A round card with 8 pictures. Buttons are keyed by slot so keyboard focus stays put when the card changes
* (the picture inside is keyed by art and pops in). `onPick` null = look only.
*/
function DobbleCard({
card,
label,
side,
onPick,
hint,
hit,
shake,
children,
}: {
card: CardView;
label: string;
side: string;
onPick: ((art: number) => void) | null;
hint?: number[];
hit?: number | null;
shake?: boolean;
children?: ReactNode;
}) {
return (
<div className="db-card" role="group" aria-label={label} data-card={side} data-key={card.key} data-shake={!!shake}>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<button
key={i}
type="button"
className="db-sym"
data-art={it.art}
data-hint={!!hint?.includes(it.art)}
data-hit={hit === it.art}
disabled={!onPick}
aria-label={`${artName(it.art)}${onPick ? ' 누르기' : ''}`}
style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}
onPointerDown={(e) => {
// Fire on press, not release: every millisecond counts.
if (!onPick || e.button !== 0) return;
e.preventDefault();
onPick(it.art);
}}
onClick={(e) => {
// Keyboard activation; pointer presses were handled on pointerdown.
if (onPick && e.detail === 0) onPick(it.art);
}}
>
<span key={it.art} className="ic in" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</button>
);
})}
{children}
</div>
);
}
/** Small look-only card (target picker, rules). */
function Thumb({ card }: { card: CardView | null }) {
if (!card) return <span className="db-thumb" aria-hidden style={{ opacity: 0.4 }} />;
return (
<span className="db-thumb" aria-hidden>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<span key={i} className="db-sym" style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}>
<span className="ic" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</span>
);
})}
</span>
);
}
// ---------------------------------------------------------------- seats
function countText(mode: MiniGame, s: SeatView): string {
switch (mode) {
case 'tower':
return `탑 ${s.cards}장`;
case 'well':
return s.status === 'finished' ? '다 비웠어요' : `남은 카드 ${s.cards}장`;
case 'poisonedGift':
return `받은 카드 ${s.cards}장`;
case 'hotPotato':
return `${s.cards}장 · 벌점 ${s.penalty}`;
}
}
function statusTag(s: SeatView): { text: string; cls: string } | null {
if (s.status === 'finished') return { text: s.place ? `${s.place}등 완주` : '완주', cls: 'ok' };
if (s.status === 'escaped') return { text: '탈출', cls: 'ok' };
if (s.status === 'forfeit') return { text: '기권', cls: '' };
if (s.status === 'left') return { text: '나감', cls: '' };
return null;
}
function Rtt({ ms }: { ms: number | null }) {
if (ms === null) return null;
const color = ms < 80 ? '#16a34a' : ms < 200 ? '#eab308' : '#dc2626';
return (
<span className="meta rtt" aria-label={`연결 지연 ${ms}밀리초`}>
<i className="db-dot" style={{ background: color }} aria-hidden />
{ms}ms
</span>
);
}
function seatLabel(v: DobbleView, s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, countText(v.options.miniGame, s)];
const tag = statusTag(s);
if (tag) parts.push(tag.text);
return parts.join(', ');
}
// ---------------------------------------------------------------- board
interface Bubble {
key: string;
kind: 'ok' | 'bad' | 'late' | 'info';
text: string;
art?: number | null;
}
function DobbleBoard({ view: v, me, players, act, pending }: BoardProps<DobbleView>) {
const now = useServerNow(100);
const phone = usePhone();
const rtt = useRoom((s) => s.rttMs);
const [sel, setSel] = useState<string | null>(null);
const [bubble, setBubble] = useState<Bubble | null>(null);
const [shake, setShake] = useState(false);
const [hitArt, setHitArt] = useState<number | null>(null);
const [announce, setAnnounce] = useState('');
const prev = useRef<DobbleView | null>(null);
const changed = useRef({ sig: '', at: 0 });
const mode = v.options.miniGame;
const targetMode = mode === 'hotPotato' || mode === 'poisonedGift';
const mine = v.seats.find((s) => s.id === me) ?? null;
const others = v.seats.filter((s) => s.id !== me);
const pair: ClaimPair | null = targetMode ? (v.pairs.find((p) => p.target === sel) ?? v.pairs[0] ?? null) : (v.pairs[0] ?? null);
// The opponent whose card is shown big (also for spectators / while waiting).
const targetId = targetMode ? (pair?.target ?? (others.find((s) => s.id === sel && s.top) ?? others.find((s) => s.status === 'playing' && s.top) ?? others.find((s) => s.top))?.id ?? null) : null;
const targetSeat = targetId ? (v.seats.find((s) => s.id === targetId) ?? null) : null;
// Clamped to the lock length: the client clock estimate may be a little off.
const lockedLeft = mine ? Math.min(v.options.wrongLockoutMs, Math.max(0, mine.lockedUntil - now)) : 0;
const playing = v.phase === 'playing';
const canTap = !!pair && playing && lockedLeft === 0 && !pending;
const claim = (art: number) => {
if (!pair) return;
// A tap right after a compared card changed was aimed at the old card (nobody reacts that fast): treat it as late, no penalty.
if (performance.now() - changed.current.at < TAP_GRACE_MS) {
setBubble({ key: `gr${Date.now()}`, kind: 'late', text: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
return;
}
if (!canTap) {
if (lockedLeft > 0) setBubble({ key: `lk${Date.now()}`, kind: 'late', text: `잠깐! ${Math.ceil(lockedLeft / 1000)}초 뒤에 다시 눌러요` });
return;
}
haptic(15);
act({
type: 'claim',
base: pair.base,
symbol: art,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
...(rtt !== null ? { rtt } : {}),
});
};
// "늦었어요" rejects come back as `reject` messages.
useEffect(
() =>
socket.subscribe((m) => {
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
setAnnounce(m.reason);
}
}),
[],
);
// Diff against the previous view: my judgement, other people's finds, rounds.
useEffect(() => {
const p = prev.current;
prev.current = v;
if (!p) return;
const f = v.feedback;
if (f && f.seq !== p.feedback?.seq) {
if (f.kind === 'ok') {
sounds.ding();
haptic([30, 30, 60]);
setHitArt(f.art);
const b: Bubble = { key: `f${f.seq}`, kind: 'ok', text: '찾았어요!', art: f.art };
setBubble(b);
setAnnounce(`찾았어요! ${artName(f.art)}`);
} else if (f.kind === 'wrong') {
sounds.buzz();
haptic([90, 50, 90]);
setShake(true);
const lock = v.options.wrongLockoutMs ? ` · ${v.options.wrongLockoutMs / 1000}초 잠금` : '';
const b: Bubble = { key: `f${f.seq}`, kind: 'bad', text: `틀렸어요!${lock}`, art: f.art };
setAnnounce(`틀렸어요! ${artName(f.art)}는 같은 그림이 아니에요${lock}`);
setBubble(b);
} else if (f.kind === 'late') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '늦었어요! 다른 사람이 먼저 찾았어요.' });
setAnnounce('늦었어요');
} else if (f.kind === 'locked') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '잠깐! 조금 뒤에 다시 눌러요' });
}
return;
}
const h = v.lastHit;
if (h && h.seq !== p.lastHit?.seq && h.player !== me) {
sounds.stone();
let text = `${nick(players, h.player)}님이 찾았어요`;
if (mode === 'poisonedGift' && h.target === me) text = `${nick(players, h.player)}님이 나에게 카드를 줬어요`;
if (mode === 'hotPotato' && h.target === me) text = `${nick(players, h.player)}님이 카드 묶음을 넘겼어요!`;
setBubble({ key: `h${h.seq}`, kind: 'info', text, art: h.art });
setAnnounce(`${text} · ${artName(h.art)}`);
}
const r = v.lastRound;
if (r && r.round !== p.lastRound?.round) {
const text = r.loser ? `${r.round}라운드 끝! ${r.loser === me ? '내가' : `${nick(players, r.loser)}님이`} 카드 ${r.cards}장을 벌점으로 받아요` : `${r.round}라운드는 무효예요`;
setBubble({ key: `r${r.round}`, kind: r.loser === me ? 'bad' : 'info', text });
setAnnounce(text);
}
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1700);
return () => clearTimeout(t);
}, [bubble]);
useEffect(() => {
if (!shake) return;
const t = setTimeout(() => setShake(false), 450);
return () => clearTimeout(t);
}, [shake]);
useEffect(() => {
if (hitArt === null) return;
const t = setTimeout(() => setHitArt(null), 500);
return () => clearTimeout(t);
}, [hitArt]);
const countdown = v.phase === 'countdown' || v.phase === 'roundEnd' ? Math.max(0, Math.ceil((v.phaseEndsAt - now) / 1000)) : 0;
const hint = v.hintUntil > now ? v.hint : [];
const tapper = canTap || (!!pair && playing && lockedLeft > 0) ? claim : null;
// Which two cards are compared (§5.2): A = top/left, B = bottom/right.
const theirCap = (s: SeatView | null) => (s ? `${nick(players, s.id)}님 카드` : '상대 카드');
let a: { card: CardView | null; cap: string; side: string };
let b: { card: CardView | null; cap: string; side: string };
if (mode === 'hotPotato') {
a = { card: targetSeat?.top ?? null, cap: theirCap(targetSeat), side: 'target' };
b = { card: mine?.top ?? null, cap: '내 카드', side: 'mine' };
} else if (mode === 'poisonedGift') {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: targetSeat?.top ?? null, cap: `${theirCap(targetSeat)}에 줄 선물`, side: 'target' };
} else {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: mine?.top ?? null, cap: mine ? '내 카드' : '관전 중', side: 'mine' };
}
const emptyText = (side: string): string => {
if (v.phase === 'countdown') return '곧 공개돼요';
if (side === 'mine') {
if (!mine) return '관전 중이에요';
if (mine.status === 'finished') return `다 비웠어요! ${mine.place ?? ''}등`;
if (mine.status === 'escaped') return '이번 라운드 탈출!';
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요';
}
if (v.phase === 'roundEnd') return '다음 라운드를 준비해요';
return '카드가 없어요';
};
const slot = (x: typeof a, withLock: boolean) => (
<div className="db-slot">
<span className="cap">{x.cap}</span>
{x.card ? (
<DobbleCard card={x.card} label={x.cap} side={x.side} onPick={tapper} hint={hint} hit={hitArt} shake={withLock && shake}>
{withLock && lockedLeft > 0 && (
<span
className="db-lock"
aria-hidden
style={{ background: `conic-gradient(rgba(17,24,39,.45) ${(lockedLeft / Math.max(1, v.options.wrongLockoutMs)) * 360}deg, transparent 0)` }}
>
<b>잠깐! {Math.ceil(lockedLeft / 1000)}초</b>
</span>
)}
</DobbleCard>
) : (
<div className="db-card empty" data-card={x.side}>
{emptyText(x.side)}
</div>
)}
</div>
);
const sig = `${a.card?.key ?? '-'}|${b.card?.key ?? '-'}`;
if (changed.current.sig !== sig) changed.current = { sig, at: performance.now() };
const remaining = mode === 'well' ? null : mode === 'hotPotato' ? `${v.round}/${v.rounds}라운드` : `남은 카드 ${v.drawCount + (v.center ? 1 : 0)}장`;
const pickable = new Set(v.pairs.map((p) => p.target));
return (
<div className="db">
<style>{CSS}</style>
<div className="db-top">
<div className="grp">
<span className="db-pill mode">{MINI_KO[mode]}</span>
{remaining && <span className="db-pill">{remaining}</span>}
{mine && <span className="db-pill">내 카드 · {countText(mode, mine)}</span>}
</div>
<div className="grp">
{v.options.showRtt && rtt !== null && (
<span className={`db-pill${rtt >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}>
<i className="db-dot" style={{ background: rtt < 80 ? '#16a34a' : rtt < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
{rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`}
</span>
)}
</div>
</div>
{others.length > 0 && (
<ul className="db-strip" aria-label={targetMode ? '누구 카드에서 찾을지 고르기' : '다른 사람'}>
{others.map((s) => {
const tag = statusTag(s);
const canPick = targetMode && pickable.has(s.id);
const inner = (
<>
{targetMode && <Thumb card={s.top} />}
{!targetMode && <Avatar user={userOf(players, s.id)} size={28} />}
<span className="who">
<span className="nm">
{targetMode && <Avatar user={userOf(players, s.id)} size={16} />}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{nick(players, s.id)}</span>
</span>
<span className="meta">
{countText(mode, s)}
{s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''}
</span>
{v.options.showRtt && <Rtt ms={s.rtt} />}
</span>
{tag && <span className={`db-tag ${tag.cls}`}>{tag.text}</span>}
</>
);
return (
<li key={s.id}>
{canPick ? (
<button
type="button"
className="db-seat"
data-seat={s.id}
aria-pressed={targetId === s.id}
aria-label={`${seatLabel(v, s, players, me)} · 이 사람 카드 고르기`}
onClick={() => setSel(s.id)}
>
{inner}
</button>
) : (
<div className="db-seat" data-seat={s.id} data-out={s.status !== 'playing'} aria-label={seatLabel(v, s, players, me)} aria-pressed={targetMode ? targetId === s.id : undefined}>
{inner}
</div>
)}
</li>
);
})}
</ul>
)}
<div className="db-table" role="region" aria-label="카드 테이블">
{slot(a, false)}
{slot(b, true)}
{bubble && (
<div key={bubble.key} className={`db-bubble ${bubble.kind}`} data-bubble={bubble.key} aria-hidden>
{bubble.art !== undefined && bubble.art !== null && <SymbolArt art={bubble.art} className="bi" />}
<span>{bubble.text}</span>
</div>
)}
{countdown > 0 && (
<div className="db-count" aria-hidden>
{countdown}
<small>{v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'}</small>
</div>
)}
</div>
{targetMode && v.pairs.length > 1 && playing && (
<div className="db-panel small" style={{ fontWeight: 600 }}>
{mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: <strong>{targetSeat ? nick(players, targetSeat.id) : ''}님</strong>
</div>
)}
{!phone && pair && playing && (
<span className="small muted" style={{ textAlign: 'center' }}>
Tab 키로 그림을 고르고 Enter 키로 눌러요
</span>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function logText(e: LogEntry, mode: MiniGame, players: Record<string, PublicUser>, me: string | null): string {
switch (e.t) {
case 'hit': {
const n = who(players, me, e.p);
const t = e.target === 'center' ? '' : e.target === me ? '나' : `${nick(players, e.target)}님`;
if (mode === 'tower') return `${n} '${artName(e.art)}'를 찾아 가운데 카드를 가져갔어요`;
if (mode === 'well') return `${n} '${artName(e.art)}'를 찾아 카드를 가운데에 내려놓았어요`;
if (mode === 'poisonedGift') return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드를 줬어요`;
return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드 묶음을 넘겼어요`;
}
case 'done':
return `${who(players, me, e.p)} 카드를 다 비웠어요 · ${e.place}등`;
case 'round':
return e.loser ? `${e.round}라운드 끝 · ${who(players, me, e.loser)} 벌점 ${e.cards}장을 받았어요` : `${e.round}라운드는 무효예요`;
case 'out':
return e.why === 'left' ? `${who(players, me, e.p)} 나갔어요` : `${who(players, me, e.p)} 오래 자리를 비워 기권했어요`;
case 'hint':
return '45초 동안 아무도 못 찾아서 같은 그림을 살짝 보여 줬어요';
}
}
function rulesLine(o: DobbleOptions): string {
const parts = [MINI_KO[o.miniGame], `${o.deckSize}장`];
if (o.miniGame === 'hotPotato') parts.push(`${o.hotPotatoRounds}라운드`);
parts.push(o.wrongLockoutMs ? `틀리면 ${o.wrongLockoutMs / 1000}초 잠금` : '틀려도 잠금 없음');
if (o.symbolStyle === 'easy') parts.push('그림 크기 같게');
if (o.latencyComp === 'light') parts.push('지연 보정');
if (o.miniGame === 'well' && o.wellEndAtFirst) parts.push('첫 완주에 끝');
if (o.miniGame === 'tower' && o.wrongGivesCard) parts.push('틀리면 1장 반납');
return parts.join(' · ');
}
function DobbleSide({ view: v, me, players }: BoardProps<DobbleView>) {
const mode = v.options.miniGame;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>도블{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => {
const sus = v.suspects.includes(s.id);
const tag = statusTag(s);
return (
<li
key={s.id}
className="row"
style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'forfeit' || s.status === 'left' ? 0.55 : 1 }}
aria-label={`${seatLabel(v, s, players, me)}, 찾기 ${s.correct}번, 틀림 ${s.wrong}번${sus ? ', 너무 빨리 맞혀서 확인이 필요해요' : ''}`}
>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{tag ? ` · ${tag.text}` : ''}
</span>
{sus && (
<span className="db-sus small" aria-hidden title="카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요">
⚠ 의심
</span>
)}
{v.options.showRtt && s.rtt !== null && (
<span className="small" aria-hidden style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
<i className="db-dot" style={{ background: s.rtt < 80 ? '#16a34a' : s.rtt < 200 ? '#eab308' : '#dc2626' }} />
{s.rtt}
</span>
)}
<span className="small" aria-hidden>
✓{s.correct} ✗{s.wrong}
</span>
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`}
</strong>
</li>
);
})}
</ol>
{v.suspects.length > 0 && <span className="small muted">⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.</span>}
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.id}>
{r.rank}등 {nick(players, r.id)}
<span className="muted small">
{' '}
· {r.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0}
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="db-log">
{[...v.log].reverse().map((e) => (
<li key={e.n} className="small">
{logText(e, mode, players, me)}
</li>
))}
</ol>
</details>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: DobbleView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.pairs.length > 0;
}
function statusText(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'countdown') return v.round > 1 ? `${v.round}라운드 곧 시작해요!` : '곧 시작해요! 두 카드에 똑같이 있는 그림을 찾아요';
if (v.phase === 'roundEnd') return '라운드가 끝났어요 · 다음 라운드를 준비해요';
const mine = v.seats.find((s) => s.id === me);
if (mine?.status === 'finished') return `카드를 다 비웠어요! ${mine.place ?? ''}등 · 남은 게임을 지켜봐요`;
if (mine?.status === 'escaped') return '이번 라운드는 탈출했어요 · 다음 라운드를 기다려요';
if (mine && (mine.status === 'forfeit' || mine.status === 'left')) return '게임에서 빠졌어요 · 남은 게임을 지켜봐요';
if (isMyTurn(v, me)) {
return '지금 찾아요! 같은 그림을 눌러요';
}
const leader = v.lastHit ? nick(players, v.lastHit.player) : null;
return leader ? `${leader}님이 방금 찾았어요 · 다 같이 찾는 중이에요` : '다 같이 같은 그림을 찾는 중이에요';
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
const MINI_HELP: Record<MiniGame, string> = {
tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!',
well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!',
hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!',
poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!',
};
function Options({ value: o, onChange }: OptionsProps<DobbleOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<DobbleOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="미니게임" help={MINI_HELP[o.miniGame]}>
<Seg
label="미니게임"
value={o.miniGame}
options={[
['tower', '탑 쌓기'],
['well', '우물'],
['hotPotato', '뜨거운 감자'],
['poisonedGift', '독이 든 선물'],
]}
onChange={(miniGame) => set({ miniGame })}
/>
</Field>
{o.miniGame === 'hotPotato' && (
<Field label="뜨거운 감자 라운드 수">
<Seg
label="뜨거운 감자 라운드 수"
value={o.hotPotatoRounds}
options={[
[3, '3라운드'],
[5, '5라운드'],
[7, '7라운드'],
]}
onChange={(hotPotatoRounds) => set({ hotPotatoRounds })}
/>
</Field>
)}
<Field label="틀린 그림을 눌렀을 때 잠금" help="잠금 동안에는 맞는 그림을 눌러도 받아 주지 않아요.">
<Seg
label="틀린 그림을 눌렀을 때 잠금"
value={o.wrongLockoutMs}
options={[
[0, '없음'],
[1000, '1초'],
[2000, '2초'],
]}
onChange={(wrongLockoutMs) => set({ wrongLockoutMs })}
/>
</Field>
<Field label="그림 크기·회전" help={o.symbolStyle === 'easy' ? '어린이 모드: 그림 크기가 같고 돌아가 있지 않아요.' : '그림마다 크기와 방향이 달라요.'}>
<Seg
label="그림 크기·회전"
value={o.symbolStyle}
options={[
['classic', '원작처럼'],
['easy', '쉽게'],
]}
onChange={(symbolStyle) => set({ symbolStyle })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드 장수">
<Seg
label="카드 장수"
value={o.deckSize}
options={[
[57, '57장'],
[55, '55장'],
]}
onChange={(deckSize) => set({ deckSize })}
/>
</Field>
<Field label="지연 보정" help="켜면 0.06초 동안 모인 정답 가운데, 연결 지연의 절반(최대 0.08초)을 빼 준 시각이 가장 빠른 사람이 이겨요.">
<Seg
label="지연 보정"
value={o.latencyComp}
options={[
['off', '끄기'],
['light', '가볍게'],
]}
onChange={(latencyComp) => set({ latencyComp })}
/>
</Field>
<Field label="각자 핑 표시">
<Seg label="각자 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
</Field>
{o.miniGame === 'well' && (
<Field label="우물: 처음 다 비운 사람이 나오면 바로 끝내기">
<Seg label="우물 첫 완주에 끝내기" value={o.wellEndAtFirst} options={onOff} onChange={(wellEndAtFirst) => set({ wellEndAtFirst })} />
</Field>
)}
{o.miniGame === 'tower' && (
<Field label="탑 쌓기: 틀리면 내 탑 맨 위 카드를 반납">
<Seg label="틀리면 카드 반납" value={o.wrongGivesCard} options={onOff} onChange={(wrongGivesCard) => set({ wrongGivesCard })} />
</Field>
)}
</>
)}
</div>
);
}
function optionsSummary(o: DobbleOptions): string {
return rulesLine(o);
}
// ---------------------------------------------------------------- rules
/** Two tiny sample cards that share only the 고양이 (slots of layout template 0). */
const SLOTS0: [number, number][] = [[-0.448, 0.516], [-0.683, -0.028], [0.011, -0.014], [0.18, -0.659], [0.628, -0.271], [0.603, 0.322], [-0.404, -0.552], [0.124, 0.672]];
const sample = (id: number, arts: number[]): CardView => ({ id, key: `r${id}`, items: arts.map((art, i) => ({ art, x: SLOTS0[i]![0], y: SLOTS0[i]![1], r: 0.287, scale: 1, rot: 0 })) });
const SAMPLE: CardView[] = [sample(-1, [0, 1, 18, 31, 44, 11, 28, 56]), sample(-2, [3, 41, 20, 38, 18, 50, 35, 17])];
function Rules({ options }: { options?: DobbleOptions }) {
const lock = options?.wrongLockoutMs ?? 1000;
return (
<div className="stack">
<style>{CSS}</style>
<p>
동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 <strong>똑같은 그림이 딱 하나</strong> 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요.
</p>
<div className="row" style={{ gap: 10, alignItems: 'center' }}>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[0]!} label="예시 카드 1" side="r1" onPick={null} hint={[18]} />
</span>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[1]!} label="예시 카드 2" side="r2" onPick={null} hint={[18]} />
</span>
<span>→ 같은 그림은 고양이!</span>
</div>
<ul>
<li>
<strong>탑 쌓기</strong>: {MINI_HELP.tower}
</li>
<li>
<strong>우물</strong>: {MINI_HELP.well}
</li>
<li>
<strong>뜨거운 감자</strong>: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요.
</li>
<li>
<strong>독이 든 선물</strong>: {MINI_HELP.poisonedGift}
</li>
</ul>
<p>
틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요).
</p>
<p>PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.</p>
</div>
);
}
const ui: GameUI<DobbleView, DobbleOptions> = {
Board: DobbleBoard,
Side: DobbleSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View 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: '탑 쌓기는 많이 모으면, 우물은 내 카드를 먼저 비우면, 독이 든 선물은 적게 받으면, 뜨거운 감자는 벌점이 적으면 이겨요.',
},
];

View 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);
}
});

View 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');
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More