diff --git a/src/game/ai.ts b/src/game/ai.ts index 6feb35e..9d08ddf 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -32,6 +32,49 @@ function getTeamPile(state: GameState, playerIdx: PlayerIndex): Card[] { return [...state.players[playerIdx].pile, ...state.players[partnerOf(playerIdx)].pile]; } +/** Is this the very last play of the round? (all hands have 0 or 1 cards, and it's this player's turn) */ +function isLastPlay(state: GameState, playerIdx: PlayerIndex): boolean { + for (let i = 0; i < 4; i++) { + if (i === playerIdx) { + if (state.players[i].hand.length !== 1) return false; + } else { + if (state.players[i].hand.length !== 0) return false; + } + } + return true; +} + +/** Count how many cards in hand match a given value (anchor candidates) */ +function countValueInHand(hand: Card[], value: number): number { + let n = 0; + for (const c of hand) if (c.value === value) n++; + return n; +} + +/** Check if partner likely holds a card of given value (via tracker inference) */ +function partnerLikelyHolds( + value: number, playerIdx: PlayerIndex, state: GameState, + tracker: CardTracker | undefined, myHand: Card[], table: Card[], +): number { + const partner = partnerOf(playerIdx); + const partnerHandSize = state.players[partner].hand.length; + if (partnerHandSize === 0) return 0; + + const unseen = tracker + ? tracker.getUnseenCards(myHand, table) + : getUnseenWithoutTracker(state, playerIdx); + + let unseenWithValue = 0; + for (const c of unseen) if (c.value === value) unseenWithValue++; + if (unseenWithValue === 0) return 0; + + // P(partner has ≥1 card of this value) ≈ 1 - hypergeometric(0 drawn) + const totalUnseen = unseen.length; + if (totalUnseen === 0) return 0; + const probNone = hypergeometricNone(totalUnseen, unseenWithValue, partnerHandSize); + return 1 - probNone; +} + /** Race state: who's winning each scoring category */ interface RaceState { myCards: number; oppCards: number; @@ -43,6 +86,7 @@ interface RaceState { behindInDenari: boolean; needSettebello: boolean; need7s: boolean; + aheadOverall: boolean; } function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { @@ -60,6 +104,14 @@ function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { const opp7s = oppPile.filter(c => c.value === 7).length; const myScope = mine.reduce((s, p) => s + p.scope, 0); const oppScope = opps.reduce((s, p) => s + p.scope, 0); + + // Simple overall advantage estimate + let myAdv = 0; + if (myCards > oppCards) myAdv++; else if (oppCards > myCards) myAdv--; + if (myDenari > oppDenari) myAdv++; else if (oppDenari > myDenari) myAdv--; + if (mySettebello) myAdv++; else if (oppSettebello) myAdv--; + myAdv += myScope - oppScope; + return { myCards, oppCards, myDenari, oppDenari, mySettebello, oppSettebello, my7s, opp7s, myScope, oppScope, @@ -67,6 +119,7 @@ function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { behindInDenari: myDenari < oppDenari, needSettebello: !mySettebello && !oppSettebello, need7s: my7s <= opp7s, + aheadOverall: myAdv > 0, }; } @@ -80,8 +133,8 @@ function countScopaThreats( tracker: CardTracker | undefined, state: GameState, playerIdx: PlayerIndex, -): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean } { - if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false }; +): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean; partnerCanScopa: boolean } { + if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false, partnerCanScopa: false }; const unseen = tracker ? tracker.getUnseenCards(myHand, afterTable) @@ -89,40 +142,44 @@ function countScopaThreats( // Count every unseen card that has at least one capture clearing the full table let totalThreats = 0; + const threatCardIds = new Set(); for (const uc of unseen) { const caps = findCaptures(uc, afterTable); for (const cap of caps) { - if (cap.length === afterTable.length) { totalThreats++; break; } + if (cap.length === afterTable.length) { + totalThreats++; + threatCardIds.add(uc.id); + break; + } } } - // Probabilistic check for each opponent + // Probabilistic check for each player const next = nextPlayer(playerIdx); - const second = nextPlayer(nextPlayer(playerIdx)); + const second = nextPlayer(next); + const third = nextPlayer(second); // = partner const unseenCount = unseen.length; let nextOppCanScopa = false; let secondOppCanScopa = false; + let partnerCanScopa = false; if (totalThreats > 0 && unseenCount > 0) { - // P(at least one threat card in hand) = 1 - C(non-threat, handSize) / C(all, handSize) - if (isOpponent(playerIdx, next)) { - const hs = state.players[next].hand.length; - if (hs > 0) { - const probNone = hypergeometricNone(unseenCount, totalThreats, hs); - nextOppCanScopa = (1 - probNone) > 0.25; - } - } - if (isOpponent(playerIdx, second)) { - const hs = state.players[second].hand.length; - if (hs > 0) { - const probNone = hypergeometricNone(unseenCount, totalThreats, hs); - secondOppCanScopa = (1 - probNone) > 0.25; + for (const other of [next, second, third]) { + const hs = state.players[other].hand.length; + if (hs === 0) continue; + const probNone = hypergeometricNone(unseenCount, totalThreats, hs); + const prob = 1 - probNone; + if (isOpponent(playerIdx, other)) { + if (other === next) nextOppCanScopa = prob > 0.20; + else secondOppCanScopa = prob > 0.20; + } else if (other !== playerIdx) { + partnerCanScopa = prob > 0.30; } } } - return { totalThreats, nextOppCanScopa, secondOppCanScopa }; + return { totalThreats, nextOppCanScopa, secondOppCanScopa, partnerCanScopa }; } /** P(0 threat cards drawn) using hypergeometric approx */ @@ -153,7 +210,7 @@ export function chooseMove( } // =========================================================================== -// BEGINNER — beatable but not stupid +// BEGINNER — beatable but not stupid, basic strategy awareness // =========================================================================== function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { @@ -162,9 +219,10 @@ function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr const phase = gamePhase(state); const next = nextPlayer(playerIdx); const nextIsOpp = isOpponent(playerIdx, next); + const lastPlay = isLastPlay(state, playerIdx); - // 8% pure random (reduced from 15%) - if (Math.random() < 0.08) { + // 5% pure random (reduced from 8%) + if (Math.random() < 0.05) { return randomMove(state, playerIdx); } @@ -175,13 +233,13 @@ function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) { - const base = scoreCaptureBeginner(card, captureSet, table, state, playerIdx, phase, nextIsOpp); - const score = base + (Math.random() - 0.5) * Math.max(80, Math.abs(base) * 0.25); + const base = scoreCaptureBeginner(card, captureSet, table, state, playerIdx, phase, nextIsOpp, lastPlay); + const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.2); if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { - const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp); - const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.25); + const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp, player.hand); + const score = base + (Math.random() - 0.5) * Math.max(50, Math.abs(base) * 0.2); if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } } @@ -201,33 +259,42 @@ function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove { function scoreCaptureBeginner( played: Card, captured: Card[], table: Card[], - state: GameState, playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, + state: GameState, playerIdx: PlayerIndex, phase: number, + nextIsOpp: boolean, lastPlay: boolean, ): number { let score = 100; const allCaptured = [played, ...captured]; const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const isScopa = afterTable.length === 0; - if (isScopa) score += 600; - if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 400; - // If settebello is on table and we DON'T take it — even beginners know this is bad + // Scopa — but not on the last play (it doesn't count!) + if (isScopa && !lastPlay) score += 600; + else if (isScopa && lastPlay) score += 30; // still captures cards, mildly good + + if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 450; + // If settebello is on table and we DON'T take it if (table.some(c => c.suit === 'denara' && c.value === 7) && - !allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score -= 200; + !allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score -= 250; - score += allCaptured.filter(c => c.suit === 'denara').length * 60; - score += captured.length * 25; - score += allCaptured.filter(c => c.value === 7).length * 50; - for (const c of allCaptured) score += primieraVal(c) * 1.5; + score += allCaptured.filter(c => c.suit === 'denara').length * 65; + score += captured.length * 30; + score += allCaptured.filter(c => c.value === 7).length * 55; + for (const c of allCaptured) score += primieraVal(c) * 1.8; - // Basic cooperation - if (!isScopa && !isOpponent(playerIdx, nextPlayer(playerIdx))) score += 15; + // Basic cooperation: partner next → don't rush to clear + if (!isScopa && !isOpponent(playerIdx, nextPlayer(playerIdx))) { + score += 20; + // Don't clear table when partner could benefit + const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + if (afterTable.length > 0 && tableSum >= 1 && tableSum <= 10) score += 25; + } // Anti-scopa: don't leave clearable table for opponent if (!isScopa && nextIsOpp) { const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (tableSum <= 10) score -= 120; - if (afterTable.length === 1) score -= 80; - if (tableSum >= 11) score += 50; + if (tableSum <= 10) score -= 140; + if (afterTable.length === 1) score -= 100; + if (tableSum >= 11) score += 60; } return score; @@ -236,6 +303,7 @@ function scoreCaptureBeginner( function scoreDumpBeginner( card: Card, table: Card[], state: GameState, playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, + hand: Card[], ): number { let score = 0; const afterTable = [...table, card]; @@ -244,21 +312,34 @@ function scoreDumpBeginner( // NEVER dump settebello if (card.suit === 'denara' && card.value === 7) return -5000; - if (card.suit === 'denara') score -= 60; - if (card.value === 7) score -= 70; - if (card.value >= 8) score += 20 + card.value; + // Protect valuable cards + if (card.suit === 'denara') score -= 70; + if (card.value === 7) score -= 80; + if (card.value === 6) score -= 35; + if (card.value === 1) score -= 25; + if (card.value >= 8) score += 25 + card.value; + + // Anchor: prefer dumping values you hold duplicates of (you can recapture) + const dupes = countValueInHand(hand, card.value); + if (dupes >= 2) score += 45; // Anti-scopa - if (tableSum >= 11) score += 80; - else if (tableSum <= 10 && nextIsOpp) score -= 80; - if (tableSum <= 5 && nextIsOpp) score -= 50; - if (afterTable.length >= 3 && tableSum >= 11) score += 20; + if (tableSum >= 11) score += 90; + else if (tableSum <= 10 && nextIsOpp) score -= 100; + if (tableSum <= 5 && nextIsOpp) score -= 60; + if (afterTable.length >= 3 && tableSum >= 11) score += 25; + + // Basic partner awareness + if (!isOpponent(playerIdx, nextPlayer(playerIdx))) { + score += 15; + } return score; } // =========================================================================== // ADVANCED — strong heuristic with card counting, race tracking, cooperation +// anchor strategy, whirlwind detection, team signaling // =========================================================================== function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { @@ -270,6 +351,7 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr const nextIsOpp = isOpponent(playerIdx, next); const partner = partnerOf(playerIdx); const partnerHandSize = state.players[partner].hand.length; + const lastPlay = isLastPlay(state, playerIdx); let bestMove: AIMove | null = null; let bestScore = -Infinity; @@ -280,14 +362,14 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr for (const captureSet of captures) { const score = scoreCaptureAdv( card, captureSet, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { const score = scoreDumpAdv( card, table, state, playerIdx, race, - tracker, player.hand, phase, nextIsOpp, partnerHandSize, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, lastPlay, ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } @@ -300,37 +382,58 @@ function scoreCaptureAdv( played: Card, captured: Card[], table: Card[], state: GameState, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, + lastPlay: boolean, ): number { let score = 100; const allCaptured = [played, ...captured]; const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const isScopa = afterTable.length === 0; - // --- SCOPA --- - if (isScopa) score += 900 + phase * 300; + // --- SCOPA (never on last play!) --- + if (isScopa) { + if (lastPlay) { + score += 40; // still captures cards but no scopa point + } else { + score += 1000 + phase * 350; + } + } // --- SETTEBELLO --- const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); - if (capturesSettebello) score += 700; - if (table.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) score -= 500; + if (capturesSettebello) score += 800; + if (table.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) score -= 600; - // --- DENARI (race-aware: prioritize more when behind) --- + // --- DENARI (race-aware) --- const denariCount = allCaptured.filter(c => c.suit === 'denara').length; - score += denariCount * (race.behindInDenari ? 100 : 60); + score += denariCount * (race.behindInDenari ? 120 : 65); // --- CARD COUNT (race-aware) --- - score += captured.length * (race.behindInCards ? 40 : 25) + phase * captured.length * 10; + score += captured.length * (race.behindInCards ? 45 : 28) + phase * captured.length * 12; // --- PRIMIERA --- - for (const c of allCaptured) score += primieraVal(c) * 3; + for (const c of allCaptured) score += primieraVal(c) * 3.5; const sevens = allCaptured.filter(c => c.value === 7).length; - score += sevens * (race.need7s ? 90 : 45); + score += sevens * (race.need7s ? 100 : 50); // Capturing a 7 in a suit we're missing for primiera const teamPile = getTeamPile(state, playerIdx); for (const c of allCaptured) { if (c.value === 7 && !teamPile.some(tc => tc.suit === c.suit && tc.value === 7)) { - score += 65; + score += 75; + } + } + + // --- ANCHOR STRATEGY --- + // Prefer captures that leave table cards matching values we hold (we can recapture) + if (!isScopa) { + for (const tc of afterTable) { + const dupes = countValueInHand(myHand, tc.value); + if (dupes >= 1) score += 35; // we hold a card that can recapture this + if (dupes >= 2) score += 25; // even stronger anchor + + // Check if partner likely holds this value + const partnerProb = partnerLikelyHolds(tc.value, playerIdx, state, tracker, myHand, afterTable); + if (partnerProb > 0.4) score += 30; } } @@ -339,47 +442,78 @@ function scoreCaptureAdv( const tableSum = afterTable.reduce((s, c) => s + c.value, 0); if (tableSum >= 11) { - score += 100; + score += 120; } else { // Only run expensive threat counting when table is actually clearable const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (threats.nextOppCanScopa) score -= 550; - if (threats.secondOppCanScopa) score -= 250; - score -= threats.totalThreats * 75; + if (threats.nextOppCanScopa) score -= 600; + if (threats.secondOppCanScopa) score -= 300; + score -= threats.totalThreats * 85; - if (tableSum <= 3) score -= 120; - else if (tableSum <= 7) score -= 50; + if (tableSum <= 3) score -= 140; + else if (tableSum <= 7) score -= 60; - if (afterTable.length === 1 && nextIsOpp) score -= 200; - if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120; + if (afterTable.length === 1 && nextIsOpp) score -= 250; + if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 140; } } // --- PARTNER COOPERATION --- const next = nextPlayer(playerIdx); if (!isScopa && !isOpponent(playerIdx, next)) { - // Partner plays next — leaving cards for them - score += 45; - if (afterTable.some(c => c.suit === 'denara') && partnerHandSize > 0) score += 30; - - // Partner scopa setup: if after-table is clearable and partner is next + // Partner plays next + score += 50; const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (afterTable.length > 0 && tableSum >= 1 && tableSum <= 10) { - score += 70; // leave table so partner could scopa + + // Leave denari on table for partner to capture + if (afterTable.some(c => c.suit === 'denara') && partnerHandSize > 0) score += 40; + + // Partner scopa setup — check if partner can actually clear + if (afterTable.length > 0 && tableSum >= 1 && tableSum <= 10 && partnerHandSize > 0) { + const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); + if (threats.partnerCanScopa) { + score += 200; // whirlwind setup: we clear, opponent dumps, partner clears + } else { + score += 40; // generic setup opportunity + } + } + + // Leave settebello for partner if we can't take it + if (afterTable.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) { + const partnerSettebello = partnerLikelyHolds(7, playerIdx, state, tracker, myHand, afterTable); + if (partnerSettebello > 0.3) score += 60; } } - // If opponent is next and after-table clearable, but after THAT is partner... - // Consider if the table after opponent's play might be good for partner (complex, skip) + // When opponent is next but partner is after — consider 2-step play + if (!isScopa && nextIsOpp) { + const afterOppTurn = nextPlayer(next); + if (!isOpponent(playerIdx, afterOppTurn) && partnerHandSize > 0) { + const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + // If table sum ≥11, opponent can't scopa but might dump something partner can benefit from + if (tableSum >= 11) score += 30; + } + } // Endgame: partner finished, maximize own captures - if (partnerHandSize === 0) score += captured.length * 25; + if (partnerHandSize === 0) score += captured.length * 30; // --- CARD TRACKER REFINEMENTS --- if (tracker && !isScopa) { if (tracker.isSettebelloUnseen() && !capturesSettebello) { if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) { - score -= 350; // opponent might grab settebello + score -= 400; + } + } + + // Track which 7s are still unseen — protect primiera + for (const suit of SUITS) { + const sevenId = `${suit}_7`; + if (!tracker.hasBeenPlayed(sevenId)) { + // 7 of this suit still in play + if (afterTable.some(c => c.suit === suit && c.value === 7)) { + if (nextIsOpp) score -= 60; // opponent might grab it + } } } @@ -388,11 +522,19 @@ function scoreCaptureAdv( const confidence = Math.min(1, tracker.playedCount / 25); const afterSum = afterTable.reduce((s, c) => s + c.value, 0); if (afterTable.length > 0 && afterSum <= 10) { - score -= Math.round(confidence * 100); + score -= Math.round(confidence * 120); } } } + // --- DEFENSIVE POSTURE when ahead --- + if (race.aheadOverall && !isScopa) { + const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + // When winning, prefer safe plays (high table sum) + if (tableSum >= 11) score += 50; + if (tableSum <= 5 && nextIsOpp) score -= 60; + } + return score; } @@ -400,6 +542,7 @@ function scoreDumpAdv( card: Card, table: Card[], state: GameState, playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, + lastPlay: boolean, ): number { let score = 0; const afterTable = [...table, card]; @@ -409,40 +552,65 @@ function scoreDumpAdv( if (card.suit === 'denara' && card.value === 7) return -10000; // --- CARD PROTECTION (race-aware) --- - if (card.suit === 'denara') score -= (race.behindInDenari ? 120 : 70); - if (card.value === 7) score -= (race.need7s ? 110 : 65); - if (card.value === 6) score -= 50; - if (card.value === 1) score -= 40; - if (card.value >= 8) score += 25 + card.value * 3; + if (card.suit === 'denara') score -= (race.behindInDenari ? 140 : 80); + if (card.value === 7) score -= (race.need7s ? 130 : 75); + if (card.value === 6) score -= 55; + if (card.value === 1) score -= 45; + if (card.value >= 8) score += 30 + card.value * 3; + + // --- ANCHOR STRATEGY --- + // Dump values you hold duplicates of → you can recapture later + const dupes = countValueInHand(myHand, card.value); + if (dupes >= 2) score += 80; // strong anchor: dump one, recapture with the other + if (dupes >= 3) score += 40; // even more control + + // Check if partner likely holds same value → team anchor + const partnerProb = partnerLikelyHolds(card.value, playerIdx, state, tracker, myHand, table); + if (partnerProb > 0.4) score += 55; // partner can recapture what we dump // --- ANTI-SCOPA --- if (tableSum >= 11) { - score += 130; + score += 150; } else { const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (threats.nextOppCanScopa) score -= 650; - if (threats.secondOppCanScopa) score -= 300; - score -= threats.totalThreats * 95; - if (tableSum <= 3) score -= 100; - if (afterTable.length === 1 && nextIsOpp) score -= 150; - if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 100; + if (threats.nextOppCanScopa) score -= 700; + if (threats.secondOppCanScopa) score -= 350; + score -= threats.totalThreats * 100; + if (tableSum <= 3) score -= 120; + if (afterTable.length === 1 && nextIsOpp) score -= 170; + if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120; + + // Whirlwind defense: if we're dumping and the table was empty, we're giving scopa + if (table.length === 0 && nextIsOpp && card.value <= 10) { + score -= 200; // opponent will almost certainly capture our lone card + } } - if (afterTable.length >= 4 && tableSum >= 15) score += 35; + if (afterTable.length >= 4 && tableSum >= 15) score += 40; // --- PARTNER SETUP --- const next = nextPlayer(playerIdx); if (!isOpponent(playerIdx, next)) { - score += 55; + score += 60; // Dump creates partner scopa opportunity if (afterTable.length >= 1 && tableSum >= 1 && tableSum <= 10 && partnerHandSize > 0) { - score += 60; + const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); + if (threats.partnerCanScopa) { + score += 180; // actively setting up partner scopa + } else { + score += 50; + } } } + // --- TEAM SIGNALING --- + // Dump low-primiera cards to signal what suits we're NOT collecting + if (!isOpponent(playerIdx, next) && card.value >= 8) { + score += 20; // safe dump before partner's turn, signals we don't need this suit + } + // --- CARD TRACKING --- if (tracker) { - // Count exact cards that can clear the table const unseen = tracker.getUnseenCards(myHand, afterTable); let directThreats = 0; for (const uc of unseen) { @@ -451,19 +619,37 @@ function scoreDumpAdv( if (cap.length === afterTable.length) { directThreats++; break; } } } - score -= directThreats * 65; + score -= directThreats * 75; + + // Track 7s still in play + for (const suit of SUITS) { + if (!tracker.hasBeenPlayed(`${suit}_7`)) { + // Don't dump cards that could let opponent capture an unseen 7 + if (afterTable.some(c => c.suit === suit && c.value === 7) && nextIsOpp) { + score -= 80; + } + } + } if (phase > 0.5) { const confidence = Math.min(1, tracker.playedCount / 25); - score = Math.round(score * (1 + confidence * 0.3)); + score = Math.round(score * (1 + confidence * 0.35)); } } + // --- DEFENSIVE when ahead --- + if (race.aheadOverall) { + if (tableSum >= 11) score += 40; + // Prefer high cards when winning (less useful for opponent) + if (card.value >= 8) score += 15; + } + return score; } // =========================================================================== // MASTER — deep minimax, alpha-beta, determinization, endgame solver +// improved evaluation, team-aware search, last-play awareness // =========================================================================== function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { @@ -471,7 +657,6 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac const phase = gamePhase(state); const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0); - // Reduced parameters: much faster while still strong const isDeepEndgame = cardsRemaining <= 6; const isEndgame = cardsRemaining <= 12; const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 14 : 10; @@ -484,9 +669,10 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac const deadline = Date.now() + 1500; // Quick-eval move ordering for better pruning + const lastPlay = isLastPlay(state, playerIdx); const quickScored = legalMoves.map(m => ({ move: m, - quick: quickEval(m, state, playerIdx, tracker), + quick: quickEval(m, state, playerIdx, tracker, lastPlay), })); quickScored.sort((a, b) => b.quick - a.quick); const sortedMoves = quickScored.map(qs => qs.move); @@ -526,38 +712,54 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac return bestMove; } -function quickEval(move: AIMove, state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): number { +function quickEval( + move: AIMove, state: GameState, playerIdx: PlayerIndex, + tracker: CardTracker | undefined, lastPlay: boolean, +): number { let score = 0; const table = state.table; const afterTable = table.filter(c => !move.capture.some(cc => cc.id === c.id)); const allCaptured = [move.card, ...move.capture]; const nextIsOpp = isOpponent(playerIdx, nextPlayer(playerIdx)); - // Scopa - if (move.capture.length > 0 && afterTable.length === 0) score += 1200; + // Scopa (not on last play!) + if (move.capture.length > 0 && afterTable.length === 0) { + score += lastPlay ? 50 : 1200; + } // Settebello if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 900; if (move.capture.length === 0 && move.card.suit === 'denara' && move.card.value === 7) score -= 5000; - score += move.capture.length * 60; - score += allCaptured.filter(c => c.suit === 'denara').length * 90; - score += allCaptured.filter(c => c.value === 7).length * 70; - for (const c of allCaptured) score += primieraVal(c) * 2; + score += move.capture.length * 65; + score += allCaptured.filter(c => c.suit === 'denara').length * 100; + score += allCaptured.filter(c => c.value === 7).length * 80; + for (const c of allCaptured) score += primieraVal(c) * 2.5; if (move.capture.length === 0) { score -= 200; if (move.card.value >= 8) score += 40; - if (move.card.suit === 'denara') score -= 120; - if (move.card.value === 7) score -= 90; + if (move.card.suit === 'denara') score -= 130; + if (move.card.value === 7) score -= 100; + + // Anchor bonus + const hand = state.players[playerIdx].hand; + if (countValueInHand(hand, move.card.value) >= 2) score += 60; } // Anti-scopa if (afterTable.length > 0) { const sum = afterTable.reduce((s, c) => s + c.value, 0); - if (sum <= 10 && nextIsOpp) score -= 150; - if (sum >= 11) score += 50; - if (afterTable.length === 1 && nextIsOpp) score -= 100; + if (sum <= 10 && nextIsOpp) score -= 180; + if (sum >= 11) score += 60; + if (afterTable.length === 1 && nextIsOpp) score -= 120; + } + + // Partner awareness + const next = nextPlayer(playerIdx); + if (!isOpponent(playerIdx, next) && afterTable.length > 0) { + const sum = afterTable.reduce((s, c) => s + c.value, 0); + if (sum >= 1 && sum <= 10) score += 40; // partner might scopa } return score; @@ -639,9 +841,18 @@ function alphaBeta( if (moves.length === 0) return evaluateFast(state, myTeam, phase); - // Simple move ordering: captures first, then by capture size (avoids expensive sort) + // Move ordering: settebello captures first, then scopa, then captures by size, then dumps if (moves.length > 2) { moves.sort((a, b) => { + const aSettebello = a.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0; + const bSettebello = b.capture.some(c => c.suit === 'denara' && c.value === 7) ? 1 : 0; + if (aSettebello !== bSettebello) return bSettebello - aSettebello; + + // Scopa moves first + const aScopa = a.capture.length > 0 && state.table.filter(c => !a.capture.some(cc => cc.id === c.id)).length === 0 ? 1 : 0; + const bScopa = b.capture.length > 0 && state.table.filter(c => !b.capture.some(cc => cc.id === c.id)).length === 0 ? 1 : 0; + if (aScopa !== bScopa) return bScopa - aScopa; + // Captures before dumps if (a.capture.length > 0 && b.capture.length === 0) return -1; if (a.capture.length === 0 && b.capture.length > 0) return 1; @@ -685,9 +896,11 @@ function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number { let myCards = 0, oppCards = 0; let myDenari = 0, oppDenari = 0; let mySettebello = false, oppSettebello = false; - let my7: Record = {}, opp7: Record = {}; + const my7: Record = {}, opp7: Record = {}; const myPrimBySuit: Record = {}; const oppPrimBySuit: Record = {}; + let mySixes = 0, oppSixes = 0; + let myAces = 0, oppAces = 0; for (const pile of [myA.pile, myB.pile]) { for (const c of pile) { @@ -697,6 +910,8 @@ function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number { if (c.value === 7) mySettebello = true; } if (c.value === 7) my7[c.suit] = true; + if (c.value === 6) mySixes++; + if (c.value === 1) myAces++; const pv = PRIMIERA_VALUES[c.value] ?? 0; if (!myPrimBySuit[c.suit] || pv > myPrimBySuit[c.suit]) myPrimBySuit[c.suit] = pv; } @@ -709,6 +924,8 @@ function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number { if (c.value === 7) oppSettebello = true; } if (c.value === 7) opp7[c.suit] = true; + if (c.value === 6) oppSixes++; + if (c.value === 1) oppAces++; const pv = PRIMIERA_VALUES[c.value] ?? 0; if (!oppPrimBySuit[c.suit] || pv > oppPrimBySuit[c.suit]) oppPrimBySuit[c.suit] = pv; } @@ -716,48 +933,86 @@ function evaluateFast(state: GameState, myTeam: 0 | 1, phase: number): number { let score = 0; - // Cards majority - score += (myCards - oppCards) * (22 + phase * 15); - // Denari majority - score += (myDenari - oppDenari) * 55; - // Settebello - if (mySettebello) score += 400; - if (oppSettebello) score -= 400; + // Cards majority (sharper: weighted by proximity to majority threshold) + const cardDiff = myCards - oppCards; + score += cardDiff * (25 + phase * 18); + // Bonus when near or past majority (20+ of 40) + if (myCards >= 20) score += 80; + if (oppCards >= 20) score -= 80; - // Primiera + // Denari majority (weighted by proximity to threshold: 6+ of 10) + const denariDiff = myDenari - oppDenari; + score += denariDiff * 65; + if (myDenari >= 6) score += 70; + if (oppDenari >= 6) score -= 70; + + // Settebello + if (mySettebello) score += 450; + if (oppSettebello) score -= 450; + + // Primiera — more nuanced let myPrim = 0, oppPrim = 0; let mySuits = 0, oppSuits = 0; for (const suit of SUITS) { if (myPrimBySuit[suit]) { myPrim += myPrimBySuit[suit]; mySuits++; } if (oppPrimBySuit[suit]) { oppPrim += oppPrimBySuit[suit]; oppSuits++; } - // Per-suit 7 tracking - if (my7[suit] && !opp7[suit]) score += 40; - if (opp7[suit] && !my7[suit]) score -= 40; + // Per-suit 7 control is critical for primiera + if (my7[suit] && !opp7[suit]) score += 50; + if (opp7[suit] && !my7[suit]) score -= 50; } if (mySuits === 4 && oppSuits === 4) { - score += (myPrim - oppPrim) * 4; + score += (myPrim - oppPrim) * 5; } else if (mySuits === 4) { - score += 150; + score += 180; } else if (oppSuits === 4) { - score -= 150; + score -= 180; } - // Scope - score += (myA.scope + myB.scope - oppA.scope - oppB.scope) * 350; + // Sixes and aces matter for primiera too (after 7s) + score += (mySixes - oppSixes) * 12; + score += (myAces - oppAces) * 10; - // Table position + // Scope (very important!) + const scopeDiff = (myA.scope + myB.scope) - (oppA.scope + oppB.scope); + score += scopeDiff * 400; + + // Table position — more detailed if (!state.roundOver && state.table.length > 0) { let tableSum = 0; let tableHasSettebello = false; + let tableDenari = 0; + let table7s = 0; for (const c of state.table) { tableSum += c.value; if (c.suit === 'denara' && c.value === 7) tableHasSettebello = true; + if (c.suit === 'denara') tableDenari++; + if (c.value === 7) table7s++; } const curTeam = teamOf(state.currentPlayer); - if (curTeam === myTeam && tableSum <= 10) score += 25; - if (curTeam !== myTeam && tableSum <= 10) score -= 25; - if (curTeam === myTeam && tableHasSettebello) score += 80; - if (curTeam !== myTeam && tableHasSettebello) score -= 80; + const myTurn = curTeam === myTeam; + + // Clearable table advantage + if (myTurn && tableSum <= 10) score += 35; + if (!myTurn && tableSum <= 10) score -= 35; + + // Settebello on table + if (myTurn && tableHasSettebello) score += 100; + if (!myTurn && tableHasSettebello) score -= 100; + + // Denari and 7s on table available for next player + if (myTurn) { + score += tableDenari * 15; + score += table7s * 20; + } else { + score -= tableDenari * 15; + score -= table7s * 20; + } + + // Anchor quality: cards on table matching our team's holdings + if (myTurn) { + // Good: table has cards we can capture + score += state.table.length * 5; + } } return score; diff --git a/src/game/engine.ts b/src/game/engine.ts index ed0ddde..13bdcc8 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -147,10 +147,13 @@ export function applyMove( // Add played card + captured to player's pile player.pile.push(card, ...capturedCards); - // Scopa: cleared the table + // Scopa: cleared the table (but NOT on the last play of the round) if (state2.table.length === 0) { - player.scope += 1; - isScopa = true; + const allHandsEmptyNow = state2.players.every(p => p.hand.length === 0); + if (!allHandsEmptyNow) { + player.scope += 1; + isScopa = true; + } } // Track which team made last capture