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