M4: 윷놀이 규칙·화면·테스트

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 04:29:46 +09:00
parent 5dd8e6ea07
commit 2610e37d49
12 changed files with 2706 additions and 1 deletions

View File

@@ -182,3 +182,47 @@ describe('Room isolation and lobby', () => {
expect(room.seatOf('B')).toBe(-1);
});
});
describe('윷놀이 room rules', () => {
const yutGames = () => ({ yut: GAMES.yut! });
test('team mode follows the game range to 8 seats and refuses odd counts via playerCountError', () => {
const { room, conn } = harness(yutGames());
room.join(conn('A'), 'player');
expect(room.configure('A', { maxPlayers: 4 })).toBeNull();
expect(room.configure('A', { options: { ...GAMES.yut!.defaultOptions, mode: 'team' } })).toBeNull();
expect(room.seats).toHaveLength(8);
for (const u of ['B', 'C', 'D', 'E']) room.join(conn(u), 'player');
expect(room.start('A')).toBe('팀전은 4명, 6명, 8명(짝수)으로만 할 수 있어요.');
room.join(conn('F'), 'player');
expect(room.start('A')).toBeNull();
room.clearTimer();
expect((room.game!.state as any).sides.map((s: any) => s.members)).toEqual([
['A', 'C', 'E'],
['B', 'D', 'F'],
]);
});
test('a disconnected player is auto-played after the grace period; reconnecting gets the current view', () => {
const { room, clock, conn } = harness(yutGames());
const conns = { A: conn('A'), B: conn('B') };
room.join(conns.A, 'player');
room.join(conns.B, 'player');
room.configure('A', { options: { ...GAMES.yut!.defaultOptions, turnSeconds: null } });
expect(room.start('A')).toBeNull();
room.clearTimer();
const cur = (room.game!.state as any).current as 'A' | 'B';
room.disconnect(conns[cur]);
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
clock.advance(60_000);
room.onTimer();
room.clearTimer();
expect((room.game!.state as any).turnSeq).toBe(1);
const back = conn(cur);
room.join(back, 'player');
room.clearTimer();
const st = back.msgs.find((m) => m.t === 'state') as any;
expect(st.view.turnSeq).toBe((room.game!.state as any).turnSeq);
});
});

View File

@@ -484,6 +484,8 @@ export class Room {
if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths));
const range = this.rangeFor(next.gameId, next.options);
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
// A room that had every seat its game allows keeps doing so when an option changes the range (e.g. 윷놀이 팀전 8명).
else if (next.gameId === this.config.gameId && this.config.maxPlayers === this.playerRange().max) next.maxPlayers = range.max;
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
for (const k of ['visibility', 'allowSpectators', 'chatEnabled', 'chatFilter'] as const) {
@@ -598,6 +600,8 @@ export class Room {
const lk = this.locked(def.id);
const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options);
if (!parsed.success) return '설정 값이 올바르지 않아요.';
const countErr = def.playerCountError?.(parsed.data, players.length);
if (countErr) return countErr;
const eff = this.deps.settings?.game(def.id);
if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.';
this.config = { ...this.config, options: parsed.data };