시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거

- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야
  서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은
  바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도
  먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다.
- 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와
  그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로.
- 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개
  설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-08 20:42:46 +09:00
parent 1e1408302a
commit 19c163f4d2
41 changed files with 215 additions and 264 deletions

View File

@@ -70,16 +70,16 @@ describe('Room timers', () => {
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } }); room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
room.start('A'); room.start('A');
room.clearTimer(); room.clearTimer();
// No time control: idle limit 300s. Black disconnects → grace 60s wins. // No time control: no turn deadline. Black disconnects → only the 60s grace applies.
room.disconnect(a); room.disconnect(a);
room.clearTimer(); room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000); expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true); expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true);
// Reconnect before the grace period ends → deadline goes back to the idle limit. // Reconnect before the grace period ends → no deadline again.
clock.advance(30_000); clock.advance(30_000);
room.join(conn('A'), 'player'); room.join(conn('A'), 'player');
room.clearTimer(); room.clearTimer();
expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000); expect(room.effectiveDeadline()).toBeNull();
}); });
test('restore postpones overdue deadlines by 30s and keeps game state', () => { test('restore postpones overdue deadlines by 30s and keeps game state', () => {

View File

@@ -589,8 +589,6 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: BadukView;
clock = ( clock = (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && ms <= 10_000 ? 'var(--danger)' : undefined }}>{mmss(ms)}</span> <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && ms <= 10_000 ? 'var(--danger)' : undefined }}>{mmss(ms)}</span>
); );
} else if (active) {
clock = <TurnTimer deadline={deadline} totalMs={c.idleLimitSec * 1000} mine={mine} />;
} }
// Byoyomi countdown beeps for my own clock (last 5 seconds of a period). // Byoyomi countdown beeps for my own clock (last 5 seconds of a period).

View File

@@ -620,7 +620,7 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView;
const now = useServerNow(100); const now = useServerNow(100);
const tc = v.timeControl; const tc = v.timeControl;
let clock: React.ReactNode = null; let clock: React.ReactNode = null;
if (active && !v.started[color]) { if (tc.kind === 'fischer' && active && !v.started[color]) {
clock = ( clock = (
<span className="row" style={{ gap: 6 }}> <span className="row" style={{ gap: 6 }}>
<span className="small muted">첫 수</span> <span className="small muted">첫 수</span>
@@ -647,8 +647,6 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView;
{fmtClock(ms)} {fmtClock(ms)}
</span> </span>
); );
} else if (active) {
clock = <TurnTimer deadline={deadline} totalMs={v.idleLimitSec * 1000} mine={id === me} />;
} }
const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b)); const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b));
const adv = color === 'white' ? v.materialDiff : -v.materialDiff; const adv = color === 'white' ? v.materialDiff : -v.materialDiff;
@@ -688,7 +686,7 @@ function statusText(v: ChessView, _me: string | null, players: Record<string, Pu
if (mine && v.draw.pendingBy && v.draw.pendingBy !== mine) return '상대가 무승부를 제안했어요'; if (mine && v.draw.pendingBy && v.draw.pendingBy !== mine) return '상대가 무승부를 제안했어요';
if (mine === v.turn) { if (mine === v.turn) {
if (v.checkSquare) return '체크! 킹을 지켜야 해요'; if (v.checkSquare) return '체크! 킹을 지켜야 해요';
if (!v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`; if (v.timeControl.kind === 'fischer' && !v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`;
if (v.claimable) return '내 차례예요. 무승부를 주장할 수 있어요'; if (v.claimable) return '내 차례예요. 무승부를 주장할 수 있어요';
return `내 차례예요 (${COLOR_KO[mine]}). 움직일 기물을 눌러 주세요`; return `내 차례예요 (${COLOR_KO[mine]}). 움직일 기물을 눌러 주세요`;
} }
@@ -797,16 +795,12 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
</div> </div>
</div> </div>
)} )}
{o.timeControl.kind === 'none' && ( {o.timeControl.kind === 'fischer' && (
<div className="stack" style={{ gap: 6 }}>
<strong>응답이 없으면 시간패</strong>
<Seg value={o.idleLimitSec} options={[[300, '5분'], [600, '10분']]} onChange={(idleLimitSec) => set({ idleLimitSec })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>첫 수 제한 (넘기면 대국 취소)</strong> <strong>첫 수 제한 (넘기면 대국 취소)</strong>
<Seg value={o.firstMoveSec} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(firstMoveSec) => set({ firstMoveSec })} /> <Seg value={o.firstMoveSec} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(firstMoveSec) => set({ firstMoveSec })} />
</div> </div>
)}
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>3회 반복 · 50수 무승부</strong> <strong>3회 반복 · 50수 무승부</strong>
<Seg value={o.drawMode} options={[['auto', '자동으로 무승부'], ['fide', 'FIDE (주장해야 무승부)']]} onChange={(drawMode) => set({ drawMode })} /> <Seg value={o.drawMode} options={[['auto', '자동으로 무승부'], ['fide', 'FIDE (주장해야 무승부)']]} onChange={(drawMode) => set({ drawMode })} />

View File

@@ -671,7 +671,7 @@ function Clock({ view: v, side, deadline, mine }: { view: JanggiView; side: Side
return v.setup.waitingFor.includes(side) ? <TurnTimer deadline={deadline} totalMs={v.rules.setupTimeSec * 1000} mine={mine} /> : null; return v.setup.waitingFor.includes(side) ? <TurnTimer deadline={deadline} totalMs={v.rules.setupTimeSec * 1000} mine={mine} /> : null;
} }
const active = v.phase === 'playing' && v.turn === side; const active = v.phase === 'playing' && v.turn === side;
if (tc.kind === 'none') return active ? <TurnTimer deadline={deadline} totalMs={v.rules.idleLimitSec * 1000} mine={mine} /> : null; if (tc.kind === 'none') return null;
const periodMs = tc.periodSec * 1000; const periodMs = tc.periodSec * 1000;
const elapsed = active ? Math.max(0, now - v.clock.turnStartedAt) : 0; const elapsed = active ? Math.max(0, now - v.clock.turnStartedAt) : 0;
const main = v.clock.mainMs[side] - elapsed; const main = v.clock.mainMs[side] - elapsed;

View File

@@ -298,9 +298,8 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView;
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span> </span>
); );
} else if (active) { } else if (active && tc.kind === 'perMove') {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : 300_000; clock = <TurnTimer deadline={deadline} totalMs={tc.perMoveSec * 1000} mine={id === me} />;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
} }
return ( return (
<div <div

View File

@@ -414,9 +414,8 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: OthelloVie
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span> </span>
); );
} else if (active) { } else if (active && tc.kind === 'perMove') {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000; clock = <TurnTimer deadline={deadline} totalMs={tc.perMoveSec * 1000} mine={id === me} />;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
} }
const score = v.result ? v.result.displayScore[color] : v.discs[color]; const score = v.result ? v.result.displayScore[color] : v.discs[color];
return ( return (

View File

@@ -571,9 +571,8 @@ function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: Quorid
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')} {Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span> </span>
); );
} else if (active) { } else if (active && tc.kind === 'perMove') {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000; clock = <TurnTimer deadline={deadline} totalMs={tc.perMoveSec * 1000} mine={x.playerId === me} size={40} />;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={x.playerId === me} size={40} />;
} }
const state = const state =
x.status === 'finished' x.status === 'finished'

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test';
import { OptionSync, optionsKey, withLocks } from './option-sync';
const base = { ruleSet: 'korean', timeControl: { kind: 'perMove', perMoveSec: 30 }, undo: '3' };
const a = { ...base, ruleSet: 'free' };
const ab = { ...a, timeControl: { kind: 'none', perMoveSec: 30 } };
describe('OptionSync', () => {
test('in sync: every room update is followed', () => {
const s = new OptionSync();
expect(s.received(base)).toBe(true);
expect(s.received(a)).toBe(true);
});
test('two quick clicks: a stale update and the first echo do not undo the second click', () => {
const s = new OptionSync();
s.sent(base, a);
s.sent(a, ab);
expect(s.received(base)).toBe(false); // someone joined before our first change reached the server
expect(s.received(a)).toBe(false); // echo of the first click
expect(s.received(a)).toBe(false); // another update between the two echoes
expect(s.received(ab)).toBe(true); // caught up
expect(s.received(base)).toBe(true); // later server change (e.g. game switch) is followed again
});
test('a server-side change we did not send (admin lock) is followed', () => {
const s = new OptionSync();
s.sent(base, a);
expect(s.received({ ...base, undo: 'off' })).toBe(true);
});
test('key order does not matter', () => {
expect(optionsKey({ x: 1, y: { b: 2, a: 1 } })).toBe(optionsKey({ y: { a: 1, b: 2 }, x: 1 }));
});
});
test('withLocks keeps locked paths from the current options', () => {
const current = { ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 }, undo: '3' };
const next = { ruleSet: 'korean', timeControl: { kind: 'none', perMoveSec: 45 }, undo: '1' };
expect(withLocks(next, current, ['ruleSet', 'timeControl.kind'])).toEqual({ ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 }, undo: '1' });
expect(withLocks(next, current, [])).toBe(next);
});

View File

