Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions rules/bidding/systems/five-card-high/explanations-nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@
return `gevonden fit: steun voor partners kleur. ${handFactsText({ ruleName, result })}`;
case "continuation.acceptMajorInvite":
return `invite aangenomen: na de enkele steun heeft responder de hoge range; met 8+ HCP biedt hij de manche. ${handFactsText({ ruleName, result })}`;
case "pass.responderAfterAcceptedMajorRaiseGame":
return `pas na geaccepteerde hoge-kleurinvite: openaar heeft met 4${result.suit} de manche gekozen. Zonder duidelijke sleminteresse verhoogt antwoorder niet naar 5${result.suit}. ${handFactsText({ ruleName, result })}`;
case "continuation.responderPreference":
return `tweede bijbod met minimum: antwoorder houdt het laag en geeft preferentie voor openaars eerste kleur. ${handFactsText({ ruleName, result })}`;
case "continuation.responderOneNotrumpMinimum":
Expand Down
14 changes: 14 additions & 0 deletions rules/bidding/systems/five-card-high/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
openerAfterNotrumpResponseRuleName,
openerRebidAfterNewSuitFiveCardHigh,
isSingleRaiseInviteFiveCardHigh,
isAcceptedMajorRaiseGameFiveCardHigh,
rebidResponderAfterSingleRaiseInviteFiveCardHigh,
rebidResponderAfterOneNotrumpFiveCardHigh,
rebidResponderAfterTwoNotrumpFiveCardHigh,
Expand Down Expand Up @@ -381,6 +382,19 @@
support: shape.counts[openerRebidCall.bid.strain] || 0
});
}
if (
openingCall?.seat === partnerOf(seat) &&
isAcceptedMajorRaiseGameFiveCardHigh?.(openingCall?.bid, responseCall?.bid, openerRebidCall?.bid)
) {
return fiveCardHighBidChoiceResult(Pass(), "pass.responderAfterAcceptedMajorRaiseGame", "basic", "Pass because opener accepted the major-suit invite by bidding game; responder should not raise to the five-level without slam interest.", {
...base,
category: "continuation",
suit: openingCall.bid.strain,
partnerSuit: openingCall.bid.strain,
support: shape.counts[openingCall.bid.strain] || 0,
range: "noSlamInterest"
});
}
const openerTransferSuit = openingCall?.seat === seat && bidEquals(openingCall?.bid, 1, "NT")
? notrumpTransferSuit(openingCall.bid, responseCall?.bid)
: null;
Expand Down
45 changes: 43 additions & 2 deletions rules/bidding/systems/five-card-high/rebids.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,20 +488,41 @@
if (strongSecondMajor) return strongSecondMajor;
}
if (isOneSuitOpeningFiveCardHigh(openingBid)) {
const secondBid = rebidResponderAfterOneSuitOpeningFiveCardHigh(shape, openingBid, responseBid, openerRebid);
const secondBid = rebidResponderAfterOneSuitOpeningFiveCardHigh(shape, hand, openingBid, responseBid, openerRebid);
if (secondBid) return secondBid;
return chooseFiveCardHighNaturalContinuation(hand, openerRebid, openerRebid);
}
return Pass();
}

