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