M2: 바둑 추가, 4게임 E2E 안정화, 로드맵 M2 결과 기록
- 바둑: 9·13·19줄, 따내기·자충수 금지·슈퍼코(Zobrist)/한국식 삼패 무승부, 접바둑·덤, 패스, 사석 확인 단계(자동 제안·동의·이의 시 재개), 집/영역 계가, 초읽기·피셔 시계 (테스트 50개) - 쓰이지 않던 바둑 옵션(연결 끊김 유예 — 방 설정이 담당) 제거 - 홈 '방 만들기'가 게임 고르기 창을 거치도록 바뀐 것에 맞춰 E2E 수정, 장기 상차림 단계 E2E 안정화 - 전체: 테스트 230개, E2E 오목·관리자·4게임·접근성 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
883
packages/games/src/baduk/index.ts
Normal file
883
packages/games/src/baduk/index.ts
Normal file
@@ -0,0 +1,883 @@
|
||||
/** 바둑 (docs/games/baduk.md). */
|
||||
import { z } from 'zod';
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
||||
import {
|
||||
atariStones,
|
||||
chainAt,
|
||||
emptyRegionAt,
|
||||
hashBoard,
|
||||
illegalPoints,
|
||||
markGroup,
|
||||
neighbours,
|
||||
regionsOf,
|
||||
score,
|
||||
suggestMarks,
|
||||
tryPlace,
|
||||
type Cell,
|
||||
type IllegalReason,
|
||||
type ScoreBreakdown,
|
||||
type Stone,
|
||||
} from './board';
|
||||
|
||||
export type { Cell, IllegalReason, ScoreBreakdown } from './board';
|
||||
|
||||
export type Color = 'black' | 'white';
|
||||
const STONE: Record<Color, Stone> = { black: 1, white: 2 };
|
||||
const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
|
||||
const HANDICAP_LABELS = { 0: '없음(맞바둑)', 2: '2점', 3: '3점', 4: '4점', 5: '5점', 6: '6점', 7: '7점', 8: '8점', 9: '9점' };
|
||||
|
||||
export const badukOptionsSchema = z
|
||||
.object({
|
||||
boardSize: z
|
||||
.union([z.literal(9), z.literal(13), z.literal(19)])
|
||||
.default(19)
|
||||
.meta({ title: '판 크기', labels: { 9: '9줄(처음이면 추천)', 13: '13줄', 19: '19줄' } }),
|
||||
scoring: z
|
||||
.enum(['territory', 'area'])
|
||||
.default('territory')
|
||||
.meta({ title: '계가 방식', labels: { territory: '집 계산(한국·일본식)', area: '영역 계산(중국식)' } }),
|
||||
komiAuto: z.boolean().default(true).meta({ title: '덤 자동(맞바둑 6.5집 · 중국식 7.5점 · 접바둑 0.5)' }),
|
||||
komi: z.number().min(-10).max(10).multipleOf(0.5).default(6.5).meta({ title: '덤(자동을 끈 경우, 0.5 단위)' }),
|
||||
handicap: z
|
||||
.union([z.literal(0), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6), z.literal(7), z.literal(8), z.literal(9)])
|
||||
.default(0)
|
||||
.meta({ title: '접바둑 치석(9줄은 5점까지)', labels: HANDICAP_LABELS }),
|
||||
koRule: z
|
||||
.enum(['positionalSuperko', 'korean'])
|
||||
.default('positionalSuperko')
|
||||
.meta({ title: '패 규칙', labels: { positionalSuperko: '동형 반복 금지', korean: '한국식(단순 패 금지, 같은 국면 3번이면 무승부)' } }),
|
||||
colorAssignment: z
|
||||
.enum(['nigiri', 'hostBlack', 'hostWhite'])
|
||||
.default('nigiri')
|
||||
.meta({ title: '흑백 정하기', labels: { nigiri: '무작위(돌 가리기)', hostBlack: '방장이 흑(접바둑이면 하수)', hostWhite: '방장이 백(접바둑이면 상수)' } }),
|
||||
timeControl: z
|
||||
.object({
|
||||
kind: z
|
||||
.enum(['none', 'byoyomi', 'fischer'])
|
||||
.default('byoyomi')
|
||||
.meta({ title: '시간 방식', labels: { none: '제한 없음', byoyomi: '기본 시간 + 초읽기', fischer: '기본 시간 + 수당 추가' } }),
|
||||
mainSecAuto: z.boolean().default(true).meta({ title: '기본 시간 자동(9줄 3분 · 13줄 10분 · 19줄 20분)' }),
|
||||
mainSec: z.number().int().min(0).max(3600).default(1200).meta({ title: '기본 시간(초, 자동을 끈 경우)' }),
|
||||
periodSec: z
|
||||
.union([z.literal(10), z.literal(20), z.literal(30), z.literal(60)])
|
||||
.default(30)
|
||||
.meta({ title: '초읽기 한 번(초)', labels: { 10: '10초', 20: '20초', 30: '30초', 60: '60초' } }),
|
||||
periods: z.number().int().min(1).max(5).default(3).meta({ title: '초읽기 횟수' }),
|
||||
incSec: z.number().int().min(0).max(30).default(10).meta({ title: '한 수마다 추가(초, 피셔)' }),
|
||||
})
|
||||
.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')
|
||||
.meta({ title: '무르기(1인당, 상대 동의)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
|
||||
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
||||
drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
||||
atariWarning: z.boolean().default(true).meta({ title: '단수 경고(내 돌이 단수에 몰리면 표시)' }),
|
||||
scoringDeadlineSec: z
|
||||
.union([z.literal(120), z.literal(180), z.literal(300)])
|
||||
.default(180)
|
||||
.meta({ title: '사석 확인 제한 시간', labels: { 120: '2분', 180: '3분', 300: '5분' } }),
|
||||
})
|
||||
.superRefine((o, ctx) => {
|
||||
if (o.boardSize === 9 && o.handicap > 5) ctx.addIssue({ code: 'custom', path: ['handicap'], message: '9줄 판은 접바둑 5점까지만 둘 수 있어요.' });
|
||||
});
|
||||
export type BadukOptions = z.infer<typeof badukOptionsSchema>;
|
||||
|
||||
const coord = z.number().int().min(0).max(18);
|
||||
export const badukActionSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('place'), x: coord, y: coord }),
|
||||
z.object({ type: z.literal('pass') }),
|
||||
z.object({ type: z.literal('toggleDead'), x: coord, y: coord }),
|
||||
z.object({ type: z.literal('toggleSeki'), x: coord, y: coord }),
|
||||
z.object({ type: z.literal('agreeScore') }),
|
||||
z.object({ type: z.literal('resumePlay') }),
|
||||
z.object({ type: z.literal('requestUndo') }),
|
||||
z.object({ type: z.literal('respondUndo'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('offerDraw') }),
|
||||
z.object({ type: z.literal('respondDraw'), accept: z.boolean() }),
|
||||
z.object({ type: z.literal('cancelOffer') }),
|
||||
z.object({ type: z.literal('resign') }),
|
||||
]);
|
||||
/** Client-sendable actions plus the system-only timeout action (not in the schema). */
|
||||
export type BadukAction = z.infer<typeof badukActionSchema> | { type: 'timeout' };
|
||||
|
||||
export interface Pt {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type BadukMove = { kind: 'place'; color: Color; pt: Pt; captured: Pt[] } | { kind: 'pass'; color: Color };
|
||||
export type BadukReason = 'score' | 'jigo' | 'resign' | 'timeLoss' | 'drawAgreed' | 'repetition';
|
||||
|
||||
export interface BadukScore extends ScoreBreakdown {
|
||||
/** Winner's margin (e.g. 2.5); 0 for jigo. */
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export interface BadukResult {
|
||||
winner: Color | null;
|
||||
reason: BadukReason;
|
||||
score?: BadukScore;
|
||||
summaryKo: string;
|
||||
}
|
||||
|
||||
export interface BadukState {
|
||||
options: BadukOptions;
|
||||
size: 9 | 13 | 19;
|
||||
komi: number;
|
||||
handicap: number;
|
||||
board: Cell[];
|
||||
players: Record<Color, PlayerId>;
|
||||
turn: Color;
|
||||
phase: 'playing' | 'scoring' | 'finished';
|
||||
/** Stones each colour captured during play. */
|
||||
captures: Record<Color, number>;
|
||||
consecutivePasses: number;
|
||||
moves: BadukMove[];
|
||||
/** Zobrist hash (16 hex chars) of the current arrangement. */
|
||||
hash: string;
|
||||
/** Hashes of every arrangement so far, initial position first (superko; replayable from moves). */
|
||||
positionHistory: string[];
|
||||
/** koRule=korean: (arrangement + side to move) occurrence counts. */
|
||||
situationCounts: Record<string, number>;
|
||||
/** koRule=korean: simple-ko banned point for the side to move. */
|
||||
koPoint: Pt | null;
|
||||
/** moves.length when play last resumed from the scoring phase (undo can't cross it). */
|
||||
lastResumeAt: number;
|
||||
scoringMarks: {
|
||||
dead: number[];
|
||||
/** One point per seki-marked empty region (regions are found after removing dead stones). */
|
||||
seki: number[];
|
||||
agreed: Record<Color, boolean>;
|
||||
startedAt: number;
|
||||
} | null;
|
||||
clock: {
|
||||
mainMs: Record<Color, number>;
|
||||
periodsLeft: Record<Color, number>;
|
||||
inByoyomi: Record<Color, boolean>;
|
||||
turnStartedAt: number;
|
||||
};
|
||||
undo: { usedBy: Record<Color, number>; pending: { by: Color; plies: 1 | 2 } | null };
|
||||
draw: { pendingBy: Color | null; lastOfferMove: Record<Color, number | null> };
|
||||
result: BadukResult | null;
|
||||
}
|
||||
|
||||
export interface BadukView {
|
||||
size: number;
|
||||
board: Cell[];
|
||||
players: Record<Color, PlayerId>;
|
||||
myColor: Color | null;
|
||||
turn: Color;
|
||||
phase: BadukState['phase'];
|
||||
rules: { scoring: BadukOptions['scoring']; koRule: BadukOptions['koRule']; komi: number; handicap: number; atariWarning: boolean };
|
||||
captures: Record<Color, number>;
|
||||
moveCount: number;
|
||||
moves: { color: Color; pt: Pt | null; captured: number }[];
|
||||
lastMove: Pt | null;
|
||||
/** Stones removed by the last move (for the capture animation). */
|
||||
lastCaptured: Pt[];
|
||||
consecutivePasses: number;
|
||||
clock: {
|
||||
kind: BadukOptions['timeControl']['kind'];
|
||||
mainSec: number;
|
||||
periodSec: number;
|
||||
periods: number;
|
||||
incSec: number;
|
||||
idleLimitSec: number;
|
||||
mainMs: Record<Color, number>;
|
||||
periodsLeft: Record<Color, number>;
|
||||
inByoyomi: Record<Color, boolean>;
|
||||
turnStartedAt: number;
|
||||
};
|
||||
undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null };
|
||||
draw: { allowed: boolean; pendingBy: Color | null };
|
||||
/** Only for the player to move. */
|
||||
illegalPoints: (Pt & { reason: IllegalReason })[];
|
||||
/** Only for the player to move (atariWarning option): own stones in atari. */
|
||||
atariWarnings: Pt[];
|
||||
scoring: {
|
||||
dead: Pt[];
|
||||
/** Every point of seki-marked regions. */
|
||||
seki: Pt[];
|
||||
agreed: Record<Color, boolean>;
|
||||
deadlineAt: number;
|
||||
durationMs: number;
|
||||
preview: ScoreBreakdown;
|
||||
} | null;
|
||||
result: BadukResult | null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
const COLS = 'ABCDEFGHJKLMNOPQRST';
|
||||
/** "D4" → index, columns skip I, rows count from the bottom. */
|
||||
function labelIdx(size: number, label: string): number {
|
||||
const x = COLS.indexOf(label[0]!);
|
||||
const y = size - Number(label.slice(1));
|
||||
return y * size + x;
|
||||
}
|
||||
export const ptLabel = (size: number, p: Pt): string => `${COLS[p.x]}${size - p.y}`;
|
||||
|
||||
/** §4 handicap placement table (fixed, Korean/Japanese convention). */
|
||||
export function handicapPoints(size: number, n: number): number[] {
|
||||
if (n < 2) return [];
|
||||
const t =
|
||||
size === 19
|
||||
? { c: ['Q16', 'D4', 'Q4', 'D16'], lr: ['D10', 'Q10'], tb: ['K4', 'K16'], m: 'K10' }
|
||||
: size === 13
|
||||
? { c: ['K10', 'D4', 'K4', 'D10'], lr: ['D7', 'K7'], tb: ['G4', 'G10'], m: 'G7' }
|
||||
: { c: ['G7', 'C3', 'G3', 'C7'], lr: [], tb: [], m: 'E5' };
|
||||
let labels: string[];
|
||||
if (n <= 4) labels = t.c.slice(0, n);
|
||||
else if (n === 5) labels = [...t.c, t.m];
|
||||
else if (n === 6) labels = [...t.c, ...t.lr];
|
||||
else if (n === 7) labels = [...t.c, ...t.lr, t.m];
|
||||
else if (n === 8) labels = [...t.c, ...t.lr, ...t.tb];
|
||||
else labels = [...t.c, ...t.lr, ...t.tb, t.m];
|
||||
return labels.map((l) => labelIdx(size, l));
|
||||
}
|
||||
|
||||
const DEFAULT_MAIN: Record<9 | 13 | 19, number> = { 9: 180, 13: 600, 19: 1200 };
|
||||
export function effectiveKomi(o: BadukOptions): number {
|
||||
if (!o.komiAuto) return o.komi;
|
||||
if (o.handicap > 0) return 0.5;
|
||||
return o.scoring === 'area' ? 7.5 : 6.5;
|
||||
}
|
||||
export function effectiveMainSec(o: BadukOptions): number {
|
||||
return o.timeControl.mainSecAuto ? DEFAULT_MAIN[o.boardSize] : o.timeControl.mainSec;
|
||||
}
|
||||
const handicapComp = (s: BadukState) => (s.options.scoring === 'area' && s.handicap > 0 ? s.handicap : 0);
|
||||
|
||||
const toPt = (i: number, size: number): Pt => ({ x: i % size, y: Math.floor(i / size) });
|
||||
const undoLimit = (o: BadukOptions): number => (o.undo === 'off' ? 0 : o.undo === 'unlimited' ? Infinity : Number(o.undo));
|
||||
const colorOf = (s: BadukState, p: PlayerId): Color | null =>
|
||||
s.players.black === p ? 'black' : s.players.white === p ? 'white' : null;
|
||||
const situationKey = (hash: string, turn: Color) => hash + (turn === 'black' ? 'b' : 'w');
|
||||
|
||||
function initialBoard(size: number, handicap: number): Cell[] {
|
||||
const b = new Array<Cell>(size * size).fill(0);
|
||||
for (const i of handicapPoints(size, handicap)) b[i] = 1;
|
||||
return b;
|
||||
}
|
||||
|
||||
function pendingResponder(s: BadukState): Color | null {
|
||||
if (s.undo.pending) return other(s.undo.pending.by);
|
||||
if (s.draw.pendingBy) return other(s.draw.pendingBy);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Previous arrangement before the opponent's last move — repeating it is a plain ko. */
|
||||
function koHashOf(s: BadukState): string | null {
|
||||
const last = s.moves[s.moves.length - 1];
|
||||
return last?.kind === 'place' && s.positionHistory.length >= 2 ? s.positionHistory[s.positionHistory.length - 2]! : null;
|
||||
}
|
||||
|
||||
function illegalMap(s: BadukState): Map<number, IllegalReason> {
|
||||
const superko = s.options.koRule === 'positionalSuperko';
|
||||
const ko = s.koPoint ? s.koPoint.y * s.size + s.koPoint.x : null;
|
||||
return illegalPoints(s.board, s.size, STONE[s.turn], s.hash, {
|
||||
koPoint: superko ? null : ko,
|
||||
history: superko ? new Set(s.positionHistory) : null,
|
||||
koHash: superko ? koHashOf(s) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const ILLEGAL_MSG: Record<IllegalReason, string> = {
|
||||
suicide: '둘 곳의 활로가 없어 둘 수 없어요(자충수).',
|
||||
ko: '패는 바로 되따낼 수 없어요. 다른 곳에 한 수 두고 오세요.',
|
||||
superko: '같은 모양이 반복되어 둘 수 없어요.',
|
||||
};
|
||||
|
||||
/** Places a validated stone and updates board, captures, hashes and ko state (no clock/turn). */
|
||||
function commitPlace(s: BadukState, color: Color, i: number, captured: number[], hash: string): void {
|
||||
const size = s.size;
|
||||
s.board[i] = STONE[color];
|
||||
for (const c of captured) s.board[c] = 0;
|
||||
s.captures[color] += captured.length;
|
||||
s.hash = hash;
|
||||
s.positionHistory.push(hash);
|
||||
s.moves.push({ kind: 'place', color, pt: toPt(i, size), captured: captured.map((c) => toPt(c, size)) });
|
||||
s.koPoint = null;
|
||||
if (captured.length === 1) {
|
||||
const ch = chainAt(s.board, size, i);
|
||||
if (ch.stones.length === 1 && ch.libs.length === 1) s.koPoint = toPt(captured[0]!, size);
|
||||
}
|
||||
s.consecutivePasses = 0;
|
||||
s.turn = other(color);
|
||||
}
|
||||
|
||||
function commitPass(s: BadukState, color: Color): void {
|
||||
s.moves.push({ kind: 'pass', color });
|
||||
s.koPoint = null;
|
||||
s.consecutivePasses++;
|
||||
s.turn = other(color);
|
||||
}
|
||||
|
||||
/** Rebuilds board, captures, superko history, ko point and repetition counts by replaying moves (undo). */
|
||||
function replay(s: BadukState, moves: BadukMove[]): void {
|
||||
s.board = initialBoard(s.size, s.handicap);
|
||||
s.captures = { black: 0, white: 0 };
|
||||
s.hash = hashBoard(s.board);
|
||||
s.positionHistory = [s.hash];
|
||||
s.koPoint = null;
|
||||
s.moves = [];
|
||||
s.turn = s.handicap > 0 ? 'white' : 'black';
|
||||
s.situationCounts = { [situationKey(s.hash, s.turn)]: 1 };
|
||||
for (const m of moves) {
|
||||
if (m.kind === 'pass') {
|
||||
commitPass(s, m.color);
|
||||
continue;
|
||||
}
|
||||
const i = m.pt.y * s.size + m.pt.x;
|
||||
const r = tryPlace(s.board, s.size, i, STONE[m.color], s.hash);
|
||||
if (!r.ok) throw new Error(`baduk replay: illegal move ${ptLabel(s.size, m.pt)}`);
|
||||
commitPlace(s, m.color, i, r.captured, r.hash);
|
||||
const key = situationKey(s.hash, s.turn);
|
||||
s.situationCounts[key] = (s.situationCounts[key] ?? 0) + 1;
|
||||
}
|
||||
let trailing = 0;
|
||||
for (let k = s.moves.length - 1; k >= s.lastResumeAt && s.moves[k]!.kind === 'pass'; k--) trailing++;
|
||||
s.consecutivePasses = trailing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges the mover's clock for the time spent on this turn (§8.2). Returns false when the
|
||||
* player had already lost on time (the move is then not played).
|
||||
*/
|
||||
function chargeClock(s: BadukState, me: Color, now: number): boolean {
|
||||
const tc = s.options.timeControl;
|
||||
const elapsed = Math.max(0, now - s.clock.turnStartedAt);
|
||||
if (tc.kind === 'byoyomi') {
|
||||
const main = s.clock.mainMs[me];
|
||||
if (elapsed <= main) {
|
||||
s.clock.mainMs[me] = main - elapsed;
|
||||
return true;
|
||||
}
|
||||
const over = elapsed - main;
|
||||
const periodMs = tc.periodSec * 1000;
|
||||
const used = Math.ceil(over / periodMs) - 1; // within one period → keep it
|
||||
s.clock.mainMs[me] = 0;
|
||||
s.clock.inByoyomi[me] = true;
|
||||
if (used >= s.clock.periodsLeft[me]) {
|
||||
s.clock.periodsLeft[me] = 0;
|
||||
return false;
|
||||
}
|
||||
s.clock.periodsLeft[me] -= used;
|
||||
return true;
|
||||
}
|
||||
if (tc.kind === 'fischer') {
|
||||
const left = s.clock.mainMs[me] - elapsed;
|
||||
if (left <= 0) {
|
||||
s.clock.mainMs[me] = 0;
|
||||
return false;
|
||||
}
|
||||
s.clock.mainMs[me] = left + tc.incSec * 1000;
|
||||
return true;
|
||||
}
|
||||
return elapsed <= s.options.idleLimitSec * 1000;
|
||||
}
|
||||
|
||||
function marginKo(m: number, method: 'territory' | 'area'): string {
|
||||
if (method === 'area') return `${m}점`;
|
||||
const whole = Math.floor(m);
|
||||
const half = m - whole >= 0.5;
|
||||
if (whole === 0) return half ? '반집' : '0집';
|
||||
return `${whole}집${half ? ' 반' : ''}`;
|
||||
}
|
||||
|
||||
function finish(s: BadukState, winner: Color | null, reason: BadukReason, sc?: BadukScore): void {
|
||||
s.phase = 'finished';
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
let summaryKo: string;
|
||||
switch (reason) {
|
||||
case 'score':
|
||||
summaryKo = `${COLOR_KO[winner!]} ${marginKo(sc!.margin, sc!.method)} 승`;
|
||||
break;
|
||||
case 'jigo':
|
||||
summaryKo = '빅 (동점 무승부)';
|
||||
break;
|
||||
case 'resign':
|
||||
summaryKo = `${COLOR_KO[winner!]} 불계승`;
|
||||
break;
|
||||
case 'timeLoss':
|
||||
summaryKo = `${COLOR_KO[winner!]} 시간승`;
|
||||
break;
|
||||
case 'drawAgreed':
|
||||
summaryKo = '합의 무승부';
|
||||
break;
|
||||
case 'repetition':
|
||||
summaryKo = '같은 국면이 세 번 반복되어 무승부';
|
||||
break;
|
||||
}
|
||||
s.result = { winner, reason, summaryKo, ...(sc ? { score: sc } : {}) };
|
||||
}
|
||||
|
||||
function scoreOf(s: BadukState): ScoreBreakdown {
|
||||
const m = s.scoringMarks;
|
||||
return score({
|
||||
board: s.board,
|
||||
size: s.size,
|
||||
dead: new Set(m?.dead ?? []),
|
||||
seki: m?.seki ?? [],
|
||||
method: s.options.scoring,
|
||||
captures: s.captures,
|
||||
komi: s.komi,
|
||||
handicapComp: handicapComp(s),
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeScore(s: BadukState, events: GameEvent[]): void {
|
||||
const b = scoreOf(s);
|
||||
const margin = Math.abs(b.diff);
|
||||
const sc: BadukScore = { ...b, margin };
|
||||
if (b.diff === 0) finish(s, null, 'jigo', sc);
|
||||
else finish(s, b.diff > 0 ? 'black' : 'white', 'score', sc);
|
||||
events.push({ type: 'gameEnded', reason: s.result!.reason, winner: s.result!.winner });
|
||||
}
|
||||
|
||||
function enterScoring(s: BadukState, now: number, events: GameEvent[]): void {
|
||||
s.phase = 'scoring';
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
const sug = suggestMarks(s.board, s.size);
|
||||
s.scoringMarks = { dead: sug.dead, seki: sug.seki, agreed: { black: false, white: false }, startedAt: now };
|
||||
events.push({ type: 'scoringStarted' });
|
||||
}
|
||||
|
||||
function sekiPoints(s: BadukState): number[] {
|
||||
const m = s.scoringMarks;
|
||||
if (!m || m.seki.length === 0) return [];
|
||||
const dead = new Set(m.dead);
|
||||
const reg = regionsOf(s.board, s.size, (i) => s.board[i] === 0 || dead.has(i));
|
||||
const ids = new Set(m.seki.map((i) => reg.id[i]!).filter((r) => r >= 0));
|
||||
return [...ids].flatMap((r) => reg.list[r]!.points);
|
||||
}
|
||||
|
||||
/** Single-point own eyes (every orthogonal neighbour is an own stone) — skipped by random playouts. */
|
||||
function isOwnEye(s: BadukState, i: number, c: Stone): boolean {
|
||||
const nb = neighbours(s.size);
|
||||
const n = nb[i * 5]!;
|
||||
for (let k = 1; k <= n; k++) if (s.board[nb[i * 5 + k]!] !== c) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ definition
|
||||
|
||||
export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOptions> = {
|
||||
id: 'baduk',
|
||||
nameKo: '바둑',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
stateVersion: 1,
|
||||
defaultOptions: badukOptionsSchema.parse({}),
|
||||
optionsSchema: badukOptionsSchema,
|
||||
actionSchema: badukActionSchema as z.ZodType<BadukAction>,
|
||||
|
||||
setup({ players, options, rng, now }) {
|
||||
const [host, guest] = players as [PlayerId, PlayerId];
|
||||
const hostBlack =
|
||||
options.colorAssignment === 'hostBlack' ? true : options.colorAssignment === 'hostWhite' ? false : rng.int(2) === 0;
|
||||
const size = options.boardSize;
|
||||
const handicap = size === 9 ? Math.min(options.handicap, 5) : options.handicap;
|
||||
const board = initialBoard(size, handicap);
|
||||
const hash = hashBoard(board);
|
||||
const turn: Color = handicap > 0 ? 'white' : 'black';
|
||||
const tc = options.timeControl;
|
||||
const main = tc.kind === 'none' ? 0 : effectiveMainSec(options) * 1000;
|
||||
const periods = tc.kind === 'byoyomi' ? tc.periods : 0;
|
||||
return {
|
||||
options,
|
||||
size,
|
||||
komi: effectiveKomi(options),
|
||||
handicap,
|
||||
board,
|
||||
players: hostBlack ? { black: host, white: guest } : { black: guest, white: host },
|
||||
turn,
|
||||
phase: 'playing',
|
||||
captures: { black: 0, white: 0 },
|
||||
consecutivePasses: 0,
|
||||
moves: [],
|
||||
hash,
|
||||
positionHistory: [hash],
|
||||
situationCounts: { [situationKey(hash, turn)]: 1 },
|
||||
koPoint: null,
|
||||
lastResumeAt: 0,
|
||||
scoringMarks: null,
|
||||
clock: {
|
||||
mainMs: { black: main, white: main },
|
||||
periodsLeft: { black: periods, white: periods },
|
||||
inByoyomi: { black: tc.kind === 'byoyomi' && main === 0, white: tc.kind === 'byoyomi' && main === 0 },
|
||||
turnStartedAt: now,
|
||||
},
|
||||
undo: { usedBy: { black: 0, white: 0 }, pending: null },
|
||||
draw: { pendingBy: null, lastOfferMove: { black: null, white: null } },
|
||||
result: null,
|
||||
};
|
||||
},
|
||||
|
||||
validate(s, actor, a) {
|
||||
const me = colorOf(s, actor);
|
||||
if (!me) return fail('이 게임의 플레이어가 아니에요.');
|
||||
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
|
||||
const playing = s.phase === 'playing';
|
||||
switch (a.type) {
|
||||
case 'place': {
|
||||
if (!playing) return fail('지금은 돌을 둘 수 없어요.');
|
||||
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
if (a.x >= s.size || a.y >= s.size) return fail('판 밖이에요.');
|
||||
const i = a.y * s.size + a.x;
|
||||
if (s.board[i] !== 0) return fail('이미 돌이 있는 자리예요.');
|
||||
if (s.options.koRule === 'korean' && s.koPoint && s.koPoint.x === a.x && s.koPoint.y === a.y) return fail(ILLEGAL_MSG.ko);
|
||||
const r = tryPlace(s.board, s.size, i, STONE[me], s.hash);
|
||||
if (!r.ok) return fail(ILLEGAL_MSG.suicide);
|
||||
if (s.options.koRule === 'positionalSuperko' && s.positionHistory.includes(r.hash)) {
|
||||
return fail(r.hash === koHashOf(s) ? ILLEGAL_MSG.ko : ILLEGAL_MSG.superko);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
case 'pass':
|
||||
if (!playing) return fail('지금은 패스할 수 없어요.');
|
||||
if (s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
return ok;
|
||||
case 'toggleDead':
|
||||
case 'toggleSeki': {
|
||||
if (s.phase !== 'scoring') return fail('사석 확인 단계가 아니에요.');
|
||||
if (a.x >= s.size || a.y >= s.size) return fail('판 밖이에요.');
|
||||
const v = s.board[a.y * s.size + a.x];
|
||||
if (a.type === 'toggleDead' && v === 0) return fail('돌이 있는 곳을 눌러 주세요.');
|
||||
if (a.type === 'toggleSeki' && v !== 0) return fail('빈 곳을 눌러 주세요.');
|
||||
return ok;
|
||||
}
|
||||
case 'agreeScore':
|
||||
if (s.phase !== 'scoring') return fail('사석 확인 단계가 아니에요.');
|
||||
if (s.scoringMarks!.agreed[me]) return fail('이미 동의했어요.');
|
||||
return ok;
|
||||
case 'resumePlay':
|
||||
if (s.phase !== 'scoring') return fail('사석 확인 단계가 아니에요.');
|
||||
return ok;
|
||||
case 'requestUndo': {
|
||||
if (!playing) return fail('지금은 무를 수 없어요.');
|
||||
if (s.options.undo === 'off') return fail('이 방은 무르기를 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
if (s.undo.usedBy[me] >= undoLimit(s.options)) return fail('무르기 횟수를 다 썼어요.');
|
||||
const last = s.moves[s.moves.length - 1];
|
||||
const plies = last?.color === me ? 1 : 2;
|
||||
const from = s.moves.length - plies;
|
||||
if (!last || from < s.lastResumeAt || s.moves[from]?.color !== me) return fail('아직 무를 수가 없어요.');
|
||||
return ok;
|
||||
}
|
||||
case 'respondUndo':
|
||||
if (!s.undo.pending || s.undo.pending.by === me) return fail('응답할 무르기 요청이 없어요.');
|
||||
return ok;
|
||||
case 'offerDraw': {
|
||||
if (!playing) return fail('지금은 무승부를 제안할 수 없어요.');
|
||||
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
const last = s.draw.lastOfferMove[me];
|
||||
const gap = s.options.drawReofferMoves;
|
||||
if (last !== null && s.moves.slice(last).filter((m) => m.color === me).length < gap) {
|
||||
return fail(`무승부 제안은 ${gap}수를 더 둔 뒤에 다시 할 수 있어요.`);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
case 'respondDraw':
|
||||
if (!s.draw.pendingBy || s.draw.pendingBy === me) return fail('응답할 무승부 제안이 없어요.');
|
||||
return ok;
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by !== me && s.draw.pendingBy !== me) return fail('취소할 요청이 없어요.');
|
||||
return ok;
|
||||
case 'resign':
|
||||
return ok;
|
||||
case 'timeout':
|
||||
if (playing && s.turn !== me) return fail('지금은 내 차례가 아니에요.');
|
||||
if (s.phase === 'scoring' && s.scoringMarks!.agreed[me]) return fail('이미 동의했어요.');
|
||||
return ok;
|
||||
}
|
||||
},
|
||||
|
||||
apply(prev, actor, a, { now }) {
|
||||
const s = structuredClone(prev);
|
||||
const me = colorOf(s, actor)!;
|
||||
const events: GameEvent[] = [];
|
||||
switch (a.type) {
|
||||
case 'place':
|
||||
case 'pass': {
|
||||
if (!chargeClock(s, me, now)) {
|
||||
finish(s, other(me), 'timeLoss');
|
||||
events.push({ type: 'timeLoss', color: me }, { type: 'gameEnded', reason: 'timeLoss', winner: other(me) });
|
||||
break;
|
||||
}
|
||||
s.undo.pending = null;
|
||||
s.draw.pendingBy = null;
|
||||
s.clock.turnStartedAt = now;
|
||||
if (a.type === 'pass') {
|
||||
commitPass(s, me);
|
||||
events.push({ type: 'pass', color: me });
|
||||
if (s.consecutivePasses >= 2) enterScoring(s, now, events);
|
||||
break;
|
||||
}
|
||||
const i = a.y * s.size + a.x;
|
||||
const r = tryPlace(s.board, s.size, i, STONE[me], s.hash);
|
||||
if (!r.ok) throw new Error('baduk: illegal place reached apply');
|
||||
commitPlace(s, me, i, r.captured, r.hash);
|
||||
const captured = r.captured.map((c) => toPt(c, s.size));
|
||||
events.push({ type: 'stonePlaced', color: me, x: a.x, y: a.y, captured });
|
||||
if (captured.length) events.push({ type: 'captured', color: me, count: captured.length });
|
||||
const nb = neighbours(s.size);
|
||||
const oppStone = STONE[other(me)];
|
||||
const seen = new Set<number>();
|
||||
for (let k = 1; k <= nb[i * 5]!; k++) {
|
||||
const q = nb[i * 5 + k]!;
|
||||
if (s.board[q] !== oppStone || seen.has(q)) continue;
|
||||
const ch = chainAt(s.board, s.size, q);
|
||||
for (const st of ch.stones) seen.add(st);
|
||||
if (ch.libs.length === 1) events.push({ type: 'atari', color: other(me), stones: ch.stones.map((st) => toPt(st, s.size)) });
|
||||
}
|
||||
if (s.options.koRule === 'korean') {
|
||||
const key = situationKey(s.hash, s.turn);
|
||||
s.situationCounts[key] = (s.situationCounts[key] ?? 0) + 1;
|
||||
if (s.situationCounts[key]! >= 3) {
|
||||
finish(s, null, 'repetition');
|
||||
events.push({ type: 'gameEnded', reason: 'repetition', winner: null });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'toggleDead': {
|
||||
const m = s.scoringMarks!;
|
||||
const dead = new Set(m.dead);
|
||||
const i = a.y * s.size + a.x;
|
||||
const group = markGroup(s.board, s.size, i, dead);
|
||||
const makeDead = !dead.has(i);
|
||||
for (const g of group) {
|
||||
if (makeDead) dead.add(g);
|
||||
else dead.delete(g);
|
||||
}
|
||||
m.dead = [...dead].sort((x, y) => x - y);
|
||||
m.agreed = { black: false, white: false };
|
||||
events.push({ type: 'marksChanged', by: me, kind: makeDead ? 'dead' : 'alive', points: group.map((g) => toPt(g, s.size)) });
|
||||
break;
|
||||
}
|
||||
case 'toggleSeki': {
|
||||
const m = s.scoringMarks!;
|
||||
const region = emptyRegionAt(s.board, s.size, a.y * s.size + a.x, new Set(m.dead));
|
||||
const inR = new Set(region);
|
||||
const had = m.seki.some((p) => inR.has(p));
|
||||
m.seki = had ? m.seki.filter((p) => !inR.has(p)) : [...m.seki, Math.min(...region)].sort((x, y) => x - y);
|
||||
m.agreed = { black: false, white: false };
|
||||
events.push({ type: 'marksChanged', by: me, kind: had ? 'unseki' : 'seki', points: region.map((g) => toPt(g, s.size)) });
|
||||
break;
|
||||
}
|
||||
case 'agreeScore':
|
||||
case 'timeout':
|
||||
if (s.phase === 'scoring') {
|
||||
s.scoringMarks!.agreed[me] = true;
|
||||
events.push({ type: 'scoreAgreed', color: me, auto: a.type === 'timeout' });
|
||||
if (s.scoringMarks!.agreed.black && s.scoringMarks!.agreed.white) finalizeScore(s, events);
|
||||
break;
|
||||
}
|
||||
// playing: no auto move in baduk — the player loses on time (§8.5).
|
||||
if (s.options.timeControl.kind === 'byoyomi') {
|
||||
s.clock.mainMs[me] = 0;
|
||||
s.clock.periodsLeft[me] = 0;
|
||||
} else if (s.options.timeControl.kind === 'fischer') s.clock.mainMs[me] = 0;
|
||||
finish(s, other(me), 'timeLoss');
|
||||
events.push({ type: 'timeLoss', color: me }, { type: 'gameEnded', reason: 'timeLoss', winner: other(me) });
|
||||
break;
|
||||
case 'resumePlay':
|
||||
s.phase = 'playing';
|
||||
s.scoringMarks = null;
|
||||
s.turn = other(me);
|
||||
s.consecutivePasses = 0;
|
||||
s.lastResumeAt = s.moves.length;
|
||||
s.clock.turnStartedAt = now;
|
||||
events.push({ type: 'playResumed', by: me, turn: s.turn });
|
||||
break;
|
||||
case 'requestUndo': {
|
||||
const last = s.moves[s.moves.length - 1]!;
|
||||
s.undo.pending = { by: me, plies: last.color === me ? 1 : 2 };
|
||||
events.push({ type: 'undoRequested', color: me });
|
||||
break;
|
||||
}
|
||||
case 'respondUndo': {
|
||||
const { by, plies } = s.undo.pending!;
|
||||
s.undo.pending = null;
|
||||
if (a.accept) {
|
||||
replay(s, s.moves.slice(0, s.moves.length - plies));
|
||||
s.turn = by;
|
||||
s.undo.usedBy[by]++;
|
||||
s.clock.turnStartedAt = now;
|
||||
events.push({ type: 'undoAccepted', color: by, plies });
|
||||
} else events.push({ type: 'undoDeclined', color: by });
|
||||
break;
|
||||
}
|
||||
case 'offerDraw':
|
||||
s.draw.pendingBy = me;
|
||||
s.draw.lastOfferMove[me] = s.moves.length;
|
||||
events.push({ type: 'drawOffered', color: me });
|
||||
break;
|
||||
case 'respondDraw':
|
||||
s.draw.pendingBy = null;
|
||||
if (a.accept) {
|
||||
finish(s, null, 'drawAgreed');
|
||||
events.push({ type: 'drawAccepted' }, { type: 'gameEnded', reason: 'drawAgreed', winner: null });
|
||||
} else events.push({ type: 'drawDeclined' });
|
||||
break;
|
||||
case 'cancelOffer':
|
||||
if (s.undo.pending?.by === me) s.undo.pending = null;
|
||||
if (s.draw.pendingBy === me) s.draw.pendingBy = null;
|
||||
events.push({ type: 'offerCancelled', color: me });
|
||||
break;
|
||||
case 'resign':
|
||||
finish(s, other(me), 'resign');
|
||||
events.push({ type: 'resigned', color: me }, { type: 'gameEnded', reason: 'resign', winner: other(me) });
|
||||
break;
|
||||
}
|
||||
return { state: s, events };
|
||||
},
|
||||
|
||||
view(s, viewer) {
|
||||
const myColor = viewer ? colorOf(s, viewer) : null;
|
||||
const limit = undoLimit(s.options);
|
||||
const tc = s.options.timeControl;
|
||||
const myTurn = s.phase === 'playing' && myColor !== null && myColor === s.turn;
|
||||
const last = s.moves[s.moves.length - 1];
|
||||
const m = s.scoringMarks;
|
||||
return {
|
||||
size: s.size,
|
||||
board: s.board,
|
||||
players: s.players,
|
||||
myColor,
|
||||
turn: s.turn,
|
||||
phase: s.phase,
|
||||
rules: { scoring: s.options.scoring, koRule: s.options.koRule, komi: s.komi, handicap: s.handicap, atariWarning: s.options.atariWarning },
|
||||
captures: s.captures,
|
||||
moveCount: s.moves.length,
|
||||
moves: s.moves.map((mv) => ({ color: mv.color, pt: mv.kind === 'place' ? mv.pt : null, captured: mv.kind === 'place' ? mv.captured.length : 0 })),
|
||||
lastMove: last?.kind === 'place' ? last.pt : null,
|
||||
lastCaptured: last?.kind === 'place' ? last.captured : [],
|
||||
consecutivePasses: s.consecutivePasses,
|
||||
clock: {
|
||||
kind: tc.kind,
|
||||
mainSec: effectiveMainSec(s.options),
|
||||
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,
|
||||
turnStartedAt: s.clock.turnStartedAt,
|
||||
},
|
||||
undo: {
|
||||
allowed: s.options.undo !== 'off',
|
||||
remaining: {
|
||||
black: Number.isFinite(limit) ? limit - s.undo.usedBy.black : null,
|
||||
white: Number.isFinite(limit) ? limit - s.undo.usedBy.white : null,
|
||||
},
|
||||
pending: s.undo.pending,
|
||||
},
|
||||
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
|
||||
illegalPoints: myTurn ? [...illegalMap(s)].map(([i, reason]) => ({ ...toPt(i, s.size), reason })) : [],
|
||||
atariWarnings: myTurn && s.options.atariWarning ? atariStones(s.board, s.size, STONE[myColor]).map((i) => toPt(i, s.size)) : [],
|
||||
scoring: m
|
||||
? {
|
||||
dead: m.dead.map((i) => toPt(i, s.size)),
|
||||
seki: sekiPoints(s).map((i) => toPt(i, s.size)),
|
||||
agreed: m.agreed,
|
||||
deadlineAt: m.startedAt + s.options.scoringDeadlineSec * 1000,
|
||||
durationMs: s.options.scoringDeadlineSec * 1000,
|
||||
preview: scoreOf(s),
|
||||
}
|
||||
: null,
|
||||
result: s.result,
|
||||
};
|
||||
},
|
||||
|
||||
activePlayers(s) {
|
||||
if (s.phase === 'scoring') return (['black', 'white'] as const).filter((c) => !s.scoringMarks!.agreed[c]).map((c) => s.players[c]);
|
||||
if (s.phase !== 'playing') return [];
|
||||
const out = [s.players[s.turn]];
|
||||
const responder = pendingResponder(s);
|
||||
if (responder && responder !== s.turn) out.push(s.players[responder]);
|
||||
return out;
|
||||
},
|
||||
|
||||
deadline(s) {
|
||||
if (s.phase === 'scoring') return s.scoringMarks!.startedAt + s.options.scoringDeadlineSec * 1000;
|
||||
if (s.phase !== 'playing') return null;
|
||||
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;
|
||||
if (tc.kind === 'fischer') return start + s.clock.mainMs[s.turn];
|
||||
return start + s.options.idleLimitSec * 1000;
|
||||
},
|
||||
|
||||
timeoutPlayers(s) {
|
||||
if (s.phase === 'scoring') return (['black', 'white'] as const).filter((c) => !s.scoringMarks!.agreed[c]).map((c) => s.players[c]);
|
||||
return s.phase === 'playing' ? [s.players[s.turn]] : [];
|
||||
},
|
||||
|
||||
onTimeout() {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
onLeave() {
|
||||
return { type: 'resign' };
|
||||
},
|
||||
|
||||
result(s): GameResult | null {
|
||||
if (!s.result) return null;
|
||||
const { winner, reason, summaryKo, score: sc } = s.result;
|
||||
const ranking = winner ? [[s.players[winner]], [s.players[other(winner)]]] : [[s.players.black, s.players.white]];
|
||||
const map: Record<BadukReason, GameResult['reason']> = {
|
||||
score: 'normal',
|
||||
jigo: 'normal',
|
||||
repetition: 'normal',
|
||||
resign: 'resign',
|
||||
timeLoss: 'timeout',
|
||||
drawAgreed: 'draw-agreed',
|
||||
};
|
||||
return {
|
||||
ranking,
|
||||
summary: summaryKo,
|
||||
reason: map[reason],
|
||||
...(sc ? { scores: { [s.players.black]: sc.black.total, [s.players.white]: sc.white.total } } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
legalActions(s, p) {
|
||||
const me = colorOf(s, p);
|
||||
if (!me || s.phase === 'finished') return [];
|
||||
if (s.phase === 'scoring') {
|
||||
if (s.scoringMarks!.agreed[me]) return [];
|
||||
const out: BadukAction[] = [{ type: 'agreeScore' }, { type: 'agreeScore' }, { type: 'resumePlay' }];
|
||||
const stone = s.board.findIndex((v) => v !== 0);
|
||||
if (stone >= 0) out.push({ type: 'toggleDead', ...toPt(stone, s.size) });
|
||||
const empty = s.board.indexOf(0);
|
||||
if (empty >= 0) out.push({ type: 'toggleSeki', ...toPt(empty, s.size) });
|
||||
return out;
|
||||
}
|
||||
const responder = pendingResponder(s);
|
||||
if (responder === me) {
|
||||
const accept = s.moves.length % 2 === 0;
|
||||
return s.undo.pending ? [{ type: 'respondUndo', accept }] : [{ type: 'respondDraw', accept: false }];
|
||||
}
|
||||
if (s.turn !== me) return [];
|
||||
const illegal = illegalMap(s);
|
||||
const out: BadukAction[] = [{ type: 'pass' }];
|
||||
for (let i = 0; i < s.board.length; i++) {
|
||||
if (s.board[i] === 0 && !illegal.has(i) && !isOwnEye(s, i, STONE[me])) out.push({ type: 'place', ...toPt(i, s.size) });
|
||||
}
|
||||
if (s.moves.length % 37 === 5 && baduk.validate(s, p, { type: 'requestUndo' }).ok) out.push({ type: 'requestUndo' });
|
||||
return out;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user