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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user