@@ -0,0 +1,55 @@
/**
* Lobby settings are sent on every change (no save button). This keeps the host's screen from jumping back
* when a room update that was already on its way (e.g. someone joined) still carries older options.
*/
/** JSON with sorted object keys, so equal options compare equal whatever order their keys were built in. */
export function optionsKey(v: unknown): string {
return JSON.stringify(v, (_k, x: unknown) =>
x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x,
);
}
/** Copies the locked option paths (e.g. `timeControl.kind`) from the room's current options, as the server does. */
export function withLocks<T>(next: T, current: T, locked: string[]): T {
if (!locked.length) return next;
const out = structuredClone(next) as Record<string, unknown>;
for (const path of locked) {
const keys = path.split('.');
let src: unknown = current;
for (const k of keys) src = (src as Record<string, unknown> | undefined)?.[k];
if (src === undefined) continue;
let dst = out;
for (const k of keys.slice(0, -1)) {
if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {};
dst = dst[k] as Record<string, unknown>;
}
dst[keys[keys.length - 1]!] = structuredClone(src);
}
return out as T;
}
/**
* While changes are on their way: [server options before the first change, each sent options...]; empty when in sync.
*/
export class OptionSync {
private history: string[] = [];
/** Call before sending `sent`; `server` is the room's options as last received. */
sent(server: unknown, sent: unknown): void {
if (!this.history.length) this.history = [optionsKey(server)];
this.history.push(optionsKey(sent));
}
/** A room update arrived. True: show the server's options. False: it is older than our latest change, keep ours. */
received(server: unknown): boolean {
const i = this.history.lastIndexOf(optionsKey(server));
if (i >= 0 && i < this.history.length - 1) {
this.history = this.history.slice(i);
return false;
}
// Caught up with our last change, or changed on the server (admin, game switch): follow the server.
this.history = [];
return true;
}
}

View File