function rebidResponderAfterOneSuitOpeningFiveCardHigh(shape, openingBid, responseBid, openerRebid) {
function rebidResponderAfterOneSuitOpeningFiveCardHigh(shape, hand, openingBid, responseBid, openerRebid) {
if (!openerRebid || openerRebid.strain === "NT") return rebidResponderAfterOpenerNotrumpRebidFiveCardHigh(shape, openingBid, responseBid, openerRebid);
if (isAcceptedMajorRaiseGameFiveCardHigh(openingBid, responseBid, openerRebid)) {
return shouldUseBlackwoodAfterAcceptedMajorRaiseGame(shape, hand, openingBid, responseBid, openerRebid)
? bid(4, "NT")
: Pass();
}
if (openerRebid.strain === openingBid.strain) return rebidResponderAfterOpenerRepeatsSuitFiveCardHigh(shape, openingBid, responseBid, openerRebid);
if (openerRebid.strain === responseBid.strain) return rebidResponderAfterOpenerRaisesResponderFiveCardHigh(shape, responseBid, openerRebid);
return rebidResponderAfterOpenerNewSuitFiveCardHigh(shape, openingBid, responseBid, openerRebid);
}

function isAcceptedMajorRaiseGameFiveCardHigh(openingBid, responseBid, openerRebid) {
return (
openingBid?.level === 1 &&
(openingBid.strain === "H" || openingBid.strain === "S") &&
responseBid?.level === 3 &&
responseBid.strain === openingBid.strain &&
bidEquals(openerRebid, gameLevel(openingBid.strain), openingBid.strain)
);
}

function shouldUseBlackwoodAfterAcceptedMajorRaiseGame(shape, hand, openingBid, responseBid, openerRebid) {
if (!isAcceptedMajorRaiseGameFiveCardHigh(openingBid, responseBid, openerRebid)) return false;
if ((shape.counts[openingBid.strain] || 0) < supportLengthForOpening(openingBid.strain)) return false;
return shape.hcp >= 15 && countAces(hand) >= 2;
}

function rebidResponderAfterOpenerNotrumpRebidFiveCardHigh(shape, openingBid, responseBid, openerRebid) {
if (!openerRebid || openerRebid.strain !== "NT") return null;
if (isOneMinorOpeningFiveCardHigh(openingBid) && bidEquals(responseBid, 1, "S") && bidEquals(openerRebid, 1, "NT") && shape.counts.S >= 5 && shape.counts.H >= 4) {
Expand Down Expand Up @@ -1131,6 +1152,24 @@
if (openerRebid?.strain !== "NT" && chosenBid.strain === openerRebid?.strain && shape.counts[openerRebid.strain] >= 4) {
return fiveCardHighBidChoiceResult(chosenBid, "continuation.responderRaiseOpenerSecondSuit", "basic", "Raise opener's second suit with a fit.", extra);
}
if (isAcceptedMajorRaiseGameFiveCardHigh(openingBid, responseBid, openerRebid) && isBlackwoodAsk(chosenBid)) {
return fiveCardHighBidChoiceResult(
chosenBid,
"continuation.blackwoodAsk",
"basic",
"Ask for aces with four notrump after opener accepted the major-suit invite.",
{
...extra,
convention: "blackwood",
artificial: true,
forcing: true,
trumpSuit: openingBid.strain,
aceCount: base.aceCount,
partnershipMinimumHcp: shape.hcp + 18,
agreementSource: "acceptedMajorRaiseGame"
}
);
}
if (chosenBid.strain === responseBid?.strain && shape.counts[responseBid.strain] >= 6) {
return fiveCardHighBidChoiceResult(chosenBid, "continuation.responderRebidOwnSixCard", "basic", "Rebid responder's own six-card suit.", extra);
}
Expand Down Expand Up @@ -1470,6 +1509,8 @@
isStrongTwoClubsTwoNotrumpRebid,
respondAfterStrongTwoClubsTwoNotrumpRebidFiveCardHigh,
rebidAfterOneSuitOpeningFiveCardHigh,
isAcceptedMajorRaiseGameFiveCardHigh,
shouldUseBlackwoodAfterAcceptedMajorRaiseGame,
openerRebidAfterMinorNotrumpFiveCardHigh,
openerRebidAfterRaiseFiveCardHigh,
openerRebidAfterMinorRaiseFiveCardHigh,
Expand Down
12 changes: 12 additions & 0 deletions rules/card-play.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
chooseReturnPartnerLeadSuit,
chooseSafeDefensiveWinner,
chooseTrumpSwitchAgainstDummyRuff,
chooseAvoidUnsupportedHonorUnderDummy,
chooseOpeningLeadAttitudeSignal,
chooseThirdHandHighOverLowLead,
chooseThirdHandUnblockHonor,
Expand Down Expand Up @@ -159,6 +160,17 @@
});
if (safeDefensiveWinner) return withPlayPlanFallback(safeDefensiveWinner, planDecision);

const avoidUnsupportedHonorUnderDummy = chooseAvoidUnsupportedHonorUnderDummy({
legal,
dummyHand,
trickHistory,
currentTrick,
seat,
declarer,
trump
});
if (avoidUnsupportedHonorUnderDummy) return withPlayPlanFallback(avoidUnsupportedHonorUnderDummy, planDecision);

return withPlayPlanFallback(
chooseLeadCardPlay(hand, legal, { contract, seat, declarer, isOpeningLead: context.isOpeningLead, auction }),
planDecision
Expand Down
2 changes: 2 additions & 0 deletions rules/card-play/declarer-play.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
suits,
compareLowCards,
lowestCard,
teamOf,
partnerOf
} = core;
const { beats } = playMechanics;
Expand Down Expand Up @@ -173,6 +174,7 @@

const winningTrumps = trumpCards.filter((card) => beats(card, winning.card, leadSuit, trump));
if (!winningTrumps.length) return null;
if (teamOf(winning.seat) !== teamOf(seat)) return null;

const card = lowestCard(discardCards);
if (!card) return null;
Expand Down
51 changes: 51 additions & 0 deletions rules/card-play/defense.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
compareLowCards,
lowestCard,
highestCard,
longestSuitForLead,
isLeadHonorRank,
isLowLeadCard,
partnerOf
Expand Down Expand Up @@ -161,6 +162,53 @@
);
}

