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:
ftc
2026-09-13 10:38:49 +03:00
co-authored by Claude Opus 5
parent c980440856
commit fddab60527
22 changed files with 1836 additions and 8 deletions
+66
View File
@@ -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);
});
});