@@ -78,14 +78,10 @@ export const api = {
myGames: (before?: string | null) => myGames: (before?: string | null) =>
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`), call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`), replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), createRoom: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId }),
/** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */ /** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */
practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }), practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }),
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`), room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),
publicRooms: (gameId: string) =>
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
}; };
export { call as apiCall }; export { call as apiCall };

View File

@@ -1,7 +1,7 @@
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */ /** 게임 소개 + 방 만들기 / 컴퓨터와 연습 / 규칙 (docs/06 §2, §10). */
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router'; import { Link, useNavigate, useParams } from 'react-router';
import { botsAllowed, catalogById, formatRoomCode } from '@bg/shared/lite'; import { botsAllowed, catalogById } from '@bg/shared/lite';
import { Sheet, TopBar } from '../components/ui'; import { Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { api } from '../net/api'; import { api } from '../net/api';
@@ -15,18 +15,9 @@ export default function GamePage() {
const g = catalog.find((c) => c.id === gameId) ?? catalogById(gameId); const g = catalog.find((c) => c.id === gameId) ?? catalogById(gameId);
const nav = useNavigate(); const nav = useNavigate();
const me = useSession((s) => s.me); const me = useSession((s) => s.me);
const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]);
const [needName, setNeedName] = useState<null | (() => void)>(null); const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
useEffect(() => {
if (!g) return;
const load = () => api.publicRooms(gameId).then((r) => setRooms(r.rooms)).catch(() => {});
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [gameId]); // eslint-disable-line react-hooks/exhaustive-deps
if (!g) return <main className="page">없는 게임이에요.</main>; if (!g) return <main className="page">없는 게임이에요.</main>;
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn)); const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const run = (p: () => Promise<{ code: string }>) => const run = (p: () => Promise<{ code: string }>) =>
@@ -61,9 +52,6 @@ export default function GamePage() {
<button className="btn btn-primary btn-block" style={{ minHeight: 64 }} disabled={busy} onClick={() => run(() => api.createRoom(g.id))}> <button className="btn btn-primary btn-block" style={{ minHeight: 64 }} disabled={busy} onClick={() => run(() => api.createRoom(g.id))}>
+ 친구랑 하기 (방 만들기) + 친구랑 하기 (방 만들기)
</button> </button>
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
⚡ 아무나와 빠른 시작
</button>
{botsAllowed(g.id) && ( {botsAllowed(g.id) && (
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.practice(g.id))}> <button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.practice(g.id))}>
🤖 컴퓨터와 연습하기 🤖 컴퓨터와 연습하기
@@ -73,23 +61,6 @@ export default function GamePage() {
📖 규칙 보기 📖 규칙 보기
</Link> </Link>
</div> </div>
<section className="stack">
<h2>기다리는 방</h2>
{rooms.length === 0 && <p className="muted">지금 열린 공개 방이 없어요. [빠른 시작]을 누르면 새 방이 열려요.</p>}
{rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between' }}>
<div>
<strong>{r.host}님의 방</strong>
<div className="muted small">
{r.seated}/{r.max}명 · 코드 {formatRoomCode(r.code)}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div>
))}
</section>
{needName && ( {needName && (
<Sheet onClose={() => setNeedName(null)} label="이름 정하기"> <Sheet onClose={() => setNeedName(null)} label="이름 정하기">
<h2>먼저 이름을 정해 주세요</h2> <h2>먼저 이름을 정해 주세요</h2>

View File

@@ -1,12 +1,11 @@
/** 홈 (docs/07-ui-ux.md §3.3): 큰 버튼 3개(방 만들기·코드로 참여·연습) + [게임 고르기 | 지금 열린 방] 탭. */ /** 홈 (docs/07-ui-ux.md §3.3): 큰 버튼 3개(방 만들기·코드로 참여·연습) + 게임 고르기. */
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite'; import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite';
import { Sheet } from '../components/ui'; import { Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { GameGrid } from '../components/GameGrid'; import { GameGrid } from '../components/GameGrid';
import { InstallCard } from '../components/InstallCard'; import { InstallCard } from '../components/InstallCard';
import { gameName } from '../components/GameNotice';
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 { toast } from '../store/toast';
@@ -19,18 +18,9 @@ export function Home() {
const [picker, setPicker] = useState<null | 'room' | 'practice'>(null); const [picker, setPicker] = useState<null | 'room' | 'practice'>(null);
const [needName, setNeedName] = useState<null | (() => void)>(null); const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
const [tab, setTab] = useState<'games' | 'rooms'>('games');
const available = catalog.filter((g) => g.available); const available = catalog.filter((g) => g.available);
const practiceGames = available.filter((g) => botsAllowed(g.id)); const practiceGames = available.filter((g) => botsAllowed(g.id));
useEffect(() => {
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn)); const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const create = (gameId: string, practice = false) => const create = (gameId: string, practice = false) =>
withName(async () => { withName(async () => {
@@ -80,41 +70,10 @@ export function Home() {
)} )}
</section> </section>
<section className="stack" style={{ gap: 16 }}> <section className="stack" style={{ gap: 16 }} aria-labelledby="home-games">
<div className="home-tabs" role="tablist" aria-label="홈 보기"> <h2 id="home-games">🎮 게임 고르기</h2>
<button role="tab" id="tab-games" aria-controls="panel-games" aria-selected={tab === 'games'} onClick={() => setTab('games')}>
🎮 게임 고르기
</button>
<button role="tab" id="tab-rooms" aria-controls="panel-rooms" aria-selected={tab === 'rooms'} onClick={() => setTab('rooms')}>
🚪 지금 열린 방{rooms.length > 0 && <span className="badge">{rooms.length}</span>}
</button>
</div>
{tab === 'games' ? (
<div role="tabpanel" id="panel-games" aria-labelledby="tab-games" className="stack" style={{ gap: 16 }}>
<GameGrid games={available} /> <GameGrid games={available} />
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>} {UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
</div>
) : (
<div role="tabpanel" id="panel-rooms" aria-labelledby="tab-rooms" className="stack" style={{ gap: 12, maxWidth: 720 }}>
{rooms.length === 0 && <p className="muted card">지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.</p>}
{rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
<span style={{ fontSize: '1.8rem' }} aria-hidden>
{GAME_ICON[r.gameId] ?? '🎲'}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<strong>{gameName(r.gameId)}</strong> <span className="muted small">· {r.host}님의 방</span>
<div className="muted small">
{r.seated}/{r.max}명 · {formatRoomCode(r.code)}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div>
))}
</div>
)}
</section> </section>
<InstallCard /> <InstallCard />

View File

@@ -9,6 +9,7 @@ import { QrModal } from '../components/QrModal';
import { api, type RoomPreview } from '../net/api'; import { api, type RoomPreview } from '../net/api';
import { socket, useRoom } from '../net/socket'; import { socket, useRoom } from '../net/socket';
import { useSession } from '../store/session'; import { useSession } from '../store/session';
import { OptionSync, withLocks } from '../lib/option-sync';
import { loadGameUI } from '../games/registry'; import { loadGameUI } from '../games/registry';
import type { GameUI } from '../games/types'; import type { GameUI } from '../games/types';
import { sounds } from '../lib/feedback'; import { sounds } from '../lib/feedback';
@@ -390,7 +391,6 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
</button> </button>
)} )}
</div> </div>
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>} {practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
{me.kind === 'guest' && !practice && <GuestWarning />} {me.kind === 'guest' && !practice && <GuestWarning />}
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>} {room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
@@ -497,22 +497,22 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl
); );
} }
/** Host settings: game options + room options. Inline on PC, in a sheet on narrow screens. */ /**
* Host settings: game options + room options. Inline on PC, in a sheet on narrow screens.
* Every change is sent right away, so [게임 시작] always uses what is on screen.
*/
function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI; inline?: boolean; onDone?: () => void }) { function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI; inline?: boolean; onDone?: () => void }) {
const [options, setOptions] = useState(room.options); const [options, setOptions] = useState(room.options);
const [visibility, setVisibility] = useState(room.visibility); const sync = useRef(new OptionSync());
const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators); useEffect(() => {
const [chatEnabled, setChatEnabled] = useState(room.chatEnabled); if (sync.current.received(room.options)) setOptions(room.options);
// Follow server-side changes (e.g. admin locks) when not editing. }, [room.options]);
useEffect(() => setOptions(room.options), [room.options]); const changeOptions = (next: typeof options) => {
const dirty = // Admin-locked rules keep the room's value, exactly as the server will (so the echo matches).
JSON.stringify(options) !== JSON.stringify(room.options) || const out = withLocks(next, room.options, room.lockedOptions);
visibility !== room.visibility || sync.current.sent(room.options, out);
allowSpectators !== room.allowSpectators || setOptions(out);
chatEnabled !== room.chatEnabled; socket.send({ t: 'config', options: out });
const save = () => {
socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled });
onDone?.();
}; };
const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => ( const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
<div className="segmented"> <div className="segmented">
@@ -527,37 +527,25 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
return ( return (
<div className="stack" style={{ gap: 14 }}> <div className="stack" style={{ gap: 14 }}>
{room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>} {room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>}
<ui.Options value={options} onChange={setOptions} /> <p className="muted small">바꾸면 바로 적용돼요.</p>
<ui.Options value={options} onChange={changeOptions} />
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} /> <hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(2, minmax(0, 1fr))' : '1fr', gap: 12 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>방 공개</strong>
{room.seats.some((s) => s && isBotId(s.user.id)) ? (
<p className="muted small">컴퓨터가 있는 방은 친구만 들어와요.</p>
) : (
seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')
)}
</div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>관전</strong> <strong>관전</strong>
{seg(allowSpectators, setAllowSpectators, '허용', '안 됨')} {seg(room.allowSpectators, (allowSpectators) => socket.send({ t: 'config', allowSpectators }), '허용', '안 됨')}
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>채팅</strong> <strong>채팅</strong>
{seg(chatEnabled, setChatEnabled, '켜기', '끄기')} {seg(room.chatEnabled, (chatEnabled) => socket.send({ t: 'config', chatEnabled }), '켜기', '끄기')}
</div> </div>
</div> </div>
<div className="row">
<button className="btn btn-primary" style={{ flex: 2 }} disabled={inline && !dirty} onClick={save}>
{inline ? (dirty ? '설정 저장' : '저장됨') : '저장'}
</button>
{!inline && ( {!inline && (
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onDone}> <button className="btn btn-primary btn-block" onClick={onDone}>
취소 닫기
</button> </button>
)} )}
</div> </div>
</div>
); );
} }

View File

@@ -1182,32 +1182,6 @@ button.header-user {
padding: 16px; padding: 16px;
} }
} }
.home-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--border);
}
.home-tabs button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--tap);
padding: 0 18px;
margin-bottom: -2px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: var(--text-sub);
font: inherit;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
}
.home-tabs button[aria-selected='true'] {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Login page (docs/07 §3.2): big Discord button, guest below */ /* Login page (docs/07 §3.2): big Discord button, guest below */
.btn-discord { .btn-discord {
min-height: 68px; min-height: 68px;

View File

@@ -19,7 +19,7 @@
- 게임: 사용자가 지정한 포커(여러 모드), 마작, 오목, 바둑, 체스, 뱅, 섯다(여러 모드) + 한국에서 인기 있는 보드게임. 전체 목록과 순서는 `games/README.md`. - 게임: 사용자가 지정한 포커(여러 모드), 마작, 오목, 바둑, 체스, 뱅, 섯다(여러 모드) + 한국에서 인기 있는 보드게임. 전체 목록과 순서는 `games/README.md`.
- 인원: 각 게임의 원래 최소/최대 인원을 그대로 따른다. - 인원: 각 게임의 원래 최소/최대 인원을 그대로 따른다.
- 계정: 디스코드 로그인 또는 닉네임만 정하는 게스트. 디스코드 로그인 사용자도 닉네임을 바꿀 수 있다. - 계정: 디스코드 로그인 또는 닉네임만 정하는 게스트. 디스코드 로그인 사용자도 닉네임을 바꿀 수 있다.
- 방: 6자리 숫자 코드, 초대 링크, 코드/링크 복사, QR, 모바일 공유 시트, 빠른 시작, 관전, 재접속. - 방: 6자리 숫자 코드, 초대 링크, 코드/링크 복사, QR, 모바일 공유 시트, 관전, 재접속.
- 기기: **PC 우선**(사용자 결정, 2026-10-04). 태블릿·휴대폰도 같은 화면을 접어서 지원. 최신 Chrome/Edge/Safari/Firefox/Samsung Internet. - 기기: **PC 우선**(사용자 결정, 2026-10-04). 태블릿·휴대폰도 같은 화면을 접어서 지원. 최신 Chrome/Edge/Safari/Firefox/Samsung Internet.
## 5. 범위 밖 (Non-goals) ## 5. 범위 밖 (Non-goals)

View File

@@ -9,7 +9,7 @@
| 주소 | 화면 | | 주소 | 화면 |
|---|---| |---|---|
| `/` | 홈: 위쪽에 큰 버튼 두 개 **[방 만들기] [코드로 참여]**, 그 아래 "진행 중인 게임으로 돌아가기"(있을 때만), 그 아래 게임 카드 목록 | | `/` | 홈: 위쪽에 큰 버튼 두 개 **[방 만들기] [코드로 참여]**, 그 아래 "진행 중인 게임으로 돌아가기"(있을 때만), 그 아래 게임 카드 목록 |
| `/g/:gameId` | 게임 소개: 한 줄 설명, 인원, 시간, 난이도, **[방 만들기] [빠른 시작] [규칙 보기]**, 열린 공개 방 목록 | | `/g/:gameId` | 게임 소개: 한 줄 설명, 인원, 시간, 난이도, **[방 만들기] [컴퓨터와 연습하기] [규칙 보기]** |
| `/r/:code` | 방(대기실 ↔ 게임 화면 ↔ 결과 화면이 같은 주소에서 전환) | | `/r/:code` | 방(대기실 ↔ 게임 화면 ↔ 결과 화면이 같은 주소에서 전환) |
| `/j` | 코드 입력 전용 화면(숫자 키패드) | | `/j` | 코드 입력 전용 화면(숫자 키패드) |
| `/me` | 계정 | | `/me` | 계정 |
@@ -26,7 +26,8 @@
## 4. 방 만들기 ## 4. 방 만들기
1. 게임 선택(홈 카드 또는 홈의 [방 만들기] → 게임 고르기 화면). 1. 게임 선택(홈 카드 또는 홈의 [방 만들기] → 게임 고르기 화면).
2. 바로 대기실 생성. 모든 옵션은 **기본값으로 이미 정해져 있다**(인원은 게임 기본 인원, 공개 여부는 "친구만(비공개)"). 2. 바로 대기실 생성. 모든 옵션은 **기본값으로 이미 정해져 있다**(인원은 게임 기본 인원, 공개 여부는 "친구만(비공개)").
3. 대기실에서 방장이 바꿀 수 있는 것: 게임 모드/옵션(시간 제한 포함 — 게임마다 다름), 최대 인원(게임의 최소~최대 범위 안), 공개/비공개, 관전 허용, 채팅 허용. 3. 대기실에서 방장이 바꿀 수 있는 것: 게임 모드/옵션(시간 제한 포함 — 게임마다 다름), 최대 인원(게임의 최소~최대 범위 안), 관전 허용, 채팅 허용. 바꾸는 즉시 서버에 저장된다(저장 버튼 없음). 그래서 [게임 시작]은 언제나 화면에 보이는 설정으로 시작한다.
- 방은 모두 친구만(비공개)이다. 아는 사람끼리만 하므로 공개 설정은 화면에 두지 않는다.
4. 옵션은 "기본 설정" 몇 개만 보이고 나머지는 "자세한 설정" 접기 안에 둔다. 4. 옵션은 "기본 설정" 몇 개만 보이고 나머지는 "자세한 설정" 접기 안에 둔다.
## 5. 대기실 화면 (PC는 두 칸 배치 — `07-ui-ux.md` 3.4. 아래는 내용 순서이며 좁은 화면에서는 이 순서로 한 칸에 쌓인다) ## 5. 대기실 화면 (PC는 두 칸 배치 — `07-ui-ux.md` 3.4. 아래는 내용 순서이며 좁은 화면에서는 이 순서로 한 칸에 쌓인다)
@@ -51,9 +52,8 @@
3. 입장 → 빈자리가 있으면 **자동으로 자리에 앉는다**. 없으면 관전자로 들어가고 "자리가 나면 앉을 수 있어요". 3. 입장 → 빈자리가 있으면 **자동으로 자리에 앉는다**. 없으면 관전자로 들어가고 "자리가 나면 앉을 수 있어요".
4. 방이 없거나 끝났으면: "이 방은 끝났어요" + [같은 게임 새 방 만들기] [홈으로]. 4. 방이 없거나 끝났으면: "이 방은 끝났어요" + [같은 게임 새 방 만들기] [홈으로].
## 7. 빠른 시작 ## 7. 빠른 시작 (화면에서 뺌)
- 게임 상세의 [빠른 시작]: 같은 게임의 공개 대기실 중 사람이 가장 많은(가득 차지 않은) 방에 들어간다. 없으면 공개 방을 새로 만들고 기다린다. - 아는 사람끼리만 하므로 [빠른 시작](모르는 사람 기다리기)은 화면에서 뺐다. 서버의 `POST /api/quick/:gameId`는 남아 있지만 화면에서 부르지 않는다.
- 기다리는 동안 "다른 사람을 기다리는 중… (공개 방)" + [친구 초대하기] 버튼.
## 8. 게임 중 ## 8. 게임 중
- 위쪽 상태 줄: 지금 할 일 문장("내 차례예요" / "OO님 차례예요"), 남은 시간 링. - 위쪽 상태 줄: 지금 할 일 문장("내 차례예요" / "OO님 차례예요"), 남은 시간 링.
@@ -68,9 +68,8 @@
- [한 판 더]는 투표: 자리에 앉은 사람 모두 누르면 같은 설정으로 즉시 새 게임(선/딜러는 규칙에 따라 교대). 안 누르는 사람이 있으면 그 사람이 [관전하기]로 일어나거나 방장이 [게임 시작]으로 바로 시작할 수 있다. - [한 판 더]는 투표: 자리에 앉은 사람 모두 누르면 같은 설정으로 즉시 새 게임(선/딜러는 규칙에 따라 교대). 안 누르는 사람이 있으면 그 사람이 [관전하기]로 일어나거나 방장이 [게임 시작]으로 바로 시작할 수 있다.
- 기록: 끝난 게임은 `game_results`에 저장, 방 안에서 "이번 방 전적"(예: OO 3승 2패) 표시. - 기록: 끝난 게임은 `game_results`에 저장, 방 안에서 "이번 방 전적"(예: OO 3승 2패) 표시.
## 10. 공개 방 목록 ## 10. 공개 방 목록 (화면에서 뺌)
- 게임 상세 화면에서만 보인다(홈을 복잡하게 하지 않음). 정렬: 곧 시작할 방(빈자리 1개) 우선. - 홈의 [지금 열린 방] 탭과 게임 상세의 "기다리는 방" 목록은 뺐다. 서버의 `GET /api/public-rooms`, `GET /api/games/:gameId/rooms`는 남아 있지만 화면에서 부르지 않는다.
- 각 줄: 방 이름(기본 "OO님의 방"), 인원, 모드, [참여].
## 11. 서버 방 모델 ## 11. 서버 방 모델
```ts ```ts

View File

@@ -66,8 +66,7 @@
│ [▶ 하던 게임으로 돌아가기] (있을 때만) │ │ [▶ 하던 게임으로 돌아가기] (있을 때만) │
│ [+ 방 만들기 (파란 큰 버튼)] [🔢 코드로 참여 → /j] [🤖 컴퓨터와 연습] │ │ [+ 방 만들기 (파란 큰 버튼)] [🔢 코드로 참여 → /j] [🤖 컴퓨터와 연습] │
├──────────────────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────────────────┤
│ [🎮 게임 고르기] [🚪 지금 열린 방 (n)] ← 탭, 한 번에 하나만 보임 │ │ 게임 카드 격자 (4~5열, 카드에 인원·시간·난이도) │
│ 게임 카드 격자 (4~5열, 카드에 인원·시간·난이도) 또는 공개 방 목록(게임·인원·참여) │
└──────────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────────┘
``` ```
- 첫 화면에 다 넣지 않는다. 설명 글은 버튼 아래 한 줄만, 나머지는 탭이나 버튼을 눌러 들어간다. - 첫 화면에 다 넣지 않는다. 설명 글은 버튼 아래 한 줄만, 나머지는 탭이나 버튼을 눌러 들어간다.
@@ -157,7 +156,7 @@
- 홈(로그인 전/후, 진행 중 게임 있음) - 홈(로그인 전/후, 진행 중 게임 있음)
- 닉네임 입력(게스트 시작 / 링크 입장) - 닉네임 입력(게스트 시작 / 링크 입장)
- 코드 입력 - 코드 입력
- 게임 상세 + 공개 방 목록 - 게임 상세
- 대기실(방장/참가자/관전자, 인원 부족/가득 참) - 대기실(방장/참가자/관전자, 인원 부족/가득 참)
- 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지) - 게임 화면(내 차례/남의 차례/연결 끊김/자리 비움/일시 정지)
- 결과 화면(승/패/무승부, 한 판 더 투표 중, 처음부터 다시 보기) - 결과 화면(승/패/무승부, 한 판 더 투표 중, 처음부터 다시 보기)

View File

@@ -12,7 +12,7 @@
| [03-realtime-protocol.md](03-realtime-protocol.md) | WebSocket 메시지, 순서 보장, 재접속, 타이머, 제한 | | [03-realtime-protocol.md](03-realtime-protocol.md) | WebSocket 메시지, 순서 보장, 재접속, 타이머, 제한 |
| [04-stability-security.md](04-stability-security.md) | 저장·복구, 장애 격리, 숨김 정보·공정성, 사행성·상표, 웹 보안 | | [04-stability-security.md](04-stability-security.md) | 저장·복구, 장애 격리, 숨김 정보·공정성, 사행성·상표, 웹 보안 |
| [05-accounts-auth.md](05-accounts-auth.md) | 게스트·디스코드 로그인, 닉네임, 세션, 계정 페이지 | | [05-accounts-auth.md](05-accounts-auth.md) | 게스트·디스코드 로그인, 닉네임, 세션, 계정 페이지 |
| [06-lobby-rooms.md](06-lobby-rooms.md) | 방 만들기/참여, 코드·링크·QR·공유, 대기실, 빠른 시작, 한 판 더 | | [06-lobby-rooms.md](06-lobby-rooms.md) | 방 만들기/참여, 코드·링크·QR·공유, 대기실, 한 판 더 |
| [07-ui-ux.md](07-ui-ux.md) | 디자인 원칙, 색·글자 토큰, 화면 틀, 컴포넌트, 접근성, 성능 예산 | | [07-ui-ux.md](07-ui-ux.md) | 디자인 원칙, 색·글자 토큰, 화면 틀, 컴포넌트, 접근성, 성능 예산 |
| [08-data-model.md](08-data-model.md) | SQLite 스키마, 쓰기 시점, 정리, 개인정보 | | [08-data-model.md](08-data-model.md) | SQLite 스키마, 쓰기 시점, 정리, 개인정보 |
| [09-game-engine.md](09-game-engine.md) | 게임 모듈 인터페이스, 결정적 난수, GameRunner, 공통 유틸, 공통 테스트 | | [09-game-engine.md](09-game-engine.md) | 게임 모듈 인터페이스, 결정적 난수, GameRunner, 공통 유틸, 공통 테스트 |

View File

@@ -21,7 +21,6 @@
| `timeControl.periodSec` | 초읽기 1회 시간 | 10 / 20 / 30 / 60 | 30 | | `timeControl.periodSec` | 초읽기 1회 시간 | 10 / 20 / 30 / 60 | 30 |
| `timeControl.periods` | 초읽기 횟수 | 1 ~ 5 | 3 | | `timeControl.periods` | 초읽기 횟수 | 1 ~ 5 | 3 |
| `timeControl.incSec` | `fischer` 수당 추가 | 0 ~ 30 | 10 | | `timeControl.incSec` | `fischer` 수당 추가 | 0 ~ 30 | 10 |
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
| `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` | | `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` |
| `atariWarning` | **관리자 전용** 위협 가이드: 단수에 몰린 내 돌 표시(방장은 못 바꿈) | `true` / `false` | `false` | | `atariWarning` | **관리자 전용** 위협 가이드: 단수에 몰린 내 돌 표시(방장은 못 바꿈) | `true` / `false` | `false` |
@@ -202,7 +201,7 @@ interface BadukState {
- `deadline(state)`: - `deadline(state)`:
- `playing` + `byoyomi`: `turnStartedAt + mainMs[turn] + periodsLeft[turn] * periodSec*1000` (현재 회차를 다 쓰면 다음 회차가 이어지므로 이 합이 최종 시간패 시각) - `playing` + `byoyomi`: `turnStartedAt + mainMs[turn] + periodsLeft[turn] * periodSec*1000` (현재 회차를 다 쓰면 다음 회차가 이어지므로 이 합이 최종 시간패 시각)
- `playing` + `fischer`: `turnStartedAt + mainMs[turn]` - `playing` + `fischer`: `turnStartedAt + mainMs[turn]`
- `playing` + `none`: `turnStartedAt + idleLimitSec*1000` - `playing` + `none`: `null` — 시간 제한 없음은 정말 마감이 없다(무응답 시간패 없음). 연결이 끊긴 사람만 방의 접속 유예(`graceSec`) 뒤에 처리한다.
- `scoring`: `scoringMarks.startedAt + scoringDeadlineSec*1000` - `scoring`: `scoringMarks.startedAt + scoringDeadlineSec*1000`
- `finished`: `null` - `finished`: `null`
- `activePlayers`: `playing`이면 차례인 1명. `scoring`이면 아직 `agreed`하지 않은 플레이어 전원(동시 행동 단계). - `activePlayers`: `playing`이면 차례인 1명. `scoring`이면 아직 `agreed`하지 않은 플레이어 전원(동시 행동 단계).

View File

@@ -18,8 +18,7 @@
| `autoQueen` | 승진 시 퀸 자동 선택(설정 끄면 선택 창) | `true` / `false` | `false` (선택 창, 퀸이 첫 번째 큰 버튼) | | `autoQueen` | 승진 시 퀸 자동 선택(설정 끄면 선택 창) | `true` / `false` | `false` (선택 창, 퀸이 첫 번째 큰 버튼) |
| `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `1` | | `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `1` |
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` | | `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `firstMoveSec` | 첫 수 제한(넘기면 대국 취소) | 15 / 30 / 60 | 30 | | `firstMoveSec` | 첫 수 제한(넘기면 대국 취소, 피셔일 때만) | 15 / 30 / 60 | 30 |
| `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 300 / 600 | 600 |
| `hints` | 초보 도움말(이동 가능 칸 표시) | `off` / `moves` | `moves` | | `hints` | 초보 도움말(이동 가능 칸 표시) | `off` / `moves` | `moves` |
| `threatHints` | **관리자 전용** 위협 가이드: 공격받고 방어 안 된 내 기물 표시(방장은 못 바꿈) | `true` / `false` | `false` | | `threatHints` | **관리자 전용** 위협 가이드: 공격받고 방어 안 된 내 기물 표시(방장은 못 바꿈) | `true` / `false` | `false` |
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 | | `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
@@ -154,9 +153,9 @@ interface ChessState {
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동 ### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- 시계 시작: 각 색의 첫 수는 `firstMoveSec` 제한만 있고 본 시계를 차감하지 않는다(lichess 방식). 백이 첫 수를 두면 흑의 첫 수 제한이 시작되고, 흑이 첫 수를 두면 백의 본 시계가 시작된다. - 시계 시작: 각 색의 첫 수는 `firstMoveSec` 제한만 있고 본 시계를 차감하지 않는다(lichess 방식). 백이 첫 수를 두면 흑의 첫 수 제한이 시작되고, 흑이 첫 수를 두면 백의 본 시계가 시작된다.
- `deadline(state)`: - `deadline(state)`:
- 해당 색 첫 수 전: `turnStartedAt + firstMoveSec*1000` - `none`: `null` — 시간 제한 없음은 정말 마감이 없다(무응답 시간패 없음). 연결이 끊긴 사람만 방의 접속 유예(`graceSec`) 뒤에 처리한다. 첫 수 제한도 없다.
- 피셔, 해당 색 첫 수 전: `turnStartedAt + firstMoveSec*1000`
- 피셔: `turnStartedAt + remainingMs[turn]` - 피셔: `turnStartedAt + remainingMs[turn]`
- `none`: `turnStartedAt + idleLimitSec*1000`
- 종료: `null` - 종료: `null`
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`: - `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`:
- 첫 수 제한 초과 → `aborted`(승패 없음). - 첫 수 제한 초과 → `aborted`(승패 없음).

