Bridge Training

Play

Trick winner, follow-suit rules, legal cards, trick building from a flat play, and engine-side replay validation.

Two ways in:

AccessWhat you get
import { … } from "@workspace/bridge/play"Pure trick helpers: winner, follow-suit, legal cards, trick building.
engine.play.*The same helpers plus validateTricks, a mode-aware replay validator, and a few hand helpers (deepCopyHands, removeCardFromHand).

engine.play is configured with the mode's validRanks and validDenominations. That configuration only matters for validateTricks: a played rank outside the mode's deck is reported, and in modes whose denominations have no playable suit (NT-only) the trump argument is neutralised. buildTricks, buildTricksFromDeal and getLegalCards are only in @workspace/bridge/play.

Play shape

A Play is the stored form: { first: Player; moves: [{ card, comment? }] } in chronological order, first being the opening leader. Trick T+1 starts with the winner of trick T, so seats are derived while walking, never stored. buildTricks turns that flat list into Trick[], the in-memory view.

Prop

Type

Trick winner

FunctionSignatureDescription
determineTrickWinner(entries: PlayEntry[], trump: Suit | null, leadSuit: Suit | null) => PlayEntry | nullHighest trump wins; otherwise highest card of the lead suit. null for an empty trick.
play.ts
const trick = new TrickBuilder().fromCompact("N / SA SK H2 S3").build();

determineTrickWinner(trick.cards, null, "S"); // => { player: "N", card: "SA" }
determineTrickWinner(trick.cards, "H", "S"); // => { player: "S", card: "H2" } (trump beats the lead suit)
determineTrickWinner([], null, null); // => null

Follow-suit rules

FunctionSignatureDescription
validateCardFollowsRules(card: CardInput, player: Player, leadSuit: Suit | null, hands: HandsData) => booleanLegal if leading, if following suit, or if void in the lead suit.
getLegalCards(hand: HandData, leadSuit: Suit | null) => Card[]Set form of the rule above. A single result means the play is forced.
play.ts
validateCardFollowsRules("H2", "S", "D", hands); // => false (South still holds diamonds)
validateCardFollowsRules("DT", "S", "D", hands); // => true
validateCardFollowsRules({ suit: "D", rank: "T" }, "S", "D", hands); // => true (CardParts form)
validateCardFollowsRules("H2", "S", null, hands); // => true (leading: anything goes)

getLegalCards(hands.W, "D"); // => ["D7", "D6", "D3", "D2"]
getLegalCards(hands.W, null).length; // => 13 (leading)

Building and reading tricks

FunctionSignatureDescription
buildTricks(play: { first: Player; moves: { card: string }[] }, trump: Suit | null) => Trick[]Chunks by four, assigns seats from the leader, computes each winner. A partial last trick has winner: null.
buildTricksFromDeal(deal: Deal) => Trick[]buildTricks with deal.play and deal.contract?.trump.
extractLeadFromTricks(tricks: Trick[]) => Card | nullThe opening lead.
hasPlayedSuit(tricks: Trick[], player: Player, suit: Suit) => booleanWhether a seat has played a card of that suit.

PlayMoveLike ({ card: string }) is the minimal move shape buildTricks accepts. A Trick is built by buildTricks; form data reaches the same shape through trickSchema (Schemas).

play.ts
const play = new PlayBuilder().fromCompact("W / D7 DA DK DT SA SJ S7 S3").build();
const tricks = buildTricks(play, null);

tricks[0]?.winner; // => "N" (DA wins the first trick)
tricks[1]?.lead; // => "N" (winner leads the next trick)
tricks.length; // => 2

extractLeadFromTricks(tricks); // => "D7"
hasPlayedSuit(tricks, "E", "S"); // => true

buildTricksFromDeal(makeDeal({ cards, play })); // trump taken from deal.contract (null here)

Replay validation (engine only)

engine.play.validateTricks(moves, first, cards, trump) replays a flat play against the original Hands and returns a CorePlayValidationResult. Every occurrence of a failure is collected:

Error typeMeaning
CARD_NOT_OWNEDThe seat does not hold the card at that point of the play.
REVOKEA follower discarded while still holding the lead suit.
INVALID_CARD_RANKThe rank is outside the mode's validRanks.

A partial last trick is accepted as long as everything so far is consistent. Non-chronological or otherwise illegal stored play is rejected at the write gate; there is no auto-repair.

Prop

Type

play.ts
const engine = createBridgeEngine(createBridgeContext({ teachingMode: "auction" }));

const ok = engine.play.validateTricks(play.moves, play.first, cards, null);
ok.isValid; // => true
ok.parsedTricks.length; // => 2

const revoke = new PlayBuilder().fromCompact("W / D7 DA DK S7").build(); // South discards a spade while holding diamonds
const bad = engine.play.validateTricks(revoke.moves, revoke.first, cards, null);
bad.isValid; // => false
bad.errors[0]?.type; // => "REVOKE"