Compare commits

..

58 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
350 changed files with 67525 additions and 480 deletions

View File

@@ -14,12 +14,14 @@ bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록
## 확인 ## 확인
```bash ```bash
bun run typecheck bun run typecheck
bun run test # 규칙·서버 통합·방 단위 테스트 (무작위 대국이 길어 제한 60초) bun run test # 규칙·서버 통합·방 단위 테스트 (4개 병렬, 무작위 대국은 시드 1/10만, 1분 안쪽)
bun run test:full # 배포 전 전체: 무작위 대국 시드 전부(1,000개 등), 순차 실행이라 10분쯤
(cd apps/web && bunx vite build) # 웹 빌드 (cd apps/web && bunx vite build) # 웹 빌드
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영 bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지) bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지)
bun e2e/a11y.e2e.ts # 접근성(axe) 검사 bun e2e/a11y.e2e.ts # 접근성(axe) 검사
bun e2e/pwa.e2e.ts # PWA: 매니페스트·아이콘, 서비스 워커, 오프라인 안내, 홈 화면에 추가
bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고) bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
``` ```
@@ -28,10 +30,11 @@ bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
```bash ```bash
cd deploy && docker compose up -d --build 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). 자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
## 관리자 ## 관리자
`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md). `/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`, `1352267557213573160`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md).
## 구조 ## 구조
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구 - `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구

View File