View File

@@ -20,7 +20,6 @@
| `moveLimit` | 수 제한(도달 시 점수 판정) | `0`(없음) / 150 / 200 / 250 (양쪽 합산 수) | `0` | | `moveLimit` | 수 제한(도달 시 점수 판정) | `0`(없음) / 150 / 200 / 250 (양쪽 합산 수) | `0` |
| `timeControl.kind` | 시간 방식 | `none` / `byoyomi` | `byoyomi` | | `timeControl.kind` | 시간 방식 | `none` / `byoyomi` | `byoyomi` |
| `timeControl.mainSec` / `periodSec` / `periods` | 기본 시간 / 초읽기 1회 / 횟수 | 5분·10분·20분 / 20·30·60초 / 1~5회 | 10분 / 30초 / 3회 | | `timeControl.mainSec` / `periodSec` / `periods` | 기본 시간 / 초읽기 1회 / 횟수 | 5분·10분·20분 / 20·30·60초 / 1~5회 | 10분 / 30초 / 3회 |
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
| `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` | | `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `hints` | 이동 가능 칸 표시 | `off` / `moves` | `moves` | | `hints` | 이동 가능 칸 표시 | `off` / `moves` | `moves` |
@@ -193,7 +192,7 @@ interface JanggiState {
- `deadline(state)`: - `deadline(state)`:
- `setup`: 단계 시작 + 60초(선택해야 할 사람 기준). - `setup`: 단계 시작 + 60초(선택해야 할 사람 기준).
- `playing` + `byoyomi`: `turnStartedAt + mainMs[turn] + periodsLeft[turn]*periodSec*1000`. - `playing` + `byoyomi`: `turnStartedAt + mainMs[turn] + periodsLeft[turn]*periodSec*1000`.
- `playing` + `none`: `turnStartedAt + idleLimitSec*1000`. - `playing` + `none`: `null` — 시간 제한 없음은 정말 마감이 없다(무응답 시간패 없음). 연결이 끊긴 사람만 방의 접속 유예(`graceSec`) 뒤에 처리한다.
- 종료: `null`. - 종료: `null`.
- `activePlayers`: `setup`에서 `hanFirst`면 순서에 맞는 1명, `simultaneous`면 아직 선택 안 한 전원. `playing`이면 차례인 1명. - `activePlayers`: `setup`에서 `hanFirst`면 순서에 맞는 1명, `simultaneous`면 아직 선택 안 한 전원. `playing`이면 차례인 1명.
- `onTimeout(state, player)`: - `onTimeout(state, player)`:

View File

@@ -20,7 +20,6 @@
| `timeControl.perMoveSec` | 한 수당 제한 (`perMove`) | 10 / 20 / 30 / 60 | 30 | | `timeControl.perMoveSec` | 한 수당 제한 (`perMove`) | 10 / 20 / 30 / 60 | 30 |
| `timeControl.baseSec` / `incSec` | 전체 시간 / 수당 추가 (`fischer`) | 기본 3분+2초, 5분+3초, 10분+5초 프리셋 | 5분+3초 | | `timeControl.baseSec` / `incSec` | 전체 시간 / 수당 추가 (`fischer`) | 기본 3분+2초, 5분+3초, 10분+5초 프리셋 | 5분+3초 |
| `timeoutPolicy` | `perMove`에서 시간 초과 시 처리 | `autoMove`(무작위 합법 수 자동 착수) / `lose`(시간패) | `autoMove` | | `timeoutPolicy` | `perMove`에서 시간 초과 시 처리 | `autoMove`(무작위 합법 수 자동 착수) / `lose`(시간패) | `autoMove` |
| `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 120 / 300 / 600 | 300 |
| `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` | | `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` |
| `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` | | `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` |
@@ -266,10 +265,10 @@ interface OmokState {
- `deadline(state)`: - `deadline(state)`:
- `perMove`: `turnStartedAt + perMoveSec*1000` - `perMove`: `turnStartedAt + perMoveSec*1000`
- `fischer`: `turnStartedAt + remainingMs[turn]` - `fischer`: `turnStartedAt + remainingMs[turn]`
- `none`: `turnStartedAt + idleLimitSec*1000` - `none`: `null` — 시간 제한 없음은 정말 마감이 없다(무응답 시간패 없음). 연결이 끊긴 사람만 방의 접속 유예(`graceSec`) 뒤에 처리한다.
- `finished`: `null` - `finished`: `null`
- `onTimeout(state, player)`는 항상 `{ type: 'timeout' }`을 반환하고, `apply`가 정책을 적용한다: - `onTimeout(state, player)`는 항상 `{ type: 'timeout' }`을 반환하고, `apply`가 정책을 적용한다:
- `fischer` 또는 `none`의 무응답 한도 초과, `perMove + lose`: 해당 플레이어 시간패. - `fischer` 시간 다 씀, `perMove + lose`: 해당 플레이어 시간패.
- `perMove + autoMove`: 8.4 규칙으로 자동 착수(`moves[].auto = true`), `autoMoveStreak +1`. 자동 착수 2번 뒤 세 번째로 연속 시간 초과하면 시간패. 정상 착수 시 streak 0으로 초기화. - `perMove + autoMove`: 8.4 규칙으로 자동 착수(`moves[].auto = true`), `autoMoveStreak +1`. 자동 착수 2번 뒤 세 번째로 연속 시간 초과하면 시간패. 정상 착수 시 streak 0으로 초기화.
- 서버는 연결 끊긴 플레이어의 차례에 `min(deadline, 끊긴 시각 + 방의 graceSec)`에 `onTimeout`을 호출한다(유예 시간은 방 설정, 기본 60초). 끊긴 플레이어의 차례가 아닐 때는 아무것도 하지 않고, 차례가 오면 위 규칙을 따른다. - 서버는 연결 끊긴 플레이어의 차례에 `min(deadline, 끊긴 시각 + 방의 graceSec)`에 `onTimeout`을 호출한다(유예 시간은 방 설정, 기본 60초). 끊긴 플레이어의 차례가 아닐 때는 아무것도 하지 않고, 차례가 오면 위 규칙을 따른다.
- 시간 초과 직전에 도착한 `place`는 서버 도착 시각(`ctx.now`) 기준으로 판정한다. - 시간 초과 직전에 도착한 `place`는 서버 도착 시각(`ctx.now`) 기준으로 판정한다.

View File

@@ -18,7 +18,6 @@
| `timeControl.perMoveSec` | 한 수당 제한 | 10 / 20 / 30 / 60 | 30 | | `timeControl.perMoveSec` | 한 수당 제한 | 10 / 20 / 30 / 60 | 30 |
| `timeControl.baseSec` / `incSec` | 전체 시간 / 수당 추가 | 3+2, 5+3, 10+5 | 5+3 | | `timeControl.baseSec` / `incSec` | 전체 시간 / 수당 추가 | 3+2, 5+3, 10+5 | 5+3 |
| `timeoutPolicy` | `perMove` 시간 초과 처리 | `autoMove`(무작위 합법 수) / `lose` | `autoMove` | | `timeoutPolicy` | `perMove` 시간 초과 처리 | `autoMove`(무작위 합법 수) / `lose` | `autoMove` |
| `idleLimitSec` | `none` 무응답 한도 | 120 / 300 / 600 | 300 |
| `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` | | `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `showLegalMoves` | 둘 수 있는 칸 표시 | `true` / `false` | `true` | | `showLegalMoves` | 둘 수 있는 칸 표시 | `true` / `false` | `true` |
@@ -102,7 +101,7 @@ interface OthelloState {
2. `timeoutPolicy=autoMove`의 자동 착수: 모서리(a1, h1, a8, h8)가 합법이면 그중 무작위, 아니면 전체 합법 수 중 균등 무작위. 모서리 우선은 시간 초과 당한 쪽이 지나치게 불리해지지 않게 하는 최소한의 배려. 2. `timeoutPolicy=autoMove`의 자동 착수: 모서리(a1, h1, a8, h8)가 합법이면 그중 무작위, 아니면 전체 합법 수 중 균등 무작위. 모서리 우선은 시간 초과 당한 쪽이 지나치게 불리해지지 않게 하는 최소한의 배려.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동 ### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)`: `perMove` → `turnStartedAt + perMoveSec*1000`, `fischer` → `turnStartedAt + remainingMs[turn]`, `none` → `turnStartedAt + idleLimitSec*1000`, 종료 → `null`. - `deadline(state)`: `perMove` → `turnStartedAt + perMoveSec*1000`, `fischer` → `turnStartedAt + remainingMs[turn]`, `none` → `null`(마감 없음, 무응답 시간패 없음), 종료 → `null`.
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`: - `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`:
- `perMove + autoMove`: 자동 착수(`auto: true`), streak +1, 3연속이면 시간패. - `perMove + autoMove`: 자동 착수(`auto: true`), streak +1, 3연속이면 시간패.
- 그 외: 시간패. - 그 외: 시간패.

View File

@@ -19,7 +19,6 @@
| `timeControl.perMoveSec` | 한 수당 제한 | 15 / 30 / 60 | 30 | | `timeControl.perMoveSec` | 한 수당 제한 | 15 / 30 / 60 | 30 |
| `timeControl.baseSec` / `incSec` | 전체 / 수당 추가 | 3+2, 5+3 | 5+3 | | `timeControl.baseSec` / `incSec` | 전체 / 수당 추가 | 3+2, 5+3 | 5+3 |
| `timeoutPolicy` | `perMove` 시간 초과 처리 | `autoMove`(최단 경로로 말 1칸 이동) / `lose` | `autoMove` | | `timeoutPolicy` | `perMove` 시간 초과 처리 | `autoMove`(최단 경로로 말 1칸 이동) / `lose` | `autoMove` |
| `idleLimitSec` | `none` 무응답 한도 | 120 / 300 | 300 |
| `undo` | 무르기 허용 횟수(다른 모든 플레이어 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 허용 횟수(다른 모든 플레이어 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안(남은 모든 플레이어 동의) | `true` / `false` | `true` | | `drawOffer` | 무승부 제안(남은 모든 플레이어 동의) | `true` / `false` | `true` |
| `showPath` | 내 최단 경로 표시(힌트) | `true` / `false` | `true` | | `showPath` | 내 최단 경로 표시(힌트) | `true` / `false` | `true` |
@@ -155,7 +154,7 @@ interface QuoridorState {
2. `autoMove`에서 최단 경로의 첫 칸 후보가 여럿일 때 선택. 2. `autoMove`에서 최단 경로의 첫 칸 후보가 여럿일 때 선택.
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동 ### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `deadline(state)`: `perMove` → `turnStartedAt + perMoveSec*1000`, `fischer` → `turnStartedAt + remainingMs[seat]`, `none` → `turnStartedAt + idleLimitSec*1000`, 종료 → `null`. - `deadline(state)`: `perMove` → `turnStartedAt + perMoveSec*1000`, `fischer` → `turnStartedAt + remainingMs[seat]`, `none` → `null`(마감 없음, 무응답 시간패 없음), 종료 → `null`.
- `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`: - `onTimeout(state, player)` → `{ type: 'timeout' }`. `apply`:
- `perMove + autoMove`: 이동 가능 칸 중 "목표까지 BFS 거리(말 무시)가 가장 짧아지는 칸"으로 이동(점프 포함). 동률은 RNG. 이동 불가면 자동 패스. streak 3회면 시간패. - `perMove + autoMove`: 이동 가능 칸 중 "목표까지 BFS 거리(말 무시)가 가장 짧아지는 칸"으로 이동(점프 포함). 동률은 RNG. 이동 불가면 자동 패스. streak 3회면 시간패.
- 그 외: 시간패. 2인은 상대 승. 4인은 기권과 같은 처리(말 제거, `timedOut`). - 그 외: 시간패. 2인은 상대 승. 4인은 기권과 같은 처리(말 제거, `timedOut`).

View File

@@ -126,9 +126,11 @@ try {
// Trying to switch to 한국식 is overridden by the server. // Trying to switch to 한국식 is overridden by the server.
await g.getByRole('button', { name: '설정 바꾸기' }).click(); await g.getByRole('button', { name: '설정 바꾸기' }).click();
if (await g.getByText(/위험한 자리 알려|위협 가이드/).count()) throw new Error('host sees a threat-guide switch'); if (await g.getByText(/위험한 자리 알려|위협 가이드/).count()) throw new Error('host sees a threat-guide switch');
// Changes apply at once; the server keeps the locked rule and the sheet follows it.
await g.getByRole('button', { name: '한국식(쌍삼 금지)' }).click(); await g.getByRole('button', { name: '한국식(쌍삼 금지)' }).click();
await g.getByRole('button', { name: '저장' }).click();
await g.waitForTimeout(300); await g.waitForTimeout(300);
if ((await g.getByRole('button', { name: '렌주룰' }).getAttribute('aria-pressed')) !== 'true') throw new Error('locked rule not restored in the sheet');
await g.getByRole('button', { name: '닫기' }).click();
await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor(); await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor();
await g.goto(ORIGIN + '/admin'); await g.goto(ORIGIN + '/admin');
await g.getByText('권한이 없어요').waitFor(); await g.getByText('권한이 없어요').waitFor();

View File

@@ -79,8 +79,7 @@ try {
await step('오목: 상세 → 컴퓨터와 연습하기 → 대기실', async () => { await step('오목: 상세 → 컴퓨터와 연습하기 → 대기실', async () => {
await startPractice(a, 'omok', '호랑이'); await startPractice(a, 'omok', '호랑이');
await a.getByText('🤖 컴퓨터').first().waitFor(); await a.getByText('🤖 컴퓨터').first().waitFor();
// The host is seated first, the computer next; no public toggle in a practice room. await a.getByText('🤖 연습 게임이에요. 기록에는 남지 않아요.').waitFor();
await a.getByText('컴퓨터가 있는 방은 친구만 들어와요.').waitFor();
await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') }); await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') });
}); });

View File

@@ -839,7 +839,6 @@ const PLANS: Record<string, Plan> = {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '첫 등록 최소 점수' }).getByRole('button', { name: '없음' }).click(); await host.getByRole('group', { name: '첫 등록 최소 점수' }).getByRole('button', { name: '없음' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText('첫 등록 점수 없음').first().waitFor(); await host.getByText('첫 등록 점수 없음').first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -851,7 +850,6 @@ const PLANS: Record<string, Plan> = {
// 말 2개로 줄여서 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 끝까지 둔다(중간 화면도 저장). // 말 2개로 줄여서 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 끝까지 둔다(중간 화면도 저장).
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('button', { name: '2개', exact: true }).click(); await host.getByRole('button', { name: '2개', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/말 2개/).first().waitFor(); await host.getByText(/말 2개/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1062,7 +1060,6 @@ const PLANS: Record<string, Plan> = {
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('button', { name: '체스960 (첫 줄 무작위)' }).click(); await host.getByRole('button', { name: '체스960 (첫 줄 무작위)' }).click();
await host.getByRole('button', { name: '방장이 백', exact: true }).click(); await host.getByRole('button', { name: '방장이 백', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/체스960 · /).first().waitFor(); await host.getByText(/체스960 · /).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1211,7 +1208,6 @@ const PLANS: Record<string, Plan> = {
// 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows. // 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows.
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click(); await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^1판 · /).first().waitFor(); await host.getByText(/^1판 · /).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1263,7 +1259,6 @@ const PLANS: Record<string, Plan> = {
players: 4, players: 4,
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click(); await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/반응 25초/).first().waitFor(); await host.getByText(/반응 25초/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1370,7 +1365,6 @@ const PLANS: Record<string, Plan> = {
players: 3, players: 3,
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초', exact: true }).click(); await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/뒤집기 5초/).first().waitFor(); await host.getByText(/뒤집기 5초/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1455,7 +1449,6 @@ const PLANS: Record<string, Plan> = {
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click(); await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click();
await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click(); await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^15라운드 · 시작 200P/).first().waitFor(); await host.getByText(/^15라운드 · 시작 200P/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1510,7 +1503,6 @@ const PLANS: Record<string, Plan> = {
await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click(); await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click();
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click(); await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/9~12 빼기/).first().waitFor(); await host.getByText(/9~12 빼기/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1572,7 +1564,6 @@ const PLANS: Record<string, Plan> = {
// return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark. // return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark.
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click(); await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^10점 · 한 차례 60초/).first().waitFor(); await host.getByText(/^10점 · 한 차례 60초/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1691,7 +1682,6 @@ const PLANS: Record<string, Plan> = {
// through the UI until round settlements (with a duplicate-tie cancellation) and the final result appear. // through the UI until round settlements (with a duplicate-tie cancellation) and the final result appear.
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '3라운드' }).click(); await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '3라운드' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^3라운드 · 흰 주사위 자동/).first().waitFor(); await host.getByText(/^3라운드 · 흰 주사위 자동/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1744,7 +1734,6 @@ const PLANS: Record<string, Plan> = {
players: 3, players: 3,
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '같은 벌레 몇 장이면 패배' }).getByRole('button', { name: '3장(짧게)' }).click(); await host.getByRole('group', { name: '같은 벌레 몇 장이면 패배' }).getByRole('button', { name: '3장(짧게)' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/같은 벌레 3장이면 패배/).first().waitFor(); await host.getByText(/같은 벌레 3장이면 패배/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1803,7 +1792,6 @@ const PLANS: Record<string, Plan> = {
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click(); await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click();
await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click(); await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^전용 카드 · 손패 5장 · 목숨 없음 · 30초/).first().waitFor(); await host.getByText(/^전용 카드 · 손패 5장 · 목숨 없음 · 30초/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1855,7 +1843,6 @@ const PLANS: Record<string, Plan> = {
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '카드 장수' }).getByRole('button', { name: '55장' }).click(); await host.getByRole('group', { name: '카드 장수' }).getByRole('button', { name: '55장' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^탑 쌓기 · 55장 · 틀리면 1초 잠금/).first().waitFor(); await host.getByText(/^탑 쌓기 · 55장 · 틀리면 1초 잠금/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1916,7 +1903,6 @@ const PLANS: Record<string, Plan> = {
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '1라운드' }).click(); await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '1라운드' }).click();
await host.getByRole('group', { name: '그리기 시간' }).getByRole('button', { name: '60초' }).click(); await host.getByRole('group', { name: '그리기 시간' }).getByRole('button', { name: '60초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^여러 명 맞히기 · 1라운드 · 60초/).first().waitFor(); await host.getByText(/^여러 명 맞히기 · 1라운드 · 60초/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
@@ -1951,7 +1937,6 @@ const PLANS: Record<string, Plan> = {
await host.getByRole('group', { name: '지목 투표 시간' }).getByRole('button', { name: '10초', exact: true }).click(); await host.getByRole('group', { name: '지목 투표 시간' }).getByRole('button', { name: '10초', exact: true }).click();
await host.getByRole('group', { name: '최후의 반론 시간' }).getByRole('button', { name: '10초', exact: true }).click(); await host.getByRole('group', { name: '최후의 반론 시간' }).getByRole('button', { name: '10초', exact: true }).click();
await host.getByRole('group', { name: '찬반 투표 시간' }).getByRole('button', { name: '5초', exact: true }).click(); await host.getByRole('group', { name: '찬반 투표 시간' }).getByRole('button', { name: '5초', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/밤 45초 · 투표 10초/).first().waitFor(); await host.getByText(/밤 45초 · 투표 10초/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {

View File

@@ -88,17 +88,20 @@ try {
await b.screenshot({ path: join(SHOTS, '5-lobby-phone.png') }); await b.screenshot({ path: join(SHOTS, '5-lobby-phone.png') });
}); });
await step('A: 설정(방장 흑, 자유룰) → 시작', async () => { await step('A: 설정(방장 흑, 자유룰, 시간 제한 없음) → 바로 시작', async () => {
// PC: the host edits settings inline in the lobby. // PC: the host edits settings inline in the lobby. Every change applies at once (no save button).
await a.getByRole('button', { name: '자유룰' }).click(); await a.getByRole('button', { name: '자유룰' }).click();
await a.getByRole('button', { name: '방장이 흑' }).click(); await a.getByRole('button', { name: '방장이 흑' }).click();
await a.getByRole('button', { name: '설정 저장' }).click(); await a.getByRole('button', { name: '시간 제한 없음' }).click();
await a.getByText('자유룰 · 15줄').waitFor(); await a.getByText('자유룰 · 15줄 · 시간 제한 없음').waitFor();
await b.getByText('자유룰 · 15줄 · 시간 제한 없음').waitFor();
await a.screenshot({ path: join(SHOTS, '1-lobby-pc.png') }); await a.screenshot({ path: join(SHOTS, '1-lobby-pc.png') });
await a.getByRole('button', { name: '▶ 게임 시작' }).click(); await a.getByRole('button', { name: '▶ 게임 시작' }).click();
await a.getByRole('button', { name: '시작하기' }).click(); await a.getByRole('button', { name: '시작하기' }).click();
await a.getByText('내 차례예요').waitFor(); await a.getByText('내 차례예요').waitFor();
await b.getByText('호랑이님이 생각 중이에요').waitFor(); await b.getByText('호랑이님이 생각 중이에요').waitFor();
// 시간 제한 없음: no countdown anywhere.
for (const p of [a, b]) if ((await p.getByRole('timer').count()) !== 0) throw new Error('시간 제한 없음인데 타이머가 보여요');
}); });
await step('대국: 흑 가로 5목', async () => { await step('대국: 흑 가로 5목', async () => {

View File

@@ -96,7 +96,6 @@ try {
await a.getByLabel('자리').getByText('고양이').waitFor(); await a.getByLabel('자리').getByText('고양이').waitFor();
await a.getByRole('button', { name: '자유룰' }).click(); await a.getByRole('button', { name: '자유룰' }).click();
await a.getByRole('button', { name: '방장이 흑' }).click(); await a.getByRole('button', { name: '방장이 흑' }).click();
await a.getByRole('button', { name: '설정 저장' }).click();
await a.getByText('자유룰 · 15줄').waitFor(); await a.getByText('자유룰 · 15줄').waitFor();
await a.getByRole('button', { name: '▶ 게임 시작' }).click(); await a.getByRole('button', { name: '▶ 게임 시작' }).click();
await a.getByRole('button', { name: '시작하기' }).click(); await a.getByRole('button', { name: '시작하기' }).click();

View File

@@ -417,9 +417,12 @@ describe('clocks (§8.2, §8.5)', () => {
expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
}); });
test('none: idle limit deadline', () => { test('none: no deadline, and a late move is not a time loss', () => {
const s = make({ timeControl: { ...byo, kind: 'none' }, idleLimitSec: 300 }); const s = make({ timeControl: { ...byo, kind: 'none' } });
expect(baduk.deadline!(s)).toBe(300_000); expect(baduk.deadline!(s)).toBeNull();
const late = play(s, 'D4', 3_600_000);
expect(late.phase).toBe('playing');
expect(baduk.deadline!(late)).toBeNull();
}); });
test('auto main time per board size', () => { test('auto main time per board size', () => {

View File

@@ -69,10 +69,6 @@ export const badukOptionsSchema = z
}) })
.default({ kind: 'byoyomi', mainSecAuto: true, mainSec: 1200, periodSec: 30, periods: 3, incSec: 10 }) .default({ kind: 'byoyomi', mainSecAuto: true, mainSec: 1200, periodSec: 30, periods: 3, incSec: 10 })
.meta({ title: '시간 제한' }), .meta({ title: '시간 제한' }),
idleLimitSec: z
.union([z.literal(300), z.literal(600)])
.default(600)
.meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }),
undo: z undo: z
.enum(['off', '1', '3', 'unlimited']) .enum(['off', '1', '3', 'unlimited'])
.default('3') .default('3')
@@ -190,7 +186,6 @@ export interface BadukView {
periodSec: number; periodSec: number;
periods: number; periods: number;
incSec: number; incSec: number;
idleLimitSec: number;
mainMs: Record<Color, number>; mainMs: Record<Color, number>;
periodsLeft: Record<Color, number>; periodsLeft: Record<Color, number>;
inByoyomi: Record<Color, boolean>; inByoyomi: Record<Color, boolean>;
@@ -381,7 +376,7 @@ function chargeClock(s: BadukState, me: Color, now: number): boolean {
s.clock.mainMs[me] = left + tc.incSec * 1000; s.clock.mainMs[me] = left + tc.incSec * 1000;
return true; return true;
} }
return elapsed <= s.options.idleLimitSec * 1000; return true; // 시간 제한 없음
} }
function marginKo(m: number, method: 'territory' | 'area'): string { function marginKo(m: number, method: 'territory' | 'area'): string {
@@ -772,7 +767,6 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
periodSec: tc.periodSec, periodSec: tc.periodSec,
periods: tc.periods, periods: tc.periods,
incSec: tc.incSec, incSec: tc.incSec,
idleLimitSec: s.options.idleLimitSec,
mainMs: s.clock.mainMs, mainMs: s.clock.mainMs,
periodsLeft: s.clock.periodsLeft, periodsLeft: s.clock.periodsLeft,
inByoyomi: s.clock.inByoyomi, inByoyomi: s.clock.inByoyomi,
@@ -819,7 +813,7 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
const start = s.clock.turnStartedAt; const start = s.clock.turnStartedAt;
if (tc.kind === 'byoyomi') return start + s.clock.mainMs[s.turn] + s.clock.periodsLeft[s.turn] * tc.periodSec * 1000; if (tc.kind === 'byoyomi') return start + s.clock.mainMs[s.turn] + s.clock.periodsLeft[s.turn] * tc.periodSec * 1000;
if (tc.kind === 'fischer') return start + s.clock.mainMs[s.turn]; if (tc.kind === 'fischer') return start + s.clock.mainMs[s.turn];
return start + s.options.idleLimitSec * 1000; return null; // 시간 제한 없음: 차례 마감 없음
}, },
timeoutPlayers(s) { timeoutPlayers(s) {

View File

@@ -258,9 +258,12 @@ describe('clocks and time (§8.5)', () => {
expect(chess.validate(s, 'B', a).ok).toBe(false); expect(chess.validate(s, 'B', a).ok).toBe(false);
expect(act(s, 'A', a).result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); expect(act(s, 'A', a).result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
}); });
test('no time control: idle limit deadline', () => { test('no time control: no deadline, and a late first move is not aborted', () => {
const s = make({ timeControl: { kind: 'none', baseMin: 10, incSec: 0 }, idleLimitSec: 300 }); const s = make({ timeControl: { kind: 'none', baseMin: 10, incSec: 0 } });
expect(chess.deadline!(s)).toBe(300_000); expect(chess.deadline!(s)).toBeNull();
const late = play1(s, 'e2e4', 3_600_000);
expect(late.phase).toBe('playing');
expect(chess.deadline!(late)).toBeNull();
}); });
}); });

View File

@@ -68,10 +68,6 @@ export const chessOptionsSchema = z.object({
.union([z.literal(15), z.literal(30), z.literal(60)]) .union([z.literal(15), z.literal(30), z.literal(60)])
.default(30) .default(30)
.meta({ title: '첫 수 제한(넘기면 대국 취소)', labels: { 15: '15초', 30: '30초', 60: '60초' } }), .meta({ title: '첫 수 제한(넘기면 대국 취소)', labels: { 15: '15초', 30: '30초', 60: '60초' } }),
idleLimitSec: z
.union([z.literal(300), z.literal(600)])
.default(600)
.meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }),
hints: z hints: z
.enum(['off', 'moves']) .enum(['off', 'moves'])
.default('moves') .default('moves')
@@ -166,7 +162,6 @@ export interface ChessView {
checkSquare: string | null; checkSquare: string | null;
timeControl: ChessOptions['timeControl']; timeControl: ChessOptions['timeControl'];
firstMoveSec: number; firstMoveSec: number;
idleLimitSec: number;
remainingMs: Record<Color, number>; remainingMs: Record<Color, number>;
turnStartedAt: number; turnStartedAt: number;
started: Record<Color, boolean>; started: Record<Color, boolean>;
@@ -439,7 +434,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
// 1) Clock. A colour's first move only has the first-move limit; its main clock is not charged. // 1) Clock. A colour's first move only has the first-move limit; its main clock is not charged.
const elapsed = now - s.clock.turnStartedAt; const elapsed = now - s.clock.turnStartedAt;
if (!s.clock.started[me]) { if (!s.clock.started[me]) {
if (elapsed > s.options.firstMoveSec * 1000) { if (s.options.timeControl.kind === 'fischer' && elapsed > s.options.firstMoveSec * 1000) {
finish(s, null, 'aborted', events, me); finish(s, null, 'aborted', events, me);
break; break;
} }
@@ -571,7 +566,6 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null, checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null,
timeControl: s.options.timeControl, timeControl: s.options.timeControl,
firstMoveSec: s.options.firstMoveSec, firstMoveSec: s.options.firstMoveSec,
idleLimitSec: s.options.idleLimitSec,
remainingMs: s.clock.remainingMs, remainingMs: s.clock.remainingMs,
turnStartedAt: s.clock.turnStartedAt, turnStartedAt: s.clock.turnStartedAt,
started: s.clock.started, started: s.clock.started,
@@ -607,11 +601,12 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
deadline(s) { deadline(s) {
if (s.phase !== 'playing') return null; if (s.phase !== 'playing') return null;
// 시간 제한 없음: 첫 수 제한도 차례 마감도 없음
if (s.options.timeControl.kind !== 'fischer') return null;
const turn = turnOf(s); const turn = turnOf(s);
const start = s.clock.turnStartedAt; const start = s.clock.turnStartedAt;
if (!s.clock.started[turn]) return start + s.options.firstMoveSec * 1000; if (!s.clock.started[turn]) return start + s.options.firstMoveSec * 1000;
if (s.options.timeControl.kind === 'fischer') return start + s.clock.remainingMs[turn]; return start + s.clock.remainingMs[turn];
return start + s.options.idleLimitSec * 1000;
}, },
timeoutPlayers(s) { timeoutPlayers(s) {

View File

@@ -62,7 +62,6 @@ export const janggiOptionsSchema = z.object({
}) })
.default({ kind: 'byoyomi', mainSec: 600, periodSec: 30, periods: 3 }) .default({ kind: 'byoyomi', mainSec: 600, periodSec: 30, periods: 3 })
.meta({ title: '시간 제한' }), .meta({ title: '시간 제한' }),
idleLimitSec: z.number().int().min(60).max(3600).default(600).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
setupTimeSec: z.number().int().min(10).max(600).default(60).meta({ title: '상차림 고르는 시간(초)' }), setupTimeSec: z.number().int().min(10).max(600).default(60).meta({ title: '상차림 고르는 시간(초)' }),
undo: z undo: z
.enum(['off', '1', '3', 'unlimited']) .enum(['off', '1', '3', 'unlimited'])
@@ -181,7 +180,7 @@ export interface JanggiView {
legalMoves: JanggiLegalMove[] | null; legalMoves: JanggiLegalMove[] | null;
canPass: boolean; canPass: boolean;
threatened: Sq[]; threatened: Sq[];
rules: Pick<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'idleLimitSec' | 'setupTimeSec'>; rules: Pick<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'setupTimeSec'>;
clock: JanggiState['clock']; clock: JanggiState['clock'];
undo: { allowed: boolean; remaining: Record<Side, number | null>; pending: { by: Side; plies: number } | null }; undo: { allowed: boolean; remaining: Record<Side, number | null>; pending: { by: Side; plies: number } | null };
draw: { allowed: boolean; pendingBy: Side | null }; draw: { allowed: boolean; pendingBy: Side | null };
@@ -697,7 +696,6 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
moveLimit: o.moveLimit, moveLimit: o.moveLimit,
hints: o.hints, hints: o.hints,
timeControl: o.timeControl, timeControl: o.timeControl,
idleLimitSec: o.idleLimitSec,
setupTimeSec: o.setupTimeSec, setupTimeSec: o.setupTimeSec,
}, },
clock: s.clock, clock: s.clock,
@@ -729,7 +727,7 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
const tc = s.options.timeControl; const tc = s.options.timeControl;
const start = s.clock.turnStartedAt; const start = s.clock.turnStartedAt;
if (tc.kind === 'byoyomi') return start + s.clock.mainMs[s.turn] + s.clock.periodsLeft[s.turn] * tc.periodSec * 1000; if (tc.kind === 'byoyomi') return start + s.clock.mainMs[s.turn] + s.clock.periodsLeft[s.turn] * tc.periodSec * 1000;
return start + s.options.idleLimitSec * 1000; return null; // 시간 제한 없음: 차례 마감 없음
}, },
timeoutPlayers(s) { timeoutPlayers(s) {

View File

@@ -444,13 +444,13 @@ describe('clocks (§8.5)', () => {
expect(s.board[idx(1, 3)]?.kind).toBe('jol'); // the late move was not played expect(s.board[idx(1, 3)]?.kind).toBe('jol'); // the late move was not played
}); });
test('timeout action → time loss for the side to move; none uses idle limit', () => { test('timeout action → time loss for the side to move; none has no deadline', () => {
const s = standard(); const s = standard();
expect(janggi.timeoutPlayers!(s)).toEqual(['A']); expect(janggi.timeoutPlayers!(s)).toEqual(['A']);
const t = act(s, 'A', janggi.onTimeout(s, 'A')); const t = act(s, 'A', janggi.onTimeout(s, 'A'));
expect(janggi.result(t)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout' }); expect(janggi.result(t)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout' });
const n = standard({ timeControl: { kind: 'none', mainSec: 600, periodSec: 30, periods: 3 }, idleLimitSec: 300 }); const n = standard({ timeControl: { kind: 'none', mainSec: 600, periodSec: 30, periods: 3 } });
expect(janggi.deadline!(n)).toBe(n.clock.turnStartedAt + 300_000); expect(janggi.deadline!(n)).toBeNull();
}); });
}); });

View File

@@ -56,7 +56,6 @@ export const omokOptionsSchema = z.object({
.default('autoMove') .default('autoMove')
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }), .meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }),
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '자동 착수 허용 횟수(넘으면 시간패)' }), autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '자동 착수 허용 횟수(넘으면 시간패)' }),
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
undo: z undo: z
.enum(['off', '1', '3', 'unlimited']) .enum(['off', '1', '3', 'unlimited'])
.default('3') .default('3')
@@ -504,7 +503,7 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
const start = s.clock.turnStartedAt; const start = s.clock.turnStartedAt;
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000; if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn]; if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn];
return start + s.options.idleLimitSec * 1000; return null; // 시간 제한 없음: 차례 마감 없음
}, },
timeoutPlayers(s) { timeoutPlayers(s) {

View File

@@ -256,6 +256,14 @@ describe('time', () => {
s = play(s, 'A1', 80_000); // white used 70s > 60s s = play(s, 'A1', 80_000); // white used 70s > 60s
expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' }); expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
}); });
test('no time control: no deadline at all, even after a long think', () => {
const s = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } });
expect(omok.deadline!(s)).toBeNull();
const late = play(s, 'H8', 3_600_000);
expect(late.phase).toBe('playing');
expect(omok.deadline!(late)).toBeNull();
});
}); });
describe('views and playouts', () => { describe('views and playouts', () => {

View File

@@ -32,7 +32,6 @@ export const othelloOptionsSchema = z.object({
.default('autoMove') .default('autoMove')
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }), .meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }),
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 착수 허용 횟수(넘으면 시간패)' }), autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 착수 허용 횟수(넘으면 시간패)' }),
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
undo: z undo: z
.enum(['off', '1', '3', 'unlimited']) .enum(['off', '1', '3', 'unlimited'])
.default('3') .default('3')
@@ -462,7 +461,7 @@ export const othello: GameDefinition<OthelloState, OthelloAction, OthelloView, O
const start = s.clock.turnStartedAt; const start = s.clock.turnStartedAt;
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000; if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn]; if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn];
return start + s.options.idleLimitSec * 1000; return null; // 시간 제한 없음: 차례 마감 없음
}, },
timeoutPlayers(s) { timeoutPlayers(s) {

View File

@@ -289,11 +289,10 @@ describe('time', () => {
expect(s.clock.autoMoveStreak.black).toBe(0); expect(s.clock.autoMoveStreak.black).toBe(0);
}); });
test('lose policy and idle limit lose on the first timeout', () => { test('lose policy loses on the first timeout; no time control has no deadline', () => {
expect(act(make({ timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).result).toMatchObject({ winner: 'white', reason: 'timeLoss' }); expect(act(make({ timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }).result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
const idle = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 }, idleLimitSec: 120 }); const none = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } });
expect(othello.deadline!(idle)).toBe(120_000); expect(othello.deadline!(none)).toBeNull();
expect(act(idle, 'A', { type: 'timeout' }).result?.summary).toBe('흑 시간패로 백 승 (흑 2 : 백 2)');
expect(othello.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false); expect(othello.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false);
}); });

View File

@@ -61,7 +61,6 @@ export const quoridorOptionsSchema = z.object({
.default('autoMove') .default('autoMove')
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '최단 길로 말 한 칸 자동 이동', lose: '시간패' } }), .meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '최단 길로 말 한 칸 자동 이동', lose: '시간패' } }),
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 이동 허용 횟수(넘으면 시간패)' }), autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '연속 자동 이동 허용 횟수(넘으면 시간패)' }),
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
undo: z undo: z
.enum(['off', '1', '3', 'unlimited']) .enum(['off', '1', '3', 'unlimited'])
.default('3') .default('3')
@@ -778,7 +777,7 @@ export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorVie
const start = s.clock.turnStartedAt; const start = s.clock.turnStartedAt;
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000; if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.seats[s.turnIndex]!.seat]; if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.seats[s.turnIndex]!.seat];
return start + s.options.idleLimitSec * 1000; return null; // 시간 제한 없음: 차례 마감 없음
}, },
timeoutPlayers(s) { timeoutPlayers(s) {

View File

@@ -440,7 +440,7 @@ describe('time (§8.5)', () => {
test('deadline: perMove / none / fischer', () => { test('deadline: perMove / none / fischer', () => {
expect(quoridor.deadline!(go(make(), 'e2', 7000))).toBe(37_000); expect(quoridor.deadline!(go(make(), 'e2', 7000))).toBe(37_000);
expect(quoridor.deadline!(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }))).toBe(300_000); expect(quoridor.deadline!(make(2, { timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } }))).toBeNull();
const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 }; const tc = { kind: 'fischer' as const, perMoveSec: 30, baseSec: 60, incSec: 5 };
let s = make(2, { timeControl: tc }); let s = make(2, { timeControl: tc });
expect(quoridor.deadline!(s)).toBe(60_000); expect(quoridor.deadline!(s)).toBe(60_000);