M1: ядро правил дурака (чистый TypeScript, без движка)
Детерминированный конечный автомат: 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>
This commit is contained in:
@@ -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<Record<string, Suit>> = {
|
||||
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<string, Rank> = {};
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "37e27ebe-a388-487e-a30e-2c8ba1475e45",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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, string> = {
|
||||
[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, string> = {
|
||||
[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];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e301a4f0-1703-47ba-9a1c-fa73e4d2935e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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, string> = {
|
||||
[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, string> = {
|
||||
[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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "7056e716-7d05-461f-ba29-1716db6674ad",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b99118e1-0ff8-43f8-a6ac-02f26f154af7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9fd385cc-d496-485c-a5d8-64332887084f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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<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');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e28254c5-32f3-4bcd-a3ad-383bac9b2598",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5953fa1f-076c-4745-be88-e1f887983c72",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "96d082c1-3dc2-4552-91ff-a73be57f7295",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+8
-8
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createGame>['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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof legalActions>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<PlayerId, string>;
|
||||
/** 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,
|
||||
};
|
||||
Reference in New Issue
Block a user