diff --git a/rules/bidding/systems/five-card-high/explanations-nl.js b/rules/bidding/systems/five-card-high/explanations-nl.js index e375bb9..3e528ce 100644 --- a/rules/bidding/systems/five-card-high/explanations-nl.js +++ b/rules/bidding/systems/five-card-high/explanations-nl.js @@ -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": diff --git a/rules/bidding/systems/five-card-high/index.js b/rules/bidding/systems/five-card-high/index.js index ca94c7f..87b3751 100644 --- a/rules/bidding/systems/five-card-high/index.js +++ b/rules/bidding/systems/five-card-high/index.js @@ -160,6 +160,7 @@ openerAfterNotrumpResponseRuleName, openerRebidAfterNewSuitFiveCardHigh, isSingleRaiseInviteFiveCardHigh, + isAcceptedMajorRaiseGameFiveCardHigh, rebidResponderAfterSingleRaiseInviteFiveCardHigh, rebidResponderAfterOneNotrumpFiveCardHigh, rebidResponderAfterTwoNotrumpFiveCardHigh, @@ -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; diff --git a/rules/bidding/systems/five-card-high/rebids.js b/rules/bidding/systems/five-card-high/rebids.js index 5cbdace..b6c4614 100644 --- a/rules/bidding/systems/five-card-high/rebids.js +++ b/rules/bidding/systems/five-card-high/rebids.js @@ -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) { @@ -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); } @@ -1470,6 +1509,8 @@ isStrongTwoClubsTwoNotrumpRebid, respondAfterStrongTwoClubsTwoNotrumpRebidFiveCardHigh, rebidAfterOneSuitOpeningFiveCardHigh, + isAcceptedMajorRaiseGameFiveCardHigh, + shouldUseBlackwoodAfterAcceptedMajorRaiseGame, openerRebidAfterMinorNotrumpFiveCardHigh, openerRebidAfterRaiseFiveCardHigh, openerRebidAfterMinorRaiseFiveCardHigh, diff --git a/rules/card-play.js b/rules/card-play.js index 544c1a1..4fb2d30 100644 --- a/rules/card-play.js +++ b/rules/card-play.js @@ -61,6 +61,7 @@ chooseReturnPartnerLeadSuit, chooseSafeDefensiveWinner, chooseTrumpSwitchAgainstDummyRuff, + chooseAvoidUnsupportedHonorUnderDummy, chooseOpeningLeadAttitudeSignal, chooseThirdHandHighOverLowLead, chooseThirdHandUnblockHonor, @@ -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 diff --git a/rules/card-play/declarer-play.js b/rules/card-play/declarer-play.js index 1cfceab..7c8354d 100644 --- a/rules/card-play/declarer-play.js +++ b/rules/card-play/declarer-play.js @@ -19,6 +19,7 @@ suits, compareLowCards, lowestCard, + teamOf, partnerOf } = core; const { beats } = playMechanics; @@ -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; diff --git a/rules/card-play/defense.js b/rules/card-play/defense.js index 26abef2..13cf405 100644 --- a/rules/card-play/defense.js +++ b/rules/card-play/defense.js @@ -23,6 +23,7 @@ compareLowCards, lowestCard, highestCard, + longestSuitForLead, isLeadHonorRank, isLowLeadCard, partnerOf @@ -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"; @@ -456,6 +504,9 @@ isVisibleTopWinner, dummyRuffThreat, chooseTrumpSwitchAgainstDummyRuff, + higherDummyHonorInSuit, + isUnsupportedHonorLead, + chooseAvoidUnsupportedHonorUnderDummy, isLowPromisesHonorLead, isOpeningHonorSequenceLead, attitudeSignalSupport, diff --git a/scripts/flow/play-flow.js b/scripts/flow/play-flow.js index 711ced6..6fb906b 100644 --- a/scripts/flow/play-flow.js +++ b/scripts/flow/play-flow.js @@ -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})` : ""; diff --git a/tests/unit/card-play-defense.test.js b/tests/unit/card-play-defense.test.js index 9900db5..f356071 100644 --- a/tests/unit/card-play-defense.test.js +++ b/tests/unit/card-play-defense.test.js @@ -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"), diff --git a/tests/unit/card-play-plan-priority.test.js b/tests/unit/card-play-plan-priority.test.js index 54d3392..1a12132 100644 --- a/tests/unit/card-play-plan-priority.test.js +++ b/tests/unit/card-play-plan-priority.test.js @@ -2018,11 +2018,14 @@ test("chooseCardPlay discards the planned loser while partner's side winner is c assert.equal(result.action, "discardLoserOnWinner"); }); -test("chooseCardPlay avoids an unnecessary ruff with the long trump hand", () => { +test("chooseCardPlay discards low when partner is already winning instead of overruffing with the long trump hand", () => { const result = rules.chooseCardPlay({ hand: hand("AH", "KH", "QH", "2D"), partnerHand: hand("TH", "9H", "3C"), - currentTrick: [{ seat: "West", card: card("5C") }], + currentTrick: [ + { seat: "West", card: card("5C") }, + { seat: "North", card: card("9H") } + ], seat: "South", declarer: "South", dummy: "North", @@ -2031,7 +2034,65 @@ test("chooseCardPlay avoids an unnecessary ruff with the long trump hand", () => }); assert.equal(result.card.id, "2D"); - assert.equal(result.ruleId, "avoidLongHandRuff"); - assert.equal(result.action, "discardInsteadOfLongHandRuff"); - assert.equal(result.longSeat, "South"); + assert.equal(result.ruleId, "partnerWinningLow"); +}); + +test("chooseCardPlay ruffs cheaply instead of avoiding the long-hand ruff when the trick would be lost", () => { + const result = rules.chooseCardPlay({ + hand: hand("AS", "KS", "4S", "6H", "5H", "4H"), + partnerHand: hand("JS", "8S", "TH", "8C"), + currentTrick: [ + { seat: "West", card: card("6D") }, + { seat: "North", card: card("7D") } + ], + seat: "East", + declarer: "West", + dummy: "East", + contract: { level: 5, strain: "H" }, + trump: "H", + playPlan: { + priorities: [{ + kind: "cashWinners", + confidence: "basic", + suit: "S", + winnerCount: 2, + cashableWinners: 2, + cashRanks: ["A", "K"], + timing: "afterTrumps" + }] + } + }); + + assert.equal(result.card.id, "4H"); + assert.equal(result.ruleId, "cheapestWinner"); +}); + +test("chooseCardPlay wins the current trick before cashing a planned side winner", () => { + const result = rules.chooseCardPlay({ + hand: hand("AS", "5H"), + partnerHand: hand("JS", "TH"), + currentTrick: [ + { seat: "West", card: card("5D") }, + { seat: "North", card: card("8D") } + ], + seat: "East", + declarer: "West", + dummy: "East", + contract: { level: 5, strain: "H" }, + trump: "H", + playPlan: { + priorities: [{ + kind: "cashWinners", + confidence: "basic", + suit: "S", + winnerCount: 1, + cashableWinners: 1, + cashRanks: ["A"], + timing: "afterTrumps" + }] + } + }); + + assert.equal(result.card.id, "5H"); + assert.equal(result.ruleId, "cheapestWinner"); }); diff --git a/tests/unit/vijfkaart-hoog-rebids.test.js b/tests/unit/vijfkaart-hoog-rebids.test.js index fcebcb6..e2ee8c7 100644 --- a/tests/unit/vijfkaart-hoog-rebids.test.js +++ b/tests/unit/vijfkaart-hoog-rebids.test.js @@ -1672,6 +1672,44 @@ test("Vijfkaart Hoog responder accepts or declines a major invite after a single assert.equal(upperSpadeRange.ruleId, "fiveCardHigh.continuation.acceptMajorInvite"); }); +test("Vijfkaart Hoog responder does not raise to five after opener accepts a major invite", () => { + const feedbackAuction = [ + { seat: "East", bid: pass() }, + { seat: "South", bid: pass() }, + { seat: "West", bid: bid(1, "H") }, + { seat: "North", bid: pass() }, + { seat: "East", bid: bid(3, "H") }, + { seat: "South", bid: pass() }, + { seat: "West", bid: bid(4, "H") }, + { seat: "North", bid: pass() } + ]; + + const feedbackHand = rules.chooseFiveCardHighBidResult({ + hand: hand( + "AS", "KS", "4S", "3S", + "7H", "6H", "5H", "4H", "3H", + "TC", "3C", + "QD", "3D" + ), + auction: feedbackAuction, + seat: "East", + vulnerability: "NS" + }); + assert.deepEqual(feedbackHand.bid, pass()); + assert.equal(feedbackHand.ruleId, "fiveCardHigh.pass.responderAfterAcceptedMajorRaiseGame"); + + const slamTry = chooseFiveCardHighResult([ + "AS", "KS", "4S", "3S", + "AH", "KH", "QH", "JH", "TH", + "AC", "3C", + "QD", "3D" + ], feedbackAuction, "East"); + assert.deepEqual(slamTry.bid, bid(4, "NT")); + assert.equal(slamTry.ruleId, "fiveCardHigh.continuation.blackwoodAsk"); + assert.equal(slamTry.trumpSuit, "H"); + assert.equal(slamTry.agreementSource, "acceptedMajorRaiseGame"); +}); + test("Vijfkaart Hoog responder keeps the second response low with minimum values", () => { const oneHeartOneSpadeTwoDiamonds = [ { seat: "South", bid: bid(1, "H") },