Bridge Training

Card

Card and rank parsing, normalisation, rank sorting and suit display order.

Import from @workspace/bridge/card. There is no engine.card module; the engine exposes card-related configuration elsewhere:

Engine memberWhat it tells you
engine.hands.config.validRanksThe ranks in the mode's deck (CoreConfig.validRanks).
engine.hands.config.nbCardsPerHandCards per seat.
engine.ctx.cardSetWhich card-face set the mode renders with.
engine.ctx.validDenominationsDenominations that can be bid or used as trump.
engine.notation.*Locale-aware rank parsing and formatting (see Notation).
card.ts
const engine = createBridgeEngine(createBridgeContext({ teachingMode: "petit-bridge" }));

engine.hands.config.validRanks; // the ranks of the mode's deck (10 per suit for petit-bridge)
engine.ctx.cardSet; // which card-face set the mode renders with
engine.ctx.validDenominations; // denominations that can be bid / used as trump

Types

Suit is "S" | "H" | "D" | "C". Rank is "A" | "K" | "Q" | "J" | "T" | "9" … "2"; ten is always "T" internally. Card is the template `${Suit}${Rank}` ("SA", "H7", "DT"): the canonical form, stored in Deal, PBN, LIN and the database, and the one every function returns.

Two more types describe what the public API accepts:

TypeDefinitionDescription
CardPartsReadonly<{ suit: Suit; rank: Rank }>A card as its two fields.
CardInputCard | CardPartsEither form. Functions that take a card accept it and normalise through toCard, so they throw on a value that denotes no card; internal loops keep working on Card strings.

CardInput is a TypeScript convenience: the Zod schemas and Deal keep the string form, so the trust boundary does not move. Both types are imported from @workspace/bridge/card.

Type guards

FunctionSignatureDescription
isRank(value: string) => value is RankInternal notation only: "10" is not a rank.
isCard(value: unknown) => value is CardSuit letter followed by a rank.
card.ts
isRank("T"); // => true
isRank("10"); // => false (internal notation is "T")
isCard("SA"); // => true
isCard("XY"); // => false

Parsing and construction

FunctionSignatureDescription
parseSuit(value: string) => Suit | nullExact letter.
parseRank(value: string) => Rank | nullSingle canonical character; "10" is not accepted.
parseCard(value: string) => Card | nullThe entry for untrusted strings: uppercases, collapses "10" to "T", then validates. null when invalid.
toCard(input: CardInput) => CardCanonical string from either form, for values the caller vouches for. A canonical string is returned as is; any other string is uppercased, its "10" collapsed, then validated. Throws InvalidNotationError (INVALID_CARD) when the string or the parts denote no card.
suitOf(card: Card) => SuitReader; use it instead of slicing the string.
rankOf(card: Card) => RankReader.

Two contracts: parse* takes untrusted input and returns null when it is invalid, while to* takes a value the caller vouches for and throws InvalidNotationError (see Errors) when it is not.

card.ts
parseSuit("D"); // => "D"
parseRank("1"); // => null
parseCard("H7"); // => "H7" (typed Card)
parseCard("h10"); // => "HT" (casing and the PBN ten are normalised first)
parseCard("H1"); // => null

toCard({ suit: "S", rank: "A" }); // => "SA" (canonical string from parts)
toCard("sa" as Card); // => "SA" (casing normalised for values cast from storage)
toCard("H1" as Card); // throws InvalidNotationError (INVALID_CARD): use parseCard for untrusted input
suitOf("H7"); // => "H"
rankOf("H7"); // => "7"

Rank normalisation

PBN and display use "10"; the package uses "T".

FunctionSignatureDescription
normalizeRankString(ranks: string) => stringReplace every "10" in a joined rank string.
parseRankString(raw: string) => Rank[]Uppercase, collapse "10", drop anything that is not a rank.
card.ts
normalizeRankString("AKQ1093"); // => "AKQT93"
parseRankString("a k 10 x 7"); // => ["A", "K", "T", "7"] (invalid chars dropped)

Sorting

FunctionSignatureDescription
DEFAULT_RANK_ORDERreadonly Rank[]A, K, Q, J, T, 9 … 2.
sortRanksDefault(ranks: readonly Rank[]) => Rank[]Default order, highest first; unknown ranks go last.
sortRankString(ranks: string) => stringSort a joined rank string (assumes "T" notation).
sortCardsByRank<T extends string>(cards: readonly T[], order?: readonly Rank[]) => T[]Sort cards ("SA") or bare ranks ("A") by their last character, highest first; unknown ranks go last.
card.ts
sortRanksDefault(["3", "A", "K"]); // => ["A", "K", "3"]
sortRankString("Q3KA"); // => "AKQ3"
sortCardsByRank(["S3", "SA", "SK"]); // => ["SA", "SK", "S3"]

Suit display order

How a fanned hand reads: trump first, then the other suits arranged to alternate colours, with the longest remaining suit at the far end. priority picks the criterion: "length" (default) or "alternation", which maximises black/red flips over the suits actually held and can align the hero's fan on the dummy's.

FunctionSignatureDescription
suitDisplayOrder(trump: Suit | null, distribution?: SuitDistribution, priority?: SuitOrderPriority, leadSuit?: Suit | null, referenceOrder?: readonly Suit[]) => readonly Suit[]Without a distribution: canonical S, H, C, D shifted so trump leads.
suitLengths(hand: Readonly<Record<Suit, string>>) => SuitDistributionSuit lengths of a rank-string hand ("10" counted once).

Prop

Type

card.ts
const distribution = suitLengths({ S: "AKQ2", H: "KQ3", D: "A54", C: "K32" }); // => { S: 4, H: 3, D: 3, C: 3 }

suitDisplayOrder(null); // => ["S", "H", "C", "D"] (canonical, no trump, no distribution)
suitDisplayOrder("H", distribution); // trump first, then arranged for colour alternation
suitDisplayOrder(null, distribution, "alternation", "D"); // NT: the lead suit opens the fan