function higherDummyHonorInSuit(card, dummyHand) {
if (!card || !isLeadHonorRank(card.rank) || !dummyHand?.length) return null;
return cardsInSuit(dummyHand, card.suit)
.filter((dummyCard) => isLeadHonorRank(dummyCard.rank))
.filter((dummyCard) => rankOrder.indexOf(dummyCard.rank) > rankOrder.indexOf(card.rank))
.sort(compareLowCards)[0] || null;
}

function isUnsupportedHonorLead(card, cards) {
if (!card || !isLeadHonorRank(card.rank)) return false;
const sequence = touchingHonorSequence(cards, 2);
return sequence?.card?.id !== card.id;
}

function chooseAvoidUnsupportedHonorUnderDummy({ legal, dummyHand, trickHistory, currentTrick = [], seat, declarer, trump }) {
if (!isDefensivePlaySeat(seat, declarer) || !dummyHand?.length || !trickHistory.length || currentTrick.length) return null;

const leadSuit = longestSuitForLead(legal);
const suitedLegal = cardsInSuit(legal, leadSuit);
const candidate = highestCard(suitedLegal);
const dummyHonor = higherDummyHonorInSuit(candidate, dummyHand);
if (!dummyHonor || !isUnsupportedHonorLead(candidate, suitedLegal)) return null;

const lowSameSuit = lowestCard(suitedLegal.filter(isLowLeadCard));
const saferSideLow = suits
.filter((suit) => suit !== leadSuit && suit !== trump)
.flatMap((suit) => cardsInSuit(legal, suit).filter(isLowLeadCard))
.filter((card) => !higherDummyHonorInSuit(card, dummyHand))
.sort(compareLowCards)[0] || null;
const card = lowSameSuit || saferSideLow;
if (!card) return null;

return cardPlayResult(
card,
"avoidUnsupportedHonorUnderDummy",
"basic",
"Avoid leading an unsupported honor under a visibly higher dummy honor when a low card or safer side suit is available.",
{
suit: card.suit,
avoidedSuit: leadSuit,
avoidedHonor: candidate.rank,
dummyHonor: dummyHonor.rank,
action: "avoidUnsupportedHonorUnderDummy"
}
);
}

