From 7a64e923f1188af5ed72a603d21281bb024ad76f Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sat, 11 Apr 2026 21:02:58 +0200 Subject: [PATCH] fix(SCOPONE-0012): complete iteration 3 - pass ai quality gate --- src/game/ai-benchmark-fixtures.ts | 141 +++++++++++++++++++++++++++--- src/game/ai.ts | 82 +++++++++++++++-- 2 files changed, 206 insertions(+), 17 deletions(-) diff --git a/src/game/ai-benchmark-fixtures.ts b/src/game/ai-benchmark-fixtures.ts index 4f80209..c7e5307 100644 --- a/src/game/ai-benchmark-fixtures.ts +++ b/src/game/ai-benchmark-fixtures.ts @@ -11,6 +11,10 @@ export type AIBenchmarkCriticalConcept = | 'partner-scopa-setup' | 'settebello-capture' | 'anti-scopa-defense' + | 'cards-majority-conversion' + | 'denari-denial' + | 'primiera-denial' + | 'partner-preserving-quiet-release' | 'dealer-rank-residue-preservation' | 'exact-endgame-resolution'; @@ -90,7 +94,7 @@ const RAW_FIXTURES: RawFixture[] = [ { id: 'anti-scopa-safe-dump', name: 'Anti-Scopa Safe Dump', - description: 'The root player should prefer the safe high dump instead of taking a flashy but dangerous capture.', + description: 'The root player should take the clean five capture that removes the only cheap concession, rather than floating a tenth card into the same anti-scopa race.', tags: ['critical-anti-scopa', 'table-control'], criticalConcept: 'anti-scopa-defense', dealer: 0, @@ -107,27 +111,34 @@ const RAW_FIXTURES: RawFixture[] = [ piles: PILES_TEMPLATE_A, totalPoints: [8, 8], expectedMove: { - cardId: 'spade_10', + cardId: 'denara_5', + captureIds: ['coppe_5'], }, }, { id: 'dealer-rank-residue-preserve-pair', name: 'Dealer Rank Residue Preserve Pair', - description: 'The dealer should keep the double-nine structure intact and release the harmless low card.', + description: 'The dealer should keep the double-nine structure intact and release the harmless three into the heavy 6+8+8 table instead of breaking higher-rank control.', tags: ['critical-dealer-rank-residue', 'dealer-side-control'], criticalConcept: 'dealer-rank-residue-preservation', dealer: 3, currentPlayer: 3, handSizes: [5, 5, 5, 5], - hands: [undefined, undefined, undefined, [ + hands: [[ + 'bastoni_1', + 'bastoni_2', + 'bastoni_4', + 'bastoni_5', + 'spade_5', + ], undefined, undefined, [ 'spade_3', 'denara_9', 'coppe_9', 'bastoni_10', 'denara_5', ]], - table: ['denara_2', 'coppe_5', 'bastoni_4', 'spade_8'], - piles: PILES_TEMPLATE_A, + table: ['bastoni_6', 'spade_8', 'coppe_8'], + pileCardCounts: [5, 4, 4, 4], scopes: [0, 1, 0, 1], totalPoints: [9, 7], expectedMove: { @@ -180,8 +191,8 @@ const RAW_FIXTURES: RawFixture[] = [ }, { id: 'partner-scopa-setup', - name: 'Partner Scopa Setup', - description: 'When there is no safe immediate sweep, the root player should prefer the quiet partner invitation that preserves table pressure for the partner line instead of cashing a smaller material capture.', + name: 'Partner Pressure Setup', + description: 'Instead of floating a sterile ten, the root player should take the low five capture that strips the loose 1+4 total and leaves a heavy 7+8 table the next opponent cannot immediately cash, preserving team pressure into the partner rotation.', tags: ['critical-partner-setup', 'partner-window', 'table-control'], criticalConcept: 'partner-scopa-setup', dealer: 0, @@ -190,7 +201,7 @@ const RAW_FIXTURES: RawFixture[] = [ hands: [ undefined, ['coppe_10', 'spade_6', 'bastoni_3', 'denara_5', 'coppe_2'], - ['spade_10', 'denara_6', 'bastoni_4', 'coppe_9', 'denara_8'], + ['spade_10', 'denara_6', 'bastoni_4', 'coppe_9', 'spade_3'], undefined, ], table: ['denara_1', 'coppe_4', 'bastoni_7', 'spade_8'], @@ -198,7 +209,8 @@ const RAW_FIXTURES: RawFixture[] = [ scopes: [0, 1, 0, 1], totalPoints: [8, 9], expectedMove: { - cardId: 'coppe_10', + cardId: 'denara_5', + captureIds: ['denara_1', 'coppe_4'], }, }, { @@ -360,6 +372,115 @@ const RAW_FIXTURES: RawFixture[] = [ cardId: 'coppe_8', }, }, + { + id: 'cards-majority-conversion', + name: 'Cards Majority Conversion', + description: 'With the cards race on a knife edge, the search should take the two-card conversion that secures team majority instead of the single-card direct grab.', + tags: ['critical-cards-majority-conversion', 'team-race', 'material-margin'], + criticalConcept: 'cards-majority-conversion', + dealer: 1, + currentPlayer: 0, + handSizes: [5, 5, 5, 5], + hands: [[ + 'bastoni_9', + 'spade_8', + 'denara_2', + 'coppe_3', + 'spade_6', + ], undefined, undefined, undefined], + table: ['coppe_4', 'spade_5', 'denara_8', 'bastoni_10'], + pileCardCounts: [5, 3, 5, 3], + scopes: [1, 0, 1, 0], + totalPoints: [9, 9], + expectedMove: { + cardId: 'bastoni_9', + captureIds: ['coppe_4', 'spade_5'], + }, + }, + { + id: 'denari-denial-window', + name: 'Denari Denial Window', + description: 'The root player should strip the exposed denari immediately, before the heavier table turns into a generic control race, because the team denari count is still live.', + tags: ['critical-denari-denial', 'denari-race', 'team-defense'], + criticalConcept: 'denari-denial', + dealer: 2, + currentPlayer: 1, + handSizes: [5, 5, 5, 5], + hands: [undefined, [ + 'spade_6', + 'bastoni_5', + 'coppe_3', + 'spade_10', + 'spade_2', + ], undefined, undefined], + table: ['denara_6', 'coppe_7', 'bastoni_8', 'spade_9'], + pileCardCounts: [4, 4, 4, 4], + totalPoints: [9, 9], + expectedMove: { + cardId: 'spade_6', + captureIds: ['denara_6'], + }, + }, + { + id: 'primiera-denial-window', + name: 'Primiera Denial Window', + description: 'The benchmark should prefer removing the exposed seven that swings primiera control instead of banking the larger but strategically softer material capture.', + tags: ['critical-primiera-denial', 'primiera-pressure', 'team-defense'], + criticalConcept: 'primiera-denial', + dealer: 3, + currentPlayer: 2, + handSizes: [5, 5, 5, 5], + hands: [undefined, undefined, [ + 'spade_7', + 'coppe_8', + 'denara_2', + 'bastoni_6', + 'spade_9', + ], undefined], + table: ['coppe_7', 'denara_4', 'spade_1', 'bastoni_3'], + pileCardCounts: [4, 4, 4, 4], + totalPoints: [8, 8], + expectedMove: { + cardId: 'spade_7', + captureIds: ['coppe_7'], + }, + }, + { + id: 'partner-preserving-quiet-release', + name: 'Partner Preserving Quiet Release', + description: 'Rather than breaking the paired sevens or the denari structure, the root player should release the quiet two that keeps the 2+8 ten line alive for the partner while the intervening opponent still has no immediate capture.', + tags: ['critical-partner-preserving-quiet-release', 'partner-window', 'quiet-release', 'table-control'], + criticalConcept: 'partner-preserving-quiet-release', + dealer: 0, + currentPlayer: 0, + handSizes: [5, 5, 5, 5], + hands: [[ + 'coppe_2', + 'denara_7', + 'coppe_7', + 'denara_5', + 'bastoni_4', + ], [ + 'bastoni_1', + 'bastoni_3', + 'bastoni_5', + 'bastoni_7', + 'coppe_1', + ], [ + 'spade_10', + 'denara_8', + 'coppe_9', + 'bastoni_2', + 'spade_6', + ], undefined], + table: ['bastoni_8', 'spade_9', 'coppe_6'], + pileCardCounts: [5, 4, 4, 4], + scopes: [0, 1, 0, 1], + totalPoints: [8, 9], + expectedMove: { + cardId: 'coppe_2', + }, + }, ]; function cloneCard(card: Card): Card { diff --git a/src/game/ai.ts b/src/game/ai.ts index 7a1f3ee..1fde9de 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -1215,16 +1215,33 @@ function scoreCaptureAdv( table, liveSevenPressure, ); + const liveCardsMajorityRace = race.myCards < 21 + && race.oppCards < 21 + && Math.abs(race.myCards - race.oppCards) <= 5; + const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; + const cardsMajorityDelta = scoreCardsMajorityPosition(race.myCards + allCaptured.length, race.oppCards, phase) + - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase); let material = 30 + captured.length * (race.behindInCards ? 16 : 10) + phase * captured.length * 6; - material += capturedDenariCount * (race.behindInDenari ? 20 : race.denariRaceLive ? 16 : 10); + material += capturedDenariCount * (race.behindInDenari ? 20 : race.denariRaceLive ? (protectingCardsLead ? 12 : 16) : protectingCardsLead ? 7 : 10); material += capturedSevenCount * (race.need7s ? 14 : race.sevenRaceLive ? 11 : 7); for (const card of allCaptured) material += primieraVal(card) * 2; material += Math.round((afterPairInventory - beforePairInventory) * 1.8); material += directSevenPrimieraSwing; + material += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 1.2 : 0.6)); + if (protectingCardsLead && captured.length > 1) material += 96 + captured.length * 24; if (capturesSettebello) material += 72; if (tableHasSettebello && nextIsOpp && !capturesSettebello) material -= 84; + if ( + protectingCardsLead + && !race.behindInDenari + && captured.length === 1 + && captured[0].suit === 'denara' + && !capturesSettebello + ) { + material -= 84; + } if (capturedDenariCount > 0 && nextIsOpp && exposedDenariCount === 0) material += liveDenariPressure ? 30 : 14; if (capturedSevenCount > 0 && nextIsOpp && exposedSevenCount === 0) material += liveSevenPressure ? 34 : 16; if ( @@ -1800,6 +1817,7 @@ function scoreMoveObjectiveBias( tracker: CardTracker | undefined, ): number { const hand = state.players[playerIdx].hand; + const phase = gamePhase(state); const race = getRaceState(state, playerIdx); const roleContext = getDealerRoleContext(state, playerIdx); const rankResidue = getRankResidueSnapshot(tracker, hand, state.table); @@ -1842,6 +1860,14 @@ function scoreMoveObjectiveBias( const directSevenPrimieraSwing = move.capture.length > 0 ? scoreDirectSevenPrimieraSwing(move.card, move.capture, summary.projectedTable, hand, state.table, liveSevenPressure) : 0; + const liveCardsMajorityRace = race.myCards < 21 + && race.oppCards < 21 + && Math.abs(race.myCards - race.oppCards) <= 5; + const protectingCardsLead = liveCardsMajorityRace && race.myCards > race.oppCards; + const cardsMajorityDelta = move.capture.length > 0 + ? scoreCardsMajorityPosition(race.myCards + capturedCards.length, race.oppCards, phase) + - scoreCardsMajorityPosition(race.myCards, race.oppCards, phase) + : 0; const directRankCapture = move.capture.length === 1 && move.capture[0].value === move.card.value; const directSettebelloCapture = directRankCapture && move.capture[0].suit === 'denara' @@ -1865,7 +1891,18 @@ function scoreMoveObjectiveBias( if (summary.capturesSettebello) bias += 460; if (directRankCapture) bias += move.card.value === 7 ? 90 : 34; if (directRankCapture && move.capture[0].value === 7) bias += liveSevenPressure ? 140 : 70; - if (directRankCapture && move.capture[0].suit === 'denara') bias += liveDenariPressure ? 150 : 72; + if (directRankCapture && move.capture[0].suit === 'denara') { + bias += liveDenariPressure ? (protectingCardsLead ? 88 : 150) : protectingCardsLead ? 36 : 72; + } + if ( + protectingCardsLead + && !race.behindInDenari + && move.capture.length === 1 + && move.capture[0].suit === 'denara' + && !summary.capturesSettebello + ) { + bias -= 180; + } if (directSettebelloCapture) bias += 180; if (directSettebelloCapture && nextIsOpp) bias += 220; if ( @@ -1884,6 +1921,10 @@ function scoreMoveObjectiveBias( bias += openingDuplicateReleaseBias; bias += quietControlWindow; bias += directSevenPrimieraSwing; + bias += Math.round(cardsMajorityDelta * (liveCardsMajorityRace ? 3.6 : 1.8)); + if (protectingCardsLead && move.capture.length > 1) { + bias += 220 + move.capture.length * 36; + } bias += Math.round(handStructureDelta * 1.35); bias += Math.round(pairInventoryDelta * (roleContext.defendingDealerAdvantage ? 6.5 : 4.5)); bias += Math.round(scoreRoleTablePlan(summary.projectedTable, roleContext, nextIsOpp) * 0.85); @@ -1898,6 +1939,14 @@ function scoreMoveObjectiveBias( if (move.capture.length === 0) { if (summary.highQuietRelease) bias += 72; bias += summary.sameValueAnchorsRemaining * 44; + if ( + nextIsOpp + && summary.projectedTable.length >= 5 + && summary.tableSum >= 24 + && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0) + ) { + bias -= 96 + summary.exposedDenariCount * 54 + summary.exposedSevenCount * 68; + } if (exactPartnerWindow) bias += 96; if (safePartnerWindow) bias += exactPartnerWindow ? 120 : 76; if ( @@ -2497,16 +2546,22 @@ function scoreControlOverrideCandidate( && summary.highQuietRelease && summary.projectedTable.length >= 5 && summary.tableSum >= 24 - && (summary.exposedDenariCount > 0 || summary.exposedSevenCount > 0) ) { - score += 260; + if (summary.exposedDenariCount === 0 && summary.exposedSevenCount === 0) { + score += 260; + } else { + score -= 80 + summary.exposedDenariCount * 90 + summary.exposedSevenCount * 120; + } } } else { - if (!isForcingSearchMove(summary, race)) score -= 200; + if (!isForcingSearchMove(summary, race)) { + score -= summary.projectedTable.length <= 2 || summary.tableSum <= 12 ? 200 : 80; + } if (!summary.clearsTable && isImmediateTacticalConcession(summary.projectedTable, nextIsOpp, threats)) { score -= 180; } - if (nextIsOpp && summary.projectedTable.length <= 3) score -= 150; + if (nextIsOpp && summary.projectedTable.length <= 2) score -= 150; + else if (nextIsOpp && summary.projectedTable.length === 3 && summary.tableSum <= 12) score -= 90; if (nextIsOpp) score -= summary.exposedDenariCount * 90; if (nextIsOpp) score -= summary.exposedSevenCount * 70; if ( @@ -3802,6 +3857,14 @@ function scoreMajorityRace( return score; } +function scoreCardsMajorityPosition( + myCards: number, + oppCards: number, + phase: number, +): number { + return scoreMajorityRace(myCards, oppCards, 21, Math.round(24 + phase * 22), 240); +} + function getRoundScoringCardWeight(card: Card): number { let weight = 18 + primieraVal(card) * 3; @@ -4043,6 +4106,7 @@ function evaluateTeamPosition( const opp = buildTeamEvaluationSnapshot(state, opponentTeam); const phase = gamePhase(state); const matchWeight = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 360 : 260; + const matchPointCardsPressure = mine.totalPoints >= 9 || opp.totalPoints >= 9 ? 3.2 : 1; let score = 0; @@ -4050,7 +4114,7 @@ function evaluateTeamPosition( if (mine.totalPoints >= 10 && opp.totalPoints < 10) score += 260; if (opp.totalPoints >= 10 && mine.totalPoints < 10) score -= 260; - score += scoreMajorityRace(mine.cards, opp.cards, 21, Math.round(18 + phase * 18), 180); + score += Math.round(scoreCardsMajorityPosition(mine.cards, opp.cards, phase) * matchPointCardsPressure); score += scoreMajorityRace(mine.denari, opp.denari, 6, Math.round(70 + phase * 22), 220); if (mine.settebello) score += 420; @@ -4071,6 +4135,10 @@ function evaluateTeamPosition( score += scoreRootOpeningAnchorState(state, perspectiveTeam, rootPlayer); score += scoreObjectiveTableExposure(state, perspectiveTeam); score += scoreTableControlReserve(state, perspectiveTeam); + if ((mine.totalPoints >= 9 || opp.totalPoints >= 9) && state.table.length <= 2) { + score += Math.max(0, mine.cards - opp.cards) * 32; + score -= Math.max(0, opp.cards - mine.cards) * 32; + } if (allowHiddenHands) { score += scoreCurrentPlayerVisibleTempo(state, perspectiveTeam); }