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'); }