@@ -47,6 +47,8 @@ describe('roles', () => {
const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie }); const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie });
expect(me.body.role).toBe('superadmin'); expect(me.body.role).toBe('superadmin');
expect((await api(s.base, '/api/me', { cookie: sup.cookie })).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 () => { test('superadmin adds/removes an admin by Discord ID; takes effect immediately; admin cannot manage users', async () => {
@@ -58,7 +60,7 @@ describe('roles', () => {
// Pre-register before the person ever logged in. // 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' }) }); 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.res.status).toBe(200);
expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, adminDiscord]); expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, '1352267557213573160', adminDiscord]);
const adm = await discordLogin(s, 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/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/users', { cookie: adm.cookie })).res.status).toBe(403);
@@ -139,6 +141,37 @@ describe('game settings', () => {
}); });
}); });
describe('admin-only options (threat guides)', () => {
test('off by default; the host cannot turn them on; the admin default always wins and is not shown as a lock', async () => {
const s = start();
const host = await guest(s.base, '방장');
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
const c = await new Client(s.base, host.cookie, host.id).connect();
clients.push(c);
c.send({ t: 'join', code: room.body.code, as: 'player' });
const first = await c.next('room');
expect((first.room.options as any).threatHints).toBe(false);
expect(first.room.lockedOptions).toEqual([]);
c.send({ t: 'config', options: { ...(first.room.options as any), threatHints: true, boardSize: 19 } });
const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19);
expect((after.room.options as any).threatHints).toBe(false);
const sup = await discordLogin(s, SUPER);
const put = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ defaultOptions: { threatHints: true } }) });
expect(put.res.status).toBe(200);
const room2 = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
const c2 = await new Client(s.base, host.cookie, host.id).connect();
clients.push(c2);
c2.send({ t: 'join', code: room2.body.code, as: 'player' });
const r2 = await c2.next('room');
expect((r2.room.options as any).threatHints).toBe(true);
expect(r2.room.lockedOptions).toEqual([]);
c2.send({ t: 'config', options: { ...(r2.room.options as any), threatHints: false, boardSize: 19 } });
const after2 = await c2.next('room', (m) => (m.room.options as any).boardSize === 19);
expect((after2.room.options as any).threatHints).toBe(true);
});
});
describe('site settings, rooms, users', () => { describe('site settings, rooms, users', () => {
test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => { test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => {
const s = start(); const s = start();

View File

@@ -3,6 +3,8 @@ import type { Database } from 'bun:sqlite';
const DAY = 24 * 3600_000; const DAY = 24 * 3600_000;
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const; export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
export const SESSION_COOKIE = 'sid'; 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 { export function randomToken(bytes = 32): string {
const buf = new Uint8Array(bytes); const buf = new Uint8Array(bytes);
@@ -30,8 +32,8 @@ export class Sessions {
resolve(token: string, now = Date.now()): string | null { resolve(token: string, now = Date.now()): string | null {
const hash = sha256Hex(token); const hash = sha256Hex(token);
const row = this.db const row = this.db
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>( .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 FROM sessions s JOIN users u ON u.id = s.user_id `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`, WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`,
) )
.get(hash); .get(hash);
@@ -45,6 +47,8 @@ export class Sessions {
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?') .query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
.run(now, now + SESSION_TTL[row.kind], 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; return row.user_id;
} }

View File

@@ -150,6 +150,32 @@ export class Users {
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId); 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 { softDelete(userId: string, now = Date.now()): void {
this.db.transaction(() => { this.db.transaction(() => {
this.db this.db

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

View File

@@ -14,8 +14,8 @@ export interface Config {
loadTestNoHttpLimits: boolean; loadTestNoHttpLimits: boolean;
} }
/** The site owner's Discord ID (docs/14-admin.md §2). */ /** Superadmin Discord IDs, comma-separated (docs/14-admin.md §2). */
export const DEFAULT_SUPERADMIN = '293719842274541579'; export const DEFAULT_SUPERADMIN = '293719842274541579,1352267557213573160';
export function loadConfig(env: Record<string, string | undefined> = process.env): Config { export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, ''); const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');

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

@@ -2,7 +2,7 @@
import { Hono, type Context } from 'hono'; import { Hono, type Context } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import type { AnyGameDefinition } from '@bg/engine'; import type { AnyGameDefinition } from '@bg/engine';
import { nicknameError, normalizeNickname } from '@bg/shared'; import { botProfile, nicknameError, normalizeNickname } from '@bg/shared';
import type { Audit, Role, Roles } from '../admin/roles'; import type { Audit, Role, Roles } from '../admin/roles';
import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings'; import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings';
import type { Sessions } from '../auth/sessions'; import type { Sessions } from '../auth/sessions';
@@ -100,7 +100,11 @@ export function createAdminApp(d: AdminDeps) {
gameId: r.config.gameId, gameId: r.config.gameId,
status: r.status, status: r.status,
host: d.users.get(r.hostId)?.nickname ?? null, host: d.users.get(r.hostId)?.nickname ?? null,
seats: r.seats.map((s) => (s ? { id: s.userId, nickname: d.users.get(s.userId)?.nickname ?? '?', connected: !!r.presence.get(s.userId)?.connected } : null)), seats: r.seats.map((s) => {
if (!s) return null;
const bot = botProfile(s.userId);
return { id: s.userId, nickname: bot?.nickname ?? d.users.get(s.userId)?.nickname ?? '?', connected: !!bot || !!r.presence.get(s.userId)?.connected };
}),
spectators: r.spectators.size, spectators: r.spectators.size,
connections: r.conns.size, connections: r.conns.size,
visibility: r.config.visibility, visibility: r.config.visibility,

View File

@@ -8,6 +8,7 @@ import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } fro
import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions'; import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
import { toPublicUser, type Users } from '../auth/users'; import { toPublicUser, type Users } from '../auth/users';
import type { RoomManager } from '../rooms/manager'; import type { RoomManager } from '../rooms/manager';
import type { Replays } from '../rooms/replay';
import { WindowLimiter } from '../ws/rate-limit'; import { WindowLimiter } from '../ws/rate-limit';
import { createAdminApp, type AdminDeps } from './admin'; import { createAdminApp, type AdminDeps } from './admin';
import type { Roles } from '../admin/roles'; import type { Roles } from '../admin/roles';
@@ -18,6 +19,7 @@ export interface HttpDeps {
users: Users; users: Users;
sessions: Sessions; sessions: Sessions;
rooms: RoomManager; rooms: RoomManager;
replays: Replays;
/** Injected for tests. */ /** Injected for tests. */
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>; discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
ipOf: (req: Request) => string; ipOf: (req: Request) => string;
@@ -25,6 +27,8 @@ export interface HttpDeps {
roles: Roles; roles: Roles;
settings: SettingsStore; settings: SettingsStore;
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>; 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 } }; type Env = { Variables: { userId: string | null } };
@@ -37,6 +41,7 @@ export function createHttpApp(d: HttpDeps) {
const createLimiter = new WindowLimiter(10 * lift, 60_000); const createLimiter = new WindowLimiter(10 * lift, 60_000);
const lookupLimiter = new WindowLimiter(30 * lift, 60_000); const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
const guestLimiter = new WindowLimiter(20 * lift, 60_000); const guestLimiter = new WindowLimiter(20 * lift, 60_000);
const replayLimiter = new WindowLimiter(30 * lift, 60_000);
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`; const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
const cookieOpts = (maxAgeMs: number) => ({ const cookieOpts = (maxAgeMs: number) => ({
@@ -166,7 +171,10 @@ export function createHttpApp(d: HttpDeps) {
app.post('/api/auth/logout', (c) => { app.post('/api/auth/logout', (c) => {
const token = getCookie(c, SESSION_COOKIE); const token = getCookie(c, SESSION_COOKIE);
if (token) d.sessions.revoke(token); const u = requireUser(c);
// A guest has no way to log back in, so logging out deletes the account (docs/05 §4).
if (u && d.users.get(u)?.kind === 'guest') d.removeGuest(u);
else if (token) d.sessions.revoke(token);
deleteCookie(c, SESSION_COOKIE, { path: '/' }); deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.json({ ok: true }); return c.json({ ok: true });
}); });
@@ -238,15 +246,37 @@ export function createHttpApp(d: HttpDeps) {
return c.json({ stats: d.users.stats(u) }); 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) => { app.post('/api/rooms', async (c) => {
const u = requireUser(c); const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const body = z const body = z
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() }) .object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional(), practice: z.boolean().optional() })
.safeParse(await c.req.json().catch(() => null)); .safeParse(await c.req.json().catch(() => null));
if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400); if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
const r = d.rooms.create(u, body.data.gameId, body.data.visibility); const r = body.data.practice ? d.rooms.createPractice(u, body.data.gameId) : d.rooms.create(u, body.data.gameId, body.data.visibility);
if (typeof r === 'string') return c.json({ error: r }, 429); if (typeof r === 'string') return c.json({ error: r }, 429);
return c.json({ code: r.code }); return c.json({ code: r.code });
}); });

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

@@ -1,7 +1,7 @@
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */ /** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
import { ulid } from 'ulid'; import { ulid } from 'ulid';
import type { AnyGameDefinition } from '@bg/engine'; import type { AnyGameDefinition } from '@bg/engine';
import { isAllowedRoomCode, type PublicUser } from '@bg/shared'; import { botsAllowed, isAllowedRoomCode, isBotId, type PublicUser } from '@bg/shared';
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room'; import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
import type { RoomStore } from './store'; import type { RoomStore } from './store';
import type { SettingsStore } from '../admin/settings'; import type { SettingsStore } from '../admin/settings';
@@ -10,6 +10,9 @@ export const MAX_ROOMS = 2000;
export const MAX_ROOMS_PER_USER = 3; export const MAX_ROOMS_PER_USER = 3;
const EMPTY_LOBBY_TTL = 10 * 60_000; const EMPTY_LOBBY_TTL = 10 * 60_000;
const FINISHED_TTL = 30 * 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 { export interface ManagerDeps {
store: RoomStore; store: RoomStore;
@@ -19,6 +22,7 @@ export interface ManagerDeps {
log?: RoomDeps['log']; log?: RoomDeps['log'];
random?: () => number; random?: () => number;
settings?: SettingsStore; settings?: SettingsStore;
botDelay?: RoomDeps['botDelay'];
} }
export class RoomManager { export class RoomManager {
@@ -40,7 +44,13 @@ export class RoomManager {
now: this.now, now: this.now,
log: this.deps.log, log: this.deps.log,
settings: this.deps.settings, 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) => { onSeatChange: (room, userId, seated) => {
if (isBotId(userId)) return;
const set = this.seatIndex.get(userId) ?? new Set<string>(); const set = this.seatIndex.get(userId) ?? new Set<string>();
if (seated) set.add(room.code); if (seated) set.add(room.code);
else set.delete(room.code); else set.delete(room.code);
@@ -112,6 +122,23 @@ export class RoomManager {
return room; 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. */ /** Rooms where the user holds a seat in an unfinished game. */
activeRoomFor(userId: string): string | null { activeRoomFor(userId: string): string | null {
for (const code of this.seatIndex.get(userId) ?? []) { for (const code of this.seatIndex.get(userId) ?? []) {
@@ -130,9 +157,15 @@ export class RoomManager {
} }
} }
/** Frees every seat the user holds (account removed). */
leaveAll(userId: string): void {
for (const code of [...(this.seatIndex.get(userId) ?? [])]) this.byCode.get(code)?.leave(userId);
}
publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] { publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] {
return this.all() return this.all()
.filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby') // Rooms with computer players are practice rooms: never listed, never filled by quick play.
.filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby' && !r.hasBots())
.map((r) => ({ .map((r) => ({
code: r.code, code: r.code,
gameId: r.config.gameId, gameId: r.config.gameId,
@@ -177,6 +210,13 @@ export class RoomManager {
for (const room of this.all()) { for (const room of this.all()) {
if (room.conns.size > 0) continue; if (room.conns.size > 0) continue;
const idle = now - room.lastActiveAt; 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 = const closable =
(room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) || (room.status === 'lobby' && idle > EMPTY_LOBBY_TTL) ||
(room.status === 'finished' && idle > FINISHED_TTL) || (room.status === 'finished' && idle > FINISHED_TTL) ||

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

@@ -4,9 +4,10 @@
*/ */
import { ulid } from 'ulid'; import { ulid } from 'ulid';
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine'; import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared'; import { botsAllowed, isBotId, makeBotId, botNumber, maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
import { sha256Hex } from '../auth/sessions'; import { sha256Hex } from '../auth/sessions';
import type { RoomStore } from './store'; import { BotDriver } from './bots';
import type { LogEntry, RoomStore } from './store';
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings'; import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
export interface Conn { export interface Conn {
@@ -75,14 +76,19 @@ export interface RoomDeps {
users: { publicUser(id: string): PublicUser | null }; users: { publicUser(id: string): PublicUser | null };
now?: () => number; now?: () => number;
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void; log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
onEmpty?: (room: Room) => void; /** The last connection went away: `leave` = someone left on purpose, `disconnect` = a socket dropped or the page was left. */
onEmpty?: (room: Room, why: 'leave' | 'disconnect') => void;
onSeatChange?: (room: Room, userId: string, seated: boolean) => void; onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */ /** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings }; settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
/** Computer players' thinking time (ms) for a decision with `choices` options; tests may pin it. */
botDelay?: (choices: number, withPeople: boolean) => number;
} }
const CHAT_HISTORY = 30; const CHAT_HISTORY = 30;
const ERROR_LIMIT = 3; const ERROR_LIMIT = 3;
/** Streaming actions (drawing) are logged in batches: at most this long between snapshot writes. */
const STREAM_FLUSH_MS = 1000;
export class Room { export class Room {
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby'; status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
@@ -92,6 +98,8 @@ export class Room {
spectators = new Set<string>(); spectators = new Set<string>();
presence = new Map<string, Presence>(); presence = new Map<string, Presence>();
conns = new Map<string, Conn>(); conns = new Map<string, Conn>();
/** Seated people still on the site but away from the room page (no room messages; still count as connected). */
detached = new Map<string, Conn>();
kicked = new Set<string>(); kicked = new Set<string>();
/** Players who left during a game; their seat is freed when the game ends. */ /** Players who left during a game; their seat is freed when the game ends. */
leftDuringGame = new Set<string>(); leftDuringGame = new Set<string>();
@@ -103,11 +111,18 @@ export class Room {
game: GameSlot | null = null; game: GameSlot | null = null;
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = []; chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
lastActiveAt: number; lastActiveAt: number;
/** When the last connection went away (null while someone is connected). */
emptySince: number | null;
private timer: ReturnType<typeof setTimeout> | null = null; private timer: ReturnType<typeof setTimeout> | null = null;
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
private pendingLogs: LogEntry[] = [];
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private recentCs = new Map<string, number[]>(); private recentCs = new Map<string, number[]>();
private lastChatAt = new Map<string, number>(); private lastChatAt = new Map<string, number>();
private consecutiveErrors = 0; private consecutiveErrors = 0;
private readonly now: () => number; private readonly now: () => number;
/** Plays the computer players' moves (docs/06 §12). */
readonly bots: BotDriver;
constructor( constructor(
readonly id: string, readonly id: string,
@@ -120,6 +135,29 @@ export class Room {
this.seats = new Array(config.maxPlayers).fill(null); this.seats = new Array(config.maxPlayers).fill(null);
this.now = deps.now ?? Date.now; this.now = deps.now ?? Date.now;
this.lastActiveAt = this.now(); this.lastActiveAt = this.now();
this.emptySince = this.now();
const room = this;
this.bots = new BotDriver(
{
get status() {
return room.status;
},
get game() {
return room.game;
},
get def() {
return room.def;
},
now: this.now,
run: (bot, action) => {
const parsed = this.def.actionSchema.safeParse(action);
return parsed.success ? this.runAction(bot, parsed.data, undefined, false) : '잘못된 요청이에요.';
},
log: deps.log,
},
Math.random,
deps.botDelay,
);
} }
get def(): AnyGameDefinition { get def(): AnyGameDefinition {
@@ -143,9 +181,14 @@ export class Room {
return min <= max ? { min, max } : base; return min <= max ? { min, max } : base;
} }
/** Options forced to the admin value: admin-locked ones plus the game's admin-only options (e.g. threat guides). */
private locked(gameId: string): { defaults: unknown; paths: string[] } { private locked(gameId: string): { defaults: unknown; paths: string[] } {
const eff = this.deps.settings?.game(gameId); const eff = this.deps.settings?.game(gameId);
return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] }; const adminOnly = this.deps.games[gameId]?.adminOnlyOptions ?? [];
return {
defaults: eff?.defaultOptions ?? this.deps.games[gameId]?.defaultOptions,
paths: [...new Set([...(eff?.lockedOptions ?? []), ...adminOnly])],
};
} }
seatOf(userId: string): number { seatOf(userId: string): number {
@@ -164,6 +207,15 @@ export class Room {
return !!this.game && !this.game.finished && this.game.players.includes(userId); return !!this.game && !this.game.finished && this.game.players.includes(userId);
} }
hasBots(): boolean {
return this.seats.some((s) => !!s && isBotId(s.userId));
}
/** A person still holds a seat (not counting people who already left a running game). */
hasPeopleSeated(): boolean {
return this.seats.some((s) => !!s && !isBotId(s.userId) && !this.leftDuringGame.has(s.userId));
}
// ---------------------------------------------------------------- snapshot // ---------------------------------------------------------------- snapshot
snapshot(): RoomSnapshot { snapshot(): RoomSnapshot {
@@ -198,7 +250,8 @@ export class Room {
room.game = snap.game; room.game = snap.game;
const now = room.now(); const now = room.now();
for (const s of room.seats) { for (const s of room.seats) {
if (s) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now }); // Computer players have no connection (and so no reconnect grace period).
if (s && !isBotId(s.userId)) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
} }
if (room.game && !room.game.finished) { if (room.game && !room.game.finished) {
const def = room.def; const def = room.def;
@@ -215,25 +268,50 @@ export class Room {
return room; return room;
} }
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void { /** `log.at` defaults to the current time; game actions pass the exact `now` they were applied with (replays, docs/09 §8). */
private persist(log: { actorId: string | null; kind: string; data: unknown; at?: number } | null): void {
const nextSeq = this.seq + 1; const nextSeq = this.seq + 1;
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
this.deps.store.save( this.deps.store.save(
this.id, this.id,
nextSeq, nextSeq,
JSON.stringify(this.snapshot()), JSON.stringify(this.snapshot()),
{ hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config }, { hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config },
log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null, logs,
); );
this.pendingLogs = [];
if (this.flushTimer) clearTimeout(this.flushTimer);
this.flushTimer = null;
this.seq = nextSeq; this.seq = nextSeq;
this.lastActiveAt = this.now(); this.lastActiveAt = this.now();
} }
/**
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
*/
private persistStream(log: { actorId: string | null; kind: string; data: unknown; at: number }): void {
this.seq += 1;
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, ...log });
this.lastActiveAt = this.now();
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
}
/** Writes pending streamed log entries and the snapshot. Public for tests and shutdown. */
flushStream(): void {
this.flushTimer = null;
if (this.pendingLogs.length) this.persist(null);
}
// ---------------------------------------------------------------- views // ---------------------------------------------------------------- views
roomView(): RoomView { roomView(): RoomView {
const pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; const pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const seats: (SeatView | null)[] = this.seats.map((s) => { const seats: (SeatView | null)[] = this.seats.map((s) => {
if (!s) return null; if (!s) return null;
// Computer players are always there and ready.
if (isBotId(s.userId)) return { user: pub(s.userId), ready: true, connected: true, away: false };
const p = this.presence.get(s.userId); const p = this.presence.get(s.userId);
const connected = !!p?.connected; const connected = !!p?.connected;
const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000; const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000;
@@ -259,18 +337,20 @@ export class Room {
rematchVotes: [...this.rematchVotes], rematchVotes: [...this.rematchVotes],
sessionStats: this.sessionStats, sessionStats: this.sessionStats,
seedHash: this.game?.seedHash ?? null, seedHash: this.game?.seedHash ?? null,
lockedOptions: this.locked(this.config.gameId).paths, lockedOptions: this.deps.settings?.game(this.config.gameId)?.lockedOptions ?? [],
lastSeed: this.lastSeed, lastSeed: this.lastSeed,
lastGamePk: this.game?.finished ? this.game.gamePk : null,
}; };
} }
private stateMessage(viewer: string, cs?: number): ServerMessage | null { /** `sync`: full view for (re)join / resync (`syncView`, e.g. the whole drawing). */
private stateMessage(viewer: string, cs?: number, sync = false): ServerMessage | null {
if (!this.game) return null; if (!this.game) return null;
const def = this.def; const def = this.def;
const asPlayer = this.game.players.includes(viewer) ? viewer : null; const asPlayer = this.game.players.includes(viewer) ? viewer : null;
let view: unknown; let view: unknown;
try { try {
view = def.view(this.game.state, asPlayer); view = sync && def.syncView ? def.syncView(this.game.state, asPlayer) : def.view(this.game.state, asPlayer);
} catch (err) { } catch (err) {
this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) }); this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) });
return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' }; return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' };
@@ -340,12 +420,14 @@ export class Room {
this.leftDuringGame.delete(u); this.leftDuringGame.delete(u);
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now }); this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
this.conns.set(u, conn); this.conns.set(u, conn);
this.detached.delete(u);
this.emptySince = null;
if (wasSeated && prev && !prev.connected && this.inGame(u)) { if (wasSeated && prev && !prev.connected && this.inGame(u)) {
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`); this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
} }
this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } }); this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } });
this.broadcastRoom(); this.broadcastRoom();
const st = this.stateMessage(u); const st = this.stateMessage(u, undefined, true);
if (st) conn.send(st); if (st) conn.send(st);
if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult }); if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult });
for (const m of this.chatLog) conn.send(m); for (const m of this.chatLog) conn.send(m);
@@ -353,11 +435,25 @@ export class Room {
return null; return null;
} }
/**
* Left the room page but still on the site (홈, 규칙…): no more room messages and the room no longer counts the
* connection, but the seat stays and the person still counts as connected (no "연결 끊김", no auto moves).
*/
detach(conn: Conn): void {
const u = conn.userId;
if (this.conns.get(u) !== conn) return;
if (this.spectators.has(u)) return this.disconnect(conn);
this.conns.delete(u);
this.detached.set(u, conn);
if (this.conns.size === 0) this.becameEmpty('disconnect');
}
/** Connection dropped (not an intentional leave). */ /** Connection dropped (not an intentional leave). */
disconnect(conn: Conn): void { disconnect(conn: Conn): void {
const u = conn.userId; const u = conn.userId;
if (this.conns.get(u) !== conn) return; if (this.detached.get(u) === conn) this.detached.delete(u);
this.conns.delete(u); else if (this.conns.get(u) === conn) this.conns.delete(u);
else return;
const p = this.presence.get(u); const p = this.presence.get(u);
if (p) { if (p) {
p.connected = false; p.connected = false;
@@ -371,12 +467,13 @@ export class Room {
} }
this.broadcastRoom(); this.broadcastRoom();
this.scheduleTimer(); this.scheduleTimer();
if (this.conns.size === 0) this.deps.onEmpty?.(this); if (this.conns.size === 0) this.becameEmpty('disconnect');
} }
leave(userId: string): void { leave(userId: string): void {
const conn = this.conns.get(userId); const conn = this.conns.get(userId);
this.conns.delete(userId); this.conns.delete(userId);
this.detached.delete(userId);
this.spectators.delete(userId); this.spectators.delete(userId);
this.presence.delete(userId); this.presence.delete(userId);
const seat = this.seatOf(userId); const seat = this.seatOf(userId);
@@ -387,6 +484,11 @@ export class Room {
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 }); this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
const action = this.def.onLeave?.(this.game!.state, userId); const action = this.def.onLeave?.(this.game!.state, userId);
if (action) this.runAction(userId, action, undefined, true); if (action) this.runAction(userId, action, undefined, true);
// Only computer players left: nobody to practise with, so the game ends uncounted.
const g = this.game;
if (g && !g.finished && g.players.every((u) => isBotId(u) || this.leftDuringGame.has(u))) {
this.finishGame({ ranking: [g.players], summary: '모두 나가서 연습 게임을 끝냈어요.', reason: 'abandoned' }, true);
}
} else { } else {
this.seats[seat] = null; this.seats[seat] = null;
this.rematchVotes.delete(userId); this.rematchVotes.delete(userId);
@@ -397,14 +499,19 @@ export class Room {
this.persist({ actorId: userId, kind: 'leave', data: null }); this.persist({ actorId: userId, kind: 'leave', data: null });
this.broadcastRoom(); this.broadcastRoom();
conn?.send({ t: 'bye', reason: 'closed' }); conn?.send({ t: 'bye', reason: 'closed' });
if (this.conns.size === 0) this.deps.onEmpty?.(this); if (this.conns.size === 0) this.becameEmpty('leave');
}
private becameEmpty(why: 'leave' | 'disconnect'): void {
this.emptySince ??= this.now();
this.deps.onEmpty?.(this, why);
} }
private pickNewHost(): void { private pickNewHost(): void {
const candidates = [...this.presence.entries()] const candidates = [...this.presence.entries()]
.filter(([u, p]) => this.seatOf(u) >= 0 && p.connected) .filter(([u, p]) => this.seatOf(u) >= 0 && p.connected)
.sort((a, b) => a[1].joinedAt - b[1].joinedAt); .sort((a, b) => a[1].joinedAt - b[1].joinedAt);
const next = candidates[0]?.[0] ?? this.seatedIds()[0] ?? [...this.spectators][0]; const next = candidates[0]?.[0] ?? this.seatedIds().find((u) => !isBotId(u)) ?? [...this.spectators][0];
if (next) this.hostId = next; if (next) this.hostId = next;
} }
@@ -473,6 +580,8 @@ export class Room {
next.options = eff?.defaultOptions ?? def.defaultOptions; next.options = eff?.defaultOptions ?? def.defaultOptions;
next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers; next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers;
} }
if (this.hasBots() && !botsAllowed(next.gameId)) return '컴퓨터는 이 게임을 할 수 없어요. 컴퓨터를 먼저 빼 주세요.';
if (this.hasBots() && patch.visibility === 'public') return '컴퓨터가 있는 방은 공개할 수 없어요.';
const def = this.deps.games[next.gameId]!; const def = this.deps.games[next.gameId]!;
if (patch.options !== undefined) { if (patch.options !== undefined) {
const parsed = def.optionsSchema.safeParse(patch.options); const parsed = def.optionsSchema.safeParse(patch.options);
@@ -515,6 +624,7 @@ export class Room {
kick(userId: string, target: string): string | null { kick(userId: string, target: string): string | null {
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.'; if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
if (target === userId) return '자기 자신은 내보낼 수 없어요.'; if (target === userId) return '자기 자신은 내보낼 수 없어요.';
if (isBotId(target)) return this.removeBot(userId, target);
if (!this.isParticipant(target)) return '방에 없는 사람이에요.'; if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
const conn = this.conns.get(target); const conn = this.conns.get(target);
this.kicked.add(target); this.kicked.add(target);
@@ -526,6 +636,7 @@ export class Room {
transferHost(userId: string, target: string): string | null { transferHost(userId: string, target: string): string | null {
if (userId !== this.hostId) return '방장만 넘길 수 있어요.'; if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
if (isBotId(target)) return '컴퓨터에게는 방장을 넘길 수 없어요.';
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.'; if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
this.hostId = target; this.hostId = target;
this.persist({ actorId: userId, kind: 'host', data: { target } }); this.persist({ actorId: userId, kind: 'host', data: { target } });
@@ -533,9 +644,43 @@ export class Room {
return null; return null;
} }
/** Host seats a computer player on an empty seat (docs/06 §12). Rooms with computers are private practice rooms. */
addBot(userId: string, seat?: number): string | null {
if (userId !== this.hostId) return '방장만 컴퓨터를 넣을 수 있어요.';
const err = this.requireLobby();
if (err) return err;
if (!botsAllowed(this.config.gameId)) return '이 게임은 컴퓨터와 할 수 없어요.';
const target = seat ?? this.seats.findIndex((s) => s === null);
if (target < 0 || target >= this.seats.length) return '빈자리가 없어요.';
if (this.seats[target]) return '이미 다른 사람이 앉은 자리예요.';
const used = new Set(this.seatedIds().map(botNumber));
let n = 1;
while (used.has(n)) n++;
const id = makeBotId(n, ulid(this.now()));
this.seats[target] = { userId: id, ready: true };
if (this.config.visibility === 'public') this.config = { ...this.config, visibility: 'private' };
this.persist({ actorId: userId, kind: 'addBot', data: { seat: target, bot: id } });
this.broadcastRoom();
return null;
}
removeBot(userId: string, botId: string): string | null {
if (userId !== this.hostId) return '방장만 컴퓨터를 뺄 수 있어요.';
const err = this.requireLobby();
if (err) return err;
const seat = this.seatOf(botId);
if (seat < 0 || !isBotId(botId)) return '없는 컴퓨터예요.';
this.seats[seat] = null;
this.rematchVotes.delete(botId);
this.persist({ actorId: userId, kind: 'removeBot', data: { bot: botId } });
this.broadcastRoom();
return null;
}
chat(userId: string, text: string): string | null { chat(userId: string, text: string): string | null {
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.'; if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.'; if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
const now = this.now(); const now = this.now();
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.'; if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
this.lastChatAt.set(userId, now); this.lastChatAt.set(userId, now);
@@ -553,8 +698,13 @@ export class Room {
return null; return null;
} }
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
private gameChatOnly(): boolean {
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
}
emote(userId: string, id: string): void { emote(userId: string, id: string): void {
if (!this.isParticipant(userId)) return; if (!this.isParticipant(userId) || this.gameChatOnly()) return;
const now = this.now(); const now = this.now();
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return; if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
this.lastChatAt.set(`e:${userId}`, now); this.lastChatAt.set(`e:${userId}`, now);
@@ -571,6 +721,8 @@ export class Room {
const range = this.playerRange(); const range = this.playerRange();
if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`; if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`;
if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`; if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`;
if (this.hasBots() && !botsAllowed(this.config.gameId)) return '컴퓨터는 이 게임을 할 수 없어요.';
if (players.every(isBotId)) return '사람이 한 명은 앉아야 해요.';
return this.startGame(players); return this.startGame(players);
} }
@@ -580,7 +732,7 @@ export class Room {
this.rematchVotes.add(userId); this.rematchVotes.add(userId);
const seated = this.seatedIds(); const seated = this.seatedIds();
const range = this.playerRange(); const range = this.playerRange();
if (seated.length >= range.min && seated.every((u) => this.rematchVotes.has(u))) { if (seated.length >= range.min && seated.every((u) => isBotId(u) || this.rematchVotes.has(u))) {
return this.startGame(this.orderedPlayers()); return this.startGame(this.orderedPlayers());
} }
this.persist(null); this.persist(null);
@@ -639,7 +791,7 @@ export class Room {
this.lastResult = null; this.lastResult = null;
this.consecutiveErrors = 0; this.consecutiveErrors = 0;
for (const s of this.seats) if (s) s.ready = false; for (const s of this.seats) if (s) s.ready = false;
this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash } }); this.persist({ actorId: this.hostId, kind: 'start', data: { gameId: def.id, players, seedHash, stateVersion: def.stateVersion }, at: now });
this.broadcastRoom(); this.broadcastRoom();
this.broadcastState([{ type: 'gameStarted' }]); this.broadcastState([{ type: 'gameStarted' }]);
this.scheduleTimer(); this.scheduleTimer();
@@ -670,10 +822,11 @@ export class Room {
const def = this.def; const def = this.def;
let next: { state: unknown; events: unknown[] }; let next: { state: unknown; events: unknown[] };
const rng = new SeededRng(g.rng); const rng = new SeededRng(g.rng);
const now = this.now();
try { try {
const v = def.validate(g.state, userId, action); const v = def.validate(g.state, userId, action, { now });
if (!v.ok) return v.reason; if (!v.ok) return v.reason;
next = def.apply(g.state, userId, action, { rng, now: this.now() }); next = def.apply(g.state, userId, action, { rng, now });
} catch (err) { } catch (err) {
this.consecutiveErrors++; this.consecutiveErrors++;
this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack }); this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack });
@@ -690,8 +843,15 @@ export class Room {
g.state = next.state; g.state = next.state;
g.rng = rng.state(); g.rng = rng.state();
g.deadlineFloor = null; g.deadlineFloor = null;
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action }); if (!system && def.isStreamAction?.(action)) {
this.broadcastState(next.events, userId, cs); // Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
this.persistStream({ actorId: userId, kind: 'act', data: action, at: now });
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
} else {
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action, at: now });
this.broadcastState(next.events, userId, cs);
}
const result = def.result(g.state); const result = def.result(g.state);
if (result) this.finishGame(result); if (result) this.finishGame(result);
else this.scheduleTimer(); else this.scheduleTimer();
@@ -730,6 +890,7 @@ export class Room {
endReason: voided ? 'void' : result.reason, endReason: voided ? 'void' : result.reason,
result, result,
players, players,
practice: g.players.some(isBotId),
}); });
const allFirst = players.every((p) => p.rank === 1); const allFirst = players.every((p) => p.rank === 1);
if (!voided) { if (!voided) {
@@ -766,33 +927,41 @@ export class Room {
if (!g || g.finished || this.status !== 'playing') return null; if (!g || g.finished || this.status !== 'playing') return null;
const def = this.def; const def = this.def;
const base = def.deadline?.(g.state) ?? null; const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state); const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
let best: number | null = null; let best: number | null = null;
for (const u of timed) { for (const u of timed) {
let d = base; let d = base;
const p = this.presence.get(u); const p = this.presence.get(u);
if (p && !p.connected) { if (p && !p.connected) {
const grace = p.since + this.config.graceSec * 1000; const grace = p.since + graceMs;
d = d === null ? grace : Math.min(d, grace); d = d === null ? grace : Math.min(d, grace);
} }
if (d === null) continue; if (d === null) continue;
if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor); if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor);
if (notBefore !== null) d = Math.max(d, notBefore);
best = best === null ? d : Math.min(best, d); best = best === null ? d : Math.min(best, d);
} }
return best; return best;
} }
scheduleTimer(): void { scheduleTimer(): void {
this.clearTimer(); if (this.timer) clearTimeout(this.timer);
this.timer = null;
// A computer move already planned stays planned (it re-reads the state when it fires).
this.bots.schedule();
const at = this.effectiveDeadline(); const at = this.effectiveDeadline();
if (at === null) return; if (at === null) return;
const delay = Math.max(0, at - this.now()); const delay = Math.max(0, at - this.now());
this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1)); this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1));
} }
/** Clears the turn timer and any planned computer move. */
clearTimer(): void { clearTimer(): void {
if (this.timer) clearTimeout(this.timer); if (this.timer) clearTimeout(this.timer);
this.timer = null; this.timer = null;
this.bots.cancel();
} }
/** Fires due timeouts. Public for tests. */ /** Fires due timeouts. Public for tests. */
@@ -803,15 +972,18 @@ export class Room {
const def = this.def; const def = this.def;
const now = this.now(); const now = this.now();
const base = def.deadline?.(g.state) ?? null; const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state); const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
for (const u of timed) { for (const u of timed) {
let d = base; let d = base;
const p = this.presence.get(u); const p = this.presence.get(u);
if (p && !p.connected) { if (p && !p.connected) {
const grace = p.since + this.config.graceSec * 1000; const grace = p.since + graceMs;
d = d === null ? grace : Math.min(d, grace); d = d === null ? grace : Math.min(d, grace);
} }
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor); if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
if (notBefore !== null && d !== null) d = Math.max(d, notBefore);
if (d === null || d > now) continue; if (d === null || d > now) continue;
const action = def.onTimeout(g.state, u); const action = def.onTimeout(g.state, u);
const err = this.runAction(u, action, undefined, true); const err = this.runAction(u, action, undefined, true);
@@ -829,7 +1001,7 @@ export class Room {
sendSync(conn: Conn): void { sendSync(conn: Conn): void {
conn.send({ t: 'room', seq: this.seq, room: this.roomView() }); conn.send({ t: 'room', seq: this.seq, room: this.roomView() });
const st = this.stateMessage(conn.userId); const st = this.stateMessage(conn.userId, undefined, true);
if (st) conn.send(st); if (st) conn.send(st);
} }
@@ -848,6 +1020,7 @@ export class Room {
} }
shutdown(): void { shutdown(): void {
this.flushStream();
this.clearTimer(); this.clearTimer();
for (const c of this.conns.values()) { for (const c of this.conns.values()) {
c.send({ t: 'bye', reason: 'restart' }); c.send({ t: 'bye', reason: 'restart' });

View File

@@ -44,20 +44,20 @@ export class RoomStore {
})(); })();
} }
/** Writes the log entry (optional), latest room snapshot and room metadata in one transaction. */ /** Writes the log entries (optional), latest room snapshot and room metadata in one transaction. */
save( save(
roomId: string, roomId: string,
seq: number, seq: number,
stateJson: string, stateJson: string,
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown }, meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
log: LogEntry | null, log: LogEntry | LogEntry[] | null,
now = Date.now(), now = Date.now(),
): void { ): void {
this.db.transaction(() => { this.db.transaction(() => {
if (log) { for (const e of log === null ? [] : Array.isArray(log) ? log : [log]) {
this.db this.db
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)') .query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(roomId, log.seq, log.gameNo, log.actorId, log.kind, JSON.stringify(log.data ?? null), log.at); .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 room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
this.db this.db
@@ -100,12 +100,15 @@ export class RoomStore {
endReason: string; endReason: string;
result: unknown; result: unknown;
players: { userId: string; seat: number; rank: number | null; score: number | null }[]; 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 { }): void {
this.db.transaction(() => { this.db.transaction(() => {
const result = g.practice ? { ...(g.result as object), practice: true } : g.result;
this.db this.db
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?') .query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?')
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(g.result), g.id); .run(g.seed, g.endedAt, g.endReason, JSON.stringify(result), g.id);
const counted = g.endReason !== 'void'; const counted = g.endReason !== 'void' && !g.practice;
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null); const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
const firstCount = ranks.filter((r) => r === 1).length; const firstCount = ranks.filter((r) => r === 1).length;
for (const p of g.players) { for (const p of g.players) {

View File

@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { join, normalize } from 'node:path'; import { join, normalize } from 'node:path';
import type { Server } from 'bun'; import type { Server } from 'bun';
import { GAMES } from '@bg/games'; import { GAMES } from '@bg/games';
import { botProfile } from '@bg/shared';
import type { Config } from './config'; import type { Config } from './config';
import { openDb } from './db'; import { openDb } from './db';
import { SESSION_COOKIE, Sessions } from './auth/sessions'; import { SESSION_COOKIE, Sessions } from './auth/sessions';
@@ -10,10 +11,14 @@ import { Users, toPublicUser } from './auth/users';
import { createHttpApp, type HttpDeps } from './http/app'; import { createHttpApp, type HttpDeps } from './http/app';
import { RoomManager } from './rooms/manager'; import { RoomManager } from './rooms/manager';
import { RoomStore } from './rooms/store'; import { RoomStore } from './rooms/store';
import { Replays } from './rooms/replay';
import { Gateway, type WsData } from './ws/gateway'; import { Gateway, type WsData } from './ws/gateway';
import { Audit, Roles } from './admin/roles'; import { Audit, Roles } from './admin/roles';
import { SettingsStore } from './admin/settings'; 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 type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
export const jsonLog: LogFn = (level, msg, extra) => { export const jsonLog: LogFn = (level, msg, extra) => {
@@ -27,6 +32,10 @@ export interface StartOptions {
log?: LogFn; log?: LogFn;
staticDir?: string | null; staticDir?: string | null;
discordExchange?: HttpDeps['discordExchange']; 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) { export function startServer(opts: StartOptions) {
@@ -37,6 +46,8 @@ export function startServer(opts: StartOptions) {
const sessions = new Sessions(db); const sessions = new Sessions(db);
const store = new RoomStore(db); const store = new RoomStore(db);
const publicUser = (id: string) => { const publicUser = (id: string) => {
const bot = botProfile(id);
if (bot) return bot;
const u = users.get(id); const u = users.get(id);
return u ? toPublicUser(u) : null; return u ? toPublicUser(u) : null;
}; };
@@ -44,10 +55,23 @@ export function startServer(opts: StartOptions) {
const roles = new Roles(db, config.superadminDiscordIds); const roles = new Roles(db, config.superadminDiscordIds);
const audit = new Audit(db); const audit = new Audit(db);
const startedAt = Date.now(); const startedAt = Date.now();
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings }); const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings, botDelay: opts.botDelay });
const restored = rooms.restoreAll(); const restored = rooms.restoreAll();
log('info', 'rooms restored', restored); log('info', 'rooms restored', restored);
const gateway = new Gateway({ rooms, me: publicUser, log }); const gateway = new Gateway({ rooms, me: publicUser, log, onClose: (id) => users.touch(id) });
const removeGuest = (id: string) => {
rooms.leaveAll(id);
gateway.closeUser(id);
if (users.deleteGuest(id)) log('info', 'guest removed', { user: id });
};
/** 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; let ready = true;
const ipOf = (req: Request) => const ipOf = (req: Request) =>
@@ -60,11 +84,13 @@ export function startServer(opts: StartOptions) {
users, users,
sessions, sessions,
rooms, rooms,
replays: new Replays(db, GAMES, { publicUser }),
ipOf, ipOf,
discordExchange: opts.discordExchange, discordExchange: opts.discordExchange,
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }), health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
roles, roles,
settings, settings,
removeGuest,
admin: { admin: {
audit, audit,
games: GAMES, games: GAMES,
@@ -121,6 +147,7 @@ export function startServer(opts: StartOptions) {
try { try {
rooms.sweep(); rooms.sweep();
sessions.purgeExpired(); sessions.purgeExpired();
purgeIdleGuests();
store.purgeOldLogs(); store.purgeOldLogs();
} catch (err) { } catch (err) {
log('error', 'sweep failed', { err: String(err) }); log('error', 'sweep failed', { err: String(err) });
@@ -134,11 +161,11 @@ export function startServer(opts: StartOptions) {
rooms.shutdown(); rooms.shutdown();
// Bun's stop() promise can stay pending after server-initiated WS closes even though the // Bun's stop() promise can stay pending after server-initiated WS closes even though the
// listener is already closed, so bound the wait. // listener is already closed, so bound the wait.
await Promise.race([server.stop(true), Bun.sleep(1000)]); await Promise.race([server.stop(true), Bun.sleep(opts.stopWaitMs ?? 1000)]);
db.close(); db.close();
}; };
return { server, stop, rooms, users, sessions, db, gateway, settings, roles }; return { server, stop, rooms, users, sessions, db, gateway, settings, roles, purgeIdleGuests };
} }
async function serveFile(root: string, pathname: string): Promise<Response> { async function serveFile(root: string, pathname: string): Promise<Response> {

View File

@@ -15,7 +15,7 @@ export function tempDbPath(): string {
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) { export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN }); const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
const app = startServer({ config, log: () => {}, staticDir: null, ...extra }); const app = startServer({ config, log: () => {}, staticDir: null, stopWaitMs: 50, ...extra });
const base = `http://localhost:${app.server.port}`; const base = `http://localhost:${app.server.port}`;
return { ...app, base, dbPath, config }; return { ...app, base, dbPath, config };
} }

View File

@@ -19,6 +19,8 @@ export interface GatewayDeps {
me(userId: string): PublicUser | null; me(userId: string): PublicUser | null;
now?: () => number; now?: () => number;
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void; 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; export const MAX_CONNS_PER_USER = 5;
@@ -118,6 +120,7 @@ export class Gateway {
if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId); if (this.byUser.get(ws.data.userId)?.size === 0) this.byUser.delete(ws.data.userId);
const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined; const room = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined;
if (room && ws.data.conn) room.disconnect(ws.data.conn); if (room && ws.data.conn) room.disconnect(ws.data.conn);
this.deps.onClose?.(ws.data.userId);
}, },
}; };
@@ -143,6 +146,11 @@ export class Gateway {
return; return;
} }
const room: Room | undefined = ws.data.room ? this.deps.rooms.get(ws.data.room) : undefined; 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'); if (!room || room.conns.get(u) !== ws.data.conn) return err('먼저 방에 들어가 주세요.', 'not-in-room');
let e: string | null = null; let e: string | null = null;
switch (m.t) { switch (m.t) {
@@ -173,6 +181,12 @@ export class Gateway {
case 'host': case 'host':
e = room.transferHost(u, m.userId); e = room.transferHost(u, m.userId);
break; break;
case 'addBot':
e = room.addBot(u, m.seat);
break;
case 'removeBot':
e = room.removeBot(u, m.userId);
break;
case 'act': case 'act':
room.act(u, m.cs, m.a); room.act(u, m.cs, m.a);
return; return;
@@ -203,6 +217,18 @@ export class Gateway {
} }
} }
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). */ /** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
shutdown(): void { shutdown(): void {
this.closing = true; this.closing = true;

View File

@@ -9,6 +9,12 @@
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" /> <meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <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> <title>같이 놀자 보드게임</title>
</head> </head>
<body> <body>

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#E3B76B"/><g stroke="#7a5a22" stroke-width="2"><path d="M12 22h40M12 32h40M12 42h40M22 12v40M32 12v40M42 12v40"/></g><circle cx="32" cy="32" r="8" fill="#1c1e21"/><circle cx="42" cy="22" r="8" fill="#fff" stroke="#1c1e21" stroke-width="2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c3aed"/><stop offset=".55" stop-color="#db2777"/><stop offset="1" stop-color="#f97316"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#bg)"/><ellipse cx="32" cy="14.5" rx="11" ry="3.6" fill="none" stroke="#fde047" stroke-width="2.6"/><path d="M20 21h24a10 10 0 0 1 9.6 7.2l3.3 11.6a5.6 5.6 0 0 1-9.5 5.3L43 40H21l-4.4 5.1a5.6 5.6 0 0 1-9.5-5.3l3.3-11.6A10 10 0 0 1 20 21z" fill="#fff" stroke="#1e1b4b" stroke-width="2.2" stroke-linejoin="round"/><path d="M19.6 26.2h2.8v3h3v2.8h-3v3h-2.8v-3h-3v-2.8h3z" fill="#1e1b4b"/><circle cx="43" cy="26.6" r="2.3" fill="#facc15" stroke="#1e1b4b" stroke-width="1"/><circle cx="46.8" cy="30.6" r="2.3" fill="#ef4444" stroke="#1e1b4b" stroke-width="1"/><circle cx="43" cy="34.6" r="2.3" fill="#22c55e" stroke="#1e1b4b" stroke-width="1"/><circle cx="39.2" cy="30.6" r="2.3" fill="#3b82f6" stroke="#1e1b4b" stroke-width="1"/><path d="M53 6.4Q53 11 57.6 11Q53 11 53 15.6Q53 11 48.4 11Q53 11 53 6.4z" fill="#fde047"/><path d="M10.5 11Q10.5 14 13.5 14Q10.5 14 10.5 17Q10.5 14 7.5 14Q10.5 14 10.5 11z" fill="#fff"/><path d="M54 50Q54 53 57 53Q54 53 54 56Q54 53 51 53Q54 53 54 50z" fill="#fff"/><path d="M11 51.8Q11 54 13.2 54Q11 54 11 56.2Q11 54 8.8 54Q11 54 11 51.8z" fill="#fde047"/></svg>

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 1.4 KiB

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

@@ -4,7 +4,18 @@ import { nicknameError, normalizeNickname, suggestNickname } from '@bg/shared/li
import { api, discordLoginUrl } from '../net/api'; import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session'; import { useSession } from '../store/session';
export function NicknameForm({ next, submitLabel = '이 이름으로 시작하기', onDone }: { next: string; submitLabel?: string; onDone?: () => void }) { export function NicknameForm({
next,
submitLabel = '이 이름으로 시작하기',
onDone,
showDiscord = true,
}: {
next: string;
submitLabel?: string;
onDone?: () => void;
/** The login page shows its own big Discord button, so it hides this one. */
showDiscord?: boolean;
}) {
const [nick, setNick] = useState(() => suggestNickname()); const [nick, setNick] = useState(() => suggestNickname());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -49,7 +60,7 @@ export function NicknameForm({ next, submitLabel = '이 이름으로 시작하
<button className="btn btn-primary btn-block" disabled={busy}> <button className="btn btn-primary btn-block" disabled={busy}>
{busy ? '잠시만요…' : submitLabel} {busy ? '잠시만요…' : submitLabel}
</button> </button>
{discord && ( {discord && showDiscord && (
<a className="btn btn-secondary btn-block" href={discordLoginUrl(next)}> <a className="btn btn-secondary btn-block" href={discordLoginUrl(next)}>
디스코드로 로그인 디스코드로 로그인
</a> </a>

View File

@@ -1,28 +1,12 @@
/** PC top menu with an always-available room-code box (docs/07-ui-ux.md §3.2). */ /** PC top menu (docs/07-ui-ux.md §3.2): 로고·메뉴, 오른쪽에 테마 버튼과 로그인/계정 메뉴. */
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router'; import { Link, NavLink, useNavigate } from 'react-router';
import { normalizeRoomCode } from '@bg/shared/lite';
import { api } from '../net/api'; import { api } from '../net/api';
import { useSession } from '../store/session'; import { useSession } from '../store/session';
import { toast } from '../store/toast'; import { Avatar, ConfirmSheet } from './ui';
import { Avatar } from './ui';
export function SiteHeader() { export function SiteHeader() {
const { me, siteName, role } = useSession(); const { me, siteName, role } = useSession();
const [code, setCode] = useState('');
const nav = useNavigate();
const join = async (e: React.FormEvent) => {
e.preventDefault();
const c = normalizeRoomCode(code);
if (!c) return toast('방 코드 6자리를 넣어 주세요.', 'error');
try {
await api.room(c);
setCode('');
nav(`/r/${c}`);
} catch (ex) {
toast((ex as Error).message, 'error');
}
};
return ( return (
<header className="site-header"> <header className="site-header">
<div className="site-header-inner"> <div className="site-header-inner">
@@ -37,15 +21,100 @@ export function SiteHeader() {
<NavLink to="/games">게임 목록</NavLink> <NavLink to="/games">게임 목록</NavLink>
{role !== 'user' && <NavLink to="/admin">관리자</NavLink>} {role !== 'user' && <NavLink to="/admin">관리자</NavLink>}
</nav> </nav>
<form className="header-join" onSubmit={join} aria-label="코드로 참여"> <ThemeToggle />
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="방 코드 6자리" inputMode="numeric" maxLength={40} aria-label="방 코드" /> {me ? (
<button className="btn btn-primary btn-sm">참여</button> <AccountMenu />
</form> ) : (
<Link to="/me" className="header-user" aria-label="내 정보"> <Link to="/login" className="header-user header-login">
{me ? <Avatar user={me} size={36} /> : <span style={{ fontSize: 24 }}>👤</span>} 로그인
<span className="wide-only">{me ? me.nickname : '시작하기'}</span> </Link>
</Link> )}
</div> </div>
</header> </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

@@ -145,7 +145,13 @@ export function HwatuCard({
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}> <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> <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} /> <rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
<Motif month={card.month} color={color} /> {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' && ( {card.kind === 'tti' && (
<g> <g>
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} /> <rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
@@ -170,7 +176,7 @@ export function HwatuCard({
{card.month || '+'} {card.month || '+'}
</text> </text>
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif"> <text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{MONTH_NAME[card.month]} · {card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]} {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> </text>
</svg> </svg>
</Tag> </Tag>

View File

@@ -1,10 +1,11 @@
/** Shared UI primitives (docs/07 §4). */ /** Shared UI primitives (docs/07 §4). */
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { PublicUser } from '@bg/shared/lite'; import type { PublicUser } from '@bg/shared/lite';
import { formatRoomCode } from '@bg/shared/lite'; import { formatRoomCode, isBotId } from '@bg/shared/lite';
import { useToasts, toast } from '../store/toast'; import { useToasts, toast } from '../store/toast';
import { useRoom } from '../net/socket'; import { useRoom } from '../net/socket';
import { haptic } from '../lib/feedback'; import { haptic } from '../lib/feedback';
import { useOnline } from '../lib/pwa';
const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626']; const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626'];
@@ -14,7 +15,7 @@ export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nic
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length]; const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
return ( return (
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden> <span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]} {user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : isBotId(user.id) ? '🤖' : [...user.nickname][0]}
</span> </span>
); );
} }
@@ -138,7 +139,8 @@ export function Toasts() {
export function ConnectionBanner() { export function ConnectionBanner() {
const status = useRoom((s) => s.status); const status = useRoom((s) => s.status);
const code = useRoom((s) => s.code); const code = useRoom((s) => s.code);
if (!code) return null; const online = useOnline();
if (!code) return online ? null : <div className="conn-banner" role="status">인터넷 연결이 없어요. 연결되면 다시 쓸 수 있어요.</div>;
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>; if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>; if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
return null; return null;

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

@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { TurnTimer, useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback'; import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types'; import type { BoardProps, GameUI, OptionsProps } from '../types';
import { helpNow } from './help';
import { quickStart } from './quick';
type Color = 'black' | 'white'; type Color = 'black' | 'white';
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' }; const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
@@ -782,8 +784,7 @@ function Options({ value: o, onChange }: OptionsProps<BadukOptions>) {
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} /> <Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>도움 표시</strong> <strong>무승부 제안</strong>
<Seg value={o.atariWarning ? 'on' : 'off'} options={[['on', '단수 경고 켜기'], ['off', '단수 경고 끄기']]} onChange={(k) => set({ atariWarning: k === 'on' })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} /> <Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
@@ -854,6 +855,8 @@ const ui: GameUI<BadukView, BadukOptions> = {
Options, Options,
optionsSummary, optionsSummary,
Rules, Rules,
quickStart,
helpNow,
}; };
export default ui; 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

@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { TurnTimer, useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback'; import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types'; import type { BoardProps, GameUI, OptionsProps } from '../types';
import { helpNow } from './help';
import { quickStart } from './quick';
type Color = 'white' | 'black'; type Color = 'white' | 'black';
type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p'; type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p';
@@ -277,7 +279,8 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
onPointerDown={(e) => { onPointerDown={(e) => {
setKbd(false); setKbd(false);
const sq = cellAt(e); const sq = cellAt(e);
if (!sq || !ownMovable(sq) || pending) return; // Chess960: with the king picked, my own rook is a castling target, not a new selection.
if (!sq || !ownMovable(sq) || pending || (selected && targets.some((m) => m.to === sq))) return;
drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false }; drag.current = { from: sq, wasSelected: selected === sq, sx: e.clientX, sy: e.clientY, moved: false };
setSelected(sq); setSelected(sq);
svgRef.current?.setPointerCapture(e.pointerId); svgRef.current?.setPointerCapture(e.pointerId);
@@ -361,6 +364,18 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
{showDots && {showDots &&
targets.map((m) => { targets.map((m) => {
const c = cell(m.to); 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]); const capture = !!pieceAt(v, m.to) || (pieceAt(v, m.from)?.toLowerCase() === 'p' && m.from[0] !== m.to[0]);
return capture ? ( 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.45} fill="none" stroke="rgba(20,40,10,0.45)" strokeWidth={0.08} style={{ pointerEvents: 'none' }} />
@@ -386,6 +401,15 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
<p className="sr-only" aria-live="polite"> <p className="sr-only" aria-live="polite">
{announce} {announce}
</p> </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 && ( {!v.myColor && (
<div className="row" style={{ justifyContent: 'center' }}> <div className="row" style={{ justifyContent: 'center' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setFlip(!flip)}> <button className="btn btn-secondary btn-sm" onClick={() => setFlip(!flip)}>
@@ -450,8 +474,15 @@ function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<
const bottom: Color = mine ?? 'white'; const bottom: Color = mine ?? 'white';
const beforeFirst = !!mine && !v.started[mine]; const beforeFirst = !!mine && !v.started[mine];
const canClaim = v.drawMode === 'fide' && !!v.claimable && !!mine && v.turn === 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 ( 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={top} players={players} deadline={deadline} me={me} />
<PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} /> <PlayerBar view={v} color={bottom} players={players} deadline={deadline} me={me} />
{v.phase === 'playing' && mine && ( {v.phase === 'playing' && mine && (
@@ -476,6 +507,15 @@ function ChessSide({ view: v, me, players, act, pending, deadline }: BoardProps<
</button> </button>
</div> </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 && ( {canClaim && (
<button className="btn btn-primary" onClick={() => setConfirm('claim')}> <button className="btn btn-primary" onClick={() => setConfirm('claim')}>
⚖ 무승부 주장 ({v.claimable === 'threefold' ? '3회 반복' : '50수 규칙'}) ⚖ 무승부 주장 ({v.claimable === 'threefold' ? '3회 반복' : '50수 규칙'})
@@ -691,6 +731,11 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min)); const num = (val: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(val) || min));
return ( return (
<div className="stack" style={{ gap: 16 }}> <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 }}> <div className="stack" style={{ gap: 6 }}>
<strong>시간 (기본 분 + 한 수마다 추가 초)</strong> <strong>시간 (기본 분 + 한 수마다 추가 초)</strong>
<Seg <Seg
@@ -712,7 +757,7 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>초보 도움말</strong> <strong>초보 도움말</strong>
<Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['movesAndThreats', '+ 위험한 기물 경고'], ['off', '끄기']]} onChange={(hints) => set({ hints })} /> <Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
</div> </div>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}> <button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'} {more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
@@ -788,7 +833,7 @@ function timeLabel(tc: ChessOptions['timeControl']): string {
function optionsSummary(o: ChessOptions): string { function optionsSummary(o: ChessOptions): string {
const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`; const undo = o.undo === 'off' ? '무르기 없음' : o.undo === 'unlimited' ? '무르기 무제한' : `무르기 ${o.undo}번`;
return ['표준', timeLabel(o.timeControl), undo].join(' · '); return [o.variant === 'chess960' ? '체스960' : '표준', timeLabel(o.timeControl), undo].join(' · ');
} }
// ---------------------------------------------------------------- rules // ---------------------------------------------------------------- rules
@@ -804,8 +849,15 @@ const PIECE_RULES: [PieceType, string][] = [
function Rules({ options }: { options?: ChessOptions }) { function Rules({ options }: { options?: ChessOptions }) {
const fide = options?.drawMode === 'fide'; const fide = options?.drawMode === 'fide';
const c960 = options?.variant === 'chess960';
return ( return (
<div className="stack"> <div className="stack">
{c960 && (
<p>
<strong>체스960</strong>: 뒷줄 기물(킹·퀸·룩·비숍·나이트) 배치가 960가지 중 하나로 무작위로 정해져요. 흑은 백과 똑같이 마주 보게 놓여요. 두 비숍은 서로 다른 색 칸에, 킹은
두 룩 사이에 있어요. 그 밖의 규칙은 일반 체스와 같아요.
</p>
)}
<p> <p>
백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 <strong>킹</strong>을 공격해서(체크) 어디로도 피할 수 없게 만들면(<strong>체크메이트</strong>) 이겨요. 백과 흑이 번갈아 한 수씩 둬요. 백이 먼저 둬요. 상대 <strong>킹</strong>을 공격해서(체크) 어디로도 피할 수 없게 만들면(<strong>체크메이트</strong>) 이겨요.
내 킹이 공격받게 되는 수는 둘 수 없어요. 내 킹이 공격받게 되는 수는 둘 수 없어요.
@@ -820,10 +872,18 @@ function Rules({ options }: { options?: ChessOptions }) {
</div> </div>
))} ))}
</div> </div>
<p> {c960 ? (
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 <p>
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요. <strong>캐슬링(체스960)</strong>: 끝나는 자리는 일반 체스와 같아요. 킹 쪽(O-O)은 킹이 g열, 룩이 f열로, 퀸 쪽(O-O-O)은 킹이 c열, 룩이 d열로 가요. 킹과 그 룩이 한 번도
</p> 움직이지 않았고, 둘이 지나가는 칸이 (둘 자신 말고는) 모두 비어 있어야 해요. 체크 중이거나 킹이 지나가는 칸·도착 칸이 공격받으면 안 돼요. 킹을 누른 뒤 노란 테두리 칸(내 룩이나
g·c열 칸)을 누르거나, [캐슬링] 버튼을 눌러요.
</p>
) : (
<p>
<strong>캐슬링</strong>: 킹과 룩이 한 번도 움직이지 않았고 사이가 비어 있으면, 킹을 룩 쪽으로 두 칸 옮기고 룩을 킹 너머로 넘겨요. 체크 중이거나 킹이 지나가는 칸·도착 칸이
공격받으면 안 돼요. 킹을 누르면 캐슬링할 칸도 표시돼요.
</p>
)}
<p> <p>
<strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요. <strong>앙파상</strong>: 상대 폰이 처음 자리에서 두 칸 나와 내 폰 바로 옆에 섰다면, 바로 다음 수에 한해 한 칸만 나온 것처럼 대각선으로 잡을 수 있어요.
</p> </p>
@@ -851,6 +911,8 @@ const ui: GameUI<ChessView, ChessOptions> = {
Options, Options,
optionsSummary, optionsSummary,
Rules, Rules,
quickStart,
helpNow,
}; };
export default ui; 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

@@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer'; import { TurnTimer } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback'; import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types'; 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_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
const COLOR_SHORT: Record<TColor, string> = { black: '검정', white: '흰색' }; const COLOR_SHORT: Record<TColor, string> = { black: '검정', white: '흰색' };
@@ -951,6 +953,8 @@ const ui: GameUI<DavinciView, DavinciOptions> = {
Options, Options,
optionsSummary, optionsSummary,
Rules, Rules,
quickStart,
helpNow,
}; };
export default ui; 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

View File

@@ -0,0 +1,87 @@
/** 고스톱 30초 규칙 요약 (docs/07 §5). */
import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard';
import type { QuickCard } from '../types';
const card = (id: number, month: number, kind: HwatuInfo['kind'], name: string, tti?: HwatuInfo['tti']): HwatuInfo => ({ id, month, kind, name, tti });
const row = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 } as const;
const label = { fontWeight: 900, fontSize: '1.4rem' } as const;
function Match() {
return (
<div style={row} aria-hidden>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<HwatuCard card={card(8, 3, 'gwang', '3월 광')} size="md" highlight />
<small>내 손패</small>
</div>
<span style={label}>→</span>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<HwatuCard card={card(10, 3, 'pi', '3월 피')} size="md" />
<small>바닥</small>
</div>
<span style={label}>=</span>
<div style={{ display: 'flex' }}>
<HwatuCard card={card(8, 3, 'gwang', '3월 광')} size="sm" />
<HwatuCard card={card(10, 3, 'pi', '3월 피')} size="sm" style={{ marginLeft: -16 }} />
</div>
</div>
);
}
function Jokbo() {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }} aria-hidden>
<div style={row}>
<HwatuCard card={card(1, 1, 'tti', '1월 홍단', 'hong')} size="sm" />
<HwatuCard card={card(5, 2, 'tti', '2월 홍단', 'hong')} size="sm" />
<HwatuCard card={card(9, 3, 'tti', '3월 홍단', 'hong')} size="sm" />
<span style={label}>홍단 3점</span>
</div>
<div style={row}>
<HwatuCard card={card(0, 1, 'gwang', '1월 광')} size="sm" />
<HwatuCard card={card(8, 3, 'gwang', '3월 광')} size="sm" />
<HwatuCard card={card(28, 8, 'gwang', '8월 광')} size="sm" />
<span style={label}>삼광 3점</span>
</div>
</div>
);
}
function GoStop() {
const btn = (bg: string, text: string, sub: string) => (
<div style={{ background: bg, color: '#fff', borderRadius: 14, width: 104, height: 84, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', fontWeight: 900, fontSize: '1.6rem', lineHeight: 1.1 }}>
{text}
<small style={{ fontSize: '.8rem', fontWeight: 600 }}>{sub}</small>
</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }} aria-hidden>
<strong style={{ fontSize: '1.2rem' }}>7점 났어요!</strong>
<div style={row}>
{btn('#dc2626', '고', '계속해요')}
{btn('#2563eb', '스톱', '받고 끝내요')}
</div>
</div>
);
}
export const quickStart: QuickCard[] = [
{
pic: <Match />,
alt: '손패의 3월 광을 내서 바닥의 3월 피와 짝지어 두 장을 가져와요',
title: '같은 월끼리 먹어요',
text: '내 차례에 손패 1장을 내고 더미에서 1장을 뒤집어요. 바닥에 같은 월(숫자) 패가 있으면 함께 가져와요.',
},
{
pic: <Jokbo />,
alt: '홍단 띠 3장은 3점, 광 3장은 3점이라는 그림이에요',
title: '모은 패로 점수를 내요',
text: '광 3장, 홍단·청단·초단 띠 3장, 피 10장(쌍피는 2장 값)처럼 족보를 모으면 점수가 나요. 게임 중 [족보표]에서 볼 수 있어요.',
},
{
pic: <GoStop />,
alt: '7점이 나서 빨간 고 버튼과 파란 스톱 버튼이 보여요',
title: '고? 스톱?',
text: '기준 점수(기본 맞고 7점, 고스톱 3점)가 나면 스톱으로 끝내고 이기거나, 고로 더 키워요. 고 뒤에 남이 먼저 나면 손해예요.',
},
];

View File

@@ -0,0 +1,55 @@
import { expect, test } from 'bun:test';
import { setupGame, usersOf } from '../help-kit.test-util';
import { GAMES } from '@bg/games';
import { helpNow } from './help';
const users = usersOf(['A', 'B', 'C']);
const view = (s: unknown, who: string | null) => GAMES['halli-galli']!.view(s, who) as any;
function playing(options: Record<string, unknown> = {}) {
const { state } = setupGame('halli-galli', 3, options);
state.phase = 'playing';
return state;
}
test('할리갈리: 카운트다운 / 내 차례 / 남의 차례 / 관전', () => {
const { state: s0 } = setupGame('halli-galli', 3);
expect(helpNow(view(s0, 'A'), 'A', users)).toContain('곧 시작해요');
const s = playing();
const turn: string = s.turn;
const other = ['A', 'B', 'C'].find((p) => p !== turn)!;
const t = helpNow(view(s, turn), turn, users);
expect(t).toContain('[뒤집기]');
expect(t).toContain('딱 5개');
expect(t).toContain('3초');
const o = helpNow(view(s, other), other, users);
expect(o).toContain(`${users[turn]!.nickname}님이 뒤집을 차례예요`);
expect(o).toContain('종은 언제든');
expect(helpNow(view(s, null), null, users)).toContain(`${users[turn]!.nickname}님이 카드를 뒤집을 차례예요`);
});
test('할리갈리: 자동 뒤집기·벌칙 규칙과 덱 0장', () => {
const s = playing({ autoFlip: true, wrongToPot: true, strikeOut: true });
const turn: string = s.turn;
const t = helpNow(view(s, turn), turn, users);
expect(t).toContain('자동으로 뒤집혀요');
expect(t).toContain('종 밑에');
expect(t).toContain('3번 틀리면 탈락');
const other = ['A', 'B', 'C'].find((p) => p !== turn)!;
s.decks[other] = [];
expect(helpNow(view(s, other), other, users)).toContain('틀리면 바로 탈락');
});
test('할리갈리: 2명 남은 원작 규칙 / 탈락 / 끝', () => {
const s = playing({ endMode: 'official' });
const [a, b, c] = s.order as string[];
s.active = [a, b];
s.eliminated = [c];
s.outReason = { [c!]: 'empty' };
s.officialDuel = true;
expect(helpNow(view(s, a!), a!, users)).toContain('다음 종으로 게임이 끝나고');
expect(helpNow(view(s, c!), c!, users)).toContain('탈락했어요');
s.phase = 'ended';
s.final = { endReason: 'official', ranking: [], summary: '호랑이님 우승!' };
expect(helpNow(view(s, a!), a!, users)).toContain('호랑이님 우승!');
});

View File

@@ -0,0 +1,45 @@
/** 할리갈리 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { HalliGalliView } from '@bg/games/halli-galli';
import type { PublicUser } from '@bg/shared/lite';
const BELL = '펼쳐진 카드 맨 위 그림을 모두 합쳐 한 과일이 딱 5개가 되면 가운데 [종!]을 먼저 눌러요. 4개나 6개일 때는 누르면 안 돼요.';
function penalty(v: HalliGalliView): string {
const o = v.options;
const where = o.wrongToPot ? '종 밑에 카드를 넣어야 해요' : '다른 사람들에게 카드를 1장씩 줘야 해요';
const more = o.penaltyEscalation ? '(틀릴 때마다 벌칙 장수가 늘어요)' : '';
const strike = o.strikeOut ? ' 3번 틀리면 탈락이에요.' : '';
const alive = v.seats.filter((s) => s.status === 'playing').length;
if (alive === 2 && o.duelWrongLoses && !v.officialDuel) return '1대1이라 이 방 규칙에서는 잘못 누르면 바로 져요.';
return `잘못 누르면 ${where}${more}.${strike}`;
}
export function helpNow(v: HalliGalliView, me: string | null, players: Record<string, PublicUser>): string {
const nick = (id: string | null) => (id ? (players[id]?.nickname ?? '다른 사람') : '다른 사람');
if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위에서 각자 남은 카드 수를 볼 수 있어요.';
if (v.phase === 'countdown') {
return `곧 시작해요. 차례대로 카드를 한 장씩 뒤집어요.\n${BELL}`;
}
const mine = v.seats.find((s) => s.id === me) ?? null;
const duel = v.officialDuel ? '\n2명만 남았어요. 다음 종으로 게임이 끝나고 카드가 많은 사람이 이겨요.' : '';
if (!mine) {
return `${nick(v.turn)}님이 카드를 뒤집을 차례예요. 한 과일이 딱 5개가 되는 순간 종을 먼저 누른 사람이 펼친 카드를 모두 가져가요.${duel}`;
}
if (mine.status !== 'playing') {
return `${mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요.'} 남은 사람들의 게임을 지켜봐요. 지금은 ${nick(v.turn)}님이 뒤집을 차례예요.`;
}
const lines: string[] = [];
if (v.turn === me) {
if (v.options.autoFlip) lines.push('내 차례예요. 이 방은 카드가 자동으로 뒤집혀요.');
else if (v.pendingFlip) lines.push('내 차례예요. 뒤집기를 눌렀어요. 곧 카드가 펼쳐져요.');
else if (mine.deck === 0) lines.push('내 차례인데 내 덱(뒤집을 카드 더미)이 비었어요.');
else lines.push(`내 차례예요. 아래 [뒤집기]를 눌러 카드를 한 장 펼쳐요. ${v.flipTotalMs / 1000}초가 지나면 자동으로 뒤집혀요.`);
} else {
lines.push(`${nick(v.turn)}님이 뒤집을 차례예요. 차례가 아니어도 종은 언제든 누를 수 있어요.`);
if (mine.deck === 0) lines.push('내 덱이 0장이에요. 내 차례가 오기 전에 종을 맞히면 카드를 얻어 계속할 수 있지만, 틀리면 바로 탈락이에요.');
}
lines.push(BELL);
if (v.options.kidMode) lines.push('아래 과일 합계 막대에서 숫자가 5인 과일이 있는지 보세요.');
lines.push(`맞히면 펼친 카드를 모두 가져가요. ${penalty(v)}`);
return lines.join('\n') + duel;
}

View File

@@ -0,0 +1,964 @@
/** 할리갈리 화면 (docs/games/halli-galli.md §9). */
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { Fruit, FruitCount, HalliGalliOptions, HalliGalliView, LogEntry, SeatView, TopCard } from '@bg/games/halli-galli';
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';
const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum'];
const FRUIT_KO: Record<Fruit, string> = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' };
const FRUIT_INK: Record<Fruit, string> = { strawberry: '#e11d48', banana: '#ca8a04', lime: '#4d7c0f', plum: '#7e22ce' };
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 cardName = (c: Pick<TopCard, 'fruit' | 'count'>) => `${FRUIT_KO[c.fruit]} ${c.count}개`;
const CSS = `
.hg { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; align-self: center; }
.hg-top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; justify-content: space-between; font-weight: 700; }
.hg-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.hg-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; }
.hg-pill.warn { background: var(--warning-soft); color: var(--warning); }
.hg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.hg-table { position: relative; width: 100%; height: clamp(440px, 64vh, 600px); border-radius: 28px; border: 8px solid var(--wood); box-shadow: var(--shadow);
background: radial-gradient(ellipse at center, rgba(255,255,255,.14), rgba(255,255,255,0) 62%), var(--felt); overflow: hidden; touch-action: manipulation; }
.hg-seat { position: absolute; transform: translate(-50%, -50%); width: var(--sw); display: flex; flex-direction: column; align-items: center; gap: 3px;
padding: 5px 5px 6px; border-radius: 14px; background: rgba(0,0,0,.28); color: #fff; border: 3px solid transparent; z-index: 2; }
.hg-seat[data-me='true'] { background: rgba(0,0,0,.42); }
.hg-seat[data-turn='true'] { border-color: #fde047; animation: hg-blink 1s ease-in-out infinite; }
.hg-seat[data-out='true'] { opacity: .6; }
.hg-seat .nm { display: flex; align-items: center; gap: 4px; max-width: 100%; font-weight: 800; font-size: .86rem; line-height: 1.2; }
.hg-seat .nm span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hg-pile { position: relative; width: var(--cw); height: calc(var(--cw) * 1.4); }
.hg-pile .stack { position: absolute; inset: 0; border-radius: 9px; }
.hg-pile .empty { position: absolute; inset: 0; border-radius: 9px; border: 2px dashed rgba(255,255,255,.45); }
.hg-pile .shadow1 { position: absolute; inset: 0; transform: translate(3px, 3px); border-radius: 9px; background: #e7e2d3; border: 1px solid #b9b2a0; }
.hg-pile .face { position: absolute; inset: 0; animation: hg-flip .22s ease-out; }
.hg-pile .gauge { position: absolute; right: -12px; bottom: -8px; width: 30px; height: 30px; }
.hg-meta { display: flex; gap: 6px; align-items: center; font-size: .82rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.hg-back { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #1e3a8a; border: 1.5px solid #93c5fd; vertical-align: -2px; }
.hg-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; }
.hg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; z-index: 3; }
.hg-bell { width: var(--bell); height: var(--bell); border-radius: 50%; border: 0; padding: 0; background: radial-gradient(circle at 40% 35%, #fff7d1, #fbbf24 45%, #b45309 100%);
box-shadow: 0 8px 0 #7c2d12, 0 12px 24px rgba(0,0,0,.35); cursor: pointer; position: relative; display: flex; align-items: center; justify-content: center;
transition: transform .06s ease, box-shadow .06s ease; -webkit-tap-highlight-color: transparent; touch-action: manipulation; user-select: none; }
.hg-bell .art { width: 70%; height: 70%; display: block; pointer-events: none; }
.hg-bell .art svg { width: 100%; height: 100%; display: block; }
.hg-bell .art[data-ring='true'] { animation: hg-ring .45s ease-in-out; }
.hg-bell:active:not(:disabled) { transform: translateY(6px) scale(.97); box-shadow: 0 2px 0 #7c2d12, 0 4px 10px rgba(0,0,0,.35); }
.hg-bell:disabled { cursor: not-allowed; filter: grayscale(.85) brightness(.85); }
.hg-bell:focus-visible { outline: 4px solid var(--focus); outline-offset: 6px; }
.hg-bell .lock { position: absolute; inset: -6px; border-radius: 50%; pointer-events: none; }
.hg-bell .lbl { position: absolute; bottom: 14%; left: 0; right: 0; text-align: center; font-weight: 900; color: #451a03; font-size: calc(var(--bell) * .11); pointer-events: none; }
.hg-keys { color: var(--text-sub); font-weight: 600; font-size: .85rem; }
.hg-pot { position: absolute; left: 50%; top: -2px; transform: translateX(-50%); color: #fff; font-weight: 800; font-size: .78rem; z-index: 4; pointer-events: none;
background: #1e3a8a; border: 2px solid #93c5fd; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
.hg-bubble { position: absolute; left: 50%; top: 2px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
font-weight: 900; font-size: 1.25rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: hg-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; }
.hg-bubble.ok { border: 4px solid #16a34a; }
.hg-bubble.bad { border: 4px solid #dc2626; }
.hg-bubble.late { border: 4px solid #6b7280; font-size: .95rem; }
.hg-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.35); color: #fff;
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; }
.hg-count small { font-size: 1.1rem; }
.hg-ghost { position: absolute; width: var(--cw); height: calc(var(--cw) * 1.4); transform: translate(-50%, -50%); z-index: 5; pointer-events: none;
animation: hg-fly .6s cubic-bezier(.5,.1,.3,1) forwards; animation-delay: var(--d, 0ms); left: var(--fx); top: var(--fy); }
.hg-mine { display: flex; gap: 10px; align-items: stretch; }
.hg-mine .info { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 8px 12px; display: flex; flex-direction: column; justify-content: center; min-width: 96px; }
.hg-mine .info b { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.hg-flipbtn { flex: 1; min-height: 64px; font-size: 1.25rem !important; }
.hg-kid { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: grid; gap: 6px; }
.hg-bar { display: grid; grid-template-columns: 28px 52px 1fr 28px; gap: 8px; align-items: center; font-weight: 800; }
.hg-bar .track { position: relative; height: 14px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.hg-bar .track i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; }
.hg-bar .track::after { content: ''; position: absolute; left: calc(100% * 5 / 8); top: -2px; bottom: -2px; width: 2px; background: var(--text); }
.hg-bar[data-five='true'] { color: var(--success); }
.hg-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; }
.hg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
.hg-fruit-ic { width: 26px; height: 26px; display: inline-block; vertical-align: middle; }
@keyframes hg-blink { 0%,100% { box-shadow: 0 0 0 0 rgba(253,224,71,.0); } 50% { box-shadow: 0 0 0 6px rgba(253,224,71,.55); } }
@keyframes hg-flip { from { transform: rotateY(85deg) scale(.9); } to { transform: none; } }
@keyframes hg-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 hg-ring { 0%,100% { transform: rotate(0); } 20% { transform: rotate(-14deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(5deg); } }
@keyframes hg-fly { from { left: var(--fx); top: var(--fy); opacity: 1; } 85% { opacity: .9; } to { left: var(--tx); top: var(--ty); opacity: 0; transform: translate(-50%, -50%) scale(.6); } }
@media (max-width: 719px) {
.hg-table { height: 450px; border-width: 6px; border-radius: 22px; }
.hg-seat { padding: 3px 3px 4px; gap: 2px; }
.hg-seat .nm { font-size: .78rem; }
.hg-meta { font-size: .76rem; gap: 4px; }
.hg-bubble { font-size: .95rem; padding: 6px 12px; }
}
@media (prefers-reduced-motion: reduce) {
.hg-seat[data-turn='true'] { animation: none; box-shadow: 0 0 0 4px rgba(253,224,71,.55); }
.hg-pile .face, .hg-bell .art[data-ring='true'], .hg-bubble { animation: none; }
.hg-ghost { display: none; }
}
`;
// ---------------------------------------------------------------- fruit art
/** One fruit in a 40×40 box. Distinct silhouettes so colour is never the only cue. */
function FruitShape({ fruit }: { fruit: Fruit }) {
switch (fruit) {
case 'strawberry':
return (
<g>
<path d="M20 38 C9 30 4 20 7.5 13.5 C10.5 8.5 29.5 8.5 32.5 13.5 C36 20 31 30 20 38 Z" fill="#e11d48" stroke="#9f1239" strokeWidth="1.5" />
{[
[14, 17],
[20, 15],
[26, 17],
[12, 23],
[18, 22],
[24, 22],
[28, 23],
[16, 28],
[22, 28],
[19, 33],
].map(([x, y]) => (
<ellipse key={`${x}-${y}`} cx={x} cy={y} rx="1.1" ry="1.6" fill="#fde68a" />
))}
<path d="M11 11 L16 9 L20 4 L24 9 L29 11 L23.5 12.5 L20 15 L16.5 12.5 Z" fill="#16a34a" stroke="#14532d" strokeWidth="1" />
</g>
);
case 'banana':
return (
<g>
<path d="M7 9 C7 27 22 36 36 30 C37 28 36 27 34 27.5 C22 30 13 22 12 8 C11 6 8 6 7 9 Z" fill="#facc15" stroke="#a16207" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M11 12 C12 22 19 28 30 29" fill="none" stroke="#eab308" strokeWidth="1.4" />
<path d="M7 9 L8.5 4.5 L11.5 5.5 L12 8" fill="#78350f" />
<circle cx="35.6" cy="29" r="1.6" fill="#78350f" />
</g>
);
case 'lime':
return (
<g>
<circle cx="20" cy="20" r="16" fill="#3f6212" />
<circle cx="20" cy="20" r="13.5" fill="#f7fee7" />
<circle cx="20" cy="20" r="12" fill="#a3e635" />
{Array.from({ length: 8 }, (_, i) => {
const a = (i * Math.PI) / 4;
return <line key={i} x1="20" y1="20" x2={20 + Math.cos(a) * 12} y2={20 + Math.sin(a) * 12} stroke="#f7fee7" strokeWidth="1.6" />;
})}
<circle cx="20" cy="20" r="2.2" fill="#f7fee7" />
</g>
);
case 'plum':
return (
<g>
<path d="M20 8 C30 8 34 17 33 25 C32 33 26 37 20 37 C14 37 8 33 7 25 C6 17 10 8 20 8 Z" fill="#7e22ce" stroke="#4c1d95" strokeWidth="1.5" />
<path d="M20 10 C17 18 17 28 20 36" fill="none" stroke="#4c1d95" strokeWidth="1.6" />
<ellipse cx="13" cy="18" rx="2.4" ry="4.5" fill="#c4b5fd" opacity=".75" />
<path d="M20 9 L21 3" stroke="#78350f" strokeWidth="2" strokeLinecap="round" />
<path d="M21 5 C25 2 30 3 31 5 C28 8 24 8 21 5 Z" fill="#16a34a" />
</g>
);
}
}
const SPOTS: Record<FruitCount, [number, number][]> = {
1: [[50, 70]],
2: [
[50, 38],
[50, 102],
],
3: [
[28, 34],
[50, 70],
[72, 106],
],
4: [
[29, 38],
[71, 38],
[29, 102],
[71, 102],
],
5: [
[27, 32],
[73, 32],
[50, 70],
[27, 108],
[73, 108],
],
};
export function FruitCardArt({ card }: { card: Pick<TopCard, 'fruit' | 'count'> | null }) {
if (!card) {
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="2" y="2" width="96" height="136" rx="11" fill="#1e3a8a" stroke="#93c5fd" strokeWidth="3" />
<rect x="12" y="12" width="76" height="116" rx="7" fill="none" stroke="#93c5fd" strokeWidth="2" strokeDasharray="6 5" />
<circle cx="50" cy="70" r="16" fill="#fbbf24" stroke="#fde68a" strokeWidth="3" />
</svg>
);
}
const size = card.count === 1 ? 54 : card.count === 5 ? 37 : 41;
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="2" y="2" width="96" height="136" rx="11" fill="#fffef8" stroke={FRUIT_INK[card.fruit]} strokeWidth="4" />
{SPOTS[card.count].map(([x, y], i) => (
<g key={i} transform={`translate(${x - size / 2} ${y - size / 2}) scale(${size / 40})`}>
<FruitShape fruit={card.fruit} />
</g>
))}
</svg>
);
}
function FruitIcon({ fruit }: { fruit: Fruit }) {
return (
<svg viewBox="0 0 40 40" className="hg-fruit-ic" aria-hidden>
<FruitShape fruit={fruit} />
</svg>
);
}
function BellArt() {
return (
<svg viewBox="0 0 100 100" aria-hidden>
<ellipse cx="50" cy="80" rx="40" ry="9" fill="#78350f" />
<path d="M18 76 C18 50 30 26 50 26 C70 26 82 50 82 76 Z" fill="#fde68a" stroke="#92400e" strokeWidth="3" />
<path d="M30 70 C30 52 37 36 48 33" fill="none" stroke="#fffbeb" strokeWidth="5" strokeLinecap="round" opacity=".8" />
<rect x="43" y="14" width="14" height="13" rx="4" fill="#92400e" />
<circle cx="50" cy="12" r="8" fill="#b91c1c" stroke="#7f1d1d" strokeWidth="2" />
</svg>
);
}
// ---------------------------------------------------------------- geometry
interface Pos {
left: string;
top: string;
}
const CENTER: Pos = { left: '50%', top: '50%' };
/**
* Seat centres on an ellipse (me at the bottom, then clockwise: left → top → right). The radii are
* "half the table minus half a seat", so seats always stay inside the felt whatever its size.
* `tall` (narrow phone table): seats that are not level with the bell move further up/down so they clear it.
*/
function seatPositions(n: number, seat: { w: number; h: number }, maxRx: number, tall: boolean): Pos[] {
const f = (x: number) => (Math.abs(x) < 1e-9 ? 0 : Number(x.toFixed(4)));
return Array.from({ length: n }, (_, k) => {
const t = (2 * Math.PI * k) / n;
const c = f(Math.cos(t));
const y = tall && c !== 0 ? Math.sign(c) * Math.max(Math.abs(c), 0.75) : c;
return {
left: `calc(50% - ${f(Math.sin(t))} * min(50% - ${seat.w / 2 + 6}px, ${maxRx}px))`,
top: `calc(50% + ${y} * (50% - ${seat.h / 2 + 6}px))`,
};
});
}
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;
}
// ---------------------------------------------------------------- seat
function Gauge({ start, end, now }: { start: number; end: number; now: number }) {
const frac = Math.max(0, Math.min(1, (end - now) / Math.max(1, end - start)));
const r = 12;
const c = 2 * Math.PI * r;
const color = frac < 0.34 ? '#ef4444' : frac < 0.67 ? '#f59e0b' : '#22c55e';
return (
<svg className="gauge" viewBox="0 0 30 30" aria-hidden>
<circle cx="15" cy="15" r={r + 2} fill="#111827" />
<circle cx="15" cy="15" r={r} fill="none" stroke="#374151" strokeWidth="4" />
<circle cx="15" cy="15" r={r} fill="none" stroke={color} strokeWidth="4" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} transform="rotate(-90 15 15)" strokeLinecap="round" />
</svg>
);
}
function seatLabel(s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`];
parts.push(s.top ? `펼친 카드 ${cardName(s.top)}` : '펼친 카드 없음');
parts.push(`덱 ${s.deck}장`);
if (s.pile > 1) parts.push(`펼친 더미 ${s.pile}장`);
if (s.status === 'out') parts.push('탈락');
if (s.status === 'left') parts.push('나감');
if (s.isTurn) parts.push('뒤집을 차례');
return parts.join(', ');
}
function Seat({ s, v, pos, players, me, now }: { s: SeatView; v: HalliGalliView; pos: Pos; players: Record<string, PublicUser>; me: string | null; now: number }) {
const u = userOf(players, s.id);
const locked = s.lockedUntil > now && s.status === 'playing';
return (
<li
className="hg-seat"
data-turn={s.isTurn && v.phase === 'playing'}
data-out={s.status !== 'playing'}
data-me={s.id === me}
data-seat={s.id}
style={{ left: pos.left, top: pos.top }}
aria-label={seatLabel(s, players, me)}
>
<div className="nm">
<Avatar user={u} size={18} />
<span>{s.id === me ? '나' : u.nickname}</span>
</div>
<div className="hg-pile">
{s.pile > 1 && <span className="shadow1" />}
{s.top ? (
<span key={s.top.key} className="face">
<FruitCardArt card={s.top} />
</span>
) : (
<span className="empty" />
)}
{s.isTurn && v.phase === 'playing' && <Gauge start={v.turnStartedAt} end={v.turnDeadline} now={now} />}
</div>
<div className="hg-meta" aria-hidden>
<span>
<i className="hg-back" /> {s.deck}
</span>
{s.status === 'out' && (
<span className="hg-tag" style={{ background: '#dc2626' }}>
탈락
</span>
)}
{s.status === 'left' && (
<span className="hg-tag" style={{ background: '#6b7280' }}>
나감
</span>
)}
{locked && (
<span className="hg-tag" style={{ background: '#f59e0b', color: '#111827' }}>
잠금
</span>
)}
</div>
</li>
);
}
// ---------------------------------------------------------------- bell feedback
interface Bubble {
key: string;
kind: 'ok' | 'bad' | 'late';
text: string;
}
interface Ghost {
key: string;
from: Pos;
to: Pos;
card: Pick<TopCard, 'fruit' | 'count'> | null;
delay: number;
}
function bellText(v: HalliGalliView, players: Record<string, PublicUser>): Bubble | null {
const b = v.lastBell;
if (!b) return null;
const who = b.player === v.me ? '내가' : `${nick(players, b.player)}님이`;
if (b.correct) return { key: `b${b.seq}`, kind: 'ok', text: `땡! ${who} 먼저 쳤어요 · ${b.fruit ? FRUIT_KO[b.fruit] : ''} 5개 · ${b.cards}장 가져가요` };
const duel = v.phase === 'ended' && (v.options.duelWrongLoses || v.officialDuel);
if (duel) return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요 · 승부가 끝났어요` };
const lost = b.cards === 0 ? '' : v.options.wrongToPot ? ` · 종 밑에 ${b.cards}장` : ` · ${b.cards}장 나눠 줘요`;
return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요${lost}` };
}
// ---------------------------------------------------------------- board
function HalliGalliBoard({ view: v, me, players, act, pending }: BoardProps<HalliGalliView>) {
const now = useServerNow(100);
const phone = usePhone();
const rtt = useRoom((s) => s.rttMs);
const [bubble, setBubble] = useState<Bubble | null>(null);
const [ghosts, setGhosts] = useState<Ghost[]>([]);
const [hit, setHit] = useState(0);
const [announce, setAnnounce] = useState('');
const sentFor = useRef<number | null>(null);
const prev = useRef<HalliGalliView | null>(null);
// Seats rotated so that I sit at the bottom (spectators: first seat at the bottom).
const myIdx = v.seats.findIndex((s) => s.id === me);
const rot = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx), ...v.seats.slice(0, myIdx)];
const many = rot.length >= 5;
const cw = phone ? (many ? 46 : 54) : many ? 70 : 86;
const sw = phone ? (many ? 74 : 86) : many ? 116 : 132;
const pos = seatPositions(rot.length, { w: sw, h: cw * 1.4 + (phone ? 50 : 58) }, phone ? 400 : 380, phone);
const posOf = (id: string) => pos[rot.findIndex((s) => s.id === id)] ?? CENTER;
const mine = v.seats.find((s) => s.id === me) ?? null;
const playing = v.phase === 'playing';
const lockedLeft = mine ? Math.max(0, mine.lockedUntil - now) : 0;
const canRing = !!mine && mine.status === 'playing' && playing && lockedLeft === 0;
const myTurn = !!mine && playing && v.turn === me;
const canFlip = myTurn && !v.options.autoFlip && !v.pendingFlip && mine.deck > 0 && !pending;
const ring = () => {
if (!canRing || sentFor.current === v.tableVersion) return;
sentFor.current = v.tableVersion;
setHit((h) => h + 1);
sounds.ding();
haptic(30);
act({ type: 'ring', version: v.tableVersion, ...(v.options.latencyComp === 'light' && rtt !== null ? { rtt } : {}) });
};
const flip = () => {
if (!canFlip) return;
act({ type: 'flip' });
};
const ringRef = useRef(ring);
const flipRef = useRef(flip);
ringRef.current = ring;
flipRef.current = flip;
// Keyboard: Space = bell, Enter = flip (not while typing; Enter on a focused button keeps its own meaning).
useEffect(() => {
const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
const down = (e: KeyboardEvent) => {
if (typing(e.target) || e.altKey || e.ctrlKey || e.metaKey) return;
if (e.code === 'Space' || e.key === ' ') {
e.preventDefault();
if (!e.repeat) ringRef.current();
} else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement)) {
if (!e.repeat) flipRef.current();
}
};
const up = (e: KeyboardEvent) => {
if ((e.code === 'Space' || e.key === ' ') && !typing(e.target)) e.preventDefault();
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, []);
// "늦었어요" and other bell 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: flips, bells, card flights.
useEffect(() => {
const p = prev.current;
prev.current = v;
if (!p) return;
const b = v.lastBell;
if (b && b.seq !== p.lastBell?.seq) {
const bub = bellText(v, players);
if (bub) {
setBubble(bub);
setAnnounce(bub.text);
}
const to = posOf(b.player);
const fly: Ghost[] = [];
if (b.correct) {
if (b.player !== me) sounds.ding();
else haptic([40, 30, 80]);
p.seats.forEach((s, i) => s.top && fly.push({ key: `g${b.seq}-${s.id}`, from: posOf(s.id), to, card: s.top, delay: i * 40 }));
if (p.pot > 0) fly.push({ key: `g${b.seq}-pot`, from: CENTER, to, card: null, delay: 0 });
} else {
sounds.buzz();
if (b.player === me) haptic([90, 50, 90]);
const from = posOf(b.player);
const targets = v.options.wrongToPot ? [CENTER] : p.seats.filter((s) => s.id !== b.player && s.status === 'playing').map((s) => posOf(s.id));
targets.slice(0, Math.max(1, b.cards)).forEach((t, i) => fly.push({ key: `w${b.seq}-${i}`, from, to: t, card: null, delay: i * 60 }));
}
setGhosts(fly);
return;
}
if (v.tableVersion !== p.tableVersion && v.lastFlipBy) {
sounds.stone();
const s = v.seats.find((x) => x.id === v.lastFlipBy);
if (s?.top) setAnnounce(`${s.id === me ? '내가' : `${nick(players, s.id)}님이`} ${cardName(s.top)}를 뒤집었어요`);
}
if (v.turn === me && p.turn !== me && playing) haptic(20);
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
// A new table (or a judged bell) lets me ring again.
useEffect(() => {
if (sentFor.current !== null && sentFor.current !== v.tableVersion) sentFor.current = null;
}, [v.tableVersion]);
useEffect(() => {
if (!ghosts.length) return;
const t = setTimeout(() => setGhosts([]), 1000);
return () => clearTimeout(t);
}, [ghosts]);
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1900);
return () => clearTimeout(t);
}, [bubble]);
const bellSize = phone ? (rot.length >= 5 ? 128 : 'min(40vw, 156px)') : 'clamp(140px, 20vh, 190px)';
const countdown = v.phase === 'countdown' ? Math.max(0, Math.ceil((v.countdownEndsAt - now) / 1000)) : 0;
const totals = FRUITS.map((f) => ({ f, n: v.seats.reduce((a, s) => a + (s.top && s.top.fruit === f ? s.top.count : 0), 0) }));
const timeLeft = v.endsAt !== null ? Math.max(0, v.endsAt - Math.max(now, v.countdownEndsAt)) : null;
const ping = rtt;
return (
<div className="hg">
<style>{CSS}</style>
<div className="hg-top">
<div className="grp">
{timeLeft !== null && (
<span className={`hg-pill${timeLeft < 60_000 ? ' warn' : ''}`} role="timer" aria-label={`남은 시간 ${Math.floor(timeLeft / 60_000)}분 ${Math.floor((timeLeft % 60_000) / 1000)}초`}>
⏱ {Math.floor(timeLeft / 60_000)}:{String(Math.floor((timeLeft % 60_000) / 1000)).padStart(2, '0')}
</span>
)}
{v.officialDuel && playing && <span className="hg-pill warn">2명 남음 · 다음 종으로 끝나요</span>}
</div>
<div className="grp">
{!phone && <span className="hg-pill hg-keys">스페이스 바 = 종 · Enter = 뒤집기</span>}
{v.options.showRtt && ping !== null && (
<span className={`hg-pill${ping >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${ping}밀리초`}>
<i className="hg-dot" style={{ background: ping < 80 ? '#16a34a' : ping < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
{ping >= 200 ? `연결이 느려요 · ${ping}ms` : `핑 ${ping}ms`}
</span>
)}
</div>
</div>
<div
className="hg-table"
role="region"
aria-label="카드 테이블"
style={{ '--bell': typeof bellSize === 'number' ? `${bellSize}px` : bellSize, '--cw': `${cw}px`, '--sw': `${sw}px` } as CSSProperties}
>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }} aria-label="사람들의 펼친 카드">
{rot.map((s, i) => (
<Seat key={s.id} s={s} v={v} pos={pos[i]!} players={players} me={me} now={now} />
))}
</ul>
<div className="hg-center">
<button
type="button"
className="hg-bell"
disabled={!canRing}
onPointerDown={(e) => {
// Fire on press, not release: every millisecond counts.
if (e.button !== 0) return;
e.preventDefault();
ring();
}}
onClick={(e) => {
// Keyboard activation (Enter on the focused bell); pointer presses were handled on pointerdown.
if (e.detail === 0) ring();
}}
aria-label={canRing ? '종 치기 (스페이스 바)' : lockedLeft > 0 ? '종 잠금 중이에요' : '종 (지금은 칠 수 없어요)'}
aria-keyshortcuts="Space"
>
<span key={hit} className="art" data-ring={hit > 0}>
<BellArt />
</span>
<span className="lbl" aria-hidden>
종!
</span>
{lockedLeft > 0 && (
<span
className="lock"
aria-hidden
style={{ background: `conic-gradient(rgba(17,24,39,.55) ${(lockedLeft / Math.max(1, v.options.wrongRingLockoutMs)) * 360}deg, transparent 0)` }}
/>
)}
</button>
{v.pot > 0 && (
<span className="hg-pot" aria-label={`종 밑 카드 ${v.pot}장`}>
종 밑 {v.pot}장
</span>
)}
</div>
{ghosts.map((g) => (
<span
key={g.key}
className="hg-ghost"
style={{ '--fx': g.from.left, '--fy': g.from.top, '--tx': g.to.left, '--ty': g.to.top, '--d': `${g.delay}ms` } as CSSProperties}
>
<FruitCardArt card={g.card} />
</span>
))}
{bubble && (
<div key={bubble.key} className={`hg-bubble ${bubble.kind}`} aria-hidden>
{bubble.text}
</div>
)}
{countdown > 0 && (
<div className="hg-count" aria-hidden>
{countdown}
<small>과일이 딱 5개가 되면 종을 쳐요!</small>
</div>
)}
</div>
{mine && mine.status === 'playing' && v.phase !== 'ended' && (
<div className="hg-mine">
<div className="info" aria-label={`내 덱 ${mine.deck}장`}>
<span className="small muted">내 덱</span>
<b>
<i className="hg-back" /> {mine.deck}장
</b>
</div>
<button type="button" className={`btn ${myTurn ? 'btn-primary' : 'btn-secondary'} hg-flipbtn`} disabled={!canFlip} onClick={flip} aria-keyshortcuts="Enter">
{v.options.autoFlip ? (myTurn ? '곧 자동으로 뒤집혀요' : '자동 뒤집기') : v.pendingFlip && myTurn ? '곧 뒤집혀요…' : myTurn ? '뒤집기' : `${v.turn ? nick(players, v.turn) : ''}님 차례`}
</button>
</div>
)}
{mine && mine.status !== 'playing' && v.phase !== 'ended' && (
<div className="hg-panel">
<strong>{mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
</div>
)}
{v.options.kidMode && v.phase !== 'ended' && (
<div className="hg-kid" aria-label="과일 합계">
<strong>5개가 되면 종을 쳐요</strong>
{totals.map(({ f, n }) => (
<div key={f} className="hg-bar" data-five={n === 5}>
<FruitIcon fruit={f} />
<span>{FRUIT_KO[f]}</span>
<span className="track">
<i style={{ width: `${Math.min(100, (n / 8) * 100)}%`, background: FRUIT_INK[f] }} />
</span>
<span>{n}</span>
</div>
))}
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
const n = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
switch (e.t) {
case 'collect':
return `${n(e.p)} 종을 쳐서 ${e.cards}장을 가져갔어요 (${FRUIT_KO[e.fruit]} 5개)`;
case 'wrong':
return e.toPot ? `${n(e.p)} 틀려서 ${e.gave}장을 종 밑에 넣었어요` : `${n(e.p)} 틀려서 ${e.gave}장을 나눠 줬어요`;
case 'duelWrong':
return `${n(e.p)} 틀려서 ${nick(players, e.by)}님이 ${e.cards}장을 가져갔어요`;
case 'out':
return e.why === 'left' ? `${n(e.p)} 나갔어요` : e.why === 'strikeOut' ? `${n(e.p)} 3번 틀려서 탈락했어요` : `${n(e.p)} 탈락했어요`;
}
}
function rulesLine(o: HalliGalliOptions): string {
const parts = [o.endMode === 'official' ? '2명 남으면 다음 종에서 끝' : '한 명 남을 때까지', o.timeLimitMin ? `${o.timeLimitMin}분 제한` : '시간 제한 없음'];
parts.push(o.autoFlip ? `자동 뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초` : `뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초`);
if (o.wrongRingLockoutMs) parts.push(`틀리면 종 ${o.wrongRingLockoutMs / 1000}초 잠금`);
if (o.penaltyEscalation) parts.push('벌칙 점점 늘어남');
if (o.wrongToPot) parts.push('벌칙은 종 밑으로');
if (o.strikeOut) parts.push('3번 틀리면 탈락');
if (o.duelWrongLoses) parts.push('1대1 오답은 패배');
if (o.latencyComp === 'light') parts.push('지연 보정');
return parts.join(' · ');
}
function HalliGalliSide({ view: v, me, players }: BoardProps<HalliGalliView>) {
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) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'playing' ? 1 : 0.55 }} aria-label={`${seatLabel(s, players, me)}, 정답 ${s.correct}번, 오답 ${s.wrong}번`}>
<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 }}>
{s.isTurn ? '▶ ' : ''}
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{s.status === 'out' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : ''}
</span>
<span className="small" aria-hidden>
✓{s.correct} ✗{s.wrong}
</span>
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{s.cards}장
</strong>
</li>
))}
</ol>
{(v.pot > 0 || v.removed > 0) && (
<span className="small muted">
{v.pot > 0 ? `종 밑 ${v.pot}장` : ''}
{v.pot > 0 && v.removed > 0 ? ' · ' : ''}
{v.removed > 0 ? `게임에서 뺀 카드 ${v.removed}장` : ''}
</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.cards}장 · 정답 {r.correct} · 오답 {r.wrong}
</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="hg-log">
{[...v.log].reverse().map((e) => (
<li key={e.n} className="small">
{logText(e, players, me)}
</li>
))}
</ol>
</details>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: HalliGalliView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.turn === me && v.seats.some((s) => s.id === me && s.status === 'playing');
}
function statusText(v: HalliGalliView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'countdown') return '곧 시작해요! 과일이 딱 5개가 되면 종을 쳐요';
const mine = v.seats.find((s) => s.id === me);
if (mine && mine.status !== 'playing') return '탈락했어요 · 남은 게임을 지켜봐요';
if (isMyTurn(v, me)) return v.options.autoFlip ? '내 차례예요 · 곧 자동으로 뒤집혀요' : '내 차례예요 · 카드를 뒤집어 주세요';
return `${v.turn ? nick(players, v.turn) : ''}님 차례예요 · 5개가 되면 종!`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean | null>({ 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, '끄기'],
];
function Options({ value: o, onChange }: OptionsProps<HalliGalliOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<HalliGalliOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="끝나는 방식" help={o.endMode === 'official' ? '3명 이상일 때, 2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '한 사람이 남을 때까지 해요.'}>
<Seg
label="끝나는 방식"
value={o.endMode}
options={[
['lastStanding', '한 명 남을 때까지'],
['official', '2명 남으면 다음 종에서'],
]}
onChange={(endMode) => set({ endMode })}
/>
</Field>
<Field label="전체 제한 시간" help="시간이 다 되면 카드가 많은 사람이 이겨요.">
<Seg
label="전체 제한 시간"
value={o.timeLimitMin}
options={[
[5, '5분'],
[10, '10분'],
[15, '15분'],
[null, '없음'],
]}
onChange={(timeLimitMin) => set({ timeLimitMin })}
/>
</Field>
<Field label="뒤집기 제한 시간" help="시간 안에 안 뒤집으면 자동으로 뒤집혀요.">
<Seg
label="뒤집기 제한 시간"
value={o.flipSeconds}
options={[
[2, '2초'],
[3, '3초'],
[5, '5초'],
[8, '8초'],
]}
onChange={(flipSeconds) => set({ flipSeconds })}
/>
</Field>
<Field label="어린이 모드" help="8초 뒤집기, 과일 합계 막대를 보여 줘요.">
<Seg label="어린이 모드" value={o.kidMode} options={onOff} onChange={(kidMode) => set({ kidMode })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드를 자동으로 뒤집기">
<Seg label="카드를 자동으로 뒤집기" value={o.autoFlip} options={onOff} onChange={(autoFlip) => set({ autoFlip })} />
</Field>
<Field label="뒤집은 뒤 다음 뒤집기까지 최소 간격" help="연결이 느린 사람도 같은 카드를 보고 종을 칠 시간이에요.">
<Seg
label="뒤집기 최소 간격"
value={o.flipGuardMs}
options={[
[400, '0.4초'],
[600, '0.6초'],
[1000, '1초'],
]}
onChange={(flipGuardMs) => set({ flipGuardMs })}
/>
</Field>
<Field label="틀린 종 뒤 종 잠금">
<Seg
label="틀린 종 뒤 종 잠금"
value={o.wrongRingLockoutMs}
options={[
[0, '없음'],
[1000, '1초'],
[2000, '2초'],
]}
onChange={(wrongRingLockoutMs) => set({ wrongRingLockoutMs })}
/>
</Field>
<Field label="나누고 남은 카드">
<Seg
label="나누고 남은 카드"
value={o.leftoverCards}
options={[
['pot', '종 밑에 두기'],
['remove', '빼기'],
]}
onChange={(leftoverCards) => set({ leftoverCards })}
/>
</Field>
<Field label="지연 보정" help="켜면 연결 지연의 절반(최대 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>
<Field label="1대1에서 틀린 종을 치면 바로 패배">
<Seg label="1대1 오답 패배" value={o.duelWrongLoses} options={onOff} onChange={(duelWrongLoses) => set({ duelWrongLoses })} />
</Field>
<Field label="틀린 종 3번이면 탈락">
<Seg label="오답 3번 탈락" value={o.strikeOut} options={onOff} onChange={(strikeOut) => set({ strikeOut })} />
</Field>
<Field label="틀릴 때마다 벌칙 장수 늘리기">
<Seg label="벌칙 늘리기" value={o.penaltyEscalation} options={onOff} onChange={(penaltyEscalation) => set({ penaltyEscalation })} />
</Field>
<Field label="벌칙 카드를 종 밑으로">
<Seg label="벌칙 카드를 종 밑으로" value={o.wrongToPot} options={onOff} onChange={(wrongToPot) => set({ wrongToPot })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: HalliGalliOptions): string {
return rulesLine(o) + (o.kidMode ? ' · 어린이 모드' : '');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: HalliGalliOptions }) {
const o = options;
return (
<div className="stack">
<p>
과일 카드 56장(딸기·바나나·라임·자두)을 똑같이 나눠 받고, 차례대로 내 덱 맨 위 카드를 뒤집어 내 앞에 펼쳐요. 펼쳐진 카드 맨 위에 보이는 과일 가운데 <strong>한 종류가 딱 5개</strong>가 되면 가운데 종을 쳐요.
</p>
<div className="row" style={{ gap: 8, alignItems: 'center' }}>
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'strawberry', count: 2 }} />
</span>
+
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'strawberry', count: 3 }} />
</span>
+
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'banana', count: 4 }} />
</span>
<span>= 딸기 5개 → 땡!</span>
</div>
<p>
<strong>먼저 친 사람</strong>(서버에 먼저 도착한 사람)이 펼쳐진 카드를 모두 가져가 덱 맨 아래에 넣고, 다음에 뒤집어요. 6개나 4개일 때 치면 틀린 거예요. 틀리면 다른 사람에게 1장씩 줘요
{o?.wrongToPot ? '(이 방에서는 종 밑에 넣어요)' : ''}. 조금 늦게 친 종은 "늦었어요"로 무시되고 벌칙은 없어요.
</p>
<p>
차례가 왔는데 덱이 비어 있으면 탈락해요. 덱이 비어도 차례가 오기 전에 종을 맞히면 다시 살아나요.{' '}
{o?.endMode === 'official' ? '2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '마지막까지 남은 사람이 이겨요.'}{' '}
{o?.timeLimitMin === null ? '' : `${o?.timeLimitMin ?? 10}분이 지나면 카드(덱 + 내 펼친 카드)가 많은 사람이 이겨요.`}
</p>
<p>
PC에서는 <strong>스페이스 바</strong>로 종을, <strong>Enter</strong>로 뒤집기를 할 수 있어요. 휴대폰은 종 그림을 누르면 바로 울려요.
</p>
</div>
);
}
const ui: GameUI<HalliGalliView, HalliGalliOptions> = {
Board: HalliGalliBoard,
Side: HalliGalliSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View File

@@ -0,0 +1,130 @@
/** 할리갈리 30초 규칙 요약 (docs/07 §5). Simple redraws (the real card art lives in index.tsx). */
import type { ReactNode } from 'react';
import type { QuickCard } from '../types';
type F = 'strawberry' | 'banana';
const INK: Record<F, string> = { strawberry: '#e11d48', banana: '#ca8a04' };
/** One fruit centred at (x, y), about 16 units wide. */
function Fruit({ f, x, y }: { f: F; x: number; y: number }) {
if (f === 'strawberry') {
return (
<g transform={`translate(${x - 8} ${y - 8}) scale(0.4)`}>
<path d="M20 38 C9 30 4 20 7.5 13.5 C10.5 8.5 29.5 8.5 32.5 13.5 C36 20 31 30 20 38 Z" fill="#e11d48" stroke="#9f1239" strokeWidth="2" />
<path d="M11 11 L16 9 L20 4 L24 9 L29 11 L23.5 12.5 L20 15 L16.5 12.5 Z" fill="#16a34a" />
</g>
);
}
return (
<g transform={`translate(${x - 8} ${y - 8}) scale(0.4)`}>
<path d="M7 9 C7 27 22 36 36 30 C37 28 36 27 34 27.5 C22 30 13 22 12 8 C11 6 8 6 7 9 Z" fill="#facc15" stroke="#a16207" strokeWidth="2" />
</g>
);
}
const SPOTS: Record<number, [number, number][]> = {
1: [[20, 28]],
2: [[20, 16], [20, 40]],
3: [[11, 14], [20, 28], [29, 42]],
4: [[11, 15], [29, 15], [11, 41], [29, 41]],
5: [[11, 13], [29, 13], [20, 28], [11, 43], [29, 43]],
};
/** A fruit card 40×56 placed at (x, y). */
function Card({ f, n, x, y }: { f: F; n: number; x: number; y: number }) {
return (
<g transform={`translate(${x} ${y})`}>
<rect x={0} y={0} width={40} height={56} rx={5} fill="#fffef8" stroke={INK[f]} strokeWidth={2} />
{SPOTS[n]!.map(([fx, fy], i) => (
<Fruit key={i} f={f} x={fx} y={fy} />
))}
</g>
);
}
function Back({ x, y }: { x: number; y: number }) {
return (
<g transform={`translate(${x} ${y})`}>
<rect x={0} y={0} width={40} height={56} rx={5} fill="#1e3a8a" stroke="#93c5fd" strokeWidth={2} />
<circle cx={20} cy={28} r={7} fill="#fbbf24" />
</g>
);
}
function Bell({ x, y }: { x: number; y: number }) {
return (
<g transform={`translate(${x - 22} ${y - 22}) scale(0.44)`}>
<ellipse cx="50" cy="80" rx="40" ry="9" fill="#78350f" />
<path d="M18 76 C18 50 30 26 50 26 C70 26 82 50 82 76 Z" fill="#fde68a" stroke="#92400e" strokeWidth="3" />
<rect x="43" y="14" width="14" height="13" rx="4" fill="#92400e" />
<circle cx="50" cy="12" r="8" fill="#b91c1c" />
</g>
);
}
function Table({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 240 150" width={240} height={150} aria-hidden>
<rect x={0} y={0} width={240} height={150} rx={14} fill="#166534" />
{children}
</svg>
);
}
export const quickStart: QuickCard[] = [
{
pic: (
<Table>
<Back x={20} y={80} />
<path d="M66 108 L88 108" stroke="#fff" strokeWidth={3} />
<path d="M84 102 L92 108 L84 114" fill="none" stroke="#fff" strokeWidth={3} />
<Card f="banana" n={3} x={100} y={80} />
<Card f="strawberry" n={2} x={60} y={12} />
<Card f="banana" n={1} x={140} y={12} />
<Bell x={196} y={110} />
</Table>
),
alt: '뒤집힌 내 카드 더미에서 한 장을 펼쳐 내 앞에 놓는 모습이에요',
title: '차례대로 한 장씩 펼쳐요',
text: '내 차례에 [뒤집기]를 누르면 내 카드가 한 장 펼쳐져요. 각자 앞에 맨 위 카드만 보여요.',
},
{
pic: (
<Table>
<Card f="strawberry" n={2} x={14} y={20} />
<Card f="strawberry" n={3} x={62} y={20} />
<Card f="banana" n={4} x={110} y={20} />
<text x={84} y={108} textAnchor="middle" fontSize={20} fontWeight={800} fill="#fff">
딸기 2+3 = 5
</text>
<Bell x={196} y={52} />
<text x={196} y={110} textAnchor="middle" fontSize={22} fontWeight={900} fill="#fde047">
땡!
</text>
</Table>
),
alt: '딸기 2개 카드와 딸기 3개 카드가 보여서 딸기가 모두 5개예요. 종을 치면 땡!',
title: '딱 5개면 종을 쳐요',
text: '펼쳐진 카드에서 한 과일이 모두 합쳐 딱 5개면 가운데 [종!]을 먼저 눌러요. 맞히면 펼친 카드를 다 가져가요.',
},
{
pic: (
<Table>
<Card f="strawberry" n={2} x={14} y={20} />
<Card f="strawberry" n={3} x={62} y={20} />
<Card f="strawberry" n={1} x={110} y={20} />
<text x={84} y={108} textAnchor="middle" fontSize={20} fontWeight={800} fill="#fff">
딸기 6개
</text>
<Bell x={196} y={52} />
<g stroke="#ef4444" strokeWidth={6} strokeLinecap="round">
<line x1={176} y1={92} x2={216} y2={132} />
<line x1={216} y1={92} x2={176} y2={132} />
</g>
</Table>
),
alt: '딸기가 모두 6개라서 종을 치면 안 된다는 빨간 ✕ 표시예요',
title: '틀리면 카드를 줘요',
text: '4개나 6개일 때 누르면 벌칙으로 카드를 내줘요. 카드를 다 잃으면 탈락이고, 끝까지 남은 사람(시간이 끝나면 카드가 가장 많은 사람)이 이겨요.',
},
];

View File

@@ -0,0 +1,66 @@
/**
* Test helpers for the beginner help (docs/07 §5): real views from the rule modules, so help text
* is checked against the actual view shape. Only imported by *.test.ts files.
*/
import { GAMES } from '@bg/games';
import type { PublicUser } from '@bg/shared/lite';
import { SeededRng, type RngState } from '../../../../packages/engine/src/rng';
export const NICKS = ['호랑이', '고양이', '토끼', '거북이', '여우', '사슴', '곰', '부엉이', '다람쥐', '오리'];
/** Player ids are 'A', 'B', ...; nicknames from NICKS. */
export function seatIds(n: number): string[] {
return Array.from({ length: n }, (_, i) => String.fromCharCode(65 + i));
}
export function usersOf(ids: string[]): Record<string, PublicUser> {
return Object.fromEntries(ids.map((id, i) => [id, { id, nickname: NICKS[i % NICKS.length]!, avatar: null, kind: 'guest' as const }]));
}
/** Fresh state for a game with `n` players and options merged over the defaults. */
export function setupGame(id: string, n: number, options: Record<string, unknown> = {}, seed: RngState = [1, 2, 3, 4], now = 1_700_000_000_000) {
const def = GAMES[id]!;
const players = seatIds(n);
const opts = def.optionsSchema.parse({ ...(def.defaultOptions as object), ...options });
const rng = SeededRng.fromSeed(seed);
const state = def.setup({ players, options: opts, rng, now });
return { def, players, rng, state: state as any, now };
}
/** Apply an action (validated) and return the new state. */
export function applyAction(id: string, state: any, actor: string, action: unknown, rng = SeededRng.fromSeed([9, 9, 9, 9]), now = 1_700_000_100_000): any {
const def = GAMES[id]!;
const v = def.validate(state, actor, action, { now });
if (!v.ok) throw new Error(`${id}: rejected ${JSON.stringify(action)}: ${v.reason}`);
return def.apply(state, actor, action, { rng, now }).state;
}
/**
* Random playout that hands every intermediate view (each player + spectator) to `visit`.
* Uses legal actions when the module lists them, otherwise the timeout action.
*/
export function eachView(id: string, n: number, visit: (view: any, viewer: string | null, state: any) => void, { steps = 400, seed = [5, 6, 7, 8] as RngState, options = {} as Record<string, unknown> } = {}) {
const { def, players, rng, state: s0 } = setupGame(id, n, options, seed);
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
let state = s0;
let now = 1_700_000_000_000;
for (let step = 0; step <= steps; step++) {
for (const p of [...players, null]) visit(def.view(state, p), p, state);
if (def.result(state)) return;
const actives = def.activePlayers(state);
if (actives.length === 0) return;
const actor = actives[chooser.int(actives.length)]!;
const legal = def.legalActions?.(state, actor) ?? [];
const action = legal.length > 0 ? legal[chooser.int(legal.length)] : def.onTimeout(state, actor);
now += 1000;
if (!def.validate(state, actor, action, { now }).ok) return;
state = def.apply(state, actor, action, { rng, now }).state;
}
}
/** Help text must read as finished Korean: no leaked placeholders. */
export function assertCleanText(text: string, where: string) {
if (typeof text !== 'string' || text.trim().length === 0) throw new Error(`${where}: empty help`);
const bad = /undefined|NaN|null|\[object|\$\{|님님/.exec(text);
if (bad) throw new Error(`${where}: "${bad[0]}" in help: ${text}`);
}

View File

@@ -0,0 +1,50 @@
/** Beginner help (docs/07 §5) for every game: 3 summary cards, and help text on every view of a random game. */
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { GAMES } from '@bg/games';
import { GAME_UI } from './registry';
import { assertCleanText, eachView, seatIds, usersOf } from './help-kit.test-util';
const IDS = Object.keys(GAME_UI);
test('모든 게임에 도움말이 있어요', () => {
expect(IDS).toHaveLength(26);
for (const id of IDS) {
const src = readFileSync(join(import.meta.dir, id, 'index.tsx'), 'utf8');
expect(src, id).toMatch(/\bquickStart,/);
expect(src, id).toMatch(/\bhelpNow,/);
}
});
describe.each(IDS)('%s', (id) => {
test('30초 요약 카드 3장', async () => {
const { quickStart } = await import(`./${id}/quick`);
expect(quickStart).toHaveLength(3);
for (const c of quickStart) {
expect(c.pic).toBeTruthy();
expect(c.alt.length).toBeGreaterThan(4);
expect(c.title.length).toBeGreaterThan(1);
expect(c.title.length).toBeLessThanOrEqual(20);
expect(c.text.length).toBeGreaterThan(10);
expect(c.text.length).toBeLessThanOrEqual(110);
}
});
test('무작위 한 판의 모든 화면(각 자리 + 관전)에서 지금 할 수 있는 것', async () => {
const { helpNow } = await import(`./${id}/help`);
const def = GAMES[id]!;
const range = def.playersFor?.(def.defaultOptions) ?? { min: def.minPlayers, max: def.maxPlayers };
// 3 players where allowed; otherwise the nearest count the game accepts (e.g. 쿼리도: 2 or 4).
const want = Math.min(range.max, Math.max(range.min, 3));
const counts = Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i).sort((a, b) => Math.abs(a - want) - Math.abs(b - want) || a - b);
const n = counts.find((c) => !def.playerCountError?.(def.defaultOptions, c)) ?? want;
const users = usersOf(seatIds(n));
let count = 0;
eachView(id, n, (view, who) => {
assertCleanText(helpNow(view, who, users), `${id} viewer=${who}`);
count++;
});
expect(count).toBeGreaterThan(n);
});
});

View File

@@ -0,0 +1,72 @@
import { expect, test } from 'bun:test';
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
import { GAMES } from '@bg/games';
import { helpNow } from './help';
const users = usersOf(['A', 'B']);
const view = (s: unknown, who: string | null) => GAMES.janggi!.view(s, who) as any;
const mv = (f1: number, r1: number, f2: number, r2: number) => ({ type: 'move', from: { f: f1, r: r1 }, to: { f: f2, r: r2 } });
/** A = 초, B = 한; both 상차림 chosen (한 first), 초 to move. */
function started(o: Record<string, unknown> = {}) {
const { state } = setupGame('janggi', 2, { sideAssignment: 'hostCho', setupOrder: 'hanFirst', ...o });
const s = applyAction('janggi', state, 'B', { type: 'chooseSetup', arrangement: 'MSSM' });
return applyAction('janggi', s, 'A', { type: 'chooseSetup', arrangement: 'SMMS' });
}
/** Custom position in play (kept consistent with the rule module: 장군 flag from the real board). */
function custom(pieces: [string, string, number, number][], turn: 'cho' | 'han', inCheck: 'cho' | 'han' | null) {
const s = started();
s.board = new Array(90).fill(null);
for (const [side, kind, f, r] of pieces) s.board[r * 9 + f] = { side, kind };
s.startBoard = s.board.slice();
s.turn = turn;
s.inCheck = inCheck;
s.positionCounts = {};
return s;
}
test('장기: 상차림 단계 (한 먼저 고르기)', () => {
const { state } = setupGame('janggi', 2, { sideAssignment: 'hostCho', setupOrder: 'hanFirst' });
expect(helpNow(view(state, 'B'), 'B', users)).toContain('상차림을 골라 주세요(나는 한)');
const cho = helpNow(view(state, 'A'), 'A', users);
expect(cho).toContain('한이 먼저 상차림을 골라요');
expect(cho).toContain('고양이님이 상차림을 고르는 중');
expect(helpNow(view(state, null), null, users)).toContain('고양이님이 상차림');
const s = applyAction('janggi', state, 'B', { type: 'chooseSetup', arrangement: 'MSSM' });
expect(helpNow(view(s, 'A'), 'A', users)).toContain('한이 안상차림을 골랐어요');
});
test('장기: 내 차례 / 상대 차례 / 관전 (점수 포함)', () => {
const s = started();
const mine = helpNow(view(s, 'A'), 'A', users);
expect(mine).toContain('내 차례예요(초)');
expect(mine).toContain('초록 점');
expect(mine).toContain('[⏸ 한수쉼]');
expect(mine).toContain('초 72점, 한 73.5점');
expect(helpNow(view(s, 'B'), 'B', users)).toContain('나는 한이에요');
expect(helpNow(view(s, null), null, users)).toContain('호랑이님(초) 차례예요');
});
test('장기: 장군을 받으면 한수쉼 없이 궁을 지키라고 안내', () => {
const s = custom([['cho', 'king', 3, 0], ['han', 'king', 4, 9], ['cho', 'cha', 0, 9]], 'han', 'han');
const t = helpNow(view(s, 'B'), 'B', users);
expect(t).toContain('장군이에요!');
expect(t).not.toContain('[⏸ 한수쉼]');
expect(helpNow(view(s, 'A'), 'A', users)).toContain('내가 장군을 불렀어요');
expect(helpNow(view(s, null), null, users)).toContain('한 궁이 장군을 받았어요');
});
test('장기: 빅장을 받은 쪽 안내 (점수제/비점수제)', () => {
for (const scoring of [true, false]) {
const s0 = started({ bikjang: 'kja30', scoring });
const s = custom([['cho', 'king', 4, 1], ['cho', 'jol', 4, 4], ['cho', 'cha', 0, 0], ['han', 'king', 4, 8], ['han', 'jol', 0, 6], ['han', 'cha', 8, 5], ['han', 'sa', 3, 9]], 'cho', null);
s.options = s0.options;
const after = applyAction('janggi', s, 'A', mv(4, 4, 3, 4));
expect(after.bikjang?.calledBy).toBe('cho');
const t = helpNow(view(after, 'B'), 'B', users);
expect(t).toContain('빅장이에요!');
expect(t).toContain(scoring ? '점수로 승부를 가려요' : '무승부로 끝나요');
expect(helpNow(view(after, null), null, users)).toContain('초가 빅장을 불렀어요');
}
});

View File

@@ -0,0 +1,81 @@
/** 장기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { Arrangement, JanggiView, Side } from '@bg/games/janggi';
import type { PublicUser } from '@bg/shared/lite';
const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' };
const ARR_KO: Record<Arrangement, string> = { MSSM: '안상차림', SMMS: '바깥상차림', SMSM: '왼상차림', MSMS: '오른상차림' };
const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho');
/** 초가 / 한이 (subject particle). */
const SUBJ: Record<Side, string> = { cho: '초가', han: '한이' };
/** 초예요 / 한이에요. */
const IS: Record<Side, string> = { cho: '초예요', han: '한이에요' };
export function helpNow(v: JanggiView, _me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return `대국이 끝났어요. ${v.result?.summaryKo ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
const nick = (s: Side) => `${players[v.players[s]]?.nickname ?? SIDE_KO[s]}님`;
const judge = v.rules.scoring ? '점수로 승부를 가려요' : '무승부로 끝나요';
const score = `지금 기물 점수는 초 ${v.scores.cho}점, 한 ${v.scores.han}점이에요${v.rules.scoring ? '(한은 덤 1.5점 포함)' : ''}.`;
const me = v.mySide;
if (v.phase === 'setup') {
const waiting = v.setup.waitingFor;
if (!me) return waiting.length ? `${waiting.map(nick).join('·')}이 상차림(뒷줄 마·상 자리)을 고르는 중이에요. 다 고르면 초부터 둬요.` : '곧 대국이 시작돼요.';
if (waiting.includes(me)) {
const han = v.setup.choice.han;
const lines = [
me === 'cho' && v.setup.order === 'hanFirst' && han ? `한이 ${ARR_KO[han]}을 골랐어요. 이제 내 상차림을 골라요(나는 초).` : `상차림을 골라 주세요(나는 ${SIDE_KO[me]}).`,
'아래 네 가지(안상차림·바깥상차림·왼상차림·오른상차림) 중 하나를 누르면 뒷줄의 마·상 자리가 정해져요. 내 쪽에서 본 왼쪽→오른쪽 순서예요.',
];
if (v.setup.order === 'simultaneous') lines.push('상대는 내가 고른 것을 대국이 시작될 때까지 볼 수 없어요.');
return lines.join('\n');
}
const mine = v.setup.choice[me];
const head = mine ? `${ARR_KO[mine]}을 골랐어요. ` : v.setup.order === 'hanFirst' ? '이 방은 한이 먼저 상차림을 골라요. ' : '';
return `${head}${waiting.length ? `${waiting.map(nick).join('·')}이 상차림을 고르는 중이에요. 기다려 주세요.` : '곧 대국이 시작돼요.'}\n[대국 취소]를 누르면 승패 없이 그만둘 수 있어요.`;
}
const lastMove = v.moves[v.moves.length - 1];
if (!me) {
const lines = [`${nick(v.turn)}(${SIDE_KO[v.turn]}) 차례예요. 상대 궁을 피할 곳 없이 몰면(외통) 이겨요.`];
if (v.inCheck) lines.push(`${SIDE_KO[v.inCheck]} 궁이 장군을 받았어요.`);
if (v.bikjang) lines.push(`${SUBJ[v.bikjang.calledBy]} 빅장을 불렀어요. ${SUBJ[other(v.bikjang.calledBy)]} 막지 못하면 ${judge}.`);
lines.push(score);
return lines.join('\n');
}
if (v.undo.pending && v.undo.pending.by !== me) return `상대가 ${v.undo.pending.plies}수 무르기를 부탁했어요. [수락]을 누르면 기물이 되돌아가고, [거절]을 누르면 그대로 계속해요.`;
if (v.draw.pendingBy && v.draw.pendingBy !== me) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.';
if (v.turn !== me) {
const lines = [`상대(${nick(v.turn)}) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${IS[me]}`];
if (v.inCheck === v.turn) lines.push('내가 장군을 불렀어요. 상대는 궁을 지켜야 해요.');
if (v.bikjang?.calledBy === me) lines.push(`내가 빅장을 불렀어요. 상대가 막지 못하면 ${judge}.`);
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
lines.push(score);
return lines.join('\n');
}
const lines: string[] = [];
if (v.inCheck === me) lines.push('장군이에요! 내 궁이 공격받고 있어요(빨간 동그라미). 궁을 피하거나, 사이를 막거나, 공격한 기물을 잡아야 해요(멍군). 한수쉼은 할 수 없어요.');
else lines.push(`내 차례예요(${SIDE_KO[me]}).`);
if (v.bikjang && v.bikjang.calledBy !== me) lines.push(`빅장이에요! 두 궁이 마주보고 있어요. 사이에 기물을 넣거나 궁을 옆으로 옮기지 않으면 ${judge}.`);
lines.push(
v.rules.hints === 'off'
? '움직일 기물을 누른 뒤 갈 자리를 눌러요. 못 가는 자리면 이유를 알려 줘요.'
: '움직일 기물을 누르면 갈 수 있는 곳에 초록 점이 찍혀요(잡을 수 있는 상대 기물은 빨간 동그라미). 그 자리를 누르면 둬요.',
);
if (v.canPass) {
if (lastMove?.kind === 'pass' && lastMove.side !== me) lines.push(`상대가 방금 한수쉼을 했어요. 나도 [⏸ 한수쉼]을 누르면 대국이 끝나고 ${judge}.`);
else lines.push('기물을 움직이지 않고 [⏸ 한수쉼]으로 차례를 넘길 수도 있어요.');
}
if (v.repeatWarning)
lines.push(
v.rules.repetition === 'forbid'
? '같은 국면이 3번 나왔어요. 한 번 더 반복하는 수는 둘 수 없어요.'
: v.rules.repetition === 'lose'
? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!'
: `같은 국면이 3번 나왔어요. 한 번 더 반복하면 ${judge}.`,
);
if (v.threatened.length) lines.push('주황 점선 동그라미가 쳐진 내 기물은 상대가 잡을 수 있어요. 조심하세요.');
lines.push(score);
return lines.join('\n');
}

View File

@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { TurnTimer, useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback'; import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types'; import type { BoardProps, GameUI, OptionsProps } from '../types';
import { helpNow } from './help';
import { quickStart } from './quick';
const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' }; const SIDE_KO: Record<Side, string> = { cho: '초', han: '한' };
const INK: Record<Side, string> = { cho: '#1d4ed8', han: '#c0262d' }; const INK: Record<Side, string> = { cho: '#1d4ed8', han: '#c0262d' };
@@ -888,7 +890,7 @@ function Options({ value: o, onChange }: OptionsProps<JanggiOptions>) {
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} /> <Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</Field> </Field>
<Field title="도움 표시"> <Field title="도움 표시">
<Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['movesAndThreats', '+ 잡힐 위험'], ['off', '끄기']]} onChange={(hints) => set({ hints })} /> <Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
</Field> </Field>
<Field title="그 밖에"> <Field title="그 밖에">
<Seg value={o.allowPass ? 'on' : 'off'} options={[['on', '한수쉼 허용'], ['off', '한수쉼 안 함']]} onChange={(k) => set({ allowPass: k === 'on' })} /> <Seg value={o.allowPass ? 'on' : 'off'} options={[['on', '한수쉼 허용'], ['off', '한수쉼 안 함']]} onChange={(k) => set({ allowPass: k === 'on' })} />
@@ -983,6 +985,8 @@ const ui: GameUI<JanggiView, JanggiOptions> = {
Options, Options,
optionsSummary, optionsSummary,
Rules, Rules,
quickStart,
helpNow,
}; };
export default ui; export default ui;

View File

@@ -0,0 +1,106 @@
/** 장기 30초 규칙 요약 (docs/07 §5). */
import type { ReactNode } from 'react';
import type { QuickCard } from '../types';
type Side = 'cho' | 'han';
const INK: Record<Side, string> = { cho: '#1d4ed8', han: '#c0262d' };
function octagon(cx: number, cy: number, r: number): string {
return Array.from({ length: 8 }, (_, i) => {
const a = Math.PI / 8 + (i * Math.PI) / 4;
return `${(cx + r * Math.cos(a)).toFixed(3)},${(cy + r * Math.sin(a)).toFixed(3)}`;
}).join(' ');
}
/** Octagonal piece with its hanja, blue for 초 and red for 한. */
function Piece({ ch, side, x, y, r = 0.4 }: { ch: string; side: Side; x: number; y: number; r?: number }) {
return (
<g>
<polygon points={octagon(x, y, r)} fill="#fbf3dc" stroke={INK[side]} strokeWidth={0.05} />
<text x={x} y={y} fill={INK[side]} fontSize={r * 1.05} fontWeight={700} fontFamily="'Noto Serif KR','Noto Serif CJK KR','Batang',serif" textAnchor="middle" dominantBaseline="central">
{ch}
</text>
</g>
);
}
/** Wooden board of w×h line crossings; `palace` draws the X of a 3×3 palace whose top-left crossing is given. */
function Board({ w, h, size, palace, children }: { w: number; h: number; size: number; palace?: [number, number]; children: ReactNode }) {
const pad = 0.55;
const vw = w - 1 + 2 * pad;
const vh = h - 1 + 2 * pad;
const scale = size / Math.max(vw, vh);
return (
<svg viewBox={`${-pad} ${-pad} ${vw} ${vh}`} width={vw * scale} height={vh * scale} aria-hidden>
<rect x={-pad} y={-pad} width={vw} height={vh} rx={0.2} fill="#e8c48a" />
<g stroke="#7a5228" strokeWidth={0.04}>
{Array.from({ length: w }, (_, x) => (
<line key={`v${x}`} x1={x} y1={0} x2={x} y2={h - 1} />
))}
{Array.from({ length: h }, (_, y) => (
<line key={`h${y}`} x1={0} y1={y} x2={w - 1} y2={y} />
))}
{palace && (
<>
<line x1={palace[0]} y1={palace[1]} x2={palace[0] + 2} y2={palace[1] + 2} />
<line x1={palace[0] + 2} y1={palace[1]} x2={palace[0]} y2={palace[1] + 2} />
</>
)}
</g>
{children}
</svg>
);
}
const Dot = ({ x, y }: { x: number; y: number }) => <circle cx={x} cy={y} r={0.15} fill="#16a34a" />;
const Cross = ({ x, y }: { x: number; y: number }) => (
<g stroke="#dc2626" strokeWidth={0.09}>
<line x1={x - 0.17} y1={y - 0.17} x2={x + 0.17} y2={y + 0.17} />
<line x1={x + 0.17} y1={y - 0.17} x2={x - 0.17} y2={y + 0.17} />
</g>
);
export const quickStart: QuickCard[] = [
{
pic: (
<Board w={3} h={4} size={160} palace={[0, 0]}>
<circle cx={1} cy={1} r={0.6} fill="rgba(220,38,38,0.2)" stroke="#dc2626" strokeWidth={0.07} />
<Piece ch="漢" side="han" x={1} y={1} r={0.46} />
<Piece ch="車" side="cho" x={1} y={3} />
</Board>
),
alt: '파란 차가 궁성 안의 빨간 궁을 곧게 겨누고 있어요(장군)',
title: '상대 궁을 몰아요',
text: '초(파랑)가 먼저 두고 번갈아 한 수씩 둬요. 상대 궁이 어디로 피해도 잡히게 만들면(외통) 이겨요.',
},
{
pic: (
<Board w={5} h={5} size={160}>
{[[0, 1], [4, 1], [0, 3], [4, 3], [1, 4], [3, 4]].map(([x, y]) => (
<Dot key={`${x}-${y}`} x={x!} y={y!} />
))}
<Cross x={1} y={0} />
<Cross x={3} y={0} />
<Piece ch="馬" side="cho" x={2} y={2} />
<Piece ch="卒" side="cho" x={2} y={1} r={0.3} />
</Board>
),
alt: '가운데 마 둘레에 갈 수 있는 곳이 초록 점으로, 바로 위가 막힌 쪽은 빨간 ✕로 보여요',
title: '누르면 갈 곳이 보여요',
text: '기물을 누르면 갈 수 있는 곳에 초록 점이 찍혀요. 기물은 칸이 아니라 선이 만나는 점에 놓여요. 마·상은 바로 앞(멱)이 막히면 못 가요.',
},
{
pic: (
<Board w={5} h={2} size={220}>
<Dot x={3} y={1} />
<Dot x={4} y={1} />
<path d="M 0 0.55 Q 1.5 -0.4 3 0.6" fill="none" stroke="#7c3aed" strokeWidth={0.07} strokeDasharray="0.14 0.08" />
<Piece ch="包" side="cho" x={0} y={1} />
<Piece ch="兵" side="han" x={2} y={1} r={0.3} />
</Board>
),
alt: '포가 기물 하나를 뛰어넘어 그 너머 점으로 가는 모습이에요',
title: '포는 넘어서 가요',
text: '포는 꼭 기물 하나를 뛰어넘어서만 움직여요. 장군을 받으면 다른 수보다 먼저 궁을 피하거나 막아야 해요.',
},
];

View File

@@ -0,0 +1,45 @@
import { expect, test } from 'bun:test';
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
import { GAMES } from '@bg/games';
import { helpNow } from './help';
const users = usersOf(['A', 'B', 'C']);
const view = (s: unknown, who: string | null) => GAMES['las-vegas']!.view(s, who) as any;
test('라스베가스: 굴리기 차례 / 기다리기 / 관전', () => {
const { state } = setupGame('las-vegas', 3);
const cur: string = state.seats[state.current];
const other = state.seats.find((p: string) => p !== cur)!;
const nick = users[cur]!.nickname;
const mine = helpNow(view(state, cur), cur, users);
expect(mine).toContain('[🎲 주사위 굴리기]');
expect(mine).toContain('남은 주사위 8개');
const wait = helpNow(view(state, other), other, users);
expect(wait).toContain(`${nick}님이 주사위를 굴릴 차례예요`);
expect(wait).toContain('내 주사위는 8개 남았어요');
const spec = helpNow(view(state, null), null, users);
expect(spec).toContain(`${nick}님이 주사위를 굴릴 차례예요`);
expect(spec).toContain('1/4라운드');
});
test('라스베가스: 굴린 뒤 숫자 고르기와 미리 보기', () => {
const { state } = setupGame('las-vegas', 2);
const cur: string = state.seats[state.current];
const other = state.seats.find((p: string) => p !== cur)!;
const s = applyAction('las-vegas', state, cur, { type: 'ROLL' });
const t = helpNow(view(s, cur), cur, users);
expect(t).toContain('개 놓기] 버튼');
expect(t).toContain('지금 상황에서 놓으면');
expect(t).toContain('일부만 놓을 수는 없어요');
expect(helpNow(view(s, other), other, users)).toContain('놓을 숫자를 고르는 중이에요');
// 2명: 흰 주사위 4개를 함께 가져요.
expect(helpNow(view(state, other), other, users)).toContain('흰 주사위 4개 포함');
});
test('라스베가스: 기권하면 구경', () => {
const { state } = setupGame('las-vegas', 3);
const cur: string = state.seats[state.current];
const other = state.seats.find((p: string) => p !== cur)!;
const s = applyAction('las-vegas', state, other, { type: 'CONCEDE' });
expect(helpNow(view(s, other), other, users)).toContain('기권해서');
});

View File

@@ -0,0 +1,53 @@
/** 라스베가스 주사위 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { LasVegasChoice, LasVegasView } from '@bg/games/las-vegas';
import type { PublicUser } from '@bg/shared/lite';
const STANDING: Record<LasVegasChoice['standing'], (c: LasVegasChoice) => string> = {
first: (c) => `1등${c.note !== null ? `(${c.note}점 지폐)` : ''}`,
award: (c) => (c.note !== null ? `${c.note}점 지폐` : '지폐 받음'),
tie: () => '동점이라 꽝',
none: (c) => (c.own === 0 ? '흰 주사위만 놓임' : '받을 지폐 없음'),
};
const choiceText = (c: LasVegasChoice) => `${c.value}번에 ${c.own + c.neutral}개 → ${STANDING[c.standing](c)}`;
export function helpNow(v: LasVegasView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished' || !v.current) return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
const round = `${v.round}/${v.rounds}라운드`;
const who = `${players[v.current]?.nickname ?? '다른 사람'}님`;
const rule = '라운드가 끝나면 카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 꽝이에요.';
const seated = !!me && v.seats.includes(me);
if (!seated) {
const doing = v.phase === 'roll' ? '주사위를 굴릴 차례예요.' : '나온 숫자 중 하나를 골라 카지노에 놓는 중이에요.';
return `${who}이 ${doing} (${round})\n${rule}`;
}
if (v.conceded.includes(me!)) return `나는 기권해서 지금은 구경만 할 수 있어요. ${who} 차례예요.`;
const hand = v.hand[me!];
const left = hand ? hand.own + hand.neutral : 0;
const mine = v.money[me!];
const score = mine && mine.total !== null ? `지금까지 내 점수는 ${mine.total}점(지폐 ${mine.count}장)이에요.` : '';
if (v.current !== me) {
const doing = v.phase === 'roll' ? '주사위를 굴릴 차례예요.' : '놓을 숫자를 고르는 중이에요.';
const lines = [`${who}이 ${doing} 기다려 주세요. (${round})`];
lines.push(left > 0 ? `내 주사위는 ${left}개 남았어요${hand!.neutral > 0 ? `(흰 주사위 ${hand!.neutral}개 포함)` : ''}.` : '내 주사위는 이번 라운드에 다 놓았어요. 다른 사람이 다 놓을 때까지 기다려요.');
if (score) lines.push(score);
return lines.join('\n');
}
// My turn.
if (v.phase === 'roll') {
const lines = [`내 차례예요(${round}). [🎲 주사위 굴리기]를 누르면 남은 주사위 ${left}개를 모두 굴려요.`];
lines.push('굴린 뒤 숫자 하나를 고르면, 그 숫자가 나온 주사위를 모두 같은 번호 카지노에 놓아요.');
if (v.houseRoll && v.houseRoll.round === v.round && hand?.own === 8) lines.push(`이번 라운드 시작에 남는 흰 주사위가 ${v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요.`);
if (score) lines.push(score);
return lines.join('\n');
}
const lines = [`내 차례예요(${round}). 나온 숫자(또는 같은 번호 카지노)를 누르면 미리 보기가 나와요. 그다음 [… 카지노에 …개 놓기] 버튼을 눌러 확정해요.`];
lines.push('고른 숫자의 주사위는 한꺼번에 모두 놓여요. 일부만 놓을 수는 없어요.');
if (v.choices.length) lines.push(`지금 상황에서 놓으면: ${v.choices.map(choiceText).join(' · ')}`);
if (v.choices.some((c) => c.standing === 'tie')) lines.push('동점이 되는 곳은 그대로 끝나면 같은 개수인 사람 모두 아무것도 못 받아요.');
return lines.join('\n');
}

View File

@@ -0,0 +1,820 @@
/** 라스베가스 화면 (docs/games/las-vegas.md §9). Bills are play points of this room only (docs/04 §6). */
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { LasVegasChoice, LasVegasOptions, LasVegasPayoutView, LasVegasView } from '@bg/games/las-vegas';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar, ConfirmSheet } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
import { helpNow } from './help';
import { quickStart } from './quick';
const ROLL_MS = 650;
const HOUSE = 'house';
/** Seat colours (dice), plus white for the neutral "house" dice. */
const COLORS = ['#e5484d', '#2f7cf6', '#1f9d55', '#f2a10c', '#8e5cf0'];
const COLOR_NAMES = ['빨강', '파랑', '초록', '노랑', '보라'];
/** Play-bill colours per face value (deliberately toy-like). */
const BILL: Record<number, [string, string]> = {
1: ['#fde68a', '#78350f'],
2: ['#bbf7d0', '#14532d'],
3: ['#bae6fd', '#0c4a6e'],
4: ['#fbcfe8', '#831843'],
5: ['#ddd6fe', '#3b0764'],
6: ['#fed7aa', '#7c2d12'],
7: ['#a7f3d0', '#064e3b'],
8: ['#c7d2fe', '#1e1b4b'],
9: ['#fecaca', '#7f1d1d'],
};
const nick = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '하우스(흰 주사위)' : (players[id]?.nickname ?? '알 수 없음'));
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const seatColor = (v: LasVegasView, owner: string) => (owner === HOUSE ? '#ffffff' : (COLORS[v.seats.indexOf(owner)] ?? '#888'));
const initial = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '흰' : ([...nick(players, id)][0] ?? '?'));
const CSS = `
.lv-wrap { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; }
.lv-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; padding: 10px 14px; }
.lv-help { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.lv-help span { background: var(--warning-soft); color: var(--text); border: 2px solid var(--warning); border-radius: 999px; padding: 4px 14px; font-weight: 800; font-size: 1.02rem; }
.lv-casinos { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.lv-casino { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 8px; min-height: 196px; border-radius: var(--radius); border: 3px solid var(--border);
background: var(--surface); box-shadow: var(--shadow); text-align: left; color: var(--text); font: inherit; transition: border-color .15s, transform .15s; min-width: 0; }
button.lv-casino { cursor: pointer; }
button.lv-casino:hover { border-color: var(--primary); }
.lv-casino:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.lv-casino.can { border-style: dashed; border-color: var(--primary); }
.lv-casino.sel { border-style: solid; border-color: var(--primary); transform: translateY(-3px); box-shadow: 0 0 0 4px var(--primary-soft), var(--shadow); }
.lv-casino.first { border-color: var(--success); box-shadow: 0 0 0 4px var(--success-soft), var(--shadow); }
.lv-casino.tie { border-color: var(--warning); box-shadow: 0 0 0 4px var(--warning-soft), var(--shadow); }
.lv-ctop { display: flex; align-items: center; gap: 6px; }
.lv-cnum { font-size: 1.5rem; font-weight: 900; line-height: 1; }
.lv-cname { font-size: .8rem; color: var(--text-sub); font-weight: 700; white-space: nowrap; }
.lv-bills { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; }
.lv-bill { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 28px; padding: 0 6px; border-radius: 6px;
font-weight: 900; font-size: 1.05rem; border: 2px dashed rgba(0,0,0,.35); box-shadow: inset 0 0 0 2px rgba(255,255,255,.55); font-variant-numeric: tabular-nums; }
.lv-bill::before { content: '★'; font-size: .55rem; position: absolute; left: 3px; top: 1px; opacity: .6; }
.lv-bill.sm { min-width: 26px; height: 22px; font-size: .85rem; padding: 0 4px; }
.lv-bill.hidden { background: repeating-linear-gradient(45deg, var(--surface-2), var(--surface-2) 4px, var(--border) 4px, var(--border) 8px); color: var(--text-sub); }
.lv-nobill { color: var(--text-sub); font-size: .8rem; }
.lv-stacks { display: flex; flex-direction: column; gap: 3px; margin-top: auto; }
.lv-stack { display: flex; align-items: center; gap: 4px; font-size: .82rem; font-weight: 800; min-width: 0; }
.lv-pips { display: flex; flex-wrap: wrap; gap: 2px; min-width: 0; }
.lv-dot { width: 13px; height: 13px; border-radius: 3px; border: 1.5px solid rgba(0,0,0,.45); flex: none; }
.lv-tag { position: absolute; top: -11px; right: 6px; font-size: .78rem; font-weight: 900; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
.lv-tag.first { background: var(--success); color: #fff; }
.lv-tag.tie { background: #facc15; color: #422006; }
.lv-tag.award { background: var(--primary); color: var(--primary-text); }
.lv-tag.none { background: var(--surface-2); color: var(--text-sub); border: 1px solid var(--border); }
@keyframes lv-hit { 0% { transform: scale(1); } 40% { transform: scale(1.05); box-shadow: 0 0 0 6px var(--primary-soft); } 100% { transform: scale(1); } }
.lv-casino.hit { animation: lv-hit .55s ease-out; }
.lv-lower { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 10px; align-items: start; }
.lv-tray { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
.lv-groups { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.lv-group { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 92px; min-height: 64px; padding: 6px 10px; border-radius: 12px;
border: 3px solid var(--border); background: var(--surface); color: var(--text); font: inherit; cursor: pointer; }
.lv-group:disabled { cursor: default; opacity: 1; }
.lv-group[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.lv-group:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.lv-group b { font-size: 1.15rem; }
.lv-group small { color: var(--text-sub); font-weight: 700; }
.lv-mdice { display: flex; gap: 3px; flex-wrap: wrap; justify-content: center; }
.lv-die { width: 28px; height: 28px; border-radius: 7px; border: 2px solid rgba(0,0,0,.4); flex: none; display: inline-block; }
.lv-die svg { display: block; width: 100%; height: 100%; }
.lv-die.lg { width: 40px; height: 40px; border-radius: 9px; }
@keyframes lv-tumble { 0% { transform: translateY(-16px) rotate(-40deg); } 50% { transform: translateY(3px) rotate(190deg); } 100% { transform: translateY(0) rotate(360deg); } }
.lv-die.rolling { animation: lv-tumble ${ROLL_MS}ms ease-out both; }
.lv-preview { border-radius: 10px; padding: 8px 12px; font-weight: 700; text-align: center; }
.lv-preview.first { background: var(--success-soft); color: var(--success); }
.lv-preview.tie { background: var(--warning-soft); color: var(--warning); }
.lv-preview.award, .lv-preview.none { background: var(--surface-2); }
.lv-roll { min-height: 60px; font-size: 1.15rem; }
.lv-settle { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.lv-settle h3 { margin: 0; font-size: 1rem; }
.lv-prow { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 6px; align-items: center; font-size: .85rem; padding: 3px 0; border-bottom: 1px solid var(--border); }
.lv-prow:last-child { border-bottom: 0; }
.lv-pbody { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-width: 0; }
.lv-chip { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; font-weight: 700; }
.lv-tieout { display: inline-flex; align-items: center; gap: 3px; color: var(--warning); font-weight: 800; }
.lv-tieout .lv-dot { opacity: .55; }
@keyframes lv-poof { 0% { transform: scale(1.4); opacity: 0; } 50% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } }
.lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: lv-poof .6s ease-out both; }
.lv-pname { max-width: 84px; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: bottom; }
.lv-final { padding: 12px; }
.lv-final table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.lv-final th, .lv-final td { padding: 4px 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
.lv-final td.notes { white-space: normal; text-align: left; }
@media (max-width: 1099px) {
.lv-lower { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 719px) {
.lv-casinos { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.lv-casino { min-height: 132px; padding: 6px; gap: 4px; }
.lv-wide { display: none; }
.lv-cnum { font-size: 1.25rem; }
.lv-cname { display: none; }
.lv-bill { min-width: 26px; height: 24px; font-size: .9rem; padding: 0 4px; }
.lv-dot { width: 11px; height: 11px; }
.lv-help { gap: 6px; flex-wrap: nowrap; }
.lv-help span { font-size: .8rem; padding: 2px 8px; white-space: nowrap; }
.lv-group { min-width: 76px; padding: 6px 8px; }
.lv-die { width: 22px; height: 22px; border-radius: 6px; }
.lv-die.lg { width: 32px; height: 32px; }
/* The tray sticks to the bottom of the screen so rolling / placing is always in reach. */
.lv-lower { display: contents; }
.lv-tray { position: sticky; bottom: 0; z-index: 4; box-shadow: 0 -4px 14px rgba(0,0,0,.12); }
}
@media (prefers-reduced-motion: reduce) {
.lv-casino, .lv-casino.sel { transition: none; transform: none; }
.lv-casino.hit, .lv-die.rolling, .lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: none; }
}
`;
// ---------------------------------------------------------------- small pieces
// prettier-ignore
const PIPS: Record<number, [number, number][]> = {
1: [[50, 50]],
2: [[28, 28], [72, 72]],
3: [[26, 26], [50, 50], [74, 74]],
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
};
/** Pip colour that reads on the die colour (white dice → dark pips). */
const pipOn = (bg: string) => (['#ffffff', '#fffdf8', '#f2a10c'].includes(bg) ? '#1c1e21' : '#ffffff');
function Die({ value, color, size = '', rolling = false }: { value: number; color: string; size?: '' | 'lg'; rolling?: boolean }) {
return (
<span className={`lv-die${size ? ` ${size}` : ''}${rolling ? ' rolling' : ''}`} style={{ background: color }} aria-hidden>
<svg viewBox="0 0 100 100">
{PIPS[value]!.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={value === 1 ? 14 : 10} fill={pipOn(color)} />
))}
</svg>
</span>
);
}
function Bill({ note, small }: { note: number | null; small?: boolean }) {
if (note === null)
return (
<span className={`lv-bill hidden${small ? ' sm' : ''}`} aria-label="금액 비공개 지폐">
?
</span>
);
const [bg, fg] = BILL[note] ?? ['#eee', '#111'];
return (
<span className={`lv-bill${small ? ' sm' : ''}`} style={{ background: bg, color: fg }} aria-label={`지폐 ${note}점`}>
{note}
</span>
);
}
function Dot({ color }: { color: string }) {
return <span className="lv-dot" style={{ background: color }} aria-hidden />;
}
function OwnerChip({ v, players, owner, me }: { v: LasVegasView; players: Record<string, PublicUser>; owner: string; me: string | null }) {
return (
<span className="lv-chip">
<Dot color={seatColor(v, owner)} />
<span className="lv-pname">{owner === me ? '나' : owner === HOUSE ? '하우스' : nick(players, owner)}</span>
</span>
);
}
const STANDING_TAG: Record<LasVegasChoice['standing'], string> = { first: '1등!', tie: '동점 주의', award: '지폐 받음', none: '못 받아요' };
function previewText(c: LasVegasChoice): string {
const head = `${c.value}번 카지노에 ${c.own + c.neutral}개를 놓으면`;
switch (c.standing) {
case 'first':
return `${head} 지금은 1등이에요! (${c.note}점 지폐)`;
case 'award':
return `${head} 지금은 ${c.note}점 지폐를 받을 수 있어요.`;
case 'tie':
return `${head} 개수가 같아져서 지금은 둘 다 꽝이에요. 동점 주의!`;
default:
return c.own === 0 ? `${head} 흰 주사위만 놓여요. 내 주사위는 그대로예요.` : `${head} 지금은 받을 지폐가 없어요.`;
}
}
// ---------------------------------------------------------------- board
function LasVegasBoard({ view: v, me, players, act, pending }: BoardProps<LasVegasView>) {
const myTurn = v.phase !== 'finished' && !!me && v.current === me;
const [sel, setSel] = useState<number | null>(null);
const [rolling, setRolling] = useState(false);
const [flick, setFlick] = useState<number[] | null>(null);
const [hit, setHit] = useState<{ value: number; key: number } | null>(null);
const [announce, setAnnounce] = useState('');
const prevRoll = useRef(v.rollSeq);
const prevTurn = useRef(v.turnSeq);
const prevRounds = useRef(v.roundResults.length);
// New roll: tumble animation + screen-reader text.
useLayoutEffect(() => {
if (v.rollSeq <= prevRoll.current || !v.lastRoll) {
prevRoll.current = v.rollSeq;
return;
}
prevRoll.current = v.rollSeq;
setSel(null);
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
const all = [...v.lastRoll.own, ...v.lastRoll.neutral].sort((a, b) => a - b);
setAnnounce(`${who} 주사위 ${all.length}개를 굴렸어요: ${all.join(', ')}`);
if (reducedMotion()) {
sounds.stone();
return;
}
setRolling(true);
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!rolling || !v.lastRoll) return;
const n = v.lastRoll.own.length + v.lastRoll.neutral.length;
const t = setInterval(() => setFlick(Array.from({ length: n }, () => 1 + Math.floor(Math.random() * 6))), 70);
const end = setTimeout(() => {
setRolling(false);
setFlick(null);
sounds.stone();
}, ROLL_MS);
return () => {
clearInterval(t);
clearTimeout(end);
};
}, [rolling]); // eslint-disable-line react-hooks/exhaustive-deps
// Turn changes: placement flash, round settlement and my-turn cue.
useEffect(() => {
if (v.turnSeq === prevTurn.current) return;
prevTurn.current = v.turnSeq;
setSel(null);
const parts: string[] = [];
const lp = v.lastPlace;
if (lp) {
setHit({ value: lp.value, key: v.turnSeq });
const who = lp.player === me ? '내가' : `${nick(players, lp.player)}님이`;
parts.push(
`${who} ${lp.value}번 카지노에 주사위 ${lp.own + lp.neutral}개를 놓았어요${lp.neutral ? `(흰 ${lp.neutral}개 포함)` : ''}${lp.auto ? ' (시간 초과로 자동)' : ''}.`,
);
}
if (v.roundResults.length > prevRounds.current) {
prevRounds.current = v.roundResults.length;
const rr = v.roundResults[v.roundResults.length - 1]!;
const ties = rr.payouts.filter((p) => p.removedTies.length > 0).length;
parts.push(`${rr.round}라운드 정산이 끝났어요.${ties ? ` 동점 무효가 ${ties}곳에서 나왔어요.` : ''}`);
haptic([50, 30, 50]);
}
if (myTurn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
if (parts.length) setAnnounce(parts.join(' '));
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
const choices = v.phase === 'place' && !rolling ? v.choices : [];
const selChoice = choices.find((c) => c.value === sel) ?? null;
const canPick = myTurn && v.phase === 'place' && !pending && !rolling;
const pick = (value: number) => {
if (!canPick || !choices.some((c) => c.value === value)) return;
setSel(sel === value ? null : value);
};
const place = () => {
if (!canPick || sel === null) return;
act({ type: 'PLACE', value: sel });
setSel(null);
};
const roll = () => {
if (myTurn && v.phase === 'roll' && !pending) act({ type: 'ROLL' });
};
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
const hand = me ? v.hand[me] : undefined;
const lastRR = v.roundResults[v.roundResults.length - 1];
return (
<div className="lv-wrap">
<style>{CSS}</style>
<div className="card lv-head">
<strong>{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.rounds} 라운드`}</strong>
{v.current && (
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
<Dot color={seatColor(v, v.current)} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
</span>
)}
<span className="small muted">
{v.phase !== 'finished' && `선 ${v.startPlayer === me ? '나' : nick(players, v.startPlayer)} · `}
<span className="lv-wide">남은 지폐 </span>더미 {v.pileCount}장
</span>
</div>
{v.round === 1 && v.phase !== 'finished' && (
<div className="lv-help" aria-label="처음 하는 분을 위한 도움말">
<span>🎲 같은 숫자는 한꺼번에</span>
<span>⚠️ 개수가 같으면 둘 다 꽝</span>
</div>
)}
{v.result && lastRR && (
<div className="lv-lower">
<FinalTable v={v} me={me} players={players} />
<Settlement round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
</div>
)}
{!v.result && (
<section className="lv-casinos" aria-label="카지노 6곳">
{v.casinos.map((c) => {
const ch = choices.find((x) => x.value === c.value);
const isSel = sel === c.value;
const owners = Object.entries(c.dice)
.filter(([, n]) => (n ?? 0) > 0)
.sort((a, b) => b[1]! - a[1]! || v.seats.indexOf(a[0]) - v.seats.indexOf(b[0]));
const label = `${c.value}번 카지노. 지폐 ${c.notes.length ? c.notes.map((n) => `${n}점`).join(', ') : '없음'}. 주사위 ${
owners.length ? owners.map(([o, n]) => `${o === me ? '나' : nick(players, o)} ${n}개`).join(', ') : '없음'
}${ch && isSel ? `. ${STANDING_TAG[ch.standing]}` : ''}`;
const cls = `lv-casino${ch && canPick ? ' can' : ''}${isSel ? ` sel ${ch?.standing === 'first' ? 'first' : ch?.standing === 'tie' ? 'tie' : ''}` : ''}${hit?.value === c.value ? ' hit' : ''}`;
const body = (
<>
<div className="lv-ctop">
<Die value={c.value} color="#fffdf8" />
<span className="lv-cnum">{c.value}</span>
<span className="lv-cname">번 카지노</span>
</div>
<div className="lv-bills">
{c.notes.length ? c.notes.map((n, i) => <Bill key={i} note={n} />) : <span className="lv-nobill">지폐 없음</span>}
</div>
<div className="lv-stacks">
{owners.map(([o, n]) => (
<div key={o} className="lv-stack" title={nick(players, o)}>
<span className="lv-pips">
{Array.from({ length: Math.min(n!, 8) }, (_, i) => (
<Dot key={i} color={seatColor(v, o)} />
))}
</span>
<span>{n! > 8 ? `${n}` : ''}</span>
<span className="muted" style={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{initial(players, o)}
</span>
</div>
))}
{ch && isSel && (
<div className="lv-stack" style={{ opacity: 0.85 }}>
<span className="lv-pips">
{Array.from({ length: ch.own }, (_, i) => (
<Dot key={`o${i}`} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: ch.neutral }, (_, i) => (
<Dot key={`n${i}`} color="#ffffff" />
))}
</span>
<span className="small">+{ch.own + ch.neutral}</span>
</div>
)}
</div>
{ch && isSel && <span className={`lv-tag ${ch.standing}`}>{STANDING_TAG[ch.standing]}</span>}
</>
);
return ch && canPick ? (
<button
key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`}
type="button"
className={cls}
aria-pressed={isSel}
aria-label={`${label}. 누르면 여기에 놓을 숫자로 골라요`}
onClick={() => pick(c.value)}
>
{body}
</button>
) : (
<div key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`} className={cls} role="group" aria-label={label}>
{body}
</div>
);
})}
</section>
)}
{!v.result && (
<div className="lv-lower">
<section className="card lv-tray" aria-label="주사위 굴리기와 놓기">
{v.phase === 'finished' ? (
<p className="muted" style={{ textAlign: 'center' }}>
게임이 끝났어요
</p>
) : rolling && v.lastRoll ? (
<div className="lv-mdice" aria-hidden>
{[...v.lastRoll.own.map(() => seatColor(v, v.current!)), ...v.lastRoll.neutral.map(() => '#ffffff')].map((col, i) => (
<Die key={`${v.rollSeq}-${i}`} value={flick?.[i] ?? 1} color={col} size="lg" rolling />
))}
</div>
) : v.phase === 'place' && v.current ? (
<>
<div className="lv-groups" role="group" aria-label={canPick ? '나온 숫자. 하나를 골라 주세요' : `${nick(players, v.current)}님이 굴린 주사위`}>
{choices.map((c) => (
<button
key={c.value}
type="button"
className="lv-group"
data-standing={c.standing}
aria-pressed={canPick ? sel === c.value : undefined}
disabled={!canPick}
aria-label={`${c.value} 주사위 ${c.own + c.neutral}개${c.neutral ? `(흰 ${c.neutral}개 포함)` : ''}. ${STANDING_TAG[c.standing]}`}
onClick={() => pick(c.value)}
>
<span className="lv-mdice">
{Array.from({ length: c.own }, (_, i) => (
<Die key={`o${i}`} value={c.value} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: c.neutral }, (_, i) => (
<Die key={`n${i}`} value={c.value} color="#ffffff" />
))}
</span>
<b>
{c.value} × {c.own + c.neutral}개
</b>
{c.neutral > 0 && <small>흰 {c.neutral} 포함</small>}
</button>
))}
</div>
{canPick && selChoice && <div className={`lv-preview ${selChoice.standing}`}>{previewText(selChoice)}</div>}
{canPick ? (
<button className="btn btn-primary btn-block lv-roll" disabled={!selChoice} onClick={place}>
{selChoice ? `${selChoice.value}번 카지노에 ${selChoice.own + selChoice.neutral}개 놓기` : '놓을 숫자를 골라 주세요'}
</button>
) : (
<p className="muted small" style={{ textAlign: 'center' }}>
{myTurn ? '잠시만요…' : `${nick(players, v.current)}님이 놓을 숫자를 고르는 중이에요`}
</p>
)}
</>
) : myTurn ? (
<button className="btn btn-primary btn-block lv-roll" disabled={pending} onClick={roll}>
🎲 주사위 굴리기 ({(hand?.own ?? 0) + (hand?.neutral ?? 0)}개)
</button>
) : (
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
{v.current ? `${nick(players, v.current)}님이 굴릴 차례예요` : ''}
</p>
)}
{hand && v.phase !== 'finished' && !v.conceded.includes(me!) && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
내 주사위 {hand.own}개{v.neutralPer > 0 ? ` · 흰 주사위 ${hand.neutral}개` : ''} 남음
</p>
)}
{v.houseRoll && v.houseRoll.round === v.round && v.phase !== 'finished' && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
라운드 시작: 남는 흰 주사위 {v.houseRoll.values.length}개가 {v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요
</p>
)}
</section>
{lastRR ? (
<Settlement key={lastRR.round} round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
) : (
<section className="card lv-settle" aria-label="정산 안내">
<h3>라운드가 끝나면</h3>
<p className="small muted" style={{ margin: 0 }}>
카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 무효예요.
</p>
</section>
)}
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function Settlement({
round,
payouts,
v,
me,
players,
}: {
round: number;
payouts: LasVegasPayoutView[];
v: LasVegasView;
me: string | null;
players: Record<string, PublicUser>;
}) {
const fresh = v.phase !== 'finished' && round === v.round - 1;
return (
<section className={`card lv-settle${fresh ? ' lv-fresh' : ''}`} aria-label={`${round}라운드 정산`}>
<h3>{round}라운드 정산</h3>
{payouts.map((p) => (
<div key={p.casino} className="lv-prow">
<strong>{p.casino}번</strong>
<div className="lv-pbody">
{p.removedTies.length > 0 && (
<span className="lv-tieout" aria-label={`동점 무효: ${p.removedTies.map((o) => (o === me ? '나' : nick(players, o))).join(', ')}`}>
💥 동점 무효
{p.removedTies.map((o) => (
<Dot key={o} color={seatColor(v, o)} />
))}
</span>
)}
{p.awards.map((a, i) => (
<span key={a.key ?? `${a.owner}-${i}`} className="lv-chip">
<OwnerChip v={v} players={players} owner={a.owner} me={me} />
<Bill note={a.note} small />
{a.note === null && <span className="muted">1장</span>}
</span>
))}
{p.returned.length > 0 && <span className="lv-chip muted">↩ 더미 아래로 {p.returned.map((n) => n).join('·')}</span>}
{p.awards.length === 0 && p.removedTies.length === 0 && p.returned.length === 0 && <span className="muted">아무도 없었어요</span>}
</div>
</div>
))}
</section>
);
}
function FinalTable({ v, me, players }: { v: LasVegasView; me: string | null; players: Record<string, PublicUser> }) {
const r = v.result!;
return (
<section className="card lv-final" aria-label="최종 결과">
<h3 style={{ margin: '0 0 6px' }}>최종 결과</h3>
<table>
<thead>
<tr>
<th scope="col">순위</th>
<th scope="col">이름</th>
{v.roundResults.map((rr) => (
<th key={rr.round} scope="col">
{rr.round}R
</th>
))}
<th scope="col">합계</th>
</tr>
</thead>
<tbody>
{r.ranking.map((row) => (
<tr key={row.player}>
<td>{row.rank}등</td>
<td style={{ textAlign: 'left' }}>
<OwnerChip v={v} players={players} owner={row.player} me={me} />
{row.conceded && <span className="muted small"> (기권)</span>}
</td>
{(r.roundTotals[row.player] ?? []).map((t, i) => (
<td key={i}>{t}</td>
))}
<td>
<strong>{row.total}점</strong>
<div className="small muted">{row.notes.length}장</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- side panel
function LasVegasSide({ view: v, me, players, act, deadline }: BoardProps<LasVegasView>) {
const [ask, setAsk] = useState(false);
const total = v.options.turnSeconds ? v.options.turnSeconds * 1000 : 30_000;
const mine = me ? v.money[me] : undefined;
const playing = v.phase !== 'finished';
return (
<>
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
{v.seats.map((p, i) => {
const turn = v.current === p;
const m = v.money[p]!;
const h = v.hand[p]!;
const out = v.conceded.includes(p);
return (
<div
key={p}
className="row"
style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}
>
<span aria-label={`${COLOR_NAMES[i]} 주사위`} role="img">
<Die value={i + 1} color={COLORS[i]!} />
</span>
<Avatar user={userOf(players, p)} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{nick(players, p)}
{p === me && <span className="muted small"> (나)</span>}
{v.startPlayer === p && playing && (
<span className="badge" style={{ marginLeft: 4, padding: '0 6px' }}>
선
</span>
)}
</div>
<div className="small muted">
{out ? '기권' : v.away.includes(p) ? '자리 비움' : playing ? `주사위 ${h.own}${v.neutralPer ? ` · 흰 ${h.neutral}` : ''}` : '끝'}
</div>
</div>
<span
style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
aria-label={`${nick(players, p)} 지폐 ${m.count}장${m.total !== null ? `, ${m.total}점` : ''}`}
>
<strong style={{ fontSize: '1.2rem' }}>{m.total !== null ? `${m.total}점` : '?'}</strong>
<div className="small muted">{m.count}장</div>
</span>
{turn && playing && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
</div>
);
})}
</section>
{mine && (
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="내 지폐">
<div className="row small" style={{ justifyContent: 'space-between' }}>
<strong>내 지폐</strong>
<span>
{mine.count}장 · {mine.total ?? 0}점
</span>
</div>
<div className="lv-bills">
{mine.notes && mine.notes.length ? mine.notes.map((n, i) => <Bill key={i} note={n} small />) : <span className="lv-nobill">아직 없어요</span>}
</div>
{v.options.hideMoney && playing && (
<p className="small muted" style={{ margin: 0 }}>
다른 사람에게는 장 수만 보여요.
</p>
)}
</section>
)}
{playing && me && v.money[me] && !v.conceded.includes(me) && (
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
🏳 기권
</button>
)}
{ask && (
<ConfirmSheet
title="기권할까요?"
body="남은 주사위는 모두 버려지고 이후 차례에서 빠져요. 이미 받은 지폐는 결과에 남아요."
confirm="기권하기"
danger
onConfirm={() => act({ type: 'CONCEDE' })}
onClose={() => setAsk(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: LasVegasView, me: string | null): boolean {
return v.phase !== 'finished' && !!me && v.current === me;
}
function statusText(v: LasVegasView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (me && v.conceded.includes(me)) return '기권했어요. 구경하는 중이에요';
if (isMyTurn(v, me)) return v.phase === 'roll' ? '내 차례예요. 주사위를 굴려 주세요' : '내 차례예요. 놓을 숫자를 골라 주세요';
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.rounds}라운드` : '잠시만 기다려 주세요';
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ 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<LasVegasOptions>) {
const set = (patch: Partial<LasVegasOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="라운드 수">
<Seg
label="라운드 수"
value={o.rounds}
options={[
[3, '3라운드'],
[4, '4라운드'],
[5, '5라운드'],
]}
onChange={(rounds) => set({ rounds })}
/>
</Field>
<Field label="중립(흰) 주사위">
<Seg
label="중립(흰) 주사위"
value={o.neutralDice}
options={[
['auto', '자동(2명일 때만)'],
['on', '켜기(2~4명)'],
['off', '끄기'],
]}
onChange={(neutralDice) => set({ neutralDice })}
/>
</Field>
<Field label="다른 사람이 받은 지폐 금액">
<Seg
label="다른 사람이 받은 지폐 금액"
value={o.hideMoney ? 'hide' : 'show'}
options={[
['hide', '숨기기(장 수만)'],
['show', '보여 주기'],
]}
onChange={(k) => set({ hideMoney: k === 'hide' })}
/>
</Field>
<Field label="굴리기·놓기 제한 시간">
<Seg
label="굴리기·놓기 제한 시간"
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
options={[
['20', '20초'],
['30', '30초'],
['60', '60초'],
['none', '무제한'],
]}
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 20 | 30 | 60) })}
/>
</Field>
<p className="small muted" style={{ margin: 0 }}>
흰 주사위 &quot;켜기&quot;는 5명일 때 쓸 수 없어요(5색을 모두 쓰기 때문이에요).
</p>
</div>
);
}
function optionsSummary(o: LasVegasOptions): string {
return [
`${o.rounds}라운드`,
o.neutralDice === 'auto' ? '흰 주사위 자동' : o.neutralDice === 'on' ? '흰 주사위 켜기' : '흰 주사위 끄기',
o.hideMoney ? '금액 숨김' : '금액 공개',
o.turnSeconds === null ? '시간 제한 없음' : `${o.turnSeconds}초`,
].join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: LasVegasOptions }) {
const rounds = options?.rounds ?? 4;
return (
<div className="stack">
<style>{CSS}</style>
<p>
1번부터 6번까지 카지노 6곳에 놀이용 지폐가 놓여 있어요(카지노마다 합이 5점 이상이 될 때까지). 각자 자기 색 주사위 <strong>8개</strong>를 가지고
시작해요.
</p>
<p>
내 차례에는 남은 주사위를 <strong>모두 굴린 뒤</strong>, 나온 숫자 하나를 골라 그 숫자가 나온 주사위를 <strong>한꺼번에</strong> 같은 번호 카지노에
놓아요. 일부만 놓을 수는 없어요. 주사위를 다 쓴 사람은 차례를 건너뛰어요.
</p>
<p>
모두 주사위를 다 놓으면 정산해요. 카지노마다 주사위가 가장 많은 사람이 가장 큰 지폐, 다음 사람이 그다음 지폐를 가져가요. 단,{' '}
<strong>개수가 같은 사람끼리는 모두 무효</strong>(꽝)예요.
</p>
<div className="row" style={{ gap: 6 }}>
<Bill note={8} />
<Bill note={3} />
<Bill note={1} />
<span className="small">가 놓인 카지노에 A 5개 · B 3개 · C 3개 · D 1개 → B와 C는 꽝, A가 8, D가 3을 받아요.</span>
</div>
<p>
<strong>흰 주사위</strong>(중립): 2명일 때 기본으로 각자 4개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 &quot;하우스&quot;라는
가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 3명이 켜고 하면 남는 흰 주사위 2개를 라운드마다 선 플레이어가 굴려 바로 놓아요.
</p>
<p>{rounds}라운드가 끝나면 받은 지폐 합이 가장 큰 사람이 이겨요. 같으면 지폐 장 수가 많은 사람이 이겨요. 라운드마다 선은 다음 사람에게 넘어가요.</p>
<p className="muted">
시간 안에 굴리지 않으면 자동으로 굴리고, 시간 안에 고르지 않으면 개수가 가장 많은 숫자(같으면 지폐가 큰 카지노, 그래도 같으면 작은 숫자)에 자동으로
놓아요.
</p>
<p className="muted small">지폐는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 실제 화폐나 상품과 바꿀 수 없어요.</p>
</div>
);
}
const ui: GameUI<LasVegasView, LasVegasOptions> = {
Board: LasVegasBoard,
Side: LasVegasSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View File

@@ -0,0 +1,131 @@
/** 라스베가스 주사위 30초 규칙 요약 (docs/07 §5). */
import type { QuickCard } from '../types';
const PIPS: Record<number, [number, number][]> = {
1: [[50, 50]],
2: [[28, 28], [72, 72]],
3: [[26, 26], [50, 50], [74, 74]],
4: [[28, 28], [72, 28], [28, 72], [72, 72]],
5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]],
6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]],
};
const RED = '#dc2626';
const BLUE = '#2563eb';
const GREEN = '#16a34a';
/** A coloured die at (x, y), `s` units wide (pips white on colour). */
function Die({ x, y, value, color, s = 30, dim = false }: { x: number; y: number; value: number; color: string; s?: number; dim?: boolean }) {
const k = s / 100;
return (
<g transform={`translate(${x} ${y}) scale(${k})`} opacity={dim ? 0.35 : 1}>
<rect x={3} y={3} width={94} height={94} rx={18} fill={color} stroke="#1f2937" strokeWidth={4} />
{PIPS[value]!.map(([cx, cy], i) => (
<circle key={i} cx={cx} cy={cy} r={10} fill="#fff" />
))}
</g>
);
}
function Bill({ x, y, n }: { x: number; y: number; n: number }) {
return (
<g transform={`translate(${x} ${y})`}>
<rect width={46} height={26} rx={4} fill="#bbf7d0" stroke="#15803d" strokeWidth={2} />
<text x={23} y={18} textAnchor="middle" fontSize={15} fontWeight={800} fill="#14532d">
{n}점
</text>
</g>
);
}
/** Casino card: number on top, bills inside. */
function Casino({ x, y, n, bills }: { x: number; y: number; n: number; bills: number[] }) {
return (
<g transform={`translate(${x} ${y})`}>
<rect width={66} height={44 + bills.length * 30} rx={8} fill="#fef3c7" stroke="#b45309" strokeWidth={2.5} />
<text x={33} y={26} textAnchor="middle" fontSize={20} fontWeight={900} fill="#78350f">
{n}번
</text>
{bills.map((b, i) => (
<Bill key={i} x={10} y={36 + i * 30} n={b} />
))}
</g>
);
}
function PlacePic() {
const roll = [4, 2, 4, 6, 1, 4, 5, 2];
return (
<svg viewBox="0 0 260 150" width={250} height={144} aria-hidden>
{roll.map((d, i) => (
<Die key={i} x={6 + (i % 4) * 36} y={14 + Math.floor(i / 4) * 40} value={d} color={RED} dim={d !== 4} />
))}
<text x={158} y={62} fontSize={28} fill="#374151">
➜
</text>
<Casino x={188} y={4} n={4} bills={[6, 3]} />
<text x={74} y={122} textAnchor="middle" fontSize={15} fontWeight={800} fill="#111827">
4가 3개 → 모두 4번에!
</text>
</svg>
);
}
function TiePic() {
return (
<svg viewBox="0 0 260 150" width={250} height={144} aria-hidden>
<Casino x={4} y={4} n={2} bills={[8, 3]} />
{[RED, RED].map((c, i) => (
<Die key={`r${i}`} x={90 + i * 32} y={14} value={2} color={c} />
))}
{[BLUE, BLUE].map((c, i) => (
<Die key={`b${i}`} x={90 + i * 32} y={56} value={2} color={c} />
))}
<Die x={90} y={98} value={2} color={GREEN} />
<text x={170} y={50} fontSize={20} fontWeight={900} fill="#b91c1c">
✕ 동점 꽝
</text>
<text x={134} y={122} fontSize={17} fontWeight={800} fill="#15803d">
→ 8점 받음
</text>
</svg>
);
}
function WinPic() {
return (
<svg viewBox="0 0 260 150" width={250} height={144} aria-hidden>
<Bill x={20} y={40} n={9} />
<Bill x={34} y={56} n={7} />
<Bill x={48} y={72} n={5} />
<Bill x={62} y={88} n={4} />
<text x={130} y={92} fontSize={22} fontWeight={900} fill="#111827">
= 25점
</text>
<text x={214} y={96} fontSize={44}>
🏆
</text>
</svg>
);
}
export const quickStart: QuickCard[] = [
{
pic: <PlacePic />,
alt: '빨간 주사위 8개 중 4가 나온 3개를 모두 4번 카지노에 놓아요',
title: '굴리고 한 숫자 고르기',
text: '내 차례에 남은 주사위를 모두 굴려요. 숫자 하나를 고르면 그 숫자가 나온 주사위를 모두 같은 번호 카지노에 놓아요.',
},
{
pic: <TiePic />,
alt: '2번 카지노에 빨강 2개, 파랑 2개가 동점이라 꽝이고, 초록 1개가 8점 지폐를 받아요',
title: '같은 개수면 둘 다 꽝',
text: '모두 다 놓으면 카지노마다 주사위가 많은 사람부터 큰 지폐를 받아요. 개수가 같은 사람끼리는 모두 아무것도 못 받아요.',
},
{
pic: <WinPic />,
alt: '지폐 9, 7, 5, 4점을 더해 25점, 트로피',
title: '점수가 높으면 이겨요',
text: '정해진 라운드가 모두 끝나면 받은 지폐의 점수를 더해요. 합계가 가장 높은 사람이 이겨요.',
},
];

View File

@@ -0,0 +1,57 @@
import { expect, test } from 'bun:test';
import { applyAction, seatIds, setupGame, usersOf } from '../help-kit.test-util';
import { GAMES } from '@bg/games';
import { helpNow } from './help';
const ids = seatIds(7);
const users = usersOf(ids);
const view = (s: unknown, who: string | null) => GAMES.mafia!.view(s, who) as any;
const T = 1_700_000_000_000;
function game() {
const { state } = setupGame('mafia', 7, { mafiaCount: 2 });
const byRole = (r: string) => state.players.filter((p: any) => p.role === r).map((p: any) => p.id as string);
return { state, mafia: byRole('mafia'), police: byRole('police')[0]!, doctor: byRole('doctor')[0]!, citizen: byRole('citizen')[0]! };
}
const night = { kind: 'night', startedAt: T, endsAt: T + 30_000, mafiaTarget: null, mafiaPickedBy: null, investigate: null, heal: null, mediumPeek: null };
test('마피아: 직업 확인 / 관전', () => {
const { state, citizen } = game();
expect(helpNow(view(state, citizen), citizen, users)).toContain('[내 직업] 카드를 꾹');
const spec = helpNow(view(state, null), null, users);
expect(spec).toContain('관전 중에는 전체 채팅과 발표만');
expect(spec).not.toContain('마피아예요');
});
test('마피아: 밤 안내는 내 직업에 맞게, 남의 직업은 말하지 않아요', () => {
const g = game();
const s = { ...g.state, day: 1, phase: night };
const [m1, m2] = g.mafia;
const mt = helpNow(view(s, m1!), m1!, users);
expect(mt).toContain('나는 마피아예요');
expect(mt).toContain(`동료 마피아는 ${users[m2!]!.nickname}님`);
expect(helpNow(view(s, g.police), g.police, users)).toContain('나는 경찰이에요');
expect(helpNow(view(s, g.doctor), g.doctor, users)).toContain('나는 의사예요');
const c = helpNow(view(s, g.citizen), g.citizen, users);
expect(c).toContain('지금은 할 일이 없어요');
for (const t of [c, helpNow(view(s, null), null, users)]) {
expect(t).not.toContain('마피아예요');
expect(t).not.toContain('경찰');
expect(t).not.toContain('의사');
}
// 마피아가 고르면 동료에게 대상과 고른 사람이 보여요.
const s2 = applyAction('mafia', s, m1!, { type: 'mafiaTarget', target: g.citizen });
expect(helpNow(view(s2, m2!), m2!, users)).toContain(`${users[m1!]!.nickname}님이 골랐어요`);
});
test('마피아: 지목 투표 → 찬반 투표, 탈락한 사람', () => {
const g = game();
const vote = { ...g.state, day: 2, phase: { kind: 'vote', startedAt: T, endsAt: T + 20_000, votes: {} } };
expect(helpNow(view(vote, g.citizen), g.citizen, users)).toContain('[기권]');
const confirm = { ...g.state, day: 2, phase: { kind: 'confirm', startedAt: T, endsAt: T + 10_000, accused: g.citizen, ballots: {} } };
expect(helpNow(view(confirm, g.police), g.police, users)).toContain('[찬성 (탈락)]');
expect(helpNow(view(confirm, g.citizen), g.citizen, users)).toContain('나를 탈락시킬지');
expect(helpNow(view(confirm, null), null, users)).toContain(`${users[g.citizen]!.nickname}님을 탈락시킬지`);
const dead = { ...confirm, players: g.state.players.map((p: any) => (p.id === g.doctor ? { ...p, alive: false, diedDay: 1, diedBy: 'mafia' } : p)) };
expect(helpNow(view(dead, g.doctor), g.doctor, users)).toContain('[죽은 사람] 채팅');
});

View File

@@ -0,0 +1,131 @@
/**
* 마피아 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view.
* Only what this viewer may know: the own role is named only where the screen already shows
* role-specific things (night actions, after death, after the game).
*/
import type { MafiaView } from '@bg/games/mafia';
import type { PublicUser } from '@bg/shared/lite';
const PHASE_KO: Record<MafiaView['phase']['kind'], string> = {
roleReveal: '직업 확인',
greeting: '인사 시간',
discussion: '낮 토론',
vote: '지목 투표',
defense: '최후의 반론',
confirm: '찬반 투표',
night: '밤',
ended: '게임 끝',
};
/** "~예요" after a vowel, "~이에요" after a final consonant. */
function yeyo(word: string): string {
const c = word.charCodeAt(word.length - 1) - 0xac00;
return c >= 0 && c < 11172 && c % 28 !== 0 ? `${word}이에요` : `${word}예요`;
}
const WIN = '시민 팀은 마피아를 모두 찾아내면, 마피아 팀은 살아 있는 마피아가 나머지 사람만큼 많아지면 이겨요.';
export function helpNow(v: MafiaView, me: string | null, players: Record<string, PublicUser>): string {
const n = (id: string) => `${players[id]?.nickname ?? '다른 사람'}님`;
const ph = v.phase;
const head = `${v.day}일차 ${yeyo(PHASE_KO[ph.kind])}.`;
if (ph.kind === 'ended') {
const chats = v.options.revealChatsAtEnd ? ' 모든 채팅방 기록도 이제 볼 수 있어요.' : '';
return `게임이 끝났어요. ${v.final?.summary ?? ''}`.trim() + `\n최종 공개 표에서 모든 사람의 직업을 볼 수 있어요.${chats}`;
}
const my = v.my;
// Spectators see the day chat and announcements only.
if (!my) {
const what: Record<string, string> = {
roleReveal: '모두 자기 직업을 확인하고 있어요.',
greeting: '살아 있는 사람들이 전체 채팅으로 인사하고 있어요. 오늘은 투표가 없어요.',
discussion: '살아 있는 사람들이 누가 마피아인지 이야기하고 있어요.',
vote: '살아 있는 사람들이 마피아로 의심되는 사람을 지목하고 있어요.',
defense: ph.accused ? `${n(ph.accused)}이 지목돼서 마지막으로 변론하고 있어요.` : '지목된 사람이 변론하고 있어요.',
confirm: ph.accused ? `${n(ph.accused)}을 탈락시킬지 찬반 투표를 하고 있어요.` : '찬반 투표를 하고 있어요.',
night: '각자 직업에 따라 몰래 행동하는 시간이에요. 아침에 발표가 있어요.',
};
return `${head} ${what[ph.kind] ?? ''}\n관전 중에는 전체 채팅과 발표만 볼 수 있어요.`;
}
const idle = my.idleWarning ? '\n오늘도 아무것도 하지 않으면 자리 비움으로 탈락해요.' : '';
if (!my.alive) {
const see = v.options.deadSeeAll ? ' 이 방에서는 탈락하면 모든 직업과 마피아 채팅도 볼 수 있어요.' : '';
return `${head} 나는 탈락했어요. [죽은 사람] 채팅에서만 이야기할 수 있어요.${see}\n살아 있는 사람에게 정보를 알려 주지 말아 주세요(디스코드 같은 다른 대화방도요).`;
}
switch (ph.kind) {
case 'roleReveal':
return `직업을 확인하는 시간이에요. 위의 [내 직업] 카드를 꾹 누르고 있는 동안만 직업이 보여요(옆 사람에게 보이지 않게).\n곧 ${v.options.firstPhase === 'day' ? '인사 시간' : '첫 밤'}이 시작돼요. ${WIN}`;
case 'greeting':
return `${head} [전체] 채팅으로 인사해요. 오늘은 투표가 없고, 끝나면 밤이 와요.\n다 됐으면 [인사 끝내기]를 눌러요. 살아 있는 사람이 모두 누르면 바로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`;
case 'discussion': {
const adj = my.canAdjust ? '\n[+15초]·[−15초]로 토론 시간을 하루 한 번 바꿀 수 있어요.' : '';
return `${head} [전체] 채팅으로 누가 마피아 같은지 이야기해요. 직업을 밝힐지는 내 선택이에요.${adj}\n다 이야기했으면 [토론 끝내기]를 눌러요. 모두 누르면 바로 지목 투표로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`;
}
case 'vote': {
const now = my.vote === null ? '아직 고르지 않았어요(안 고르면 기권).' : my.vote === 'abstain' ? '지금은 기권으로 되어 있어요.' : `지금 ${n(my.vote)}을 골랐어요.`;
return `${head} '사람들' 목록에서 초록 테두리 사람을 눌러 마피아로 의심되는 사람을 골라요. 시간 안에는 바꿀 수 있고, [기권]도 돼요.\n${now} 표가 가장 많은 한 명이 지목돼요. 같은 표가 나오면 아무도 지목되지 않아요.${idle}`;
}
case 'defense':
if (ph.accused === me) return `${head} 내가 지목됐어요! [전체] 채팅으로 내가 왜 마피아가 아닌지 마지막으로 말해요. 지금은 나만 말할 수 있어요.`;
return `${head} ${ph.accused ? n(ph.accused) : '지목된 사람'}의 마지막 변론을 들어요. 지금은 지목된 사람만 채팅할 수 있어요. 곧 찬반 투표를 해요.`;
case 'confirm': {
const who = ph.accused ? n(ph.accused) : '지목된 사람';
if (!my.canConfirm) return `${head} 다른 사람들이 나를 탈락시킬지 찬반 투표를 하고 있어요. 결과를 기다려 주세요.`;
const now = my.ballot ? `지금 ${my.ballot === 'yes' ? '찬성' : '반대'}했어요. 시간 안에는 바꿀 수 있어요.` : '안 누르면 집계에서 빠져요.';
return `${head} ${who}을 탈락시킬지 정해요. [찬성 (탈락)] 또는 [반대 (살리기)]를 눌러요.\n찬성이 반대보다 많아야 탈락해요. ${now}${idle}`;
}
case 'night':
return nightHelp(v, me, n) + idle;
}
return head;
}
function nightHelp(v: MafiaView, me: string | null, n: (id: string) => string): string {
const my = v.my!;
const nv = my.night;
const pick = "'사람들' 목록에서 초록 테두리 사람을 눌러 골라요. 밤이 끝날 때 고른 사람으로 정해지고, 그 전엔 바꿀 수 있어요.";
const lines: string[] = [];
const isAlive = (id: string) => v.players.some((p) => p.id === id && p.alive);
const mates = my.mates.filter(isAlive);
switch (nv?.action ?? null) {
case null: {
lines.push('밤이에요. 지금은 할 일이 없어요. 아침 발표를 기다려 주세요.');
if (my.role === 'lover' && mates.length) lines.push(`밤에는 [연인] 채팅으로 ${mates.map(n).join(', ')}과 둘이서만 이야기할 수 있어요.`);
break;
}
case 'mafiaTarget': {
lines.push(`밤이에요. 나는 마피아예요. 오늘 밤 노릴 사람을 ${pick}`);
if (mates.length) lines.push(`마피아끼리 한 명을 함께 정해요. 누가 고르든 마지막에 고른 사람이 대상이에요. 살아 있는 동료 마피아는 ${mates.map(n).join(', ')}이에요. [마피아] 채팅으로 의논해요.`);
const none = v.options.mafiaMustKill === 'random' ? '아무도 안 고르면 무작위로 한 명이 탈락해요.' : '아무도 안 고르면 오늘 밤은 아무도 탈락하지 않아요.';
lines.push(nv!.choice ? `지금 대상: ${n(nv!.choice)}${nv!.pickedBy && nv!.pickedBy !== me ? ` (${n(nv!.pickedBy)}이 골랐어요)` : ''}. [선택 취소]로 지울 수 있어요.` : none);
break;
}
case 'investigate': {
const what = v.options.policeRevealRole ? '그 사람의 직업이' : '마피아인지 아닌지가';
lines.push(`밤이에요. 나는 경찰이에요. 조사할 사람을 ${pick}`);
lines.push(`밤이 끝나면 ${what} '조사 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
break;
}
case 'heal': {
const self = v.options.doctorSelfHeal ? '나 자신도 고를 수 있어요.' : '나 자신은 고를 수 없어요.';
const rep = v.options.doctorRepeat ? '' : ' 어젯밤 지킨 사람은 오늘 다시 고를 수 없어요.';
lines.push(`밤이에요. 나는 의사예요. 지켜 줄 사람을 ${pick}`);
lines.push(`마피아가 그 사람을 노리면 살아나요. ${self}${rep}${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
break;
}
case 'mediumPeek': {
if (nv!.targets.length === 0) lines.push('밤이에요. 나는 영매예요. 아직 탈락한 사람이 없어서 알아볼 사람이 없어요.');
else {
lines.push(`밤이에요. 나는 영매예요. 직업을 알아볼 탈락한 사람을 ${pick}`);
lines.push(`결과는 밤이 끝나면 '영매 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
}
lines.push('밤에는 [죽은 사람] 채팅을 읽고 쓸 수 있어요.');
break;
}
}
return lines.join('\n');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
/** 마피아 30초 규칙 요약 (docs/07 §5). */
import type { QuickCard } from '../types';
/** Big emoji composition on a soft panel (no images). */
function Scene({ bg, rows }: { bg: string; rows: { e: string; label?: string }[][] }) {
return (
<div aria-hidden style={{ width: 240, height: 150, borderRadius: 14, background: bg, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 6, padding: 8, boxSizing: 'border-box' }}>
{rows.map((row, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'center', gap: 12 }}>
{row.map((c, j) => (
<div key={j} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 44 }}>
<span style={{ fontSize: 34, lineHeight: 1.1 }}>{c.e}</span>
{c.label && <span style={{ fontSize: 13, fontWeight: 700, color: '#111827', background: 'rgba(255,255,255,.85)', borderRadius: 6, padding: '0 4px' }}>{c.label}</span>}
</div>
))}
</div>
))}
</div>
);
}
export const quickStart: QuickCard[] = [
{
pic: <Scene bg="linear-gradient(#fde68a, #fef3c7)" rows={[[{ e: '☀️' }], [{ e: '🙂', label: '토론' }, { e: '🤔', label: '투표' }, { e: '👉', label: '탈락' }]]} />,
alt: '해가 뜬 낮에 사람들이 토론하고 투표해서 한 명을 가리켜요',
title: '낮: 토론하고 투표',
text: '낮에는 [전체] 채팅으로 누가 마피아 같은지 이야기하고, 투표로 한 명을 골라 찬성이 많으면 탈락시켜요.',
},
{
pic: <Scene bg="linear-gradient(#1e1b4b, #312e81)" rows={[[{ e: '🌙' }], [{ e: '🕶️', label: '마피아' }, { e: '🔎', label: '경찰' }, { e: '🩺', label: '의사' }]]} />,
alt: '달이 뜬 밤에 마피아, 경찰, 의사가 몰래 행동해요',
title: '밤: 직업대로 몰래',
text: '밤에는 마피아가 한 명을 노리고, 경찰은 한 명을 조사하고, 의사는 한 명을 지켜요. 내 직업은 [내 직업] 카드를 꾹 눌러 봐요.',
},
{
pic: <Scene bg="linear-gradient(#dcfce7, #fee2e2)" rows={[[{ e: '🙂', label: '시민 팀' }, { e: '⚔️' }, { e: '🕶️', label: '마피아 팀' }]]} />,
alt: '시민 팀과 마피아 팀이 마주 보고 있어요',
title: '팀이 함께 이겨요',
text: '시민 팀은 마피아를 모두 찾으면, 마피아 팀은 시민만큼 많이 남으면 이겨요. 탈락하면 살아 있는 사람에게 힌트를 주면 안 돼요.',
},
];

View File

@@ -0,0 +1,323 @@
/**
* 마작패 그림 (docs/games/mahjong.md §9): 직접 그린 SVG. 만수 = 숫자 + "만", 통수 = 동그라미, 삭수 = 막대, 자패 = 한글.
* 작은 크기(버림패)에서는 숫자를 크게 쓴 간단한 그림으로 바꿔 휴대폰에서도 읽히게 한다. 수패는 종류마다 안쪽 테두리
* 무늬(실선·점선·긴 점선)가 달라 색만으로 구별하지 않아도 된다.
*/
import type { CSSProperties } from 'react';
const SUIT_KO: Record<string, string> = { m: '만수', p: '통수', s: '삭수' };
const SUIT_SHORT: Record<string, string> = { m: '만', p: '통', s: '삭' };
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
const INK = '#1c1e21';
const RED = '#c8202a';
const BLUE = '#1f5fbf';
const GREEN = '#17804a';
/** "5m0r" → { n: 5, suit: 'm', red: true }; honors use suit 'z' and n 1..7. */
export function parseCode(code: string): { n: number; suit: 'm' | 'p' | 's' | 'z'; red: boolean; kind: string } {
return { n: Number(code[0]), suit: code[1] as 'm' | 'p' | 's' | 'z', red: code.endsWith('r'), kind: code.slice(0, 2) };
}
/** Screen-reader name: "만수 5", "적도라 통수 5", "동", "중". */
export function tileName(code: string): string {
const t = parseCode(code);
if (t.suit === 'z') return HONOR_KO[t.n - 1]!;
return `${t.red ? '적도라 ' : ''}${SUIT_KO[t.suit]} ${t.n}`;
}
/** Short name: "5만", "3통", "7삭", "동" (kind codes like "5m" work too). */
export function tileShort(code: string): string {
const t = parseCode(code);
return t.suit === 'z' ? HONOR_KO[t.n - 1]! : `${t.n}${SUIT_SHORT[t.suit]}`;
}
/** Sort key: 만 → 통 → 삭 → 자, by number, red five first. */
export function tileOrder(code: string): number {
const t = parseCode(code);
return 'mpsz'.indexOf(t.suit) * 100 + t.n * 10 + (t.red ? 0 : 1) + Number(code[2] ?? 0) / 10;
}
const PIN_POS: Record<number, [number, number][]> = {
1: [[30, 38]],
2: [
[30, 22],
[30, 54],
],
3: [
[16, 18],
[30, 38],
[44, 58],
],
4: [
[18, 22],
[42, 22],
[18, 54],
[42, 54],
],
5: [
[17, 18],
[43, 18],
[30, 38],
[17, 58],
[43, 58],
],
6: [
[19, 16],
[41, 16],
[19, 38],
[41, 38],
[19, 60],
[41, 60],
],
7: [
[14, 13],
[30, 21],
[46, 29],
[19, 46],
[41, 46],
[19, 63],
[41, 63],
],
8: [
[19, 12],
[41, 12],
[19, 30],
[41, 30],
[19, 47],
[41, 47],
[19, 64],
[41, 64],
],
9: [
[14, 16],
[30, 16],
[46, 16],
[14, 38],
[30, 38],
[46, 38],
[14, 60],
[30, 60],
[46, 60],
],
};
function pinColor(n: number, i: number, red: boolean): string {
if (red) return RED;
if (n === 1) return RED;
if (n === 2) return i === 0 ? GREEN : BLUE;
if (n === 3) return [BLUE, RED, GREEN][i]!;
if (n === 4) return i === 0 || i === 3 ? BLUE : GREEN;
if (n === 5) return i === 2 ? RED : i === 0 || i === 4 ? BLUE : GREEN;
if (n === 6) return i < 2 ? GREEN : RED;
if (n === 7) return i < 3 ? GREEN : RED;
if (n === 8) return BLUE;
return i < 3 ? BLUE : i < 6 ? RED : GREEN;
}
function Pins({ n, red }: { n: number; red: boolean }) {
const pos = PIN_POS[n]!;
const r = n === 1 ? 17 : n <= 3 ? 9.5 : n <= 5 ? 8.5 : n <= 7 ? 7 : 7;
return (
<>
{pos.map(([x, y], i) => {
const c = pinColor(n, i, red);
return (
<g key={i}>
<circle cx={x} cy={y} r={r} fill="none" stroke={c} strokeWidth={n === 1 ? 4 : 2.6} />
<circle cx={x} cy={y} r={r * 0.42} fill={c} />
</g>
);
})}
</>
);
}
/** Bamboo sticks: rows of [x positions], with red sticks for some centres. */
const SOU_ROWS: Record<number, number[][]> = {
2: [[30], [30]],
3: [[30], [18, 42]],
4: [
[20, 40],
[20, 40],
],
5: [[18, 42], [30], [18, 42]],
6: [
[15, 30, 45],
[15, 30, 45],
],
7: [[30], [15, 30, 45], [15, 30, 45]],
8: [
[12, 24, 36, 48],
[12, 24, 36, 48],
],
9: [
[15, 30, 45],
[15, 30, 45],
[15, 30, 45],
],
};
function Sticks({ n, red }: { n: number; red: boolean }) {
if (n === 1) {
// 1삭: one big stick with knots and a red top.
return (
<g>
<rect x={24} y={12} width={12} height={54} rx={6} fill={GREEN} />
<rect x={24} y={12} width={12} height={12} rx={6} fill={RED} />
<line x1={24} y1={39} x2={36} y2={39} stroke="#fff" strokeWidth={2} />
<line x1={24} y1={52} x2={36} y2={52} stroke="#fff" strokeWidth={2} />
</g>
);
}
const rows = SOU_ROWS[n]!;
const top = 8;
const bottom = 70;
const gap = 4;
const h = (bottom - top - gap * (rows.length - 1)) / rows.length;
const w = rows.some((r) => r.length === 4) ? 7 : 8;
return (
<>
{rows.map((xs, ri) =>
xs.map((x, ci) => {
const y = top + ri * (h + gap);
const center = (n === 5 && ri === 1) || (n === 7 && ri === 0) || (n === 9 && ci === 1);
const c = red ? RED : center ? RED : GREEN;
return (
<g key={`${ri}-${ci}`}>
<rect x={x - w / 2} y={y} width={w} height={h} rx={w / 2} fill={c} />
<line x1={x - w / 2} y1={y + h / 2} x2={x + w / 2} y2={y + h / 2} stroke="#fff" strokeWidth={1.4} />
</g>
);
}),
)}
</>
);
}
/** Inner frame pattern per suit (colour-blind help, §9). */
const FRAME_DASH: Record<string, string | undefined> = { m: undefined, p: '1.5 3', s: '6 3' };
function Face({ code, compact }: { code: string; compact: boolean }) {
const t = parseCode(code);
const frame = t.suit !== 'z' && (
<rect x={5} y={5} width={50} height={66} rx={5} fill="none" stroke="#c9bfa8" strokeWidth={1.6} strokeDasharray={FRAME_DASH[t.suit]} />
);
if (t.suit === 'z') {
const ch = HONOR_KO[t.n - 1]!;
if (t.n === 5) {
return (
<>
<rect x={10} y={10} width={40} height={56} rx={4} fill="none" stroke={BLUE} strokeWidth={4} />
<text x={30} y={48} textAnchor="middle" fontSize={compact ? 28 : 22} fontWeight={800} fill={BLUE}>
{ch}
</text>
</>
);
}
const color = t.n === 6 ? GREEN : t.n === 7 ? RED : INK;
return (
<text x={30} y={compact ? 53 : 52} textAnchor="middle" fontSize={compact ? 40 : 36} fontWeight={900} fill={color}>
{ch}
</text>
);
}
const numColor = t.red ? RED : INK;
if (compact) {
if (t.suit === 'm') {
return (
<>
{frame}
<text x={30} y={50} textAnchor="middle" fontSize={44} fontWeight={900} fill={numColor}>
{t.n}
</text>
<rect x={14} y={58} width={32} height={7} rx={3.5} fill={RED} />
</>
);
}
if (t.suit === 'p') {
return (
<>
{frame}
<circle cx={30} cy={38} r={23} fill="none" stroke={t.red ? RED : BLUE} strokeWidth={4} />
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : BLUE}>
{t.n}
</text>
</>
);
}
return (
<>
{frame}
<rect x={7} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
<rect x={47} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : GREEN}>
{t.n}
</text>
</>
);
}
if (t.suit === 'm') {
return (
<>
{frame}
<text x={30} y={40} textAnchor="middle" fontSize={34} fontWeight={900} fill={numColor}>
{t.n}
</text>
<text x={30} y={66} textAnchor="middle" fontSize={21} fontWeight={800} fill={RED}>
만
</text>
</>
);
}
return (
<>
{frame}
{t.suit === 'p' ? <Pins n={t.n} red={t.red} /> : <Sticks n={t.n} red={t.red} />}
<text x={52} y={14} textAnchor="end" fontSize={11} fontWeight={800} fill={t.red ? RED : '#6b6352'}>
{t.n}
</text>
</>
);
}
export interface TileProps {
/** Tile code ("5m0r") or kind code ("5m"); null draws the back. */
code: string | null;
/** Width in px (height is 4:3). */
w: number;
sideways?: boolean;
dim?: boolean;
className?: string;
style?: CSSProperties;
/** aria-hidden by default: the parent describes the tile. */
label?: string;
}
export function Tile({ code, w, sideways, dim, className, style, label }: TileProps) {
const h = Math.round((w * 4) / 3);
const compact = w < 30;
const svg = (
<svg viewBox="0 0 60 80" width={w} height={h} style={{ display: 'block', ...(sideways ? { transform: 'rotate(-90deg)' } : {}) }} aria-hidden>
{code === null ? (
<>
<rect x={1} y={1} width={58} height={78} rx={8} fill="#1d5a8c" stroke="#123b5c" strokeWidth={2} />
<rect x={7} y={7} width={46} height={62} rx={5} fill="#2a73ad" />
</>
) : (
<>
<rect x={1} y={1} width={58} height={78} rx={8} fill="#d9cdb0" stroke="#9c8f72" strokeWidth={2} />
<rect x={2} y={2} width={56} height={71} rx={7} fill="#fffcf3" />
<Face code={code} compact={compact} />
</>
)}
</svg>
);
const box: CSSProperties = sideways
? { width: h, height: w, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none', ...style }
: { width: w, height: h, display: 'inline-block', flex: 'none', ...style };
return (
<span className={`mj-tile${dim ? ' dim' : ''}${className ? ` ${className}` : ''}`} style={box} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{svg}
</span>
);
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, test } from 'bun:test';
import { GAMES } from '@bg/games';
import { applyAction, assertCleanText, eachView, setupGame, usersOf } from '../help-kit.test-util';
import { helpNow } from './help';
const users = usersOf(['A', 'B', 'C', 'D']);
const view = (s: unknown, who: string | null) => GAMES.mahjong!.view(s, who) as any;
/** First view (from random games) that matches `pred`, with its state and viewer. */
function findView(n: number, pred: (v: any, who: string | null) => boolean, options: Record<string, unknown> = {}) {
for (let seed = 1; seed <= 12; seed++) {
let found: { v: any; who: string | null; state: any } | null = null;
try {
eachView(
'mahjong',
n,
(v, who, state) => {
if (pred(v, who)) {
found = { v, who, state };
throw new Error('found');
}
},
{ steps: 4000, seed: [seed, 7, 3, 9], options },
);
} catch (e) {
if (!found) throw e;
}
if (found) return found as { v: any; who: string | null; state: any };
}
throw new Error('no such view');
}
test('마작: 내 차례 / 상대 차례 / 관전', () => {
const { state } = setupGame('mahjong', 4);
const mine = helpNow(view(state, 'A'), 'A', users);
expect(mine).toContain('내 차례예요');
expect(mine).toContain('방금 가져온 패는');
expect(mine).toContain('[○○ 버리기]');
expect(helpNow(view(state, 'B'), 'B', users)).toContain('호랑이님 차례예요. 기다려 주세요');
const spec = helpNow(view(state, null), null, users);
expect(spec).toContain('호랑이님 차례예요. 1장을 가져오고(쯔모)');
expect(spec).toContain('동1국');
});
test('마작: 버린 뒤 응답 창 — 4인은 치·퐁·깡, 3인은 퐁·깡', () => {
for (const n of [4, 3]) {
const { state } = setupGame('mahjong', n);
const v0 = view(state, null);
const dealer: string = v0.seats[v0.turn].id;
const tile = view(state, dealer).my.discardable[0];
const s = applyAction('mahjong', state, dealer, { type: 'discard', tile });
const spec = helpNow(view(s, null), null, users);
expect(spec).toContain(`${users[dealer]!.nickname}님이`);
expect(spec).toContain('버렸어요');
if (n === 4) expect(spec).toContain('치·퐁·깡');
else expect(spec).not.toContain('치·');
expect(helpNow(view(s, dealer), dealer, users)).toContain('내가');
}
});
test('마작: 론·치 버튼 안내', () => {
const ron = findView(4, (v) => !!v.my?.claim?.ron && !v.my.claim.responded);
const t = helpNow(ron.v, ron.who, users);
expect(t).toContain('[론!]');
expect(t).toContain('[패스]');
expect(t).toContain('후리텐');
const chi = findView(4, (v) => (v.my?.claim?.chi.length ?? 0) > 0 && !v.my.claim.responded);
expect(helpNow(chi.v, chi.who, users)).toContain('[치');
});
test('마작: 리치할 수 있을 때와 리치한 뒤', () => {
const f = findView(4, (v) => (v.my?.riichiDiscards.length ?? 0) > 0);
const t = helpNow(f.v, f.who, users);
expect(t).toContain('[리치]');
expect(t).toContain("'텐' 표시");
let s = applyAction('mahjong', f.state, f.who!, { type: 'discard', tile: f.v.my.riichiDiscards[0], riichi: true });
// Riichi is accepted once the claim window on that discard closes (its timer runs out, nobody calls).
const def = GAMES.mahjong!;
for (let i = 0; i < 8 && !view(s, f.who).my.riichi; i++) {
const at = (def.deadline?.(s) ?? 1_700_000_100_000) + 1;
for (const p of def.timeoutPlayers?.(s) ?? []) s = applyAction('mahjong', s, p, def.onTimeout(s, p), undefined, at);
}
const after = helpNow(view(s, f.who), f.who, users);
expect(after).toContain('리치 중');
expect(after).toContain('대기패');
expect(helpNow(view(s, null), null, users)).toContain('리치한 사람');
});
test('마작: 국 결과와 대국 끝', () => {
const r = findView(4, (v, who) => v.phase === 'handResult' && v.result?.kind === 'agari' && who !== null);
const t = helpNow(r.v, r.who, users);
expect(t).toContain('화료예요');
expect(t).toContain('내 점수');
expect(t).toMatch(/\[(다음 국으로|최종 결과 보기)\]/);
expect(helpNow(view(r.state, null), null, users)).toContain('모두 확인하면');
const end = findView(3, (v) => v.phase === 'finished');
expect(helpNow(end.v, 'A', users)).toContain('대국이 끝났어요');
expect(helpNow(end.v, null, users)).toContain('1위는');
});
describe('마작: 다른 규칙에서도 도움말이 깨지지 않아요', () => {
const variants: [number, Record<string, unknown>][] = [
[4, {}],
[4, { beginnerMode: false, length: 'tonpuu', akaDora: 0, kuitan: false }],
[3, { beginnerMode: false, sanmaTsumo: 'northBisection', abortiveDraws: false }],
];
test.each(variants)('%d인 %o', (n, options) => {
let count = 0;
eachView(
'mahjong',
n,
(v, who) => {
assertCleanText(helpNow(v, who, users), `mahjong ${n}p viewer=${who}`);
count++;
},
{ steps: 1500, seed: [3, 1, 4, 1], options },
);
expect(count).toBeGreaterThan(n * 10);
});
});

View File

@@ -0,0 +1,170 @@
/** 마작 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (my own tiles + public info). */
import type { HandResultView, MahjongView, MyView, WaitView } from '@bg/games/mahjong';
import type { PublicUser } from '@bg/shared/lite';
const WIND = ['동', '남', '서', '북'];
const HONOR = ['동', '남', '서', '북', '백', '발', '중'];
const SUIT: Record<string, string> = { m: '만', p: '통', s: '삭' };
const ABORT: Record<string, string> = { kyuushu: '구종구패', suufon: '사풍연타', suukan: '사깡유국', suucha: '사가리치', sanchaho: '삼가화' };
const LIMIT: Record<string, string> = { mangan: '만관', haneman: '하네만', baiman: '배만', sanbaiman: '삼배만', kazoe: '헤아림 역만', yakuman: '역만' };
/** "5m0r" / "5m" → "5만", "1z" → "동" (same as Tile.tsx tileShort). */
const short = (code: string) => (code[1] === 'z' ? (HONOR[Number(code[0]) - 1] ?? '자패') : `${code[0]}${SUIT[code[1] ?? ''] ?? ''}`);
const fmt = (n: number) => n.toLocaleString('ko-KR');
const signed = (n: number) => (n > 0 ? `+${fmt(n)}` : n < 0 ? `-${fmt(-n)}` : '0');
/** 을/를 by the last syllable. */
const eulReul = (w: string) => {
const c = w.charCodeAt(w.length - 1);
return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 === 0 ? `${w}를` : `${w}을`;
};
/** Same format as the screen: "3삭(2장), 6삭(4장, 역 없음)". */
const waitText = (ws: WaitView[]) => ws.map((w) => `${short(w.kind)}(${w.left}장${w.yaku ? '' : ', 역 없음'})`).join(', ');
const roundLabel = (x: { roundWind: number; handNumber: number; honba: number }) => `${WIND[x.roundWind] ?? ''}${x.handNumber + 1}국${x.honba ? ` ${x.honba}본장` : ''}`;
export function helpNow(v: MahjongView, me: string | null, players: Record<string, PublicUser>): string {
const nameOf = (seat: number | null | undefined) => {
const id = seat === null || seat === undefined ? undefined : v.seats[seat]?.id;
const n = id ? players[id]?.nickname : undefined;
return n ? `${n}님` : '다른 사람';
};
const my = me !== null ? v.my : null;
const sanma = v.mode === 'sanma';
const calls = sanma ? '퐁·깡' : '치·퐁·깡';
if (v.phase === 'finished') return finished(v, me, players);
if (v.phase === 'handResult') return handResult(v, my, nameOf);
// ---- playing
const riichiSeats = v.seats.filter((s) => s.riichi && s.seat !== my?.seat).map((s) => nameOf(s.seat));
const riichiLine = riichiSeats.length ? `리치한 사람: ${riichiSeats.join(', ')}.` : '';
if (v.stage === 'claim' && v.claimOn) {
const c = v.claimOn;
const tile = `${short(c.tile)} 패`;
const mine = !!my && c.from === my.seat;
const who = mine ? '내가' : `${nameOf(c.from)}이`;
const what =
c.source === 'discard' ? `${who} ${eulReul(tile)} 버렸어요.` : c.source === 'kakan' ? `${who} ${eulReul(tile)} 가깡(퐁에 1장 더 붙이기)했어요.` : c.source === 'ankan' ? `${who} ${short(c.tile)} 4장으로 안깡했어요.` : `${who} 북을 옆에 빼 두었어요(북 뽑기).`;
if (my?.claim && !my.claim.responded) return claimHelp(v, my, what, calls);
if (my?.claim?.responded) return `${what}\n응답했어요. 다른 사람이 고르기를 기다리는 중이에요.`;
const how = `${c.source === 'discard' ? `울기(${calls})나 ` : ''}론`;
const lines = [`${what} 다른 사람이 이 패로 ${how}을 할지 확인하는 중이에요.`];
lines.push('잠깐 기다리면 다음 차례로 넘어가요.');
if (my) waitsLine(v, my, lines);
return lines.join('\n');
}
const turn = v.turn;
if (turn === null || (v.stage !== 'afterDraw' && v.stage !== 'afterCall')) return '잠시만 기다려 주세요. 곧 다음 차례가 시작돼요.';
if (!my) {
const lines = [v.stage === 'afterCall' ? `${nameOf(turn)} 차례예요. 방금 울어서(${calls}) 패를 가져오지 않고 바로 1장을 버려요.` : `${nameOf(turn)} 차례예요. 1장을 가져오고(쯔모) 1장을 버려요.`];
lines.push(`${roundLabel(v)} · 남은 패 ${v.wallCount}장.${riichiLine ? ` ${riichiLine}` : ''}`);
lines.push('손패 4면자(같은 패 3장이나 이어진 3장) + 머리(같은 패 2장)를 먼저 완성하는 사람이 이겨요(화료).');
return lines.join('\n');
}
if (!my.canDiscard) {
const lines = [`${nameOf(turn)} 차례예요. 기다려 주세요.`];
if (my.autoPilot) lines.push('지금 자리 비움 상태라 자동으로 쳐요. 아무 패나 누르면 돌아와요.');
waitsLine(v, my, lines);
if (riichiLine) lines.push(riichiLine);
return lines.join('\n');
}
return myTurn(v, my, riichiSeats.length > 0, calls);
}
/** 리치·텐파이 대기패 / 후리텐 (only what the view already has). */
function waitsLine(v: MahjongView, my: MyView, lines: string[]) {
if (my.riichi) lines.push('나는 리치 중이에요. 화료패(손패를 완성하는 패)가 나오면 [론!], 내가 가져오면 [쯔모!]가 떠요.');
if (my.waits.length) {
const noYaku = my.waits.some((w) => !w.yaku);
lines.push(`텐파이(1장만 더 오면 완성)예요. 대기패: ${waitText(my.waits)}.${noYaku ? ' "역 없음" 패로는 론할 수 없어요.' : ''}`);
}
if (my.furiten && (my.waits.length || my.riichi)) lines.push('후리텐이에요: 내 대기패를 전에 버렸거나 론을 넘겨서 론은 못 하고, 내가 직접 가져오는 쯔모로만 화료할 수 있어요.');
const beginnerOn = v.options.beginnerMode && my.prefs.beginner;
if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`);
}
function myTurn(v: MahjongView, my: MyView, othersRiichi: boolean, calls: string): string {
const beginnerOn = v.options.beginnerMode && my.prefs.beginner;
const lines: string[] = [];
if (my.canTsumo) lines.push('화료할 수 있어요! [쯔모!]를 누르면 가져온 패로 손패 완성(쯔모 화료)이에요.');
if (my.riichi) {
lines.push(`내 차례예요. 리치 중이라 손패를 바꿀 수 없어서 방금 가져온 패만 버릴 수 있어요(나머지는 흐리게 보여요).`);
} else if (v.stage === 'afterCall') {
lines.push(`내 차례예요. 방금 울었으니(${calls}) 패를 가져오지 않고 바로 1장을 버려요.`);
if (my.locked.length) lines.push('🔒 표시 패는 방금 울은 패와 같은 패(또는 스지)라 지금 버릴 수 없어요.');
} else {
lines.push(`내 차례예요. ${my.drawn ? `방금 가져온 패는 ${short(my.drawn)} 패예요(맨 오른쪽에 조금 떨어진 패). ` : ''}손패에서 1장을 골라 버려요.`);
}
lines.push(`${my.riichi ? '가져온 패를' : '버릴 패를'} 누른 뒤 한 번 더 누르거나 [○○ 버리기] 버튼을 누르면 버려요(한 번에 놓기 설정이면 바로 버려져요).`);
if (my.riichiDiscards.length) lines.push('[리치]: 텐파이(1장만 더 오면 완성)라 리치할 수 있어요. [리치]를 누르고 밝은 패를 버리면 1,000점을 내고 리치를 걸어요. 리치는 역(점수가 나는 조건)이 되지만, 그다음부터는 가져온 패만 버려요.');
const tenpai = Object.keys(my.tenpaiAfter);
if (beginnerOn && !my.riichi && tenpai.length) {
const kinds = [...new Set(tenpai.map(short))];
lines.push(`'텐' 표시가 붙은 패(${kinds.slice(0, 4).join(', ')}${kinds.length > 4 ? ' 등' : ''})를 버리면 텐파이예요. 패를 누르면 기다리는 패와 남은 장수가 아래에 나와요.`);
}
if (my.ankan.length || my.kakan.length) lines.push('[깡]: 같은 패 4장을 모아 깡을 할 수 있어요. 깡을 하면 패를 1장 더 가져와요.');
if (my.canKita) lines.push('[북 뽑기]: 북을 옆에 빼 두고 패를 1장 더 가져와요. 빼 둔 북은 도라(보너스 1판)예요.');
if (my.canKyuushu) lines.push('[구종구패(유국)]: 1·9·자패가 9종류 이상이라 이번 국을 무승부로 끝낼 수 있어요(안 해도 돼요).');
if (my.furiten && !my.canTsumo) lines.push('후리텐이에요: 론은 못 하고 쯔모로만 화료할 수 있어요.');
if (othersRiichi && !my.riichi && !my.canTsumo) lines.push('리치한 사람이 이미 버린 패와 같은 패는 그 사람에게 론 당하지 않아요.');
if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`);
else if (!beginnerOn && v.options.beginnerMode) lines.push("'내 설정'에서 초보 모드를 켜면 텐파이 표시와 대기패·역 힌트를 볼 수 있어요.");
lines.push(v.stage === 'afterDraw' ? '시간이 지나면 방금 가져온 패를 저절로 버려요.' : '시간이 지나면 패 1장이 저절로 버려져요.');
return lines.join('\n');
}
function claimHelp(v: MahjongView, my: MyView, what: string, calls: string): string {
const c = my.claim!;
const lines = [what];
if (c.ron) lines.push('이 패로 화료할 수 있어요! [론!]을 누르면 남이 낸 패로 손패 완성(론 화료)이에요.');
if (c.pon.length) lines.push('[퐁]: 같은 패 2장에 이 패를 더해 3장을 내놓아요.');
if (c.kan.length) lines.push('[깡]: 같은 패 3장에 이 패를 더해 4장을 내놓고, 패를 1장 더 가져와요.');
if (c.chi.length) lines.push(`[치${c.chi.length > 1 ? ` (${c.chi.length}가지)` : ''}]: 바로 앞 사람이 버린 패로 이어진 3장을 만들어요.${c.chi.length > 1 ? ' 누른 뒤 쓸 조합을 골라요.' : ''}`);
lines.push(`[패스]: 그냥 넘겨요. 시간이 지나면 저절로 패스돼요.${c.ron ? ' 론을 넘기면 후리텐이 되어 한동안(리치 중이면 이번 국 끝까지) 론을 못 해요.' : ''}`);
if ((c.pon.length || c.kan.length || c.chi.length) && !my.riichi) lines.push(`울면(${calls}) 리치를 못 해요. 역패·탕야오처럼 다른 역(점수가 나는 조건)이 있어야 화료할 수 있어요.`);
if (v.mode === 'sanma' && c.chi.length === 0 && (c.pon.length || c.kan.length)) lines.push('3인 마작은 치가 없어요.');
return lines.join('\n');
}
function handResult(v: MahjongView, my: MyView | null, nameOf: (seat: number | null | undefined) => string): string {
const r = v.result as HandResultView | null;
const lines: string[] = [];
if (!r) lines.push('이번 국이 끝났어요.');
else if (r.kind === 'agari') {
for (const w of r.winners) {
const how = w.from === null ? '쯔모(직접 가져온 패로 완성)' : `론(${nameOf(w.from)}이 버린 패로 완성)`;
const yaku = w.yaku.filter((y) => !/^(도라|적도라|우라도라|북) /.test(y.name)).map((y) => y.name);
const size = w.limit === 'yakuman' ? (w.yakuman > 1 ? `${w.yakuman}배 역만` : '역만') : `${w.han}판 ${w.fu}부${LIMIT[w.limit] ? ` · ${LIMIT[w.limit]}` : ''}`;
lines.push(`${roundLabel(r)}: ${nameOf(w.seat)} ${how} 화료예요. 역: ${yaku.join(', ') || '없음'} · ${size} · ${fmt(w.points)}점.`);
}
} else if (r.kind === 'exhaustive') {
lines.push(`${roundLabel(r)}: 패가 다 떨어져 유국(무승부)이에요. 텐파이(1장만 더 오면 완성)였던 사람: ${r.tenpai.length ? r.tenpai.map(nameOf).join(', ') : '없음'}.`);
if (r.nagashi.length) lines.push(`유국만관: ${r.nagashi.map(nameOf).join(', ')}.`);
else if (r.tenpai.length && r.tenpai.length < v.n) lines.push('텐파이가 아닌 사람이 텐파이인 사람에게 점수를 줘요.');
} else {
lines.push(`${roundLabel(r)}: 도중유국(${ABORT[r.abortiveReason ?? ''] ?? '중간에 무효'})이에요. 점수는 그대로예요.`);
}
if (r && my) {
const d = r.deltas[my.seat] ?? 0;
lines.push(`내 점수: ${signed(d)} → ${fmt(r.pointsAfter[my.seat] ?? 0)}점.`);
}
const ready = my ? v.seats[my.seat]?.ready : false;
const next = v.gameOver ? '대국이 끝나요.' : v.nextHand ? `다음은 ${roundLabel(v.nextHand)}이에요.` : '';
if (!my) lines.push(`${next} 모두 확인하면 넘어가요.`.trim());
else if (ready) lines.push(`${next} 다른 사람을 기다리는 중이에요 (${v.seats.filter((s) => s.ready).length}/${v.n}).`.trim());
else lines.push(`${next} 표를 확인하고 [${v.gameOver ? '최종 결과 보기' : '다음 국으로'}]를 눌러 주세요. 시간이 지나면 저절로 넘어가요.`.trim());
return lines.join('\n');
}
function finished(v: MahjongView, me: string | null, players: Record<string, PublicUser>): string {
const f = v.final;
const lines = [`대국이 끝났어요.${f ? ` ${f.summary}` : ''}`];
const mine = f?.ranking.find((x) => x.player === me);
if (mine) lines.push(`나는 ${mine.rank}위 · ${fmt(mine.points)}점이에요.`);
else if (f?.ranking[0]) lines.push(`1위는 ${players[f.ranking[0].player]?.nickname ? `${players[f.ranking[0].player]!.nickname}님` : '다른 사람'}이에요.`);
lines.push('최종 결과 표의 "결과(우마·오카)"는 순위 보너스를 더한 놀이용 점수예요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.');
return lines.join('\n');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
/** 마작 30초 규칙 요약 (docs/07 §5). */
import type { CSSProperties, ReactNode } from 'react';
import type { QuickCard } from '../types';
import { Tile } from './Tile';
const col: CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 };
const row: CSSProperties = { display: 'flex', alignItems: 'flex-end', gap: 6 };
const label: CSSProperties = { fontSize: 12, fontWeight: 800, color: 'var(--text-sub, #555)', textAlign: 'center' };
function Group({ codes, w, note }: { codes: string[]; w: number; note?: string }) {
return (
<span style={col}>
<span style={{ display: 'inline-flex', gap: 1 }}>
{codes.map((c, i) => (
<Tile key={i} code={c} w={w} />
))}
</span>
{note && <span style={label}>{note}</span>}
</span>
);
}
function Badge({ children, bg, color = '#fff' }: { children: ReactNode; bg: string; color?: string }) {
return <span style={{ background: bg, color, borderRadius: 10, padding: '6px 14px', fontWeight: 900, fontSize: 18, lineHeight: 1.2 }}>{children}</span>;
}
export const quickStart: QuickCard[] = [
{
pic: (
<div style={col} aria-hidden>
<div style={row}>
<Group codes={['2m', '3m', '4m']} w={24} note="이어진 3장" />
<Group codes={['5p', '5p', '5p']} w={24} note="같은 3장" />
</div>
<div style={row}>
<Group codes={['6s', '7s', '8s']} w={24} />
<Group codes={['7z', '7z', '7z']} w={24} />
<Group codes={['1z', '1z']} w={24} note="머리 2장" />
</div>
</div>
),
alt: '이어진 3장, 같은 3장 묶음 4개와 같은 패 2장으로 완성된 마작 손패',
title: '1장 가져오고 1장 버려요',
text: '차례마다 1장을 가져오고(쯔모) 1장을 버려요. 3장 묶음 4개 + 같은 패 2장을 먼저 만들면 이겨요(화료).',
},
{
pic: (
<div style={{ ...col, gap: 8 }} aria-hidden>
<div style={row}>
<span style={label}>리치</span>
<Tile code="3s" w={26} />
<Tile code="9p" w={26} sideways />
<span style={{ width: 34, height: 7, background: '#fff', border: '1px solid #999', borderRadius: 4, position: 'relative', alignSelf: 'center' }}>
<span style={{ position: 'absolute', left: 14, top: 1, width: 4, height: 4, borderRadius: 2, background: '#dc2626' }} />
</span>
</div>
<div style={row}>
<span style={label}>탕야오</span>
<Tile code="2m" w={26} />
<Tile code="5p" w={26} />
<Tile code="8s" w={26} />
</div>
<div style={row}>
<span style={label}>역패</span>
<Tile code="5z" w={26} />
<Tile code="5z" w={26} />
<Tile code="5z" w={26} />
</div>
</div>
),
alt: '쉬운 역 세 가지: 리치 선언, 2~8만 모은 탕야오, 백 3장 역패',
title: '역이 꼭 있어야 해요',
text: "손패를 완성해도 '역'(점수가 나는 조건)이 1개는 있어야 이겨요. 쉬운 역: 리치, 탕야오(2~8만), 역패(백·발·중 3장).",
},
{
pic: (
<div style={{ ...col, gap: 10 }} aria-hidden>
<div style={{ ...row, gap: 1, paddingTop: 14 }}>
{['3m', '4m', '6p', '7p', '8p'].map((c, i) => (
<span key={i} style={{ position: 'relative' }}>
{i === 4 && (
<span style={{ position: 'absolute', top: -15, left: '50%', transform: 'translateX(-50%)', background: '#15803d', color: '#fff', fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '0 4px', lineHeight: 1.4 }}>텐</span>
)}
<Tile code={c} w={28} />
</span>
))}
</div>
<div style={{ ...row, gap: 10 }}>
<Badge bg="#dc2626">쯔모!</Badge>
<Badge bg="#dc2626">론!</Badge>
</div>
</div>
),
alt: "'텐' 표시가 붙은 패와 빨간 쯔모 버튼, 론 버튼",
title: '쯔모! 론! 으로 이겨요',
text: "필요한 패를 직접 가져오면 [쯔모!], 남이 버리면 [론!]을 눌러요. 초보 모드는 '텐' 표시와 기다리는 패를 알려 줘요.",
},
];

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