import { describe, expect, it } from 'vitest'; import { Card } from '@core/cards/Card'; import { Actions, ActionType, IllegalActionError } from '@core/rules/Action'; import { GameEventType } from '@core/rules/GameEvent'; import { Phase } from '@core/rules/GameState'; import { apply, currentActor, legalActions, maxTableSlots, whyCannotAdd, } from '@core/rules/DurakRules'; import { handOf, makeState, tableOf } from '../../support/fixtures'; const YOU = 'you'; const BOT = 'bot'; /** Action codes a player may take, as readable strings, for set comparisons. */ function legalCodes(state: Parameters[0], player: string): string[] { return legalActions(state, player).map((a) => { switch (a.type) { case ActionType.Attack: return `attack ${a.card.code}`; case ActionType.Defend: return `defend ${a.slot} ${a.card.code}`; case ActionType.Take: return 'take'; case ActionType.Pass: return 'pass'; } }).sort(); } describe('opening a turn', () => { it('lets the attacker play any card and offers no way to skip', () => { const state = makeState({ hands: { [YOU]: '6C 7D KH', [BOT]: '8C 9D QH' }, trump: 'S', deck: '10C 10D', attacker: YOU, }); expect(currentActor(state)).toBe(YOU); expect(legalCodes(state, YOU)).toEqual(['attack 6♣', 'attack 7♦', 'attack K♥'].sort()); expect(legalCodes(state, YOU)).not.toContain('pass'); }); it('gives the defender no say until a card is down', () => { const state = makeState({ hands: { [YOU]: '6C', [BOT]: '8C' }, trump: 'S', deck: '10C', attacker: YOU, }); expect(legalActions(state, BOT)).toEqual([]); }); it('moves to the defence once a card is played', () => { const start = makeState({ hands: { [YOU]: '6C 7D', [BOT]: '8C 9D' }, trump: 'S', deck: '10C 10D', attacker: YOU, }); const { state, events } = apply(start, Actions.attack(YOU, Card.parse('6C'))); expect(state.phase).toBe(Phase.Defence); expect(currentActor(state)).toBe(BOT); expect(tableOf(state)).toEqual(['6♣/-']); expect(handOf(state, YOU)).toEqual(['7♦']); expect(events.map((e) => e.type)).toEqual([GameEventType.Attacked]); }); it('leaves the source state untouched', () => { const start = makeState({ hands: { [YOU]: '6C 7D', [BOT]: '8C 9D' }, trump: 'S', deck: '10C 10D', attacker: YOU, }); const before = JSON.stringify(handOf(start, YOU)); apply(start, Actions.attack(YOU, Card.parse('6C'))); expect(JSON.stringify(handOf(start, YOU))).toBe(before); expect(start.table).toHaveLength(0); expect(start.phase).toBe(Phase.Attack); }); }); describe('defending', () => { const position = () => makeState({ hands: { [YOU]: '9D KH', [BOT]: '7C 8C 6S QH' }, trump: 'S', deck: '10C 10D', attacker: YOU, table: [{ attack: '7D' }], phase: Phase.Defence, }); it('offers every card that beats the attack, plus taking', () => { expect(legalCodes(position(), BOT).sort()).toEqual(['defend 0 6♠', 'take'].sort()); }); it('accepts a higher card of the same suit', () => { const state = makeState({ // The attacker keeps a seven and the defender keeps a spare card, so // the turn waits in Reinforce instead of settling the moment the // defence lands. hands: { [YOU]: '7H', [BOT]: '8D AH' }, trump: 'S', deck: '10C', attacker: YOU, table: [{ attack: '7D' }], phase: Phase.Defence, }); const result = apply(state, Actions.defend(BOT, Card.parse('8D'), 0)); expect(tableOf(result.state)).toEqual(['7♦/8♦']); expect(result.state.phase).toBe(Phase.Reinforce); }); it('refuses a card that does not beat the attack', () => { const state = position(); expect(() => apply(state, Actions.defend(BOT, Card.parse('7C'), 0))) .toThrow(IllegalActionError); }); it('refuses a card that is not in hand', () => { const state = position(); expect(() => apply(state, Actions.defend(BOT, Card.parse('AS'), 0))) .toThrow(/not in hand/); }); it('refuses a slot that is already beaten', () => { const state = makeState({ hands: { [YOU]: 'KH', [BOT]: '9D 10D' }, trump: 'S', deck: '10C', attacker: YOU, table: [{ attack: '7D', defence: '8D' }, { attack: '6H' }], phase: Phase.Defence, }); expect(() => apply(state, Actions.defend(BOT, Card.parse('9D'), 0))) .toThrow(/already beaten/); }); it('requires a trump to beat a trump', () => { const state = makeState({ hands: { [YOU]: 'KH', [BOT]: 'AH 7S' }, trump: 'S', deck: '10C', attacker: YOU, table: [{ attack: '6S' }], phase: Phase.Defence, }); expect(legalCodes(state, BOT).sort()).toEqual(['defend 0 7♠', 'take'].sort()); }); }); describe('adding cards to an attack', () => { it('only allows ranks already on the table', () => { const state = makeState({ hands: { [YOU]: '7H 9C KD', [BOT]: 'AH AD' }, trump: 'S', deck: '10C 10D', attacker: YOU, table: [{ attack: '7D', defence: '9D' }], phase: Phase.Reinforce, }); expect(legalCodes(state, YOU).sort()).toEqual(['attack 7♥', 'attack 9♣', 'pass'].sort()); expect(whyCannotAdd(state, Card.parse('KD'))).toMatch(/rank/); }); it('counts the defender cards as well as the attacking ones', () => { const state = makeState({ hands: { [YOU]: '9H', [BOT]: 'AH AD' }, trump: 'S', deck: '10C 10D', attacker: YOU, table: [{ attack: '7D', defence: '9D' }], phase: Phase.Reinforce, }); // The nine is addable only because the defender used a nine. expect(whyCannotAdd(state, Card.parse('9H'))).toBeNull(); }); it('stops at six attacking cards', () => { const state = makeState({ hands: { [YOU]: '6H', [BOT]: 'AH AD AC AS KH KD' }, trump: 'S', deck: '10C 10D', attacker: YOU, turnNumber: 3, table: [ { attack: '6C', defence: '7C' }, { attack: '6D', defence: '7D' }, { attack: '6S', defence: '7S' }, { attack: '7H', defence: '8H' }, { attack: '8C', defence: '9C' }, { attack: '8D', defence: '9D' }, ], phase: Phase.Reinforce, }); expect(maxTableSlots(state)).toBe(6); expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/already holds/); expect(legalCodes(state, YOU)).toEqual(['pass']); }); it('stops at five attacking cards on the very first turn', () => { const state = makeState({ hands: { [YOU]: '6H', [BOT]: 'AH AD AC AS KH KD' }, trump: 'S', deck: '10C 10D', attacker: YOU, turnNumber: 1, table: [ { attack: '6C', defence: '7C' }, { attack: '6D', defence: '7D' }, { attack: '6S', defence: '7S' }, { attack: '7H', defence: '8H' }, { attack: '8C', defence: '9C' }, ], phase: Phase.Reinforce, }); expect(maxTableSlots(state)).toBe(5); expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/already holds/); }); it('never leaves the defender more to beat than they hold', () => { const state = makeState({ hands: { [YOU]: '6H 6D', [BOT]: 'AH' }, trump: 'S', deck: '10C 10D', attacker: YOU, table: [{ attack: '6C' }], phase: Phase.Reinforce, defenderTook: true, }); // One unbeaten card already, and the defender holds exactly one card. expect(whyCannotAdd(state, Card.parse('6H'))).toMatch(/does not hold enough/); }); it('reopens the decision for everyone once a card is added', () => { const state = makeState({ hands: { a: '6H', b: 'AH AD', c: '6D' }, trump: 'S', deck: '10C 10D', attacker: 'a', defender: 'b', table: [{ attack: '6C', defence: '7C' }], phase: Phase.Reinforce, passed: ['c'], }); const after = apply(state, Actions.attack('a', Card.parse('6H'))).state; expect(after.passed.every((p) => p === false)).toBe(true); }); }); describe('resolving a turn', () => { it('discards the table after a successful defence and refills hands', () => { const state = makeState({ hands: { [YOU]: '9H', [BOT]: 'AH' }, trump: 'S', deck: 'AC 10C 10D JC JD QC QD KC KD', attacker: YOU, table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); const { state: after, events } = apply(state, Actions.pass(YOU)); expect(after.table).toHaveLength(0); expect(after.discard.map((c) => c.code)).toEqual(['7♦', '8♦']); expect(after.players.map((p) => p.hand.length)).toEqual([6, 6]); expect(events.map((e) => e.type)).toContain(GameEventType.TableDiscarded); }); it('refills the attacker first, then the defender', () => { const state = makeState({ hands: { [YOU]: '', [BOT]: '' }, trump: 'S', // Only three cards plus the trump are left, so the order is visible. deck: '10C 10D JC', attacker: YOU, table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); const after = apply(state, Actions.pass(YOU)).state; expect(handOf(after, YOU)).toEqual(['10♣', '10♦', 'J♣', '6♠']); expect(handOf(after, BOT)).toEqual([]); }); it('hands the attack to the defender after a successful defence', () => { const state = makeState({ hands: { [YOU]: '9H', [BOT]: 'AH' }, trump: 'S', deck: '10C 10D JC JD QC QD KC KD', attacker: YOU, table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); const after = apply(state, Actions.pass(YOU)).state; expect(after.players[after.attacker].id).toBe(BOT); expect(after.players[after.defender].id).toBe(YOU); expect(after.turnNumber).toBe(3); expect(after.phase).toBe(Phase.Attack); }); it('gives the table to a defender who took, and skips their turn', () => { const state = makeState({ hands: { [YOU]: '9H', [BOT]: 'AH' }, trump: 'S', deck: '10C 10D JC JD QC QD KC KD', attacker: YOU, table: [{ attack: '7D' }], phase: Phase.Defence, }); // The attacker holds nothing of rank seven, so there is no decision left // to make and the take resolves the whole turn in this one call. const after = apply(state, Actions.take(BOT)).state; expect(after.phase).toBe(Phase.Attack); expect(handOf(after, BOT)).toContain('7♦'); expect(after.players[after.attacker].id).toBe(YOU); expect(after.players[after.defender].id).toBe(BOT); expect(after.discard).toHaveLength(0); }); it('lets attackers add to a table that is being taken', () => { const state = makeState({ hands: { [YOU]: '7H 9C', [BOT]: 'AH AD AC' }, trump: 'S', deck: '10C 10D JC JD QC QD KC KD', attacker: YOU, table: [{ attack: '7D' }], phase: Phase.Defence, }); const took = apply(state, Actions.take(BOT)).state; expect(took.phase).toBe(Phase.Reinforce); expect(legalCodes(took, YOU).sort()).toEqual(['attack 7♥', 'pass'].sort()); const added = apply(took, Actions.attack(YOU, Card.parse('7H'))).state; // Still being taken — the defender does not get to answer the new card. expect(added.phase).toBe(Phase.Attack); expect(handOf(added, BOT)).toEqual(expect.arrayContaining(['7♦', '7♥'])); }); }); describe('ending the game', () => { it('declares the last player holding cards the fool', () => { const state = makeState({ hands: { [YOU]: '', [BOT]: 'AH' }, trump: 'S', attacker: YOU, table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); const { state: after, events } = apply(state, Actions.pass(YOU)); expect(after.phase).toBe(Phase.Finished); expect(after.loser).toBe(BOT); expect(events.map((e) => e.type)).toContain(GameEventType.GameOver); expect(events.map((e) => e.type)).toContain(GameEventType.PlayerOut); }); it('calls it a draw when both players run out at once', () => { const state = makeState({ hands: { [YOU]: '', [BOT]: '' }, trump: 'S', attacker: YOU, table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); const after = apply(state, Actions.pass(YOU)).state; expect(after.phase).toBe(Phase.Finished); expect(after.loser).toBeNull(); }); it('refuses any further action once it is over', () => { const state = makeState({ hands: { [YOU]: '', [BOT]: 'AH' }, trump: 'S', attacker: YOU, table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); const finished = apply(state, Actions.pass(YOU)).state; expect(currentActor(finished)).toBeNull(); expect(legalActions(finished, BOT)).toEqual([]); expect(() => apply(finished, Actions.pass(BOT))).toThrow(/game is over/); }); }); describe('turn order with more than two players', () => { it('polls attackers starting from the attacker, skipping the defender', () => { const state = makeState({ hands: { a: '6H', b: 'AH AD', c: '6D' }, trump: 'S', deck: '10C 10D', attacker: 'a', defender: 'b', table: [{ attack: '6C', defence: '7C' }], phase: Phase.Reinforce, }); expect(currentActor(state)).toBe('a'); const afterA = apply(state, Actions.pass('a')).state; expect(currentActor(afterA)).toBe('c'); }); it('passes the attack past a defender who took', () => { const state = makeState({ hands: { a: 'KH', b: 'AH', c: 'QD' }, trump: 'S', deck: '10C 10D JC JD QC KC', attacker: 'a', defender: 'b', table: [{ attack: '7D' }], phase: Phase.Defence, }); const after = apply(state, Actions.take('b')).state; expect(after.players[after.attacker].id).toBe('c'); expect(after.players[after.defender].id).toBe('a'); }); it('skips a seat that has left the game', () => { const state = makeState({ hands: { a: 'KH', b: 'AH', c: '' }, trump: 'S', deck: '10C 10D JC JD QC KC', attacker: 'a', defender: 'b', out: ['c'], table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); const after = apply(state, Actions.pass('a')).state; expect(after.players[after.attacker].id).toBe('b'); expect(after.players[after.defender].id).toBe('a'); }); }); describe('rejecting out-of-turn play', () => { it('refuses an action from the player who is not on move', () => { const state = makeState({ hands: { [YOU]: '6C', [BOT]: '8C' }, trump: 'S', deck: '10C', attacker: YOU, }); expect(() => apply(state, Actions.attack(BOT, Card.parse('8C')))).toThrow(IllegalActionError); }); it('refuses a pass while a card still needs beating', () => { const state = makeState({ hands: { [YOU]: '6C', [BOT]: '8C' }, trump: 'S', deck: '10C', attacker: YOU, table: [{ attack: '7D' }], phase: Phase.Defence, }); expect(() => apply(state, Actions.pass(BOT))).toThrow(/cannot pass/); }); it('refuses a take when there is nothing to beat', () => { const state = makeState({ hands: { [YOU]: '6C', [BOT]: '8C' }, trump: 'S', deck: '10C', attacker: YOU, table: [{ attack: '7D', defence: '8D' }], phase: Phase.Reinforce, }); expect(() => apply(state, Actions.take(BOT))).toThrow(IllegalActionError); }); });