시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거
- 오목에서 [시간 제한 없음]을 골라도 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:
@@ -417,9 +417,12 @@ describe('clocks (§8.2, §8.5)', () => {
|
||||
expect(s.result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
|
||||
});
|
||||
|
||||
test('none: idle limit deadline', () => {
|
||||
const s = make({ timeControl: { ...byo, kind: 'none' }, idleLimitSec: 300 });
|
||||
expect(baduk.deadline!(s)).toBe(300_000);
|
||||
test('none: no deadline, and a late move is not a time loss', () => {
|
||||
const s = make({ timeControl: { ...byo, kind: 'none' } });
|
||||
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', () => {
|
||||
|
||||
@@ -69,10 +69,6 @@ export const badukOptionsSchema = z
|
||||
})
|
||||
.default({ kind: 'byoyomi', mainSecAuto: true, mainSec: 1200, periodSec: 30, periods: 3, incSec: 10 })
|
||||
.meta({ title: '시간 제한' }),
|
||||
idleLimitSec: z
|
||||
.union([z.literal(300), z.literal(600)])
|
||||
.default(600)
|
||||
.meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }),
|
||||
undo: z
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
@@ -190,7 +186,6 @@ export interface BadukView {
|
||||
periodSec: number;
|
||||
periods: number;
|
||||
incSec: number;
|
||||
idleLimitSec: number;
|
||||
mainMs: Record<Color, number>;
|
||||
periodsLeft: Record<Color, number>;
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
return elapsed <= s.options.idleLimitSec * 1000;
|
||||
return true; // 시간 제한 없음
|
||||
}
|
||||
|
||||
function marginKo(m: number, method: 'territory' | 'area'): string {
|
||||
@@ -772,7 +767,6 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
|
||||
periodSec: tc.periodSec,
|
||||
periods: tc.periods,
|
||||
incSec: tc.incSec,
|
||||
idleLimitSec: s.options.idleLimitSec,
|
||||
mainMs: s.clock.mainMs,
|
||||
periodsLeft: s.clock.periodsLeft,
|
||||
inByoyomi: s.clock.inByoyomi,
|
||||
@@ -819,7 +813,7 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
|
||||
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 === 'fischer') return start + s.clock.mainMs[s.turn];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
return null; // 시간 제한 없음: 차례 마감 없음
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
|
||||
@@ -258,9 +258,12 @@ describe('clocks and time (§8.5)', () => {
|
||||
expect(chess.validate(s, 'B', a).ok).toBe(false);
|
||||
expect(act(s, 'A', a).result).toMatchObject({ winner: 'black', reason: 'timeLoss' });
|
||||
});
|
||||
test('no time control: idle limit deadline', () => {
|
||||
const s = make({ timeControl: { kind: 'none', baseMin: 10, incSec: 0 }, idleLimitSec: 300 });
|
||||
expect(chess.deadline!(s)).toBe(300_000);
|
||||
test('no time control: no deadline, and a late first move is not aborted', () => {
|
||||
const s = make({ timeControl: { kind: 'none', baseMin: 10, incSec: 0 } });
|
||||
expect(chess.deadline!(s)).toBeNull();
|
||||
const late = play1(s, 'e2e4', 3_600_000);
|
||||
expect(late.phase).toBe('playing');
|
||||
expect(chess.deadline!(late)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,10 +68,6 @@ export const chessOptionsSchema = z.object({
|
||||
.union([z.literal(15), z.literal(30), z.literal(60)])
|
||||
.default(30)
|
||||
.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
|
||||
.enum(['off', 'moves'])
|
||||
.default('moves')
|
||||
@@ -166,7 +162,6 @@ export interface ChessView {
|
||||
checkSquare: string | null;
|
||||
timeControl: ChessOptions['timeControl'];
|
||||
firstMoveSec: number;
|
||||
idleLimitSec: number;
|
||||
remainingMs: Record<Color, number>;
|
||||
turnStartedAt: number;
|
||||
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.
|
||||
const elapsed = now - s.clock.turnStartedAt;
|
||||
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);
|
||||
break;
|
||||
}
|
||||
@@ -571,7 +566,6 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
checkSquare: c.inCheck() ? kingSquare(c, c.turn()) : null,
|
||||
timeControl: s.options.timeControl,
|
||||
firstMoveSec: s.options.firstMoveSec,
|
||||
idleLimitSec: s.options.idleLimitSec,
|
||||
remainingMs: s.clock.remainingMs,
|
||||
turnStartedAt: s.clock.turnStartedAt,
|
||||
started: s.clock.started,
|
||||
@@ -607,11 +601,12 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
||||
|
||||
deadline(s) {
|
||||
if (s.phase !== 'playing') return null;
|
||||
// 시간 제한 없음: 첫 수 제한도 차례 마감도 없음
|
||||
if (s.options.timeControl.kind !== 'fischer') return null;
|
||||
const turn = turnOf(s);
|
||||
const start = s.clock.turnStartedAt;
|
||||
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.options.idleLimitSec * 1000;
|
||||
return start + s.clock.remainingMs[turn];
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
|
||||
@@ -62,7 +62,6 @@ export const janggiOptionsSchema = z.object({
|
||||
})
|
||||
.default({ kind: 'byoyomi', mainSec: 600, periodSec: 30, periods: 3 })
|
||||
.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: '상차림 고르는 시간(초)' }),
|
||||
undo: z
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
@@ -181,7 +180,7 @@ export interface JanggiView {
|
||||
legalMoves: JanggiLegalMove[] | null;
|
||||
canPass: boolean;
|
||||
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'];
|
||||
undo: { allowed: boolean; remaining: Record<Side, number | null>; pending: { by: Side; plies: number } | null };
|
||||
draw: { allowed: boolean; pendingBy: Side | null };
|
||||
@@ -697,7 +696,6 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
|
||||
moveLimit: o.moveLimit,
|
||||
hints: o.hints,
|
||||
timeControl: o.timeControl,
|
||||
idleLimitSec: o.idleLimitSec,
|
||||
setupTimeSec: o.setupTimeSec,
|
||||
},
|
||||
clock: s.clock,
|
||||
@@ -729,7 +727,7 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
|
||||
const tc = s.options.timeControl;
|
||||
const start = s.clock.turnStartedAt;
|
||||
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) {
|
||||
|
||||
@@ -444,13 +444,13 @@ describe('clocks (§8.5)', () => {
|
||||
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();
|
||||
expect(janggi.timeoutPlayers!(s)).toEqual(['A']);
|
||||
const t = act(s, 'A', janggi.onTimeout(s, 'A'));
|
||||
expect(janggi.result(t)).toMatchObject({ ranking: [['B'], ['A']], reason: 'timeout' });
|
||||
const n = standard({ timeControl: { kind: 'none', mainSec: 600, periodSec: 30, periods: 3 }, idleLimitSec: 300 });
|
||||
expect(janggi.deadline!(n)).toBe(n.clock.turnStartedAt + 300_000);
|
||||
const n = standard({ timeControl: { kind: 'none', mainSec: 600, periodSec: 30, periods: 3 } });
|
||||
expect(janggi.deadline!(n)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ export const omokOptionsSchema = z.object({
|
||||
.default('autoMove')
|
||||
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }),
|
||||
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
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
@@ -504,7 +503,7 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
|
||||
const start = s.clock.turnStartedAt;
|
||||
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
|
||||
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
return null; // 시간 제한 없음: 차례 마감 없음
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
|
||||
@@ -256,6 +256,14 @@ describe('time', () => {
|
||||
s = play(s, 'A1', 80_000); // white used 70s > 60s
|
||||
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', () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ export const othelloOptionsSchema = z.object({
|
||||
.default('autoMove')
|
||||
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }),
|
||||
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
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
@@ -462,7 +461,7 @@ export const othello: GameDefinition<OthelloState, OthelloAction, OthelloView, O
|
||||
const start = s.clock.turnStartedAt;
|
||||
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
|
||||
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.turn];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
return null; // 시간 제한 없음: 차례 마감 없음
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
|
||||
@@ -289,11 +289,10 @@ describe('time', () => {
|
||||
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' });
|
||||
const idle = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 }, idleLimitSec: 120 });
|
||||
expect(othello.deadline!(idle)).toBe(120_000);
|
||||
expect(act(idle, 'A', { type: 'timeout' }).result?.summary).toBe('흑 시간패로 백 승 (흑 2 : 백 2)');
|
||||
const none = make({ timeControl: { kind: 'none', perMoveSec: 30, baseSec: 300, incSec: 3 } });
|
||||
expect(othello.deadline!(none)).toBeNull();
|
||||
expect(othello.validate(make(), 'B', { type: 'timeout' }).ok).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ export const quoridorOptionsSchema = z.object({
|
||||
.default('autoMove')
|
||||
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '최단 길로 말 한 칸 자동 이동', lose: '시간패' } }),
|
||||
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
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
@@ -778,7 +777,7 @@ export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorVie
|
||||
const start = s.clock.turnStartedAt;
|
||||
if (tc.kind === 'perMove') return start + tc.perMoveSec * 1000;
|
||||
if (tc.kind === 'fischer') return start + s.clock.remainingMs[s.seats[s.turnIndex]!.seat];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
return null; // 시간 제한 없음: 차례 마감 없음
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
|
||||
@@ -440,7 +440,7 @@ describe('time (§8.5)', () => {
|
||||
|
||||
test('deadline: perMove / none / fischer', () => {
|
||||
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 };
|
||||
let s = make(2, { timeControl: tc });
|
||||
expect(quoridor.deadline!(s)).toBe(60_000);
|
||||
|
||||
Reference in New Issue
Block a user