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> = { 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 = {}; 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(); }