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
+114
View File
@@ -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();
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "37e27ebe-a388-487e-a30e-2c8ba1475e45",
"files": [],
"subMetas": {},
"userData": {}
}
+54
View File
@@ -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];
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e301a4f0-1703-47ba-9a1c-fa73e4d2935e",
"files": [],
"subMetas": {},
"userData": {}
}
+36
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "7056e716-7d05-461f-ba29-1716db6674ad",
"files": [],
"subMetas": {},
"userData": {}
}
+3
View File
@@ -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';
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b99118e1-0ff8-43f8-a6ac-02f26f154af7",
"files": [],
"subMetas": {},
"userData": {}
}