- 규칙 모듈 packages/games/src/one-card: 원카드(트럼프 54장, 6명부터 2벌)와 색깔 카드(108장) 두 모드. 공격·받아치기·3 방어·7 무늬 지정·J/Q/K·조커 기준 카드·여러 장 내기·마지막 카드 제한, 원카드 선언/잡기, 파산, 드로우 더미 재구성, +4 의심하기(의심한 사람에게만 손패 공개), +2/+4 받아치기, 목표 점수 여러 판, 시간 초과(자동 먹기·넘기기·받아들이기, 3번 연속이면 5초), 나가기. 모든 옵션에 한국어 메타. - 테스트: 문서 10절 시나리오 45개 + 모드별 무작위 대국 1,000판(카드 수 보존, 종료, 결정성, 유출, onTimeout 합법) + 엔진 randomPlayout(매 단계 유출·직렬화 검사) 18판. - 화면 apps/web/src/games/one-card: 상대 좌석, 판(더미·바닥·방향·지정 무늬/색·공격 누적), 손패(정렬·힌트· 두 번 눌러 내기·끌어 놓기·방향키), 원카드 버튼, 무늬/색 고르기, 의심하기, 기록·특수 카드 표. 색깔 카드는 직접 그린 SVG(색맹용 무늬 패턴과 색 이름 포함). - 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(곧 나와요 목록에서 제외), E2E 계획(PC 1 + 휴대폰 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1045 lines
40 KiB
TypeScript
1045 lines
40 KiB
TypeScript
/** 원카드 / 색깔 카드 (docs/games/one-card.md). Pure and deterministic; state is JSON. */
|
|
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
|
|
import { COLORS, SUITS, SUIT_SYMBOL, classicDeck, colorOf, handPoints, isJoker, isStd, unoDeck, type Card, type Color, type Suit } from './cards';
|
|
import {
|
|
attackOf,
|
|
canBlockWithThree,
|
|
canCounter,
|
|
canStartWith,
|
|
classicMatches,
|
|
currentColor,
|
|
forbiddenAsLast,
|
|
hintFor,
|
|
legalCardIds,
|
|
referenceCard,
|
|
resolvedBankrupt,
|
|
resolvedPenalty,
|
|
resolvedPlayAfterDraw,
|
|
top,
|
|
unoCanStack,
|
|
unoMatches,
|
|
} from './rules';
|
|
import {
|
|
oneCardActionSchema,
|
|
oneCardOptionsSchema,
|
|
type CardFace,
|
|
type LogEntry,
|
|
type OneCardAction,
|
|
type OneCardFinal,
|
|
type OneCardOptions,
|
|
type OneCardState,
|
|
type OneCardView,
|
|
type PlayAction,
|
|
type PlayerResult,
|
|
} from './types';
|
|
|
|
export * from './cards';
|
|
export * from './types';
|
|
export { attackOf, canCounter, canBlockWithThree, currentColor, legalCardIds, referenceCard, resolvedBankrupt, resolvedPenalty, resolvedPlayAfterDraw } from './rules';
|
|
|
|
const AWAY_MS = 5000;
|
|
const WILD4_MS = 10_000;
|
|
const ROUND_END_MS = 8000;
|
|
const AWAY_AFTER = 3;
|
|
const LOG_MAX = 30;
|
|
const LOG_VIEW = 15;
|
|
|
|
interface Ctx {
|
|
rng: Rng;
|
|
now: number;
|
|
events: GameEvent[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------- small helpers
|
|
|
|
/**
|
|
* Copy for `apply`: containers are copied, card objects / log entries / options are shared because
|
|
* nothing ever mutates them (much cheaper than structuredClone for 100+ card objects).
|
|
*/
|
|
function cloneState(s: OneCardState): OneCardState {
|
|
const rec = <T>(r: Record<PlayerId, T>): Record<PlayerId, T> => ({ ...r });
|
|
return {
|
|
...s,
|
|
seats: [...s.seats],
|
|
hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])),
|
|
drawPile: [...s.drawPile],
|
|
discard: [...s.discard],
|
|
phase: { ...s.phase },
|
|
oneCardWindow: s.oneCardWindow ? { ...s.oneCardWindow } : null,
|
|
status: rec(s.status),
|
|
finishOrder: [...s.finishOrder],
|
|
bankruptOrder: [...s.bankruptOrder],
|
|
leftOrder: [...s.leftOrder],
|
|
timeoutsInRow: rec(s.timeoutsInRow),
|
|
away: rec(s.away),
|
|
scores: rec(s.scores),
|
|
rounds: [...s.rounds],
|
|
ready: [...s.ready],
|
|
log: [...s.log],
|
|
};
|
|
}
|
|
|
|
export function playerRange(o: Pick<OneCardOptions, 'mode'>): { min: number; max: number } {
|
|
return { min: 2, max: o.mode === 'classic' ? 9 : 10 };
|
|
}
|
|
|
|
const alive = (s: OneCardState): PlayerId[] => s.seats.filter((p) => s.status[p] === 'playing');
|
|
const gone = (s: OneCardState, p: PlayerId): boolean => s.leftOrder.includes(p);
|
|
export const deckSize = (s: OneCardState): number => (s.mode === 'classic' ? 54 * s.decks : 108);
|
|
/** Every card is in exactly one place (test invariant). */
|
|
export const cardTotal = (s: OneCardState): number =>
|
|
s.drawPile.length + s.discard.length + s.seats.reduce((a, p) => a + (s.hands[p]?.length ?? 0), 0);
|
|
|
|
function log(s: OneCardState, e: Record<string, unknown> & { t: LogEntry['t'] }): void {
|
|
s.log.push({ seq: s.seq, ...e } as LogEntry);
|
|
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
|
|
}
|
|
|
|
/** Moves `steps` seats in the current direction over players still in the round (`from` may be out). */
|
|
function advance(s: OneCardState, from: PlayerId, steps: number): PlayerId {
|
|
const n = s.seats.length;
|
|
if (alive(s).length === 0) return from;
|
|
let idx = s.seats.indexOf(from);
|
|
let moved = 0;
|
|
while (moved < steps) {
|
|
idx = (idx + s.direction + n) % n;
|
|
if (s.status[s.seats[idx]!] === 'playing') moved++;
|
|
}
|
|
return s.seats[idx]!;
|
|
}
|
|
|
|
/** Next player after `from` skipping `skips` people; skipping everyone else comes back to `from`. */
|
|
function nextAfter(s: OneCardState, from: PlayerId, skips: number): PlayerId {
|
|
const steps = 1 + skips;
|
|
if (s.status[from] === 'playing' && steps >= alive(s).length) return from;
|
|
return advance(s, from, steps);
|
|
}
|
|
|
|
function startTurn(s: OneCardState, p: PlayerId, extra = false): void {
|
|
s.current = p;
|
|
s.phase = { kind: 'turn' };
|
|
s.extraTurn = extra;
|
|
s.turnNo += 1;
|
|
}
|
|
|
|
function reshuffle(s: OneCardState, ctx: Ctx): void {
|
|
// Keep the top card (and, for jokers, everything down to the card that still decides what may follow).
|
|
let keepFrom = s.discard.length - 1;
|
|
if (s.mode === 'classic') while (keepFrom > 0 && isJoker(s.discard[keepFrom]!)) keepFrom--;
|
|
const pool = s.discard.slice(0, keepFrom);
|
|
if (pool.length === 0) return;
|
|
s.discard = s.discard.slice(keepFrom);
|
|
s.drawPile = [...s.drawPile, ...ctx.rng.shuffle(pool)];
|
|
ctx.events.push({ type: 'reshuffled', count: pool.length });
|
|
log(s, { t: 'reshuffle', n: pool.length });
|
|
}
|
|
|
|
/** Draws up to `n` cards (refilling once from the discard pile), then applies bankruptcy. */
|
|
function drawCards(s: OneCardState, p: PlayerId, n: number, why: Extract<LogEntry, { t: 'draw' }>['why'], ctx: Ctx): number {
|
|
const hand = s.hands[p]!;
|
|
let got = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
if (s.drawPile.length === 0) reshuffle(s, ctx);
|
|
const c = s.drawPile.shift();
|
|
if (!c) break;
|
|
hand.push(c);
|
|
got++;
|
|
}
|
|
ctx.events.push({ type: 'cardsDrawn', player: p, count: got, why });
|
|
log(s, { t: 'draw', p, n: got, why });
|
|
if (s.oneCardWindow?.player === p && hand.length !== 1) s.oneCardWindow = null;
|
|
const limit = resolvedBankrupt(s.options);
|
|
if (limit !== null && hand.length >= limit && s.status[p] === 'playing') goBankrupt(s, p, ctx);
|
|
return got;
|
|
}
|
|
|
|
function removeFromPlay(s: OneCardState, p: PlayerId): void {
|
|
// Hand goes to the bottom of the draw pile without shuffling (§5.4).
|
|
s.drawPile.push(...s.hands[p]!);
|
|
s.hands[p] = [];
|
|
if (s.oneCardWindow?.player === p) s.oneCardWindow = null;
|
|
}
|
|
|
|
function goBankrupt(s: OneCardState, p: PlayerId, ctx: Ctx): void {
|
|
s.status[p] = 'bankrupt';
|
|
s.bankruptOrder.push(p);
|
|
removeFromPlay(s, p);
|
|
ctx.events.push({ type: 'bankrupt', player: p });
|
|
log(s, { t: 'bankrupt', p });
|
|
}
|
|
|
|
/** Someone left play outside their own turn action (bankrupt by a catch, or left the room). */
|
|
function repairAfterRemoval(s: OneCardState, p: PlayerId, ctx: Ctx): void {
|
|
if (maybeEndRound(s, ctx)) return;
|
|
const ph = s.phase;
|
|
if (ph.kind === 'wild4Response') {
|
|
if (ph.by === p) {
|
|
// The +4 of a player who is gone no longer counts; the target just plays on.
|
|
s.pendingAttack = 0;
|
|
startTurn(s, ph.target);
|
|
} else if (ph.target === p) {
|
|
s.pendingAttack = 0;
|
|
startTurn(s, nextAfter(s, p, 0));
|
|
}
|
|
return;
|
|
}
|
|
if ((ph.kind === 'turn' || ph.kind === 'afterDraw') && s.current === p) {
|
|
s.pendingAttack = 0;
|
|
startTurn(s, nextAfter(s, p, 0));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- rounds
|
|
|
|
function dealRound(s: OneCardState, starter: PlayerId, ctx: Ctx): void {
|
|
const o = s.options;
|
|
const players = s.seats.filter((p) => !gone(s, p));
|
|
const deck: Card[] = s.mode === 'classic' ? classicDeck(s.decks) : unoDeck();
|
|
s.drawPile = ctx.rng.shuffle(deck);
|
|
s.discard = [];
|
|
s.hands = Object.fromEntries(s.seats.map((p) => [p, [] as Card[]]));
|
|
for (const p of s.seats) s.status[p] = gone(s, p) ? 'left' : 'playing';
|
|
s.finishOrder = [];
|
|
s.bankruptOrder = [];
|
|
s.declaredSuit = null;
|
|
s.declaredColor = null;
|
|
s.direction = 1;
|
|
s.pendingAttack = 0;
|
|
s.extraTurn = false;
|
|
s.oneCardWindow = null;
|
|
s.reveal = null;
|
|
s.ready = [];
|
|
s.roundStarter = starter;
|
|
// Deal one card at a time from the first player.
|
|
const order: PlayerId[] = [];
|
|
for (let k = 0, p = starter; k < players.length; k++, p = advance(s, p, 1)) order.push(p);
|
|
for (let r = 0; r < o.handSize; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!);
|
|
ctx.events.push({ type: 'dealt', players: order, count: o.handSize, round: s.round });
|
|
s.current = starter;
|
|
s.phase = { kind: 'turn' };
|
|
if (s.mode === 'classic') {
|
|
// Flip until a plain number card; 2/A/3/7/J/Q/K/jokers go to the bottom (§4, §10).
|
|
for (;;) {
|
|
const c = s.drawPile.shift()!;
|
|
if (isStd(c) && [4, 5, 6, 8, 9, 10].includes(c.rank)) {
|
|
s.discard.push(c);
|
|
break;
|
|
}
|
|
s.drawPile.push(c);
|
|
}
|
|
startTurn(s, starter);
|
|
return;
|
|
}
|
|
// Colour cards: the original start-card rules (§4).
|
|
let c = s.drawPile.shift()!;
|
|
while (c.kind === 'wild4') {
|
|
s.drawPile = ctx.rng.shuffle([...s.drawPile, c]);
|
|
c = s.drawPile.shift()!;
|
|
}
|
|
s.discard.push(c);
|
|
ctx.events.push({ type: 'startCard', card: faceOf(c) });
|
|
switch (c.kind) {
|
|
case 'draw2':
|
|
drawCards(s, starter, 2, 'start', ctx);
|
|
startTurn(s, nextAfter(s, starter, 0));
|
|
break;
|
|
case 'skip':
|
|
log(s, { t: 'skip', p: starter });
|
|
startTurn(s, nextAfter(s, starter, 0));
|
|
break;
|
|
case 'reverse':
|
|
s.direction = -1;
|
|
log(s, { t: 'reverse' });
|
|
startTurn(s, advance(s, starter, 1));
|
|
break;
|
|
default:
|
|
// Numbers start as they are; a starting "색 바꾸기" leaves the colour open for the first player.
|
|
startTurn(s, starter);
|
|
}
|
|
}
|
|
|
|
function placeOf(s: OneCardState): PlayerId[][] {
|
|
const groups: PlayerId[][] = s.finishOrder.map((p) => [p]);
|
|
const rest = alive(s).sort((a, b) => cmpHand(s, a, b));
|
|
for (const p of rest) {
|
|
const last = groups[groups.length - 1];
|
|
const prev = last?.[0];
|
|
if (last && prev && rest.includes(prev) && cmpHand(s, prev, p) === 0) last.push(p);
|
|
else groups.push([p]);
|
|
}
|
|
for (const p of [...s.bankruptOrder].reverse()) groups.push([p]);
|
|
for (const p of [...s.leftOrder].reverse()) if (s.status[p] === 'left') groups.push([p]);
|
|
return groups;
|
|
}
|
|
|
|
function cmpHand(s: OneCardState, a: PlayerId, b: PlayerId): number {
|
|
const ha = s.hands[a]!;
|
|
const hb = s.hands[b]!;
|
|
return ha.length - hb.length || handPoints(ha) - handPoints(hb);
|
|
}
|
|
|
|
function roundWinner(s: OneCardState): PlayerId | null {
|
|
if (s.finishOrder[0]) return s.finishOrder[0];
|
|
const live = alive(s);
|
|
return live.length === 1 ? live[0]! : null;
|
|
}
|
|
|
|
/** Colour-card points the winner collects from everyone else's remaining cards (§6). */
|
|
function roundPoints(s: OneCardState, winner: PlayerId | null): number {
|
|
if (!winner || s.mode !== 'uno-style') return 0;
|
|
return s.seats.filter((p) => p !== winner).reduce((a, p) => a + handPoints(s.hands[p]!), 0);
|
|
}
|
|
|
|
function maybeEndRound(s: OneCardState, ctx: Ctx): boolean {
|
|
if (s.phase.kind === 'finished' || s.phase.kind === 'roundEnd') return true;
|
|
const firstOut = s.options.endCondition === 'first-out' || multiRound(s);
|
|
if ((firstOut && s.finishOrder.length > 0) || alive(s).length <= 1) {
|
|
endRound(s, ctx);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const multiRound = (s: OneCardState): boolean => s.mode === 'uno-style' && s.options.unoTargetScore > 0;
|
|
|
|
function endRound(s: OneCardState, ctx: Ctx): void {
|
|
s.oneCardWindow = null;
|
|
s.pendingAttack = 0;
|
|
const winner = roundWinner(s);
|
|
const points = roundPoints(s, winner);
|
|
if (!multiRound(s)) {
|
|
finishSingle(s, winner, points, ctx);
|
|
return;
|
|
}
|
|
if (winner) s.scores[winner] = (s.scores[winner] ?? 0) + points;
|
|
s.rounds.push({ round: s.round, winner, points });
|
|
log(s, { t: 'round', round: s.round, winner, points });
|
|
ctx.events.push({ type: 'roundEnd', round: s.round, winner, points });
|
|
const remaining = s.seats.filter((p) => !gone(s, p));
|
|
if ((winner && s.scores[winner]! >= s.options.unoTargetScore) || remaining.length <= 1) {
|
|
finishMulti(s, ctx);
|
|
return;
|
|
}
|
|
s.phase = { kind: 'roundEnd' };
|
|
s.ready = [];
|
|
s.turnNo += 1;
|
|
}
|
|
|
|
function results(s: OneCardState, ranking: PlayerId[][], pointsOf: (p: PlayerId) => number): PlayerResult[] {
|
|
const out: PlayerResult[] = [];
|
|
let rank = 1;
|
|
for (const g of ranking) {
|
|
for (const p of g) out.push({ player: p, rank, status: s.status[p]!, cardsLeft: s.hands[p]!.length, points: pointsOf(p) });
|
|
rank += g.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function finishSingle(s: OneCardState, winner: PlayerId | null, points: number, ctx: Ctx): void {
|
|
const ranking = placeOf(s);
|
|
const uno = s.mode === 'uno-style';
|
|
const pointsOf = (p: PlayerId) => (uno ? (p === winner ? points : 0) : handPoints(s.hands[p]!));
|
|
const bankrupts = s.bankruptOrder.length;
|
|
let summary: string;
|
|
if (s.finishOrder.length === 0) {
|
|
summary = s.leftOrder.length > 0 && bankrupts === 0 ? '다른 사람이 모두 나가서 남은 사람이 이겼어요' : '다른 사람이 모두 파산해서 끝까지 남은 사람이 이겼어요';
|
|
} else if (s.options.endCondition === 'last-standing' && s.finishOrder.length > 1) {
|
|
summary = `${s.finishOrder.length}명이 차례로 카드를 모두 냈어요. 끝까지 남은 사람이 꼴찌예요`;
|
|
} else {
|
|
summary = uno ? `가장 먼저 카드를 모두 내서 ${points}점을 얻었어요` : '가장 먼저 카드를 모두 낸 사람이 이겼어요';
|
|
}
|
|
if (bankrupts > 0 && s.finishOrder.length > 0) summary += ` · 파산 ${bankrupts}명`;
|
|
s.final = {
|
|
ranking,
|
|
players: results(s, ranking, pointsOf),
|
|
...(uno ? { scores: Object.fromEntries(s.seats.map((p) => [p, pointsOf(p)])) } : {}),
|
|
summary,
|
|
};
|
|
finishState(s, ctx);
|
|
}
|
|
|
|
function finishMulti(s: OneCardState, ctx: Ctx): void {
|
|
const remaining = s.seats.filter((p) => !gone(s, p)).sort((a, b) => s.scores[b]! - s.scores[a]!);
|
|
const ranking: PlayerId[][] = [];
|
|
for (const p of remaining) {
|
|
const last = ranking[ranking.length - 1];
|
|
if (last && s.scores[last[0]!] === s.scores[p]) last.push(p);
|
|
else ranking.push([p]);
|
|
}
|
|
for (const p of [...s.leftOrder].reverse()) ranking.push([p]);
|
|
const best = remaining[0];
|
|
const target = s.options.unoTargetScore;
|
|
const summary =
|
|
best && s.scores[best]! >= target
|
|
? `${s.round}판 만에 ${s.scores[best]}점으로 목표 ${target}점에 먼저 닿았어요`
|
|
: '다른 사람이 모두 나가서 점수가 가장 높은 사람이 이겼어요';
|
|
s.final = { ranking, players: results(s, ranking, (p) => s.scores[p] ?? 0), scores: { ...s.scores }, summary };
|
|
finishState(s, ctx);
|
|
}
|
|
|
|
function finishState(s: OneCardState, ctx: Ctx): void {
|
|
s.phase = { kind: 'finished' };
|
|
s.oneCardWindow = null;
|
|
s.reveal = null;
|
|
ctx.events.push({ type: 'gameEnd', ranking: s.final!.ranking });
|
|
}
|
|
|
|
// ---------------------------------------------------------------- playing cards
|
|
|
|
function closeWindowOnTurnAction(s: OneCardState, actor: PlayerId): void {
|
|
const w = s.oneCardWindow;
|
|
if (!w) return;
|
|
// Classic: open until the owner's next action. Colour cards: until the next turn action by anyone (§5.3).
|
|
if (s.mode === 'uno-style' || w.player === actor) s.oneCardWindow = null;
|
|
}
|
|
|
|
function playCards(s: OneCardState, p: PlayerId, a: PlayAction, ctx: Ctx): void {
|
|
const o = s.options;
|
|
const hand = s.hands[p]!;
|
|
const cards = a.cardIds.map((id) => hand.find((c) => c.id === id)!);
|
|
const prevColor = s.mode === 'uno-style' ? currentColor(s) : null;
|
|
const wasAttacked = s.pendingAttack > 0;
|
|
const topBefore = top(s);
|
|
const illegalWild4 = cards[0]!.kind === 'wild4' && prevColor !== null && hand.some((c) => c.id !== cards[0]!.id && colorOf(c) === prevColor);
|
|
s.hands[p] = hand.filter((c) => !a.cardIds.includes(c.id));
|
|
s.discard.push(...cards);
|
|
const handLeft = s.hands[p]!.length;
|
|
ctx.events.push({ type: 'cardPlayed', player: p, cards: cards.map(faceOf) });
|
|
let skips = 0;
|
|
let extra = false;
|
|
let immediateDraw = false;
|
|
let wild4Challenge = false;
|
|
if (s.mode === 'classic') {
|
|
const allJokers = cards.every(isJoker);
|
|
if (!(allJokers && o.afterJoker === 'previous')) s.declaredSuit = null;
|
|
const attack = cards.reduce((x, c) => x + attackOf(o, c), 0);
|
|
if (wasAttacked && attack === 0 && canBlockWithThree(o, topBefore, cards[0]!)) {
|
|
s.pendingAttack = 0;
|
|
ctx.events.push({ type: 'attackBlocked', player: p });
|
|
log(s, { t: 'blocked', p });
|
|
} else if (attack > 0) {
|
|
s.pendingAttack += attack;
|
|
ctx.events.push({ type: 'attackStacked', total: s.pendingAttack });
|
|
}
|
|
const first = cards[0]!;
|
|
const n = cards.length;
|
|
if (isStd(first)) {
|
|
switch (first.rank) {
|
|
case 7:
|
|
s.declaredSuit = a.chooseSuit!;
|
|
ctx.events.push({ type: 'suitDeclared', suit: a.chooseSuit });
|
|
break;
|
|
case 11:
|
|
skips += n;
|
|
break;
|
|
case 12:
|
|
if (alive(s).length === 2) {
|
|
if (o.qInTwoPlayer === 'as-skip') skips += n;
|
|
} else if (n % 2 === 1) {
|
|
s.direction = s.direction === 1 ? -1 : 1;
|
|
ctx.events.push({ type: 'directionChanged', direction: s.direction });
|
|
log(s, { t: 'reverse' });
|
|
}
|
|
break;
|
|
case 13:
|
|
if (o.kEffect === 'extra-turn') extra = true;
|
|
else if (o.kEffect === 'skip-two') skips += 2 * n;
|
|
break;
|
|
}
|
|
}
|
|
log(s, { t: 'play', p, cards: cards.map(faceOf), ...(first.kind === 'std' && first.rank === 7 ? { suit: a.chooseSuit } : {}) });
|
|
} else {
|
|
const c = cards[0]!;
|
|
s.declaredColor = null;
|
|
switch (c.kind) {
|
|
case 'skip':
|
|
skips = 1;
|
|
break;
|
|
case 'reverse':
|
|
if (alive(s).length <= 2) skips = 1;
|
|
else {
|
|
s.direction = s.direction === 1 ? -1 : 1;
|
|
ctx.events.push({ type: 'directionChanged', direction: s.direction });
|
|
log(s, { t: 'reverse' });
|
|
}
|
|
break;
|
|
case 'draw2':
|
|
s.pendingAttack += 2;
|
|
immediateDraw = !o.unoStacking;
|
|
break;
|
|
case 'wild':
|
|
case 'wild4':
|
|
s.declaredColor = a.chooseColor!;
|
|
ctx.events.push({ type: 'colorDeclared', color: a.chooseColor });
|
|
if (c.kind === 'wild4') {
|
|
s.pendingAttack += 4;
|
|
if (o.unoWild4Rule === 'challenge') wild4Challenge = true;
|
|
else immediateDraw = !o.unoStacking;
|
|
}
|
|
break;
|
|
}
|
|
log(s, { t: 'play', p, cards: cards.map(faceOf), ...(isWildKind(c) ? { color: a.chooseColor } : {}) });
|
|
}
|
|
|
|
if (handLeft === 0) {
|
|
s.status[p] = 'out';
|
|
s.finishOrder.push(p);
|
|
ctx.events.push({ type: 'playerOut', player: p, place: s.finishOrder.length });
|
|
log(s, { t: 'out', p, place: s.finishOrder.length });
|
|
if (s.mode === 'uno-style' && s.pendingAttack > 0 && alive(s).length > 0) {
|
|
// Going out on +2/+4: the next player still takes them (counts for the points).
|
|
const victim = nextAfter(s, p, 0);
|
|
drawCards(s, victim, s.pendingAttack, cards[0]!.kind === 'wild4' ? 'wild4' : 'draw2', ctx);
|
|
s.pendingAttack = 0;
|
|
if (maybeEndRound(s, ctx)) return;
|
|
startTurn(s, nextAfter(s, victim, 0));
|
|
return;
|
|
}
|
|
if (maybeEndRound(s, ctx)) return;
|
|
startTurn(s, nextAfter(s, p, skips));
|
|
return;
|
|
}
|
|
|
|
if (handLeft === 1) {
|
|
const declared = o.autoDeclare || !!a.declareOneCard;
|
|
s.oneCardWindow = { player: p, declared, openedSeq: s.seq };
|
|
ctx.events.push({ type: 'oneCardOpen', player: p, declared });
|
|
if (declared) log(s, { t: 'declare', p });
|
|
}
|
|
|
|
if (extra) {
|
|
startTurn(s, p, true);
|
|
return;
|
|
}
|
|
const next = nextAfter(s, p, skips);
|
|
if (wild4Challenge) {
|
|
s.current = next;
|
|
s.phase = { kind: 'wild4Response', by: p, target: next, prevColor, illegal: illegalWild4 };
|
|
s.turnNo += 1;
|
|
return;
|
|
}
|
|
if (immediateDraw) {
|
|
drawCards(s, next, s.pendingAttack, cards[0]!.kind === 'wild4' ? 'wild4' : 'draw2', ctx);
|
|
s.pendingAttack = 0;
|
|
ctx.events.push({ type: 'skipped', player: next });
|
|
if (maybeEndRound(s, ctx)) return;
|
|
startTurn(s, nextAfter(s, next, 0));
|
|
return;
|
|
}
|
|
if (skips > 0) {
|
|
for (let k = 1; k <= skips && k < alive(s).length; k++) {
|
|
const skipped = advance(s, p, k);
|
|
if (skipped !== p) log(s, { t: 'skip', p: skipped });
|
|
}
|
|
}
|
|
startTurn(s, next);
|
|
}
|
|
|
|
const isWildKind = (c: Card): boolean => c.kind === 'wild' || c.kind === 'wild4';
|
|
export const faceOf = ({ id: _id, ...face }: Card): CardFace => face as CardFace;
|
|
|
|
/** Normal or attack draw by the current player (also used by the timer). */
|
|
function drawTurn(s: OneCardState, p: PlayerId, ctx: Ctx, allowAfterDraw: boolean): void {
|
|
if (s.pendingAttack > 0) {
|
|
const n = s.pendingAttack;
|
|
s.pendingAttack = 0;
|
|
drawCards(s, p, n, 'attack', ctx);
|
|
if (maybeEndRound(s, ctx)) return;
|
|
startTurn(s, nextAfter(s, p, 0));
|
|
return;
|
|
}
|
|
const before = s.hands[p]!.length;
|
|
drawCards(s, p, 1, 'normal', ctx);
|
|
if (maybeEndRound(s, ctx)) return;
|
|
if (s.status[p] === 'playing' && allowAfterDraw && resolvedPlayAfterDraw(s.options) && s.hands[p]!.length > before) {
|
|
const drawn = s.hands[p]![s.hands[p]!.length - 1]!;
|
|
if (canStartWith(s, p, drawn)) {
|
|
s.phase = { kind: 'afterDraw', drawnCardId: drawn.id };
|
|
return;
|
|
}
|
|
}
|
|
startTurn(s, nextAfter(s, p, 0));
|
|
}
|
|
|
|
function resolveWild4(s: OneCardState, challenge: boolean, ctx: Ctx): void {
|
|
const ph = s.phase;
|
|
if (ph.kind !== 'wild4Response') throw new Error('not in wild4Response');
|
|
const { by, target, illegal } = ph;
|
|
const total = s.pendingAttack;
|
|
s.pendingAttack = 0;
|
|
if (!challenge) {
|
|
drawCards(s, target, total, 'wild4', ctx);
|
|
if (maybeEndRound(s, ctx)) return;
|
|
startTurn(s, nextAfter(s, target, 0));
|
|
return;
|
|
}
|
|
const cards = s.hands[by]!.map(faceOf);
|
|
s.reveal = { to: target, by, cards, illegal, seq: s.seq };
|
|
ctx.events.push({ type: 'wild4Challenged', by, target, illegal });
|
|
log(s, { t: 'challenge', p: target, by, illegal });
|
|
if (illegal) {
|
|
drawCards(s, by, total, 'challenge', ctx);
|
|
if (maybeEndRound(s, ctx)) return;
|
|
startTurn(s, target);
|
|
} else {
|
|
drawCards(s, target, total + 2, 'challenge', ctx);
|
|
if (maybeEndRound(s, ctx)) return;
|
|
startTurn(s, nextAfter(s, target, 0));
|
|
}
|
|
}
|
|
|
|
function declareOneCard(s: OneCardState, actor: PlayerId, ctx: Ctx): void {
|
|
const w = s.oneCardWindow!;
|
|
if (w.player === actor) {
|
|
w.declared = true;
|
|
ctx.events.push({ type: 'oneCardDeclared', player: actor });
|
|
log(s, { t: 'declare', p: actor });
|
|
return;
|
|
}
|
|
const target = w.player;
|
|
const penalty = resolvedPenalty(s.options);
|
|
s.oneCardWindow = null;
|
|
ctx.events.push({ type: 'oneCardCaught', catcher: actor, target, penalty });
|
|
log(s, { t: 'caught', p: target, by: actor, n: penalty });
|
|
drawCards(s, target, penalty, 'penalty', ctx);
|
|
if (s.status[target] !== 'playing') repairAfterRemoval(s, target, ctx);
|
|
}
|
|
|
|
function leave(s: OneCardState, p: PlayerId, ctx: Ctx): void {
|
|
s.leftOrder.push(p);
|
|
ctx.events.push({ type: 'left', player: p });
|
|
log(s, { t: 'left', p });
|
|
s.ready = s.ready.filter((x) => x !== p);
|
|
if (s.phase.kind === 'roundEnd') {
|
|
s.status[p] = 'left';
|
|
if (s.seats.filter((x) => !gone(s, x)).length <= 1) finishMulti(s, ctx);
|
|
else maybeStartNextRound(s, ctx);
|
|
return;
|
|
}
|
|
if (s.status[p] !== 'playing') return;
|
|
s.status[p] = 'left';
|
|
removeFromPlay(s, p);
|
|
repairAfterRemoval(s, p, ctx);
|
|
}
|
|
|
|
function requiredReady(s: OneCardState): PlayerId[] {
|
|
return s.seats.filter((p) => !gone(s, p));
|
|
}
|
|
|
|
function maybeStartNextRound(s: OneCardState, ctx: Ctx): void {
|
|
if (s.phase.kind !== 'roundEnd') return;
|
|
if (!requiredReady(s).every((p) => s.ready.includes(p))) return;
|
|
s.round += 1;
|
|
const players = s.seats.filter((p) => !gone(s, p));
|
|
// Next round starts one seat clockwise from the previous first player.
|
|
const n = s.seats.length;
|
|
let idx = s.seats.indexOf(s.roundStarter);
|
|
do idx = (idx + 1) % n;
|
|
while (!players.includes(s.seats[idx]!));
|
|
dealRound(s, s.seats[idx]!, ctx);
|
|
}
|
|
|
|
function waiting(s: OneCardState): PlayerId[] {
|
|
switch (s.phase.kind) {
|
|
case 'turn':
|
|
case 'afterDraw':
|
|
return [s.current];
|
|
case 'wild4Response':
|
|
return [s.phase.target];
|
|
case 'roundEnd':
|
|
return requiredReady(s).filter((p) => !s.ready.includes(p));
|
|
case 'finished':
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function canDeclare(s: OneCardState, p: PlayerId): boolean {
|
|
const w = s.oneCardWindow;
|
|
if (!w || w.declared || s.phase.kind === 'finished' || s.phase.kind === 'roundEnd') return false;
|
|
const st = s.status[p];
|
|
return st === 'playing' || (st === 'out' && w.player !== p);
|
|
}
|
|
|
|
const lastCardReason: Record<OneCardOptions['lastCardRule'], string> = {
|
|
none: '',
|
|
joker: '마지막 카드로 조커는 낼 수 없어요.',
|
|
attack: '마지막 카드로 공격 카드(2·A·조커)는 낼 수 없어요.',
|
|
special: '마지막 카드로 특수 카드는 낼 수 없어요.',
|
|
};
|
|
|
|
function validatePlay(s: OneCardState, actor: PlayerId, a: PlayAction): Validation {
|
|
const ph = s.phase;
|
|
const responding = ph.kind === 'wild4Response';
|
|
if (responding) {
|
|
if (ph.target !== actor) return fail('지금은 내 차례가 아니에요.');
|
|
if (!s.options.unoStacking) return fail('의심하기나 받아들이기를 골라 주세요.');
|
|
} else {
|
|
if (ph.kind !== 'turn' && ph.kind !== 'afterDraw') return fail('지금은 카드를 낼 수 없어요.');
|
|
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
|
|
}
|
|
const hand = s.hands[actor]!;
|
|
if (new Set(a.cardIds).size !== a.cardIds.length) return fail('같은 카드를 두 번 고를 수 없어요.');
|
|
const cards = a.cardIds.map((id) => hand.find((c) => c.id === id));
|
|
if (cards.some((c) => !c)) return fail('손에 없는 카드예요.');
|
|
const cs = cards as Card[];
|
|
if (ph.kind === 'afterDraw' && (cs.length !== 1 || cs[0]!.id !== ph.drawnCardId)) return fail('방금 먹은 카드만 낼 수 있어요.');
|
|
const first = cs[0]!;
|
|
if (s.mode === 'uno-style') {
|
|
if (cs.length !== 1) return fail('한 번에 한 장만 낼 수 있어요.');
|
|
if (responding) {
|
|
if (first.kind !== 'wild4') return fail('색 바꾸기+4로만 받아칠 수 있어요.');
|
|
} else if (s.pendingAttack > 0) {
|
|
if (!unoCanStack(top(s), first)) return fail(top(s).kind === 'wild4' ? '색 바꾸기+4로 받아치거나 카드를 먹으세요.' : '+2나 색 바꾸기+4로 받아치거나 카드를 먹으세요.');
|
|
} else if (!unoMatches(s, first, hand)) {
|
|
return fail(first.kind === 'wild4' ? '지금 색 카드가 손에 있으면 색 바꾸기+4를 낼 수 없어요.' : '같은 색이나 같은 숫자·기호 카드만 낼 수 있어요.');
|
|
}
|
|
if (isWildKind(first) && !a.chooseColor) return fail('색을 골라 주세요.');
|
|
return ok;
|
|
}
|
|
if (cs.length > 1) {
|
|
if (!s.options.multiPlay) return fail('한 번에 한 장만 낼 수 있어요.');
|
|
if (!cs.every((c) => isStd(c) && isStd(first) && c.rank === first.rank)) return fail('같은 숫자 카드만 함께 낼 수 있어요.');
|
|
}
|
|
if (s.pendingAttack > 0) {
|
|
if (!canCounter(top(s), first, s.decks === 2) && !canBlockWithThree(s.options, top(s), first)) {
|
|
return fail('공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요.');
|
|
}
|
|
} else if (!classicMatches(s, first)) {
|
|
return fail(s.declaredSuit ? `${SUIT_SYMBOL[s.declaredSuit]} 무늬나 7만 낼 수 있어요.` : '같은 무늬나 같은 숫자 카드만 낼 수 있어요.');
|
|
}
|
|
if (cs.length === hand.length && cs.some((c) => forbiddenAsLast(s.options, c))) return fail(lastCardReason[s.options.lastCardRule]);
|
|
if (isStd(first) && first.rank === 7 && !a.chooseSuit) return fail('무늬를 골라 주세요.');
|
|
return ok;
|
|
}
|
|
|
|
function drawAllowed(s: OneCardState, p: PlayerId): boolean {
|
|
if (s.phase.kind !== 'turn' || s.current !== p) return false;
|
|
return s.pendingAttack > 0 || s.options.voluntaryDraw || legalCardIds(s, p).length === 0;
|
|
}
|
|
|
|
function deadlineMs(s: OneCardState): number | null {
|
|
switch (s.phase.kind) {
|
|
case 'turn':
|
|
case 'afterDraw':
|
|
return s.away[s.current] ? AWAY_MS : s.options.turnSeconds * 1000;
|
|
case 'wild4Response':
|
|
return s.away[s.phase.target] ? AWAY_MS : WILD4_MS;
|
|
case 'roundEnd':
|
|
return ROUND_END_MS;
|
|
case 'finished':
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** All play actions for `p` built from a set of card ids (suits / colours / declare variants). */
|
|
function playVariants(s: OneCardState, p: PlayerId, ids: string[]): PlayAction[] {
|
|
const hand = s.hands[p]!;
|
|
const first = hand.find((c) => c.id === ids[0])!;
|
|
let out: PlayAction[] = [{ type: 'play', cardIds: ids }];
|
|
if (isStd(first) && first.rank === 7) out = SUITS.map((chooseSuit: Suit) => ({ type: 'play', cardIds: ids, chooseSuit }));
|
|
if (isWildKind(first)) out = COLORS.map((chooseColor: Color) => ({ type: 'play', cardIds: ids, chooseColor }));
|
|
if (hand.length - ids.length === 1) out = out.flatMap((x) => [x, { ...x, declareOneCard: true }]);
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- definition
|
|
|
|
export const oneCard: GameDefinition<OneCardState, OneCardAction, OneCardView, OneCardOptions> = {
|
|
id: 'one-card',
|
|
nameKo: '원카드',
|
|
minPlayers: 2,
|
|
maxPlayers: 10,
|
|
stateVersion: 1,
|
|
defaultOptions: oneCardOptionsSchema.parse({}),
|
|
optionsSchema: oneCardOptionsSchema as unknown as GameDefinition<OneCardState, OneCardAction, OneCardView, OneCardOptions>['optionsSchema'],
|
|
actionSchema: oneCardActionSchema as unknown as GameDefinition<OneCardState, OneCardAction, OneCardView, OneCardOptions>['actionSchema'],
|
|
playersFor: playerRange,
|
|
|
|
setup({ players, options, rng, now }) {
|
|
const range = playerRange(options);
|
|
if (players.length < range.min || players.length > range.max) throw new Error(`one-card: ${players.length} players not allowed in ${options.mode}`);
|
|
let decks: 1 | 2 = 1;
|
|
if (options.mode === 'classic') {
|
|
decks = options.deckCount === 'auto' ? (players.length >= 6 ? 2 : 1) : options.deckCount;
|
|
// One deck cannot deal this many hands and still leave a draw pile.
|
|
if (decks === 1 && players.length * options.handSize > 44) decks = 2;
|
|
}
|
|
const s: OneCardState = {
|
|
mode: options.mode,
|
|
options,
|
|
decks,
|
|
seats: [...players],
|
|
hands: {},
|
|
drawPile: [],
|
|
discard: [],
|
|
declaredSuit: null,
|
|
declaredColor: null,
|
|
direction: 1,
|
|
current: players[0]!,
|
|
phase: { kind: 'turn' },
|
|
pendingAttack: 0,
|
|
extraTurn: false,
|
|
oneCardWindow: null,
|
|
status: Object.fromEntries(players.map((p) => [p, 'playing' as const])),
|
|
finishOrder: [],
|
|
bankruptOrder: [],
|
|
leftOrder: [],
|
|
turnStartedAt: now,
|
|
seq: 0,
|
|
turnNo: 0,
|
|
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
|
|
away: Object.fromEntries(players.map((p) => [p, false])),
|
|
round: 1,
|
|
roundStarter: players[0]!,
|
|
scores: Object.fromEntries(players.map((p) => [p, 0])),
|
|
rounds: [],
|
|
ready: [],
|
|
reveal: null,
|
|
log: [],
|
|
final: null,
|
|
};
|
|
const ctx: Ctx = { rng, now, events: [] };
|
|
// §4: shuffle (inside dealRound) after picking the first player with the same RNG.
|
|
const starter = players[rng.int(players.length)]!;
|
|
dealRound(s, starter, ctx);
|
|
return s;
|
|
},
|
|
|
|
validate(s, actor, a) {
|
|
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
|
|
if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.');
|
|
if (a.type === 'leave') return gone(s, actor) ? fail('이미 나간 플레이어예요.') : ok;
|
|
if (gone(s, actor)) return fail('방을 나간 플레이어예요.');
|
|
switch (a.type) {
|
|
case 'timeout':
|
|
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
|
|
case 'ready':
|
|
return s.phase.kind === 'roundEnd' && !s.ready.includes(actor) ? ok : fail('지금은 다음 판을 기다릴 때가 아니에요.');
|
|
case 'declareOneCard': {
|
|
const w = s.oneCardWindow;
|
|
if (!w || s.phase.kind === 'roundEnd') return fail('지금은 원카드를 외칠 때가 아니에요.');
|
|
if (w.declared) return fail('이미 원카드를 외쳤어요.');
|
|
return canDeclare(s, actor) ? ok : fail('게임에서 빠진 사람은 외칠 수 없어요.');
|
|
}
|
|
case 'challengeWild4':
|
|
case 'acceptWild4':
|
|
if (s.phase.kind !== 'wild4Response' || s.phase.target !== actor) return fail('지금은 의심하기를 할 수 없어요.');
|
|
return ok;
|
|
case 'pass':
|
|
if (s.phase.kind !== 'afterDraw' || s.current !== actor) return fail('지금은 넘길 수 없어요.');
|
|
return ok;
|
|
case 'draw':
|
|
if (s.phase.kind === 'afterDraw' && s.current === actor) return fail('먹은 카드를 내거나 넘기기를 눌러 주세요.');
|
|
if (s.phase.kind !== 'turn' || s.current !== actor) return fail('지금은 내 차례가 아니에요.');
|
|
if (!drawAllowed(s, actor)) return fail('낼 수 있는 카드가 있으면 먹을 수 없어요.');
|
|
return ok;
|
|
case 'play':
|
|
return validatePlay(s, actor, a);
|
|
}
|
|
},
|
|
|
|
apply(prev, actor, a, { rng, now }) {
|
|
const s = cloneState(prev);
|
|
const ctx: Ctx = { rng, now, events: [] };
|
|
const before = `${s.turnNo}|${s.phase.kind}`;
|
|
s.seq += 1;
|
|
const turnAction = a.type === 'play' || a.type === 'draw' || a.type === 'pass' || a.type === 'challengeWild4' || a.type === 'acceptWild4' || a.type === 'timeout';
|
|
if (turnAction) {
|
|
if (s.phase.kind !== 'roundEnd') closeWindowOnTurnAction(s, actor);
|
|
if (s.reveal && s.reveal.to === actor) s.reveal = null;
|
|
}
|
|
if (a.type !== 'timeout' && a.type !== 'leave' && a.type !== 'declareOneCard') {
|
|
s.timeoutsInRow[actor] = 0;
|
|
s.away[actor] = false;
|
|
}
|
|
switch (a.type) {
|
|
case 'play':
|
|
playCards(s, actor, a, ctx);
|
|
break;
|
|
case 'draw':
|
|
drawTurn(s, actor, ctx, true);
|
|
break;
|
|
case 'pass':
|
|
ctx.events.push({ type: 'passed', player: actor });
|
|
log(s, { t: 'pass', p: actor });
|
|
startTurn(s, nextAfter(s, actor, 0));
|
|
break;
|
|
case 'declareOneCard':
|
|
declareOneCard(s, actor, ctx);
|
|
break;
|
|
case 'challengeWild4':
|
|
resolveWild4(s, true, ctx);
|
|
break;
|
|
case 'acceptWild4':
|
|
resolveWild4(s, false, ctx);
|
|
break;
|
|
case 'ready':
|
|
s.ready.push(actor);
|
|
maybeStartNextRound(s, ctx);
|
|
break;
|
|
case 'leave':
|
|
leave(s, actor, ctx);
|
|
break;
|
|
case 'timeout': {
|
|
ctx.events.push({ type: 'timeout', player: actor });
|
|
if (s.phase.kind !== 'roundEnd') {
|
|
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
|
|
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
|
|
s.away[actor] = true;
|
|
ctx.events.push({ type: 'away', player: actor });
|
|
}
|
|
log(s, { t: 'timeout', p: actor });
|
|
}
|
|
switch (s.phase.kind) {
|
|
case 'turn':
|
|
// Never auto-plays a card (§8.5): take the attack or draw one and end the turn.
|
|
drawTurn(s, actor, ctx, false);
|
|
break;
|
|
case 'afterDraw':
|
|
log(s, { t: 'pass', p: actor });
|
|
startTurn(s, nextAfter(s, actor, 0));
|
|
break;
|
|
case 'wild4Response':
|
|
resolveWild4(s, false, ctx);
|
|
break;
|
|
case 'roundEnd':
|
|
s.ready.push(actor);
|
|
maybeStartNextRound(s, ctx);
|
|
break;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (`${s.turnNo}|${s.phase.kind}` !== before) s.turnStartedAt = now;
|
|
return { state: s, events: ctx.events };
|
|
},
|
|
|
|
view(s, viewer) {
|
|
const me = viewer && s.seats.includes(viewer) ? viewer : null;
|
|
const ph = s.phase;
|
|
const live = ph.kind !== 'finished' && ph.kind !== 'roundEnd';
|
|
const w = s.oneCardWindow;
|
|
let my: OneCardView['my'] = null;
|
|
if (me) {
|
|
const hand = s.hands[me] ?? [];
|
|
const legal = live ? legalCardIds(s, me) : [];
|
|
my = {
|
|
hand,
|
|
legalCardIds: legal,
|
|
hints: Object.fromEntries(legal.map((id) => [id, hintFor(s, hand.find((c) => c.id === id)!)])),
|
|
canDraw: live && drawAllowed(s, me) && !gone(s, me),
|
|
drawCount: s.pendingAttack > 0 ? s.pendingAttack : 1,
|
|
canPass: ph.kind === 'afterDraw' && s.current === me,
|
|
drawnCardId: ph.kind === 'afterDraw' && s.current === me ? ph.drawnCardId : null,
|
|
canDeclare: !gone(s, me) && canDeclare(s, me),
|
|
canChallenge: ph.kind === 'wild4Response' && ph.target === me && !gone(s, me),
|
|
canReady: ph.kind === 'roundEnd' && !gone(s, me) && !s.ready.includes(me),
|
|
multiPlay: s.mode === 'classic' && s.options.multiPlay,
|
|
};
|
|
}
|
|
const t = s.discard[s.discard.length - 1];
|
|
const ref = s.mode === 'classic' && t && isJoker(t) && s.options.afterJoker === 'previous' ? referenceCard(s) : null;
|
|
return {
|
|
mode: s.mode,
|
|
options: s.options,
|
|
decks: s.decks,
|
|
penalty: resolvedPenalty(s.options),
|
|
bankruptLimit: resolvedBankrupt(s.options),
|
|
playAfterDraw: resolvedPlayAfterDraw(s.options),
|
|
me,
|
|
seats: s.seats.map((p) => ({
|
|
id: p,
|
|
cards: s.hands[p]?.length ?? 0,
|
|
status: s.status[p]!,
|
|
isTurn: waiting(s).includes(p) && ph.kind !== 'roundEnd',
|
|
away: !!s.away[p],
|
|
oneCard: w?.player === p ? (w.declared ? 'declared' : 'open') : null,
|
|
score: s.scores[p] ?? 0,
|
|
ready: ph.kind === 'roundEnd' && s.ready.includes(p),
|
|
})),
|
|
current: live ? (ph.kind === 'wild4Response' ? ph.target : s.current) : null,
|
|
direction: s.direction,
|
|
phase: ph.kind,
|
|
turnNo: s.turnNo,
|
|
extraTurn: s.extraTurn,
|
|
recent: s.discard.slice(-5),
|
|
reference: ref,
|
|
declaredSuit: s.declaredSuit,
|
|
declaredColor: s.declaredColor,
|
|
currentColor: s.mode === 'uno-style' && t ? currentColor(s) : null,
|
|
pendingAttack: s.pendingAttack,
|
|
drawPileCount: s.drawPile.length,
|
|
discardCount: s.discard.length,
|
|
oneCardWindow: w ? { player: w.player, declared: w.declared } : null,
|
|
wild4: ph.kind === 'wild4Response' ? { by: ph.by, target: ph.target } : null,
|
|
my,
|
|
reveal: me && s.reveal && s.reveal.to === me ? { by: s.reveal.by, cards: s.reveal.cards, illegal: s.reveal.illegal } : null,
|
|
turnStartedAt: s.turnStartedAt,
|
|
deadlineTotalMs: deadlineMs(s) ?? 0,
|
|
round: s.round,
|
|
rounds: s.rounds,
|
|
log: s.log.slice(-LOG_VIEW),
|
|
result: s.final,
|
|
};
|
|
},
|
|
|
|
activePlayers(s) {
|
|
const out = waiting(s);
|
|
if (s.oneCardWindow && !s.oneCardWindow.declared) for (const p of s.seats) if (!out.includes(p) && !gone(s, p) && canDeclare(s, p)) out.push(p);
|
|
return out;
|
|
},
|
|
|
|
deadline(s) {
|
|
const ms = deadlineMs(s);
|
|
return ms === null ? null : s.turnStartedAt + ms;
|
|
},
|
|
|
|
timeoutPlayers: waiting,
|
|
|
|
onTimeout() {
|
|
return { type: 'timeout' };
|
|
},
|
|
|
|
onLeave(s, player) {
|
|
if (!s.seats.includes(player) || gone(s, player) || s.phase.kind === 'finished') return null;
|
|
return { type: 'leave' };
|
|
},
|
|
|
|
result(s): GameResult | null {
|
|
if (!s.final) return null;
|
|
return { ranking: s.final.ranking, ...(s.final.scores ? { scores: s.final.scores } : {}), summary: s.final.summary, reason: 'normal' };
|
|
},
|
|
|
|
legalActions(s, p) {
|
|
if (!s.seats.includes(p) || gone(s, p) || s.phase.kind === 'finished') return [];
|
|
const out: OneCardAction[] = [];
|
|
const ph = s.phase;
|
|
if (ph.kind === 'roundEnd') return s.ready.includes(p) ? [] : [{ type: 'ready' }];
|
|
if (canDeclare(s, p)) out.push({ type: 'declareOneCard' });
|
|
if (ph.kind === 'wild4Response' && ph.target === p) out.push({ type: 'challengeWild4' }, { type: 'acceptWild4' });
|
|
const mineNow = ((ph.kind === 'turn' || ph.kind === 'afterDraw') && s.current === p) || (ph.kind === 'wild4Response' && ph.target === p);
|
|
if (mineNow) {
|
|
const hand = s.hands[p]!;
|
|
for (const id of legalCardIds(s, p)) {
|
|
const c = hand.find((x) => x.id === id)!;
|
|
const sets: string[][] = [[id]];
|
|
if (s.mode === 'classic' && s.options.multiPlay && isStd(c) && ph.kind === 'turn') {
|
|
const same = hand.filter((x) => x.id !== id && isStd(x) && x.rank === c.rank).map((x) => x.id);
|
|
if (same.length) sets.push([id, ...same]);
|
|
}
|
|
for (const set of sets) out.push(...playVariants(s, p, set));
|
|
}
|
|
if (drawAllowed(s, p)) out.push({ type: 'draw' });
|
|
if (ph.kind === 'afterDraw') out.push({ type: 'pass' });
|
|
}
|
|
return out.filter((a) => oneCard.validate(s, p, a).ok);
|
|
},
|
|
|
|
/** Own hand plus the draw pile — card ids exactly as they would appear in a view. */
|
|
secretsOf(s, player) {
|
|
return [...(s.hands[player] ?? []).map((c) => c.id), ...s.drawPile.map((c) => c.id)];
|
|
},
|
|
};
|
|
|
|
export type { OneCardFinal };
|