Детерминированный конечный автомат: apply(state, action) -> {state, events}.
Весь набор правил из GAMEPLAY.md проверяется headless-тестами.
- карты: Suit/Rank/Card (флайвейт, 36 штук), колода
- GameState + клонирование и сериализация в JSON
- действия (attack/defend/take/pass) и события для слоя отображения
- раздача, выбор первого атакующего, лимиты подкидывания,
взятие карт, добор, выбывание игроков, определение дурака
- 64 теста, strict typecheck
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
import { buildDeck, Card } from '../cards/Card';
|
|
import { Rank } from '../cards/Rank';
|
|
import { Suit } from '../cards/Suit';
|
|
import { Rng } from '../util/Rng';
|
|
import { Action, Actions, ActionType, IllegalActionError } from './Action';
|
|
import { GameEvent, GameEventType } from './GameEvent';
|
|
import {
|
|
cloneState, GameState, Phase, PlayerId, PlayerState, Seat, TableSlot,
|
|
} from './GameState';
|
|
|
|
/** Cards a player refills to at the end of a turn. */
|
|
export const HAND_SIZE = 6;
|
|
/** Most attacking cards allowed in one turn. */
|
|
export const MAX_TABLE_SLOTS = 6;
|
|
/** The opening turn of a game allows one fewer. */
|
|
export const FIRST_TURN_MAX_TABLE_SLOTS = 5;
|
|
|
|
export interface GameOptions {
|
|
/** Seating order, clockwise. At least two. */
|
|
players: PlayerId[];
|
|
seed: number;
|
|
}
|
|
|
|
export interface ApplyResult {
|
|
state: GameState;
|
|
events: GameEvent[];
|
|
}
|
|
|
|
/** Whether `defence` beats `attack` given the trump suit. */
|
|
export function beats(defence: Card, attack: Card, trumpSuit: Suit): boolean {
|
|
if (defence.suit === attack.suit) {
|
|
return defence.rank > attack.rank;
|
|
}
|
|
return defence.suit === trumpSuit;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createGame(options: GameOptions): ApplyResult {
|
|
if (options.players.length < 2) {
|
|
throw new RangeError('A game needs at least two players');
|
|
}
|
|
if (new Set(options.players).size !== options.players.length) {
|
|
throw new RangeError('Player ids must be unique');
|
|
}
|
|
if (options.players.length * HAND_SIZE + 1 > 36) {
|
|
throw new RangeError('Too many players for a 36-card deck');
|
|
}
|
|
|
|
const rng = new Rng(options.seed);
|
|
const shuffled = rng.shuffle(buildDeck());
|
|
const events: GameEvent[] = [];
|
|
|
|
const players: PlayerState[] = options.players.map((id) => ({ id, hand: [], out: false }));
|
|
for (const player of players) {
|
|
player.hand = shuffled.splice(0, HAND_SIZE);
|
|
events.push({ type: GameEventType.Dealt, player: player.id, cards: player.hand.slice() });
|
|
}
|
|
|
|
// The next card is turned face up to set the trump, then slid under the draw
|
|
// pile: it is the very last card anyone will draw.
|
|
const trumpCard = shuffled.shift() as Card;
|
|
const deck = [...shuffled, trumpCard];
|
|
events.push({ type: GameEventType.TrumpRevealed, card: trumpCard });
|
|
|
|
const attacker = chooseFirstAttacker(players, trumpCard.suit, rng);
|
|
const defender = (attacker + 1) % players.length;
|
|
|
|
const state: GameState = {
|
|
players,
|
|
deck,
|
|
trumpCard,
|
|
trumpSuit: trumpCard.suit,
|
|
table: [],
|
|
discard: [],
|
|
phase: Phase.Attack,
|
|
attacker,
|
|
defender,
|
|
turnNumber: 1,
|
|
defenderTook: false,
|
|
passed: players.map(() => false),
|
|
rngState: rng.state,
|
|
loser: null,
|
|
};
|
|
|
|
events.push({
|
|
type: GameEventType.TurnStarted,
|
|
turnNumber: 1,
|
|
attacker: players[attacker].id,
|
|
defender: players[defender].id,
|
|
});
|
|
|
|
return { state, events };
|
|
}
|
|
|
|
/**
|
|
* Lowest trump goes first; if nobody holds a trump, the lowest card of any suit;
|
|
* a remaining tie is broken at random. See GAMEPLAY.md.
|
|
*/
|
|
function chooseFirstAttacker(players: PlayerState[], trumpSuit: Suit, rng: Rng): Seat {
|
|
const lowestTrump = players.map((p) => lowestRankOfSuit(p.hand, trumpSuit));
|
|
let candidates = argMin(lowestTrump);
|
|
if (lowestTrump[candidates[0]] === Infinity) {
|
|
candidates = argMin(players.map((p) => lowestRank(p.hand)));
|
|
}
|
|
return candidates.length === 1 ? candidates[0] : rng.pick(candidates);
|
|
}
|
|
|
|
function lowestRankOfSuit(hand: readonly Card[], suit: Suit): number {
|
|
let best = Infinity;
|
|
for (const card of hand) {
|
|
if (card.suit === suit && card.rank < best) best = card.rank;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function lowestRank(hand: readonly Card[]): number {
|
|
let best = Infinity;
|
|
for (const card of hand) {
|
|
if (card.rank < best) best = card.rank;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function argMin(values: readonly number[]): Seat[] {
|
|
const min = Math.min(...values);
|
|
const result: Seat[] = [];
|
|
values.forEach((v, i) => {
|
|
if (v === min) result.push(i);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Queries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function seatOf(state: GameState, player: PlayerId): Seat {
|
|
const seat = state.players.findIndex((p) => p.id === player);
|
|
if (seat < 0) {
|
|
throw new RangeError(`No player '${player}' in this game`);
|
|
}
|
|
return seat;
|
|
}
|
|
|
|
/** Attacking seats this turn: the attacker first, then clockwise, defender excluded. */
|
|
export function attackSeats(state: GameState): Seat[] {
|
|
const seats: Seat[] = [];
|
|
for (let i = 0; i < state.players.length; i++) {
|
|
const seat = (state.attacker + i) % state.players.length;
|
|
if (seat === state.defender || state.players[seat].out) continue;
|
|
seats.push(seat);
|
|
}
|
|
return seats;
|
|
}
|
|
|
|
/**
|
|
* The single participant the game is waiting on, or null when the game is over.
|
|
*
|
|
* Real durak lets attackers throw cards in at any moment. Serialising that into
|
|
* one actor at a time costs nothing at this table size and makes a whole match a
|
|
* plain sequence of decisions. See docs/DECISIONS.md.
|
|
*/
|
|
export function currentActor(state: GameState): PlayerId | null {
|
|
switch (state.phase) {
|
|
case Phase.Attack:
|
|
return state.players[state.attacker].id;
|
|
case Phase.Defence:
|
|
return state.players[state.defender].id;
|
|
case Phase.Reinforce: {
|
|
const seat = attackSeats(state).find((s) => !state.passed[s]);
|
|
return seat === undefined ? null : state.players[seat].id;
|
|
}
|
|
case Phase.Finished:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Indices of table slots that still need to be beaten. */
|
|
export function unbeatenSlots(state: GameState): number[] {
|
|
const result: number[] = [];
|
|
state.table.forEach((slot, i) => {
|
|
if (slot.defence === null) result.push(i);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function maxTableSlots(state: GameState): number {
|
|
return state.turnNumber === 1 ? FIRST_TURN_MAX_TABLE_SLOTS : MAX_TABLE_SLOTS;
|
|
}
|
|
|
|
function ranksOnTable(state: GameState): Set<Rank> {
|
|
const ranks = new Set<Rank>();
|
|
for (const slot of state.table) {
|
|
ranks.add(slot.attack.rank);
|
|
if (slot.defence) ranks.add(slot.defence.rank);
|
|
}
|
|
return ranks;
|
|
}
|
|
|
|
/**
|
|
* Why a card may not be added right now, or null if it may. The three limits are
|
|
* taken from GAMEPLAY.md; how they are read is recorded in docs/DECISIONS.md.
|
|
*/
|
|
export function whyCannotAdd(state: GameState, card: Card): string | null {
|
|
if (state.table.length >= maxTableSlots(state)) {
|
|
return `the table already holds ${state.table.length} attacking cards`;
|
|
}
|
|
if (unbeatenSlots(state).length >= state.players[state.defender].hand.length) {
|
|
return 'the defender does not hold enough cards to answer another one';
|
|
}
|
|
if (state.table.length > 0 && !ranksOnTable(state).has(card.rank)) {
|
|
return `nothing of that rank is on the table`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasAddableCard(state: GameState, seat: Seat): boolean {
|
|
return state.players[seat].hand.some((card) => whyCannotAdd(state, card) === null);
|
|
}
|
|
|
|
/** Every action `player` may legally take right now. Empty when it is not their move. */
|
|
export function legalActions(state: GameState, player: PlayerId): Action[] {
|
|
if (currentActor(state) !== player) {
|
|
return [];
|
|
}
|
|
const hand = state.players[seatOf(state, player)].hand;
|
|
|
|
switch (state.phase) {
|
|
case Phase.Attack:
|
|
// The opening card of a turn: anything in hand, and no option to skip.
|
|
return hand.map((card) => Actions.attack(player, card));
|
|
|
|
case Phase.Defence: {
|
|
const actions: Action[] = [Actions.take(player)];
|
|
for (const slot of unbeatenSlots(state)) {
|
|
for (const card of hand) {
|
|
if (beats(card, state.table[slot].attack, state.trumpSuit)) {
|
|
actions.push(Actions.defend(player, card, slot));
|
|
}
|
|
}
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
case Phase.Reinforce: {
|
|
const actions: Action[] = [Actions.pass(player)];
|
|
for (const card of hand) {
|
|
if (whyCannotAdd(state, card) === null) {
|
|
actions.push(Actions.attack(player, card));
|
|
}
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
case Phase.Finished:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transition
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The one way a game moves forward. Pure: `state` is not modified; a new state
|
|
* and the events describing the change are returned.
|
|
*/
|
|
export function apply(state: GameState, action: Action): ApplyResult {
|
|
const next = cloneState(state);
|
|
const events: GameEvent[] = [];
|
|
|
|
if (next.phase === Phase.Finished) {
|
|
throw new IllegalActionError(action, 'the game is over');
|
|
}
|
|
const actor = currentActor(next);
|
|
if (actor !== action.player) {
|
|
throw new IllegalActionError(action, `it is not their move`);
|
|
}
|
|
const seat = seatOf(next, action.player);
|
|
const hand = next.players[seat].hand;
|
|
|
|
switch (action.type) {
|
|
case ActionType.Attack: {
|
|
if (next.phase !== Phase.Attack && next.phase !== Phase.Reinforce) {
|
|
throw new IllegalActionError(action, `cannot attack during the ${next.phase} phase`);
|
|
}
|
|
requireInHand(action, hand, action.card);
|
|
const refusal = whyCannotAdd(next, action.card);
|
|
if (refusal) {
|
|
throw new IllegalActionError(action, refusal);
|
|
}
|
|
removeCard(hand, action.card);
|
|
next.table.push({ attack: action.card, defence: null });
|
|
events.push({
|
|
type: GameEventType.Attacked,
|
|
player: action.player,
|
|
card: action.card,
|
|
slot: next.table.length - 1,
|
|
});
|
|
// A new card on the table reopens the decision for everyone who had
|
|
// already declared they were done.
|
|
next.passed.fill(false);
|
|
next.phase = next.defenderTook ? Phase.Reinforce : Phase.Defence;
|
|
break;
|
|
}
|
|
|
|
case ActionType.Defend: {
|
|
if (next.phase !== Phase.Defence) {
|
|
throw new IllegalActionError(action, `cannot defend during the ${next.phase} phase`);
|
|
}
|
|
const slot: TableSlot | undefined = next.table[action.slot];
|
|
if (!slot) {
|
|
throw new IllegalActionError(action, `there is no slot ${action.slot}`);
|
|
}
|
|
if (slot.defence !== null) {
|
|
throw new IllegalActionError(action, `slot ${action.slot} is already beaten`);
|
|
}
|
|
requireInHand(action, hand, action.card);
|
|
if (!beats(action.card, slot.attack, next.trumpSuit)) {
|
|
throw new IllegalActionError(action, `${action.card.code} does not beat ${slot.attack.code}`);
|
|
}
|
|
removeCard(hand, action.card);
|
|
slot.defence = action.card;
|
|
events.push({
|
|
type: GameEventType.Defended,
|
|
player: action.player,
|
|
card: action.card,
|
|
slot: action.slot,
|
|
});
|
|
next.passed.fill(false);
|
|
if (unbeatenSlots(next).length === 0) {
|
|
next.phase = Phase.Reinforce;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case ActionType.Take: {
|
|
if (next.phase !== Phase.Defence) {
|
|
throw new IllegalActionError(action, `cannot take during the ${next.phase} phase`);
|
|
}
|
|
next.defenderTook = true;
|
|
next.passed.fill(false);
|
|
next.phase = Phase.Reinforce;
|
|
events.push({ type: GameEventType.TookDeclared, player: action.player });
|
|
break;
|
|
}
|
|
|
|
case ActionType.Pass: {
|
|
if (next.phase !== Phase.Reinforce) {
|
|
throw new IllegalActionError(action, `cannot pass during the ${next.phase} phase`);
|
|
}
|
|
next.passed[seat] = true;
|
|
events.push({ type: GameEventType.Passed, player: action.player });
|
|
break;
|
|
}
|
|
}
|
|
|
|
settle(next, events);
|
|
return { state: next, events };
|
|
}
|
|
|
|
function requireInHand(action: Action, hand: readonly Card[], card: Card): void {
|
|
if (!hand.includes(card)) {
|
|
throw new IllegalActionError(action, `${card.code} is not in hand`);
|
|
}
|
|
}
|
|
|
|
function removeCard(hand: Card[], card: Card): void {
|
|
hand.splice(hand.indexOf(card), 1);
|
|
}
|
|
|
|
/**
|
|
* Advances anything that needs no decision: an attacker with nothing legal to
|
|
* add is passed silently, and a turn where every attacker is done is resolved.
|
|
*/
|
|
function settle(state: GameState, events: GameEvent[]): void {
|
|
while (state.phase === Phase.Reinforce) {
|
|
const pending = attackSeats(state).filter((s) => !state.passed[s]);
|
|
if (pending.length === 0) {
|
|
resolveTurn(state, events);
|
|
return;
|
|
}
|
|
if (hasAddableCard(state, pending[0])) {
|
|
return; // A real choice — wait for it.
|
|
}
|
|
state.passed[pending[0]] = true;
|
|
}
|
|
}
|
|
|
|
function resolveTurn(state: GameState, events: GameEvent[]): void {
|
|
const tableCards: Card[] = [];
|
|
for (const slot of state.table) {
|
|
tableCards.push(slot.attack);
|
|
if (slot.defence) tableCards.push(slot.defence);
|
|
}
|
|
|
|
if (state.defenderTook) {
|
|
state.players[state.defender].hand.push(...tableCards);
|
|
events.push({
|
|
type: GameEventType.TableTaken,
|
|
player: state.players[state.defender].id,
|
|
cards: tableCards.slice(),
|
|
});
|
|
} else if (tableCards.length > 0) {
|
|
state.discard.push(...tableCards);
|
|
events.push({ type: GameEventType.TableDiscarded, cards: tableCards.slice() });
|
|
}
|
|
state.table = [];
|
|
|
|
// Refill: the attacker first, then clockwise, and the defender last.
|
|
for (const seat of [...attackSeats(state), state.defender]) {
|
|
const player = state.players[seat];
|
|
if (player.out) continue;
|
|
const wanted = HAND_SIZE - player.hand.length;
|
|
if (wanted <= 0 || state.deck.length === 0) continue;
|
|
const drawn = state.deck.splice(0, wanted);
|
|
player.hand.push(...drawn);
|
|
events.push({
|
|
type: GameEventType.Drew,
|
|
player: player.id,
|
|
cards: drawn.slice(),
|
|
deckRemaining: state.deck.length,
|
|
});
|
|
}
|
|
|
|
// Anyone who could not refill has run out of cards and leaves the game.
|
|
for (const player of state.players) {
|
|
if (!player.out && player.hand.length === 0) {
|
|
player.out = true;
|
|
events.push({ type: GameEventType.PlayerOut, player: player.id });
|
|
}
|
|
}
|
|
|
|
const active = state.players.filter((p) => !p.out);
|
|
if (active.length <= 1) {
|
|
state.phase = Phase.Finished;
|
|
state.loser = active.length === 1 ? active[0].id : null;
|
|
events.push({ type: GameEventType.GameOver, loser: state.loser });
|
|
return;
|
|
}
|
|
|
|
// A successful defence hands the attack to the defender; a defender who took
|
|
// is skipped, so the attack passes to the seat after them.
|
|
const base = state.defenderTook ? state.defender + 1 : state.defender;
|
|
state.attacker = nextActiveSeat(state, base);
|
|
state.defender = nextActiveSeat(state, state.attacker + 1);
|
|
|
|
state.turnNumber += 1;
|
|
state.defenderTook = false;
|
|
state.passed.fill(false);
|
|
state.phase = Phase.Attack;
|
|
events.push({
|
|
type: GameEventType.TurnStarted,
|
|
turnNumber: state.turnNumber,
|
|
attacker: state.players[state.attacker].id,
|
|
defender: state.players[state.defender].id,
|
|
});
|
|
}
|
|
|
|
/** The first seat at or after `from` (wrapping) that is still in the game. */
|
|
function nextActiveSeat(state: GameState, from: Seat): Seat {
|
|
const n = state.players.length;
|
|
for (let i = 0; i < n; i++) {
|
|
const seat = (((from + i) % n) + n) % n;
|
|
if (!state.players[seat].out) return seat;
|
|
}
|
|
throw new Error('No active players left');
|
|
}
|