From fddab60527ed4ea2660d1303b1bdb367d16917fc Mon Sep 17 00:00:00 2001 From: Denis Denisov Date: Sun, 13 Sep 2026 10:38:49 +0300 Subject: [PATCH] =?UTF-8?q?M1:=20=D1=8F=D0=B4=D1=80=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B4=D1=83=D1=80=D0=B0=D0=BA=D0=B0?= =?UTF-8?q?=20(=D1=87=D0=B8=D1=81=D1=82=D1=8B=D0=B9=20TypeScript,=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Детерминированный конечный автомат: 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 --- assets/scripts/core/cards/Card.ts | 114 +++++ assets/scripts/core/cards/Card.ts.meta | 9 + assets/scripts/core/cards/Rank.ts | 54 +++ assets/scripts/core/cards/Rank.ts.meta | 9 + assets/scripts/core/cards/Suit.ts | 36 ++ assets/scripts/core/cards/Suit.ts.meta | 9 + assets/scripts/core/cards/index.ts | 3 + assets/scripts/core/cards/index.ts.meta | 9 + assets/scripts/core/rules/Action.ts | 71 +++ assets/scripts/core/rules/Action.ts.meta | 9 + assets/scripts/core/rules/DurakRules.ts | 471 ++++++++++++++++++ assets/scripts/core/rules/DurakRules.ts.meta | 9 + assets/scripts/core/rules/GameEvent.ts | 57 +++ assets/scripts/core/rules/GameEvent.ts.meta | 9 + assets/scripts/core/rules/GameState.ts | 136 ++++++ assets/scripts/core/rules/GameState.ts.meta | 9 + docs/PLAN.md | 16 +- tests/core/cards/Card.spec.ts | 66 +++ tests/core/rules/serialization.spec.ts | 44 ++ tests/core/rules/setup.spec.ts | 134 ++++++ tests/core/rules/turn.spec.ts | 482 +++++++++++++++++++ tests/support/fixtures.ts | 88 ++++ 22 files changed, 1836 insertions(+), 8 deletions(-) create mode 100644 assets/scripts/core/cards/Card.ts create mode 100644 assets/scripts/core/cards/Card.ts.meta create mode 100644 assets/scripts/core/cards/Rank.ts create mode 100644 assets/scripts/core/cards/Rank.ts.meta create mode 100644 assets/scripts/core/cards/Suit.ts create mode 100644 assets/scripts/core/cards/Suit.ts.meta create mode 100644 assets/scripts/core/cards/index.ts create mode 100644 assets/scripts/core/cards/index.ts.meta create mode 100644 assets/scripts/core/rules/Action.ts create mode 100644 assets/scripts/core/rules/Action.ts.meta create mode 100644 assets/scripts/core/rules/DurakRules.ts create mode 100644 assets/scripts/core/rules/DurakRules.ts.meta create mode 100644 assets/scripts/core/rules/GameEvent.ts create mode 100644 assets/scripts/core/rules/GameEvent.ts.meta create mode 100644 assets/scripts/core/rules/GameState.ts create mode 100644 assets/scripts/core/rules/GameState.ts.meta create mode 100644 tests/core/cards/Card.spec.ts create mode 100644 tests/core/rules/serialization.spec.ts create mode 100644 tests/core/rules/setup.spec.ts create mode 100644 tests/core/rules/turn.spec.ts create mode 100644 tests/support/fixtures.ts diff --git a/assets/scripts/core/cards/Card.ts b/assets/scripts/core/cards/Card.ts new file mode 100644 index 0000000..9d1a60e --- /dev/null +++ b/assets/scripts/core/cards/Card.ts @@ -0,0 +1,114 @@ +import { ALL_RANKS, Rank, rankLabel, rankName } from './Rank'; +import { ALL_SUITS, Suit, suitName, suitSymbol } from './Suit'; + +const RANKS_PER_SUIT = ALL_RANKS.length; + +/** + * A playing card. + * + * Cards are flyweights: exactly 36 instances exist and `Card.of` always returns + * the same object for the same suit and rank. That buys three things — identity + * comparison (`a === b`) instead of field comparison, a cheap `id` for + * serialisation, and the impossibility of two "different" six of clubs objects + * drifting apart inside one game state. + */ +export class Card { + /** Every card, in suit then ascending rank order. */ + private static readonly _registry: Card[] = Card._buildRegistry(); + + /** Stable 0..35 index, used for serialisation. */ + readonly id: number; + readonly suit: Suit; + readonly rank: Rank; + + private constructor(suit: Suit, rank: Rank) { + this.suit = suit; + this.rank = rank; + this.id = suit * RANKS_PER_SUIT + (rank - Rank.Six); + } + + private static _buildRegistry(): Card[] { + const registry: Card[] = []; + for (const suit of ALL_SUITS) { + for (const rank of ALL_RANKS) { + const card = new Card(suit, rank); + registry[card.id] = card; + } + } + return registry; + } + + /** Short human-readable code, e.g. `6♣` or `A♠`. Used in logs and tests. */ + get code(): string { + return `${rankLabel(this.rank)}${suitSymbol(this.suit)}`; + } + + /** Art asset base name, e.g. `ace_of_spades`. */ + get assetName(): string { + return `${rankName(this.rank)}_of_${suitName(this.suit)}`; + } + + toString(): string { + return this.code; + } + + static of(suit: Suit, rank: Rank): Card { + return Card._registry[suit * RANKS_PER_SUIT + (rank - Rank.Six)]; + } + + static byId(id: number): Card { + const card = Card._registry[id]; + if (!card) { + throw new RangeError(`No card with id ${id}`); + } + return card; + } + + /** + * Parses a code such as `6C`, `10H`, `KS` or `A♦`. Lenient about case and + * about symbol versus letter, because it exists to make test fixtures + * readable rather than to validate user input. + */ + static parse(code: string): Card { + const text = code.trim().toUpperCase(); + const suit = SUIT_BY_CHAR[text.slice(-1)]; + if (suit === undefined) { + throw new SyntaxError(`Unknown suit in card code '${code}'`); + } + const rank = RANK_BY_LABEL[text.slice(0, -1)]; + if (rank === undefined) { + throw new SyntaxError(`Unknown rank in card code '${code}'`); + } + return Card.of(suit, rank); + } + + /** Parses a space-separated list, e.g. `'6C 10H KS'`. */ + static parseAll(codes: string): Card[] { + return codes.split(/\s+/).filter((s) => s.length > 0).map(Card.parse); + } + + /** All 36 cards, in suit then rank order. */ + static get all(): readonly Card[] { + return Card._registry; + } +} + +const SUIT_BY_CHAR: Readonly> = { + C: Suit.Clubs, '\u2663': Suit.Clubs, + D: Suit.Diamonds, '\u2666': Suit.Diamonds, + H: Suit.Hearts, '\u2665': Suit.Hearts, + S: Suit.Spades, '\u2660': Suit.Spades, +}; + +const RANK_BY_LABEL: Record = {}; +for (const rank of ALL_RANKS) { + RANK_BY_LABEL[rankLabel(rank)] = rank; +} + +/** All 36 cards, in suit then rank order. */ +export const ALL_CARDS: readonly Card[] = Card.all; + +/** A new unshuffled 36-card deck as a mutable array. */ +export function buildDeck(): Card[] { + return Card.all.slice(); +} diff --git a/assets/scripts/core/cards/Card.ts.meta b/assets/scripts/core/cards/Card.ts.meta new file mode 100644 index 0000000..f2c4647 --- /dev/null +++ b/assets/scripts/core/cards/Card.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "37e27ebe-a388-487e-a30e-2c8ba1475e45", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/cards/Rank.ts b/assets/scripts/core/cards/Rank.ts new file mode 100644 index 0000000..0d37931 --- /dev/null +++ b/assets/scripts/core/cards/Rank.ts @@ -0,0 +1,54 @@ +/** + * Card ranks present in a 36-card deck. The numeric values are the comparison + * order, so `a > b` is a valid "beats within suit" test. + */ +export enum Rank { + Six = 6, + Seven = 7, + Eight = 8, + Nine = 9, + Ten = 10, + Jack = 11, + Queen = 12, + King = 13, + Ace = 14, +} + +export const ALL_RANKS: readonly Rank[] = [ + Rank.Six, Rank.Seven, Rank.Eight, Rank.Nine, Rank.Ten, + Rank.Jack, Rank.Queen, Rank.King, Rank.Ace, +]; + +/** Short label, as printed on the card corner. */ +const RANK_LABELS: Record = { + [Rank.Six]: '6', + [Rank.Seven]: '7', + [Rank.Eight]: '8', + [Rank.Nine]: '9', + [Rank.Ten]: '10', + [Rank.Jack]: 'J', + [Rank.Queen]: 'Q', + [Rank.King]: 'K', + [Rank.Ace]: 'A', +}; + +/** Rank names as used by the card art file names. */ +const RANK_NAMES: Record = { + [Rank.Six]: '6', + [Rank.Seven]: '7', + [Rank.Eight]: '8', + [Rank.Nine]: '9', + [Rank.Ten]: '10', + [Rank.Jack]: 'jack', + [Rank.Queen]: 'queen', + [Rank.King]: 'king', + [Rank.Ace]: 'ace', +}; + +export function rankLabel(rank: Rank): string { + return RANK_LABELS[rank]; +} + +export function rankName(rank: Rank): string { + return RANK_NAMES[rank]; +} diff --git a/assets/scripts/core/cards/Rank.ts.meta b/assets/scripts/core/cards/Rank.ts.meta new file mode 100644 index 0000000..2cec324 --- /dev/null +++ b/assets/scripts/core/cards/Rank.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "e301a4f0-1703-47ba-9a1c-fa73e4d2935e", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/cards/Suit.ts b/assets/scripts/core/cards/Suit.ts new file mode 100644 index 0000000..1ffa6d9 --- /dev/null +++ b/assets/scripts/core/cards/Suit.ts @@ -0,0 +1,36 @@ +/** The four suits of a standard deck. */ +export enum Suit { + Clubs = 0, + Diamonds = 1, + Hearts = 2, + Spades = 3, +} + +export const ALL_SUITS: readonly Suit[] = [Suit.Clubs, Suit.Diamonds, Suit.Hearts, Suit.Spades]; + +const SUIT_SYMBOLS: Record = { + [Suit.Clubs]: '\u2663', + [Suit.Diamonds]: '\u2666', + [Suit.Hearts]: '\u2665', + [Suit.Spades]: '\u2660', +}; + +/** Suit names as used by the card art file names. */ +const SUIT_NAMES: Record = { + [Suit.Clubs]: 'clubs', + [Suit.Diamonds]: 'diamonds', + [Suit.Hearts]: 'hearts', + [Suit.Spades]: 'spades', +}; + +export function suitSymbol(suit: Suit): string { + return SUIT_SYMBOLS[suit]; +} + +export function suitName(suit: Suit): string { + return SUIT_NAMES[suit]; +} + +export function isRedSuit(suit: Suit): boolean { + return suit === Suit.Diamonds || suit === Suit.Hearts; +} diff --git a/assets/scripts/core/cards/Suit.ts.meta b/assets/scripts/core/cards/Suit.ts.meta new file mode 100644 index 0000000..283ac29 --- /dev/null +++ b/assets/scripts/core/cards/Suit.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "7056e716-7d05-461f-ba29-1716db6674ad", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/cards/index.ts b/assets/scripts/core/cards/index.ts new file mode 100644 index 0000000..1cb46cf --- /dev/null +++ b/assets/scripts/core/cards/index.ts @@ -0,0 +1,3 @@ +export { Card, ALL_CARDS, buildDeck } from './Card'; +export { Rank, ALL_RANKS, rankLabel, rankName } from './Rank'; +export { Suit, ALL_SUITS, suitName, suitSymbol, isRedSuit } from './Suit'; diff --git a/assets/scripts/core/cards/index.ts.meta b/assets/scripts/core/cards/index.ts.meta new file mode 100644 index 0000000..23f3801 --- /dev/null +++ b/assets/scripts/core/cards/index.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "b99118e1-0ff8-43f8-a6ac-02f26f154af7", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/rules/Action.ts b/assets/scripts/core/rules/Action.ts new file mode 100644 index 0000000..e4bbd09 --- /dev/null +++ b/assets/scripts/core/rules/Action.ts @@ -0,0 +1,71 @@ +import { Card } from '../cards/Card'; +import { PlayerId } from './GameState'; + +export enum ActionType { + /** Lay an attacking card — either the opening card of a turn or an addition. */ + Attack = 'attack', + /** Beat one attacking card. */ + Defend = 'defend', + /** Give up the defence and pick the whole table up. */ + Take = 'take', + /** Declare that nothing more will be added this turn. */ + Pass = 'pass', +} + +export interface AttackAction { + readonly type: ActionType.Attack; + readonly player: PlayerId; + readonly card: Card; +} + +export interface DefendAction { + readonly type: ActionType.Defend; + readonly player: PlayerId; + readonly card: Card; + /** Index into `GameState.table` of the slot being beaten. */ + readonly slot: number; +} + +export interface TakeAction { + readonly type: ActionType.Take; + readonly player: PlayerId; +} + +export interface PassAction { + readonly type: ActionType.Pass; + readonly player: PlayerId; +} + +export type Action = AttackAction | DefendAction | TakeAction | PassAction; + +export const Actions = { + attack(player: PlayerId, card: Card): AttackAction { + return { type: ActionType.Attack, player, card }; + }, + defend(player: PlayerId, card: Card, slot: number): DefendAction { + return { type: ActionType.Defend, player, card, slot }; + }, + take(player: PlayerId): TakeAction { + return { type: ActionType.Take, player }; + }, + pass(player: PlayerId): PassAction { + return { type: ActionType.Pass, player }; + }, +}; + +export function describeAction(action: Action): string { + switch (action.type) { + case ActionType.Attack: return `${action.player} attacks with ${action.card.code}`; + case ActionType.Defend: return `${action.player} beats slot ${action.slot} with ${action.card.code}`; + case ActionType.Take: return `${action.player} takes`; + case ActionType.Pass: return `${action.player} passes`; + } +} + +/** Thrown when an action is not permitted in the current state. */ +export class IllegalActionError extends Error { + constructor(action: Action, reason: string) { + super(`Illegal action (${describeAction(action)}): ${reason}`); + this.name = 'IllegalActionError'; + } +} diff --git a/assets/scripts/core/rules/Action.ts.meta b/assets/scripts/core/rules/Action.ts.meta new file mode 100644 index 0000000..0efc01e --- /dev/null +++ b/assets/scripts/core/rules/Action.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "9fd385cc-d496-485c-a5d8-64332887084f", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/rules/DurakRules.ts b/assets/scripts/core/rules/DurakRules.ts new file mode 100644 index 0000000..7e8ac44 --- /dev/null +++ b/assets/scripts/core/rules/DurakRules.ts @@ -0,0 +1,471 @@ +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 { + const ranks = new Set(); + 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'); +} diff --git a/assets/scripts/core/rules/DurakRules.ts.meta b/assets/scripts/core/rules/DurakRules.ts.meta new file mode 100644 index 0000000..b259624 --- /dev/null +++ b/assets/scripts/core/rules/DurakRules.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "e28254c5-32f3-4bcd-a3ad-383bac9b2598", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/rules/GameEvent.ts b/assets/scripts/core/rules/GameEvent.ts new file mode 100644 index 0000000..3c15060 --- /dev/null +++ b/assets/scripts/core/rules/GameEvent.ts @@ -0,0 +1,57 @@ +import { Card } from '../cards/Card'; +import { PlayerId } from './GameState'; + +/** + * What happened, as opposed to what someone wanted to do. The presentation layer + * animates this stream; it is the only thing the view needs in order to stay in + * sync with the rules. + * + * The stream is a god's-eye view — `Dealt` and `Drew` carry real cards. It is + * the view's job to show only what the local player may see. Anything that + * genuinely must not know (a bot) reads a `PlayerView` instead. + */ +export enum GameEventType { + Dealt = 'dealt', + TrumpRevealed = 'trumpRevealed', + TurnStarted = 'turnStarted', + Attacked = 'attacked', + Defended = 'defended', + TookDeclared = 'tookDeclared', + Passed = 'passed', + TableDiscarded = 'tableDiscarded', + TableTaken = 'tableTaken', + Drew = 'drew', + PlayerOut = 'playerOut', + GameOver = 'gameOver', +} + +export type GameEvent = + | { type: GameEventType.Dealt; player: PlayerId; cards: Card[] } + | { type: GameEventType.TrumpRevealed; card: Card } + | { type: GameEventType.TurnStarted; turnNumber: number; attacker: PlayerId; defender: PlayerId } + | { type: GameEventType.Attacked; player: PlayerId; card: Card; slot: number } + | { type: GameEventType.Defended; player: PlayerId; card: Card; slot: number } + | { type: GameEventType.TookDeclared; player: PlayerId } + | { type: GameEventType.Passed; player: PlayerId } + | { type: GameEventType.TableDiscarded; cards: Card[] } + | { type: GameEventType.TableTaken; player: PlayerId; cards: Card[] } + | { type: GameEventType.Drew; player: PlayerId; cards: Card[]; deckRemaining: number } + | { type: GameEventType.PlayerOut; player: PlayerId } + | { type: GameEventType.GameOver; loser: PlayerId | null }; + +export function describeEvent(event: GameEvent): string { + switch (event.type) { + case GameEventType.Dealt: return `${event.player} was dealt ${event.cards.length} cards`; + case GameEventType.TrumpRevealed: return `trump is ${event.card.code}`; + case GameEventType.TurnStarted: return `turn ${event.turnNumber}: ${event.attacker} attacks ${event.defender}`; + case GameEventType.Attacked: return `${event.player} played ${event.card.code}`; + case GameEventType.Defended: return `${event.player} beat slot ${event.slot} with ${event.card.code}`; + case GameEventType.TookDeclared: return `${event.player} declared a take`; + case GameEventType.Passed: return `${event.player} passed`; + case GameEventType.TableDiscarded: return `${event.cards.length} cards discarded`; + case GameEventType.TableTaken: return `${event.player} took ${event.cards.length} cards`; + case GameEventType.Drew: return `${event.player} drew ${event.cards.length} (${event.deckRemaining} left)`; + case GameEventType.PlayerOut: return `${event.player} is out`; + case GameEventType.GameOver: return event.loser ? `${event.loser} is the fool` : 'a draw'; + } +} diff --git a/assets/scripts/core/rules/GameEvent.ts.meta b/assets/scripts/core/rules/GameEvent.ts.meta new file mode 100644 index 0000000..9b26f58 --- /dev/null +++ b/assets/scripts/core/rules/GameEvent.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "5953fa1f-076c-4745-be88-e1f887983c72", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/rules/GameState.ts b/assets/scripts/core/rules/GameState.ts new file mode 100644 index 0000000..40b76c9 --- /dev/null +++ b/assets/scripts/core/rules/GameState.ts @@ -0,0 +1,136 @@ +import { Card } from '../cards/Card'; +import { Suit } from '../cards/Suit'; + +export type PlayerId = string; + +/** Seat index into `GameState.players`. */ +export type Seat = number; + +export enum Phase { + /** The attacker must lay the opening card of the turn. Passing is not allowed. */ + Attack = 'attack', + /** At least one attacking card is unbeaten; the defender must beat it or take. */ + Defence = 'defence', + /** Nothing left to beat (or a take was declared); attackers may add or stop. */ + Reinforce = 'reinforce', + Finished = 'finished', +} + +/** One attacking card on the table, with the card that beat it if there is one. */ +export interface TableSlot { + attack: Card; + defence: Card | null; +} + +export interface PlayerState { + readonly id: PlayerId; + hand: Card[]; + /** True once the player has run out of cards and left the game. */ + out: boolean; +} + +/** + * The complete state of a match. Plain data — no methods, no engine types, no + * wall-clock time. Everything that can vary is in here, including the RNG, so a + * state plus a sequence of actions replays exactly. + */ +export interface GameState { + /** Seating order, clockwise. Fixed for the whole game, including players who are out. */ + players: PlayerState[]; + /** Face-down draw pile. Index 0 is the next card drawn; the last entry is the trump card. */ + deck: Card[]; + /** The face-up card that set the trump suit. Kept for display after it has been drawn. */ + trumpCard: Card; + trumpSuit: Suit; + table: TableSlot[]; + discard: Card[]; + phase: Phase; + attacker: Seat; + defender: Seat; + /** 1-based. The first turn of a game allows only five attacking cards. */ + turnNumber: number; + /** True once the defender has declared a take for the current turn. */ + defenderTook: boolean; + /** Per seat: this attacker has declared they are adding nothing more this turn. */ + passed: boolean[]; + rngState: number; + /** Set when the game is over: the "fool", or null for the rare mutual finish. */ + loser: PlayerId | null; +} + +export function cloneState(state: GameState): GameState { + return { + players: state.players.map((p) => ({ id: p.id, hand: p.hand.slice(), out: p.out })), + deck: state.deck.slice(), + trumpCard: state.trumpCard, + trumpSuit: state.trumpSuit, + table: state.table.map((s) => ({ attack: s.attack, defence: s.defence })), + discard: state.discard.slice(), + phase: state.phase, + attacker: state.attacker, + defender: state.defender, + turnNumber: state.turnNumber, + defenderTook: state.defenderTook, + passed: state.passed.slice(), + rngState: state.rngState, + loser: state.loser, + }; +} + +/** JSON-safe form of a state, for bug reports and future mid-match saves. */ +export interface SerializedGameState { + players: { id: PlayerId; hand: number[]; out: boolean }[]; + deck: number[]; + trumpCard: number; + table: { attack: number; defence: number | null }[]; + discard: number[]; + phase: Phase; + attacker: Seat; + defender: Seat; + turnNumber: number; + defenderTook: boolean; + passed: boolean[]; + rngState: number; + loser: PlayerId | null; +} + +export function serializeState(state: GameState): SerializedGameState { + return { + players: state.players.map((p) => ({ id: p.id, hand: p.hand.map((c) => c.id), out: p.out })), + deck: state.deck.map((c) => c.id), + trumpCard: state.trumpCard.id, + table: state.table.map((s) => ({ attack: s.attack.id, defence: s.defence ? s.defence.id : null })), + discard: state.discard.map((c) => c.id), + phase: state.phase, + attacker: state.attacker, + defender: state.defender, + turnNumber: state.turnNumber, + defenderTook: state.defenderTook, + passed: state.passed.slice(), + rngState: state.rngState, + loser: state.loser, + }; +} + +export function deserializeState(data: SerializedGameState): GameState { + const trumpCard = Card.byId(data.trumpCard); + return { + players: data.players.map((p) => ({ id: p.id, hand: p.hand.map(Card.byId), out: p.out })), + deck: data.deck.map(Card.byId), + trumpCard, + trumpSuit: trumpCard.suit, + table: data.table.map((s) => ({ + attack: Card.byId(s.attack), + defence: s.defence === null ? null : Card.byId(s.defence), + })), + discard: data.discard.map(Card.byId), + phase: data.phase, + attacker: data.attacker, + defender: data.defender, + turnNumber: data.turnNumber, + defenderTook: data.defenderTook, + passed: data.passed.slice(), + rngState: data.rngState, + loser: data.loser, + }; +} diff --git a/assets/scripts/core/rules/GameState.ts.meta b/assets/scripts/core/rules/GameState.ts.meta new file mode 100644 index 0000000..d5e2cab --- /dev/null +++ b/assets/scripts/core/rules/GameState.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "96d082c1-3dc2-4552-91ff-a73be57f7295", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 4e1429c..53ebca9 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -34,15 +34,15 @@ progress. The Cocos layer only renders state and turns taps into actions. - [x] Headless test harness (vitest) wired to `assets/scripts/core`, plus a `core` purity guard and a strict typecheck -### M1 — Rules core `[ ]` +### M1 — Rules core `[x]` - [x] Seeded RNG and shuffle -- [ ] `Suit`, `Rank`, `Card`, 36-card deck -- [ ] `GameState`: hands, deck, trump, table slots, discard, phase, turn order -- [ ] First-attacker selection per GAMEPLAY.md (lowest trump → lowest card → random) -- [ ] Legal action generation: `Attack`, `Defend`, `Take`, `Pass` -- [ ] Turn resolution: beat-all / take, discard, draw order, player elimination -- [ ] End conditions and loser ("fool") determination -- [ ] Unit tests covering every rule clause, including the add-card limits +- [x] `Suit`, `Rank`, `Card`, 36-card deck +- [x] `GameState`: hands, deck, trump, table slots, discard, phase, turn order +- [x] First-attacker selection per GAMEPLAY.md (lowest trump → lowest card → random) +- [x] Legal action generation: `Attack`, `Defend`, `Take`, `Pass` +- [x] Turn resolution: beat-all / take, discard, draw order, player elimination +- [x] End conditions and loser ("fool") determination +- [x] Unit tests covering every rule clause, including the add-card limits ### M2 — Bot and headless match runner `[ ]` - [ ] `PlayerView` projection — a bot sees only what a player legitimately sees diff --git a/tests/core/cards/Card.spec.ts b/tests/core/cards/Card.spec.ts new file mode 100644 index 0000000..1111c85 --- /dev/null +++ b/tests/core/cards/Card.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { ALL_CARDS, buildDeck, Card } from '@core/cards/Card'; +import { Rank } from '@core/cards/Rank'; +import { Suit } from '@core/cards/Suit'; + +describe('Card', () => { + it('holds exactly 36 distinct cards', () => { + expect(ALL_CARDS).toHaveLength(36); + expect(new Set(ALL_CARDS.map((c) => c.id)).size).toBe(36); + expect(new Set(ALL_CARDS.map((c) => c.code)).size).toBe(36); + }); + + it('starts at six — there are no low cards in a 36-card deck', () => { + expect(Math.min(...ALL_CARDS.map((c) => c.rank))).toBe(Rank.Six); + expect(Math.max(...ALL_CARDS.map((c) => c.rank))).toBe(Rank.Ace); + }); + + it('is a flyweight, so identity comparison works', () => { + expect(Card.of(Suit.Hearts, Rank.Queen)).toBe(Card.of(Suit.Hearts, Rank.Queen)); + expect(Card.of(Suit.Hearts, Rank.Queen)).not.toBe(Card.of(Suit.Spades, Rank.Queen)); + }); + + it('round-trips through its id', () => { + for (const card of ALL_CARDS) { + expect(Card.byId(card.id)).toBe(card); + } + expect(() => Card.byId(36)).toThrow(RangeError); + expect(() => Card.byId(-1)).toThrow(RangeError); + }); + + it('round-trips through its code', () => { + for (const card of ALL_CARDS) { + expect(Card.parse(card.code)).toBe(card); + } + }); + + it('parses letter codes as well as symbols', () => { + expect(Card.parse('6c')).toBe(Card.of(Suit.Clubs, Rank.Six)); + expect(Card.parse('10H')).toBe(Card.of(Suit.Hearts, Rank.Ten)); + expect(Card.parse(' KS ')).toBe(Card.of(Suit.Spades, Rank.King)); + expect(Card.parseAll('6C 10H KS')).toEqual([ + Card.of(Suit.Clubs, Rank.Six), + Card.of(Suit.Hearts, Rank.Ten), + Card.of(Suit.Spades, Rank.King), + ]); + }); + + it('rejects nonsense codes', () => { + expect(() => Card.parse('6X')).toThrow(SyntaxError); + expect(() => Card.parse('5C')).toThrow(SyntaxError); + expect(() => Card.parse('')).toThrow(SyntaxError); + }); + + it('names art assets the way the source files are named', () => { + expect(Card.of(Suit.Spades, Rank.Ace).assetName).toBe('ace_of_spades'); + expect(Card.of(Suit.Clubs, Rank.Ten).assetName).toBe('10_of_clubs'); + expect(Card.of(Suit.Diamonds, Rank.Jack).assetName).toBe('jack_of_diamonds'); + }); + + it('builds a fresh mutable deck that does not alias the registry', () => { + const deck = buildDeck(); + expect(deck).toHaveLength(36); + deck.pop(); + expect(ALL_CARDS).toHaveLength(36); + }); +}); diff --git a/tests/core/rules/serialization.spec.ts b/tests/core/rules/serialization.spec.ts new file mode 100644 index 0000000..a653f2e --- /dev/null +++ b/tests/core/rules/serialization.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { deserializeState, serializeState } from '@core/rules/GameState'; +import { apply, createGame, currentActor, legalActions } from '@core/rules/DurakRules'; + +describe('state serialization', () => { + it('round-trips a fresh game through JSON', () => { + const { state } = createGame({ players: ['you', 'bot'], seed: 4242 }); + const restored = deserializeState(JSON.parse(JSON.stringify(serializeState(state)))); + expect(serializeState(restored)).toEqual(serializeState(state)); + }); + + it('round-trips a game in the middle of a turn', () => { + let { state } = createGame({ players: ['you', 'bot'], seed: 99 }); + for (let i = 0; i < 5; i++) { + const actor = currentActor(state); + if (!actor) break; + state = apply(state, legalActions(state, actor)[0]).state; + } + + const restored = deserializeState(JSON.parse(JSON.stringify(serializeState(state)))); + expect(serializeState(restored)).toEqual(serializeState(state)); + expect(currentActor(restored)).toBe(currentActor(state)); + }); + + it('restores cards as the same flyweight instances', () => { + const { state } = createGame({ players: ['you', 'bot'], seed: 7 }); + const restored = deserializeState(serializeState(state)); + expect(restored.players[0].hand[0]).toBe(state.players[0].hand[0]); + expect(restored.trumpCard).toBe(state.trumpCard); + }); + + it('replays identically from a restored state', () => { + const { state } = createGame({ players: ['you', 'bot'], seed: 2001 }); + const restored = deserializeState(serializeState(state)); + + // The same action object works on both, because cards are flyweights and + // an action carries no reference to the state it was generated from. + const actor = currentActor(state) as string; + const action = legalActions(state, actor)[0]; + + expect(serializeState(apply(restored, action).state)) + .toEqual(serializeState(apply(state, action).state)); + }); +}); diff --git a/tests/core/rules/setup.spec.ts b/tests/core/rules/setup.spec.ts new file mode 100644 index 0000000..9ff3ead --- /dev/null +++ b/tests/core/rules/setup.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { Card } from '@core/cards/Card'; +import { Rank } from '@core/cards/Rank'; +import { Suit } from '@core/cards/Suit'; +import { GameEventType } from '@core/rules/GameEvent'; +import { Phase } from '@core/rules/GameState'; +import { beats, createGame, HAND_SIZE } from '@core/rules/DurakRules'; + +const PLAYERS = ['you', 'bot']; + +describe('createGame', () => { + it('deals six cards each and leaves the rest as the deck', () => { + const { state } = createGame({ players: PLAYERS, seed: 1 }); + + expect(state.players.map((p) => p.hand.length)).toEqual([HAND_SIZE, HAND_SIZE]); + expect(state.deck).toHaveLength(36 - 2 * HAND_SIZE); + expect(state.discard).toHaveLength(0); + expect(state.table).toHaveLength(0); + expect(state.phase).toBe(Phase.Attack); + expect(state.turnNumber).toBe(1); + }); + + it('uses every card exactly once', () => { + const { state } = createGame({ players: ['a', 'b', 'c'], seed: 77 }); + const all = [...state.deck, ...state.players.flatMap((p) => p.hand)]; + expect(all).toHaveLength(36); + expect(new Set(all).size).toBe(36); + }); + + it('puts the trump card at the bottom of the deck', () => { + const { state } = createGame({ players: PLAYERS, seed: 5 }); + expect(state.deck[state.deck.length - 1]).toBe(state.trumpCard); + expect(state.trumpSuit).toBe(state.trumpCard.suit); + }); + + it('announces the deal and the trump before the first turn', () => { + const { events } = createGame({ players: PLAYERS, seed: 5 }); + const types = events.map((e) => e.type); + expect(types).toEqual([ + GameEventType.Dealt, + GameEventType.Dealt, + GameEventType.TrumpRevealed, + GameEventType.TurnStarted, + ]); + }); + + it('is fully determined by its seed', () => { + const a = createGame({ players: PLAYERS, seed: 12345 }); + const b = createGame({ players: PLAYERS, seed: 12345 }); + expect(a.state.players.map((p) => p.hand.map((c) => c.code))) + .toEqual(b.state.players.map((p) => p.hand.map((c) => c.code))); + expect(a.state.attacker).toBe(b.state.attacker); + }); + + it('rejects impossible tables', () => { + expect(() => createGame({ players: ['solo'], seed: 1 })).toThrow(RangeError); + expect(() => createGame({ players: ['a', 'a'], seed: 1 })).toThrow(RangeError); + expect(() => createGame({ players: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], seed: 1 })) + .toThrow(RangeError); + }); + + it('seats the defender directly after the attacker', () => { + for (let seed = 0; seed < 50; seed++) { + const { state } = createGame({ players: ['a', 'b', 'c'], seed }); + expect(state.defender).toBe((state.attacker + 1) % 3); + } + }); +}); + +describe('first attacker', () => { + /** Finds a seed whose deal satisfies `predicate`, so the rule can be tested on a real deal. */ + function findDeal(predicate: (s: ReturnType['state']) => boolean) { + for (let seed = 0; seed < 20000; seed++) { + const { state } = createGame({ players: PLAYERS, seed }); + if (predicate(state)) return state; + } + throw new Error('No matching deal found'); + } + + it('is the holder of the lowest trump', () => { + // Any deal where exactly one side holds trumps makes the rule unambiguous. + for (let seed = 0; seed < 200; seed++) { + const { state } = createGame({ players: PLAYERS, seed }); + const lowest = state.players.map((p) => { + const trumps = p.hand.filter((c) => c.suit === state.trumpSuit); + return trumps.length ? Math.min(...trumps.map((c) => c.rank)) : Infinity; + }); + if (lowest[0] === Infinity && lowest[1] === Infinity) continue; + expect(state.attacker).toBe(lowest[0] < lowest[1] ? 0 : 1); + } + }); + + it('falls back to the lowest card when nobody holds a trump', () => { + const state = findDeal((s) => + s.players.every((p) => p.hand.every((c) => c.suit !== s.trumpSuit))); + + const lowest = state.players.map((p) => Math.min(...p.hand.map((c) => c.rank))); + if (lowest[0] !== lowest[1]) { + expect(state.attacker).toBe(lowest[0] < lowest[1] ? 0 : 1); + } + // On a tie either seat is correct; the rule says "random". + expect(state.attacker).toBeGreaterThanOrEqual(0); + }); + + it('never picks a seat that is not at the table', () => { + for (let seed = 0; seed < 500; seed++) { + const { state } = createGame({ players: ['a', 'b', 'c'], seed }); + expect(state.attacker).toBeLessThan(3); + expect(state.attacker).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe('beats', () => { + const trump = Suit.Spades; + it('beats a lower card of the same suit', () => { + expect(beats(Card.of(Suit.Hearts, Rank.Ten), Card.of(Suit.Hearts, Rank.Nine), trump)).toBe(true); + expect(beats(Card.of(Suit.Hearts, Rank.Nine), Card.of(Suit.Hearts, Rank.Ten), trump)).toBe(false); + }); + + it('does not beat another suit', () => { + expect(beats(Card.of(Suit.Clubs, Rank.Ace), Card.of(Suit.Hearts, Rank.Six), trump)).toBe(false); + }); + + it('lets any trump beat any non-trump', () => { + expect(beats(Card.of(Suit.Spades, Rank.Six), Card.of(Suit.Hearts, Rank.Ace), trump)).toBe(true); + }); + + it('only lets a higher trump beat a trump', () => { + expect(beats(Card.of(Suit.Spades, Rank.Seven), Card.of(Suit.Spades, Rank.Six), trump)).toBe(true); + expect(beats(Card.of(Suit.Spades, Rank.Six), Card.of(Suit.Spades, Rank.Seven), trump)).toBe(false); + expect(beats(Card.of(Suit.Hearts, Rank.Ace), Card.of(Suit.Spades, Rank.Six), trump)).toBe(false); + }); +}); diff --git a/tests/core/rules/turn.spec.ts b/tests/core/rules/turn.spec.ts new file mode 100644 index 0000000..a628035 --- /dev/null +++ b/tests/core/rules/turn.spec.ts @@ -0,0 +1,482 @@ +import { describe, expect, it } from 'vitest'; +import { Card } from '@core/cards/Card'; +import { Actions, ActionType, IllegalActionError } from '@core/rules/Action'; +import { GameEventType } from '@core/rules/GameEvent'; +import { Phase } from '@core/rules/GameState'; +import { + apply, currentActor, legalActions, maxTableSlots, whyCannotAdd, +} from '@core/rules/DurakRules'; +import { handOf, makeState, tableOf } from '../../support/fixtures'; + +const YOU = 'you'; +const BOT = 'bot'; + +/** Action codes a player may take, as readable strings, for set comparisons. */ +function legalCodes(state: Parameters[0], player: string): string[] { + return legalActions(state, player).map((a) => { + switch (a.type) { + case ActionType.Attack: return `attack ${a.card.code}`; + case ActionType.Defend: return `defend ${a.slot} ${a.card.code}`; + case ActionType.Take: return 'take'; + case ActionType.Pass: return 'pass'; + } + }).sort(); +} + +describe('opening a turn', () => { + it('lets the attacker play any card and offers no way to skip', () => { + const state = makeState({ + hands: { [YOU]: '6C 7D KH', [BOT]: '8C 9D QH' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + }); + + expect(currentActor(state)).toBe(YOU); + expect(legalCodes(state, YOU)).toEqual(['attack 6♣', 'attack 7♦', 'attack K♥'].sort()); + expect(legalCodes(state, YOU)).not.toContain('pass'); + }); + + it('gives the defender no say until a card is down', () => { + const state = makeState({ + hands: { [YOU]: '6C', [BOT]: '8C' }, + trump: 'S', + deck: '10C', + attacker: YOU, + }); + expect(legalActions(state, BOT)).toEqual([]); + }); + + it('moves to the defence once a card is played', () => { + const start = makeState({ + hands: { [YOU]: '6C 7D', [BOT]: '8C 9D' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + }); + + const { state, events } = apply(start, Actions.attack(YOU, Card.parse('6C'))); + + expect(state.phase).toBe(Phase.Defence); + expect(currentActor(state)).toBe(BOT); + expect(tableOf(state)).toEqual(['6♣/-']); + expect(handOf(state, YOU)).toEqual(['7♦']); + expect(events.map((e) => e.type)).toEqual([GameEventType.Attacked]); + }); + + it('leaves the source state untouched', () => { + const start = makeState({ + hands: { [YOU]: '6C 7D', [BOT]: '8C 9D' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + }); + const before = JSON.stringify(handOf(start, YOU)); + + apply(start, Actions.attack(YOU, Card.parse('6C'))); + + expect(JSON.stringify(handOf(start, YOU))).toBe(before); + expect(start.table).toHaveLength(0); + expect(start.phase).toBe(Phase.Attack); + }); +}); + +describe('defending', () => { + const position = () => makeState({ + hands: { [YOU]: '9D KH', [BOT]: '7C 8C 6S QH' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + it('offers every card that beats the attack, plus taking', () => { + expect(legalCodes(position(), BOT).sort()).toEqual(['defend 0 6♠', 'take'].sort()); + }); + + it('accepts a higher card of the same suit', () => { + const state = makeState({ + // The attacker keeps a seven and the defender keeps a spare card, so + // the turn waits in Reinforce instead of settling the moment the + // defence lands. + hands: { [YOU]: '7H', [BOT]: '8D AH' }, + trump: 'S', + deck: '10C', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + const result = apply(state, Actions.defend(BOT, Card.parse('8D'), 0)); + + expect(tableOf(result.state)).toEqual(['7♦/8♦']); + expect(result.state.phase).toBe(Phase.Reinforce); + }); + + it('refuses a card that does not beat the attack', () => { + const state = position(); + expect(() => apply(state, Actions.defend(BOT, Card.parse('7C'), 0))) + .toThrow(IllegalActionError); + }); + + it('refuses a card that is not in hand', () => { + const state = position(); + expect(() => apply(state, Actions.defend(BOT, Card.parse('AS'), 0))) + .toThrow(/not in hand/); + }); + + it('refuses a slot that is already beaten', () => { + const state = makeState({ + hands: { [YOU]: 'KH', [BOT]: '9D 10D' }, + trump: 'S', + deck: '10C', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }, { attack: '6H' }], + phase: Phase.Defence, + }); + expect(() => apply(state, Actions.defend(BOT, Card.parse('9D'), 0))) + .toThrow(/already beaten/); + }); + + it('requires a trump to beat a trump', () => { + const state = makeState({ + hands: { [YOU]: 'KH', [BOT]: 'AH 7S' }, + trump: 'S', + deck: '10C', + attacker: YOU, + table: [{ attack: '6S' }], + phase: Phase.Defence, + }); + expect(legalCodes(state, BOT).sort()).toEqual(['defend 0 7♠', 'take'].sort()); + }); +}); + +describe('adding cards to an attack', () => { + it('only allows ranks already on the table', () => { + const state = makeState({ + hands: { [YOU]: '7H 9C KD', [BOT]: 'AH AD' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + table: [{ attack: '7D', defence: '9D' }], + phase: Phase.Reinforce, + }); + + expect(legalCodes(state, YOU).sort()).toEqual(['attack 7♥', 'attack 9♣', 'pass'].sort()); + expect(whyCannotAdd(state, Card.parse('KD'))).toMatch(/rank/); + }); + + it('counts the defender cards as well as the attacking ones', () => { + const state = makeState({ + hands: { [YOU]: '9H', [BOT]: 'AH AD' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + table: [{ attack: '7D', defence: '9D' }], + phase: Phase.Reinforce, + }); + // The nine is addable only because the defender used a nine. + expect(whyCannotAdd(state, Card.parse('9H'))).toBeNull(); + }); + + it('stops at six attacking cards', () => { + const state = makeState({ + hands: { [YOU]: '6H', [BOT]: 'AH AD AC AS KH KD' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + turnNumber: 3, + table: [ + { attack: '6C', defence: '7C' }, { attack: '6D', defence: '7D' }, + { attack: '6S', defence: '7S' }, { attack: '7H', defence: '8H' }, + { attack: '8C', defence: '9C' }, { attack: '8D', defence: '9D' }, + ], + phase: Phase.Reinforce, + }); + + expect(maxTableSlots(state)).toBe(6); + expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/already holds/); + expect(legalCodes(state, YOU)).toEqual(['pass']); + }); + + it('stops at five attacking cards on the very first turn', () => { + const state = makeState({ + hands: { [YOU]: '6H', [BOT]: 'AH AD AC AS KH KD' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + turnNumber: 1, + table: [ + { attack: '6C', defence: '7C' }, { attack: '6D', defence: '7D' }, + { attack: '6S', defence: '7S' }, { attack: '7H', defence: '8H' }, + { attack: '8C', defence: '9C' }, + ], + phase: Phase.Reinforce, + }); + + expect(maxTableSlots(state)).toBe(5); + expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/already holds/); + }); + + it('never leaves the defender more to beat than they hold', () => { + const state = makeState({ + hands: { [YOU]: '6H 6D', [BOT]: 'AH' }, + trump: 'S', + deck: '10C 10D', + attacker: YOU, + table: [{ attack: '6C' }], + phase: Phase.Reinforce, + defenderTook: true, + }); + + // One unbeaten card already, and the defender holds exactly one card. + expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/does not hold enough/); + }); + + it('reopens the decision for everyone once a card is added', () => { + const state = makeState({ + hands: { a: '6H', b: 'AH AD', c: '6D' }, + trump: 'S', + deck: '10C 10D', + attacker: 'a', + defender: 'b', + table: [{ attack: '6C', defence: '7C' }], + phase: Phase.Reinforce, + passed: ['c'], + }); + + const after = apply(state, Actions.attack('a', Card.parse('6H'))).state; + expect(after.passed.every((p) => p === false)).toBe(true); + }); +}); + +describe('resolving a turn', () => { + it('discards the table after a successful defence and refills hands', () => { + const state = makeState({ + hands: { [YOU]: '9H', [BOT]: 'AH' }, + trump: 'S', + deck: 'AC 10C 10D JC JD QC QD KC KD', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + + const { state: after, events } = apply(state, Actions.pass(YOU)); + + expect(after.table).toHaveLength(0); + expect(after.discard.map((c) => c.code)).toEqual(['7♦', '8♦']); + expect(after.players.map((p) => p.hand.length)).toEqual([6, 6]); + expect(events.map((e) => e.type)).toContain(GameEventType.TableDiscarded); + }); + + it('refills the attacker first, then the defender', () => { + const state = makeState({ + hands: { [YOU]: '', [BOT]: '' }, + trump: 'S', + // Only three cards plus the trump are left, so the order is visible. + deck: '10C 10D JC', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + + const after = apply(state, Actions.pass(YOU)).state; + + expect(handOf(after, YOU)).toEqual(['10♣', '10♦', 'J♣', '6♠']); + expect(handOf(after, BOT)).toEqual([]); + }); + + it('hands the attack to the defender after a successful defence', () => { + const state = makeState({ + hands: { [YOU]: '9H', [BOT]: 'AH' }, + trump: 'S', + deck: '10C 10D JC JD QC QD KC KD', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + + const after = apply(state, Actions.pass(YOU)).state; + + expect(after.players[after.attacker].id).toBe(BOT); + expect(after.players[after.defender].id).toBe(YOU); + expect(after.turnNumber).toBe(3); + expect(after.phase).toBe(Phase.Attack); + }); + + it('gives the table to a defender who took, and skips their turn', () => { + const state = makeState({ + hands: { [YOU]: '9H', [BOT]: 'AH' }, + trump: 'S', + deck: '10C 10D JC JD QC QD KC KD', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + // The attacker holds nothing of rank seven, so there is no decision left + // to make and the take resolves the whole turn in this one call. + const after = apply(state, Actions.take(BOT)).state; + expect(after.phase).toBe(Phase.Attack); + expect(handOf(after, BOT)).toContain('7♦'); + expect(after.players[after.attacker].id).toBe(YOU); + expect(after.players[after.defender].id).toBe(BOT); + expect(after.discard).toHaveLength(0); + }); + + it('lets attackers add to a table that is being taken', () => { + const state = makeState({ + hands: { [YOU]: '7H 9C', [BOT]: 'AH AD AC' }, + trump: 'S', + deck: '10C 10D JC JD QC QD KC KD', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + const took = apply(state, Actions.take(BOT)).state; + expect(took.phase).toBe(Phase.Reinforce); + expect(legalCodes(took, YOU).sort()).toEqual(['attack 7♥', 'pass'].sort()); + + const added = apply(took, Actions.attack(YOU, Card.parse('7H'))).state; + // Still being taken — the defender does not get to answer the new card. + expect(added.phase).toBe(Phase.Attack); + expect(handOf(added, BOT)).toEqual(expect.arrayContaining(['7♦', '7♥'])); + }); +}); + +describe('ending the game', () => { + it('declares the last player holding cards the fool', () => { + const state = makeState({ + hands: { [YOU]: '', [BOT]: 'AH' }, + trump: 'S', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + + const { state: after, events } = apply(state, Actions.pass(YOU)); + + expect(after.phase).toBe(Phase.Finished); + expect(after.loser).toBe(BOT); + expect(events.map((e) => e.type)).toContain(GameEventType.GameOver); + expect(events.map((e) => e.type)).toContain(GameEventType.PlayerOut); + }); + + it('calls it a draw when both players run out at once', () => { + const state = makeState({ + hands: { [YOU]: '', [BOT]: '' }, + trump: 'S', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + + const after = apply(state, Actions.pass(YOU)).state; + + expect(after.phase).toBe(Phase.Finished); + expect(after.loser).toBeNull(); + }); + + it('refuses any further action once it is over', () => { + const state = makeState({ + hands: { [YOU]: '', [BOT]: 'AH' }, + trump: 'S', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + const finished = apply(state, Actions.pass(YOU)).state; + + expect(currentActor(finished)).toBeNull(); + expect(legalActions(finished, BOT)).toEqual([]); + expect(() => apply(finished, Actions.pass(BOT))).toThrow(/game is over/); + }); +}); + +describe('turn order with more than two players', () => { + it('polls attackers starting from the attacker, skipping the defender', () => { + const state = makeState({ + hands: { a: '6H', b: 'AH AD', c: '6D' }, + trump: 'S', + deck: '10C 10D', + attacker: 'a', + defender: 'b', + table: [{ attack: '6C', defence: '7C' }], + phase: Phase.Reinforce, + }); + + expect(currentActor(state)).toBe('a'); + const afterA = apply(state, Actions.pass('a')).state; + expect(currentActor(afterA)).toBe('c'); + }); + + it('passes the attack past a defender who took', () => { + const state = makeState({ + hands: { a: 'KH', b: 'AH', c: 'QD' }, + trump: 'S', + deck: '10C 10D JC JD QC KC', + attacker: 'a', + defender: 'b', + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + const after = apply(state, Actions.take('b')).state; + expect(after.players[after.attacker].id).toBe('c'); + expect(after.players[after.defender].id).toBe('a'); + }); + + it('skips a seat that has left the game', () => { + const state = makeState({ + hands: { a: 'KH', b: 'AH', c: '' }, + trump: 'S', + deck: '10C 10D JC JD QC KC', + attacker: 'a', + defender: 'b', + out: ['c'], + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + + const after = apply(state, Actions.pass('a')).state; + expect(after.players[after.attacker].id).toBe('b'); + expect(after.players[after.defender].id).toBe('a'); + }); +}); + +describe('rejecting out-of-turn play', () => { + it('refuses an action from the player who is not on move', () => { + const state = makeState({ + hands: { [YOU]: '6C', [BOT]: '8C' }, + trump: 'S', + deck: '10C', + attacker: YOU, + }); + expect(() => apply(state, Actions.attack(BOT, Card.parse('8C')))).toThrow(IllegalActionError); + }); + + it('refuses a pass while a card still needs beating', () => { + const state = makeState({ + hands: { [YOU]: '6C', [BOT]: '8C' }, + trump: 'S', + deck: '10C', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + expect(() => apply(state, Actions.pass(BOT))).toThrow(/cannot pass/); + }); + + it('refuses a take when there is nothing to beat', () => { + const state = makeState({ + hands: { [YOU]: '6C', [BOT]: '8C' }, + trump: 'S', + deck: '10C', + attacker: YOU, + table: [{ attack: '7D', defence: '8D' }], + phase: Phase.Reinforce, + }); + expect(() => apply(state, Actions.take(BOT))).toThrow(IllegalActionError); + }); +}); diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts new file mode 100644 index 0000000..d787b9f --- /dev/null +++ b/tests/support/fixtures.ts @@ -0,0 +1,88 @@ +import { Card } from '@core/cards/Card'; +import { Suit } from '@core/cards/Suit'; +import { GameState, Phase, PlayerId } from '@core/rules/GameState'; + +/** + * Builds an exact position from a readable description, so a rule test can say + * what it is testing instead of playing twenty moves to get there. + * + * Cards are written as codes: `'6C 10H KS'`. Seating order is the key order of + * `hands`. + */ +export interface StateSpec { + hands: Record; + /** Suit letter of the trump, e.g. `'S'`. */ + trump: string; + /** Draw pile, top card first. The trump card is appended at the bottom. */ + deck?: string; + /** Explicit trump card; defaults to the six of the trump suit. */ + trumpCard?: string; + table?: { attack: string; defence?: string }[]; + discard?: string; + phase?: Phase; + attacker?: PlayerId; + defender?: PlayerId; + turnNumber?: number; + defenderTook?: boolean; + /** Attackers who have already declared they are done this turn. */ + passed?: PlayerId[]; + out?: PlayerId[]; + seed?: number; +} + +export function makeState(spec: StateSpec): GameState { + const ids = Object.keys(spec.hands); + const trumpSuit = Card.parse(`6${spec.trump}`).suit; + const trumpCard = spec.trumpCard ? Card.parse(spec.trumpCard) : Card.parse(`6${spec.trump}`); + const outSet = new Set(spec.out ?? []); + + const drawPile = spec.deck ? Card.parseAll(spec.deck) : []; + // The trump card always sits at the bottom of a non-empty pile. + const deck = drawPile.length > 0 ? [...drawPile, trumpCard] : []; + + const attacker = ids.indexOf(spec.attacker ?? ids[0]); + const defender = ids.indexOf(spec.defender ?? ids[(attacker + 1) % ids.length]); + + return { + players: ids.map((id) => ({ + id, + hand: Card.parseAll(spec.hands[id]), + out: outSet.has(id), + })), + deck, + trumpCard, + trumpSuit, + table: (spec.table ?? []).map((s) => ({ + attack: Card.parse(s.attack), + defence: s.defence ? Card.parse(s.defence) : null, + })), + discard: spec.discard ? Card.parseAll(spec.discard) : [], + phase: spec.phase ?? Phase.Attack, + attacker, + defender, + turnNumber: spec.turnNumber ?? 2, + defenderTook: spec.defenderTook ?? false, + passed: ids.map((id) => (spec.passed ?? []).includes(id)), + rngState: spec.seed ?? 1, + loser: null, + }; +} + +/** Hand of `player` as sorted card codes, for readable assertions. */ +export function handOf(state: GameState, player: PlayerId): string[] { + const p = state.players.find((x) => x.id === player); + if (!p) throw new Error(`No player ${player}`); + return p.hand.map((c) => c.code); +} + +/** Table as `'6C/7C'` pairs (`'6C/-'` when unbeaten). */ +export function tableOf(state: GameState): string[] { + return state.table.map((s) => `${s.attack.code}/${s.defence ? s.defence.code : '-'}`); +} + +export const SUITS = { + clubs: Suit.Clubs, + diamonds: Suit.Diamonds, + hearts: Suit.Hearts, + spades: Suit.Spades, +};