function isLowPromisesHonorLead(play) {
if (!play?.card) return false;
if (play.ruleId) return play.ruleId === "notrumpLowPromisesHonor";
Expand Down Expand Up @@ -456,6 +504,9 @@
isVisibleTopWinner,
dummyRuffThreat,
chooseTrumpSwitchAgainstDummyRuff,
higherDummyHonorInSuit,
isUnsupportedHonorLead,
chooseAvoidUnsupportedHonorUnderDummy,
isLowPromisesHonorLead,
isOpeningHonorSequenceLead,
attitudeSignalSupport,
Expand Down
5 changes: 5 additions & 0 deletions scripts/flow/play-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@ function explainCardPlayResult(result) {
const visible = result.higherPlayed?.length ? " De al gespeelde hoge kaarten maken een goedkopere winnaar veilig genoeg." : "";
return `Derde hand speelt hoog, maar met de goedkoopste kaart die de slag voorlopig kan winnen.${visible}`;
}
if (ruleName === "avoidUnsupportedHonorUnderDummy") {
const avoided = rankLabel[result.avoidedHonor] || result.avoidedHonor;
const dummyHonor = rankLabel[result.dummyHonor] || result.dummyHonor;
return `Speel niet de losse ${avoided} in ${suitName(result.avoidedSuit)} onder dummy's zichtbare ${dummyHonor}; begin veiliger met een lage kaart.`;
}
if (ruleName === "returnPartnerLeadSuit") {
const lead = result.leadCard ? `${rankLabel[result.leadCard.rank] || result.leadCard.rank}${suitSymbols[result.leadCard.suit] || ""}` : suitName(result.suit);
const sequence = result.returnType === "honorSequence" && result.sequence ? ` met de hoogste kaart van je serie (${result.sequence})` : "";
Expand Down
91 changes: 91 additions & 0 deletions tests/unit/card-play-defense.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,97 @@ test("chooseCardPlay returns partner's opening lead suit when a defender wins th
assert.equal(result.leadRank, "4");
});

test("chooseCardPlay avoids a loose honor lead under a visible dummy honor mid-hand", () => {
const result = rules.chooseCardPlay({
hand: hand("5S", "QC", "TC", "7C", "9D", "4D"),
dummyHand: hand("9S", "6S", "TH", "KC", "5C", "7D"),
currentTrick: [],
trickHistory: [
{
number: 1,
winner: "North",
cards: [
{ seat: "West", card: card("7S") },
{ seat: "North", card: card("AS") },
{ seat: "East", card: card("4S") },
{ seat: "South", card: card("2S") }
]
},
{
number: 2,
winner: "North",
cards: [
{ seat: "North", card: card("8H") },
{ seat: "East", card: card("4H") },
{ seat: "South", card: card("3H") },
{ seat: "West", card: card("2H") }
]
},
{
number: 3,
winner: "North",
cards: [
{ seat: "North", card: card("9H") },
{ seat: "East", card: card("5H") },
{ seat: "South", card: card("7H") },
{ seat: "West", card: card("6H") }
]
},
{
number: 4,
winner: "East",
cards: [
{ seat: "North", card: card("KH") },
{ seat: "East", card: card("AH") },
{ seat: "South", card: card("JH") },
{ seat: "West", card: card("3D") }
]
},
{
number: 5,
winner: "North",
cards: [
{ seat: "East", card: card("8S") },
{ seat: "South", card: card("TS") },
{ seat: "West", card: card("3S") },
{ seat: "North", card: card("KS") }
]
},
{
number: 6,
winner: "West",
cards: [
{ seat: "North", card: card("5D") },
{ seat: "East", card: card("JD") },
{ seat: "South", card: card("QD") },
{ seat: "West", card: card("AD") }
]
},
{
number: 7,
winner: "West",
cards: [
{ seat: "West", card: card("AC") },
{ seat: "North", card: card("3C") },
{ seat: "East", card: card("8C") },
{ seat: "South", card: card("2C") }
]
}
],
seat: "West",
declarer: "South",
dummy: "North",
contract: { level: 2, strain: "H" },
trump: "H"
});

assert.equal(result.card.id, "7C");
assert.equal(result.ruleId, "avoidUnsupportedHonorUnderDummy");
assert.equal(result.avoidedSuit, "C");
assert.equal(result.avoidedHonor, "Q");
assert.equal(result.dummyHonor, "K");
});

test("chooseCardPlay returns partner's opening lead suit in suit contracts too", () => {
const result = rules.chooseCardPlay({
hand: hand("9H", "7H", "AC", "2S"),
Expand Down
Loading