diff --git a/src/game/ai.ts b/src/game/ai.ts index 9bd7501..9952370 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -11,26 +11,68 @@ export interface AIMove { // Helpers shared across all difficulty levels // --------------------------------------------------------------------------- -/** Who plays after the given player */ function nextPlayer(p: PlayerIndex): PlayerIndex { return ((p + 1) % 4) as PlayerIndex; } -/** Partner of a given player (across the table) */ function partnerOf(p: PlayerIndex): PlayerIndex { return ((p + 2) % 4) as PlayerIndex; } -/** Is `other` an opponent of `me`? */ function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean { return teamOf(me) !== teamOf(other); } - function primieraVal(card: Card): number { return PRIMIERA_VALUES[card.value] ?? 0; } +function gamePhase(state: GameState): number { + const totalCards = state.players.reduce((s, p) => s + p.hand.length, 0); + return 1 - totalCards / 40; +} + +function getTeamPile(state: GameState, playerIdx: PlayerIndex): Card[] { + return [...state.players[playerIdx].pile, ...state.players[partnerOf(playerIdx)].pile]; +} + +/** Race state: who's winning each scoring category */ +interface RaceState { + myCards: number; oppCards: number; + myDenari: number; oppDenari: number; + mySettebello: boolean; oppSettebello: boolean; + my7s: number; opp7s: number; + myScope: number; oppScope: number; + behindInCards: boolean; + behindInDenari: boolean; + needSettebello: boolean; + need7s: boolean; +} + +function getRaceState(state: GameState, playerIdx: PlayerIndex): RaceState { + const myTeam = teamOf(playerIdx); + const mine = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; + const opps = myTeam === 0 ? [state.players[1], state.players[3]] : [state.players[0], state.players[2]]; + const myPile = mine.flatMap(p => p.pile); + const oppPile = opps.flatMap(p => p.pile); + const myCards = myPile.length, oppCards = oppPile.length; + const myDenari = myPile.filter(c => c.suit === 'denara').length; + const oppDenari = oppPile.filter(c => c.suit === 'denara').length; + const mySettebello = myPile.some(c => c.suit === 'denara' && c.value === 7); + const oppSettebello = oppPile.some(c => c.suit === 'denara' && c.value === 7); + const my7s = myPile.filter(c => c.value === 7).length; + 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); + return { + myCards, oppCards, myDenari, oppDenari, mySettebello, oppSettebello, + my7s, opp7s, myScope, oppScope, + behindInCards: myCards < oppCards, + behindInDenari: myDenari < oppDenari, + needSettebello: !mySettebello && !oppSettebello, + need7s: my7s <= opp7s, + }; +} /** - * Count how many unseen cards can scopa a given table layout. - * Returns the count and whether the immediate next player can do it. + * Count scopa threats: how many unseen cards can clear a given table. + * Uses probabilistic assessment per-player based on hand sizes. */ function countScopaThreats( afterTable: Card[], @@ -38,63 +80,63 @@ function countScopaThreats( tracker: CardTracker | undefined, state: GameState, playerIdx: PlayerIndex, -): { totalThreats: number; nextOpponentCanScopa: boolean } { - if (afterTable.length === 0) return { totalThreats: 0, nextOpponentCanScopa: false }; +): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean } { + if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false }; const unseen = tracker ? tracker.getUnseenCards(myHand, afterTable) : getUnseenWithoutTracker(state, playerIdx); + // Count every unseen card that has at least one capture clearing the full table let totalThreats = 0; - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - - // Quick check: can any single card value clear the table? - const threatValues = new Set(); for (const uc of unseen) { - // Single-card scopa: card value == table sum and captures all - if (uc.value === tableSum && afterTable.length >= 1) { - // Check if the card actually captures all table cards - const caps = findCaptures(uc, afterTable); - for (const cap of caps) { - if (cap.length === afterTable.length) { - if (!threatValues.has(uc.value)) { - totalThreats++; - threatValues.add(uc.value); - } - } + const caps = findCaptures(uc, afterTable); + for (const cap of caps) { + if (cap.length === afterTable.length) { totalThreats++; break; } + } + } + + // Probabilistic check for each opponent + const next = nextPlayer(playerIdx); + const second = nextPlayer(nextPlayer(playerIdx)); + const unseenCount = unseen.length; + + let nextOppCanScopa = false; + let secondOppCanScopa = 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; } - } else { - const caps = findCaptures(uc, afterTable); - for (const cap of caps) { - if (afterTable.every(tc => cap.some(cc => cc.id === tc.id))) { - totalThreats++; - break; - } + } + 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; } } } - // Check specifically the next opponent - const next = nextPlayer(playerIdx); - const nextIsOpp = isOpponent(playerIdx, next); - let nextOpponentCanScopa = false; - if (nextIsOpp && afterTable.length > 0) { - // The next opponent's hand is hidden, but we can check if any unseen value could scopa - // This is a probabilistic threat — the more unseen cards that scopa, the worse - nextOpponentCanScopa = totalThreats > 0; - } - - return { totalThreats, nextOpponentCanScopa }; + return { totalThreats, nextOppCanScopa, secondOppCanScopa }; } -/** Compute the game-phase weight multiplier (early=0..1=late) */ -function gamePhase(state: GameState): number { - const totalCards = state.players.reduce((s, p) => s + p.hand.length, 0); - return 1 - totalCards / 40; // 0.0 at start, 1.0 at end +/** P(0 threat cards drawn) using hypergeometric approx */ +function hypergeometricNone(total: number, threats: number, drawn: number): number { + if (drawn >= total) return threats > 0 ? 0 : 1; + let p = 1; + for (let i = 0; i < drawn; i++) { + p *= Math.max(0, (total - threats - i)) / (total - i); + } + return p; } // --------------------------------------------------------------------------- -// Main entry point — dispatches by difficulty +// Main entry point // --------------------------------------------------------------------------- export function chooseMove( @@ -110,16 +152,19 @@ export function chooseMove( } } -// --------------------------------------------------------------------------- -// BEGINNER — weakened heuristic with random noise + basic cooperation -// --------------------------------------------------------------------------- +// =========================================================================== +// BEGINNER — beatable but not stupid +// =========================================================================== function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { const player = state.players[playerIdx]; const table = state.table; + const phase = gamePhase(state); + const next = nextPlayer(playerIdx); + const nextIsOpp = isOpponent(playerIdx, next); - // 15% chance to pick a completely random legal move - if (Math.random() < 0.15) { + // 8% pure random (reduced from 15%) + if (Math.random() < 0.08) { return randomMove(state, playerIdx); } @@ -130,20 +175,14 @@ function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) { - const base = scoreCaptureBasic(card, captureSet, table, state, playerIdx, tracker); - const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.4; - if (score > bestScore) { - bestScore = score; - bestMove = { card, capture: captureSet }; - } + 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); + if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { - const base = scoreDumpBasic(card, table, state, playerIdx, tracker); - const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.4; - if (score > bestScore) { - bestScore = score; - bestMove = { card, capture: [] }; - } + const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp); + const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.25); + if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } } @@ -160,67 +199,73 @@ function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove { return { card, capture: [] }; } -function scoreCaptureBasic( +function scoreCaptureBeginner( played: Card, captured: Card[], table: Card[], - state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker, + state: GameState, playerIdx: PlayerIndex, phase: number, nextIsOpp: 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 += 500; - if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300; - score += allCaptured.filter(c => c.suit === 'denara').length * 50; - score += captured.length * 20; - score += allCaptured.reduce((s, c) => s + primieraVal(c), 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 + if (table.some(c => c.suit === 'denara' && c.value === 7) && + !allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score -= 200; - // Basic cooperation: if next player is partner, leaving cards is OK - const next = nextPlayer(playerIdx); - if (!isOpponent(playerIdx, next) && !isScopa) score += 20; + 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; - // Don't leave easy scopa for next opponent - if (!isScopa && isOpponent(playerIdx, next)) { + // Basic cooperation + if (!isScopa && !isOpponent(playerIdx, nextPlayer(playerIdx))) score += 15; + + // 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 -= 40; + if (tableSum <= 10) score -= 120; + if (afterTable.length === 1) score -= 80; + if (tableSum >= 11) score += 50; } return score; } -function scoreDumpBasic( +function scoreDumpBeginner( card: Card, table: Card[], state: GameState, - playerIdx: PlayerIndex, tracker?: CardTracker, + playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean, ): number { let score = 0; const afterTable = [...table, card]; - - if (card.suit !== 'denara') score += 30; - if (card.suit === 'denara') score -= 40; - if (card.suit === 'denara' && card.value === 7) score -= 300; - if (card.value >= 8) score += 10; - if (card.value === 7) score -= 50; - if (card.value === 1) score -= 30; - - // Anti-scopa: prefer table sum >= 11 const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (tableSum >= 11) score += 40; - if (tableSum < 5) score -= 30; + + // 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; + + // 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; return score; } -// --------------------------------------------------------------------------- -// ADVANCED — strong heuristic with card counting + real cooperation -// --------------------------------------------------------------------------- +// =========================================================================== +// ADVANCED — strong heuristic with card counting, race tracking, cooperation +// =========================================================================== function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { const player = state.players[playerIdx]; const table = state.table; - const myTeam = teamOf(playerIdx); const phase = gamePhase(state); - - // Analyze turn context + const race = getRaceState(state, playerIdx); const next = nextPlayer(playerIdx); const nextIsOpp = isOpponent(playerIdx, next); const partner = partnerOf(playerIdx); @@ -234,23 +279,17 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr if (captures.length > 0) { for (const captureSet of captures) { const score = scoreCaptureAdv( - card, captureSet, table, state, playerIdx, myTeam, + card, captureSet, table, state, playerIdx, race, tracker, player.hand, phase, nextIsOpp, partnerHandSize, ); - if (score > bestScore) { - bestScore = score; - bestMove = { card, capture: captureSet }; - } + if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { const score = scoreDumpAdv( - card, table, state, playerIdx, myTeam, + card, table, state, playerIdx, race, tracker, player.hand, phase, nextIsOpp, partnerHandSize, ); - if (score > bestScore) { - bestScore = score; - bestMove = { card, capture: [] }; - } + if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; } } } @@ -259,7 +298,7 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr function scoreCaptureAdv( played: Card, captured: Card[], table: Card[], state: GameState, - playerIdx: PlayerIndex, myTeam: 0 | 1, tracker: CardTracker | undefined, + playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, ): number { let score = 100; @@ -267,95 +306,87 @@ function scoreCaptureAdv( const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const isScopa = afterTable.length === 0; - // --- Core scoring --- + // --- SCOPA --- + if (isScopa) score += 900 + phase * 300; - // Scopa (huge bonus, scales with game phase) - if (isScopa) score += 700 + phase * 200; - - // Settebello: absolute must-capture + // --- SETTEBELLO --- const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); - if (capturesSettebello) score += 500; - // If settebello is on table and we DON'T capture it, penalty - if (table.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) score -= 300; + if (capturesSettebello) score += 700; + if (table.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) score -= 500; - // Denari + // --- DENARI (race-aware: prioritize more when behind) --- const denariCount = allCaptured.filter(c => c.suit === 'denara').length; - score += denariCount * 70; + score += denariCount * (race.behindInDenari ? 100 : 60); - // Card count — more important in endgame - score += captured.length * (25 + phase * 15); + // --- CARD COUNT (race-aware) --- + score += captured.length * (race.behindInCards ? 40 : 25) + phase * captured.length * 10; - // Primiera — 7s are critical, then 6s, aces + // --- PRIMIERA --- + for (const c of allCaptured) score += primieraVal(c) * 3; + const sevens = allCaptured.filter(c => c.value === 7).length; + score += sevens * (race.need7s ? 90 : 45); + + // Capturing a 7 in a suit we're missing for primiera + const teamPile = getTeamPile(state, playerIdx); for (const c of allCaptured) { - score += primieraVal(c) * 3; + if (c.value === 7 && !teamPile.some(tc => tc.suit === c.suit && tc.value === 7)) { + score += 65; + } } - // Extra bonus for 7s specifically (primiera control) - const sevensCapt = allCaptured.filter(c => c.value === 7).length; - score += sevensCapt * 40; - // --- Anti-scopa defense --- + // --- ANTI-SCOPA (critical) --- if (!isScopa) { const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - // Hard rule: NEVER leave table clearable by a single card (val 1-10) - if (tableSum <= 10) { - const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (nextIsOpp && threats.nextOpponentCanScopa) { - score -= 400; // next opponent could scopa! - } - score -= threats.totalThreats * 60; - } + if (threats.nextOppCanScopa) score -= 550; + if (threats.secondOppCanScopa) score -= 250; + score -= threats.totalThreats * 75; - // Prefer leaving table sum >= 11 (unclearable by one card) - if (tableSum >= 11) score += 80; - else if (tableSum >= 8) score += 20; - else score -= 40; + if (tableSum >= 11) score += 100; + else if (tableSum <= 3) score -= 120; + else if (tableSum <= 7) score -= 50; - // Avoid leaving exactly one card (easy scopa for opponent) - if (afterTable.length === 1 && nextIsOpp) score -= 150; + // Single card on table = trivial scopa + if (afterTable.length === 1 && nextIsOpp) score -= 200; + // Two low cards = easy to sum & clear + if (afterTable.length === 2 && tableSum <= 10 && nextIsOpp) score -= 120; } - // --- Cooperation with partner --- - if (!isScopa && !nextIsOpp) { - // Next player is partner — leaving cards for them is good - // Partner might be able to scopa or capture denari - score += 30; + // --- 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; - // If table has denari and partner can still play, leaving is OK - if (afterTable.some(c => c.suit === 'denara') && partnerHandSize > 0) { - score += 20; + // Partner scopa setup: if after-table is clearable and partner is next + 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 } } - // Endgame: if partner has no cards left, capture everything you can - if (partnerHandSize === 0) { - score += captured.length * 15; - } + // 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) - // --- Card tracking threats --- + // Endgame: partner finished, maximize own captures + if (partnerHandSize === 0) score += captured.length * 25; + + // --- CARD TRACKER REFINEMENTS --- if (tracker && !isScopa) { - const unseen = tracker.getUnseenCards(myHand, afterTable); - - // Check settebello still in play — protect it if (tracker.isSettebelloUnseen() && !capturesSettebello) { - // Don't leave table where opponent could capture settebello + table if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) { - score -= 250; + score -= 350; // opponent might grab settebello } } - // Count how many suits we're missing for primiera - const myPile = state.players[playerIdx].pile.concat( - state.players[partnerOf(playerIdx)].pile - ); - const allTeamCards = [...myPile, ...allCaptured]; - for (const suit of SUITS) { - const hasSeven = allTeamCards.some(c => c.suit === suit && c.value === 7); - if (!hasSeven) { - // If this capture gives us a 7 of a missing suit, bonus - if (allCaptured.some(c => c.suit === suit && c.value === 7)) { - score += 60; - } + // Late game: more confident — sharpen penalties + if (phase > 0.5) { + 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); } } } @@ -365,101 +396,89 @@ function scoreCaptureAdv( function scoreDumpAdv( card: Card, table: Card[], state: GameState, - playerIdx: PlayerIndex, myTeam: 0 | 1, tracker: CardTracker | undefined, + playerIdx: PlayerIndex, race: RaceState, tracker: CardTracker | undefined, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, ): number { let score = 0; const afterTable = [...table, card]; const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - // --- Card value protection --- - - // NEVER dump settebello + // --- HARD RULES --- if (card.suit === 'denara' && card.value === 7) return -10000; - // Avoid dumping denari - if (card.suit === 'denara') score -= 80; - - // Avoid dumping 7s (primiera), especially in endgame - if (card.value === 7) score -= 80 - phase * 20; - - // Avoid dumping 6s and aces (primiera) - if (card.value === 6) score -= 40; - if (card.value === 1) score -= 35; - - // Prefer dumping face cards (8, 9, 10) — low primiera value - if (card.value >= 8) score += 20 + card.value * 2; - - // --- Anti-scopa defense (CRITICAL) --- + // --- 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; + // --- ANTI-SCOPA --- if (tableSum >= 11) { - score += 100; // excellent — no single card can scopa - } else if (tableSum <= 10) { + score += 130; + } else { const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); - if (nextIsOpp && threats.nextOpponentCanScopa) { - score -= 500; // DANGER — next opponent can likely scopa + 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 (afterTable.length >= 4 && tableSum >= 15) score += 35; + + // --- PARTNER SETUP --- + const next = nextPlayer(playerIdx); + if (!isOpponent(playerIdx, next)) { + score += 55; + // Dump creates partner scopa opportunity + if (afterTable.length >= 1 && tableSum >= 1 && tableSum <= 10 && partnerHandSize > 0) { + score += 60; } - score -= threats.totalThreats * 80; - - // Especially bad: leaving table sum that matches a common value - if (tableSum <= 3) score -= 60; } - // Prefer making tableSum awkward (e.g., large primes: 11, 13, etc.) - if (tableSum >= 11) score += 30; - if (afterTable.length >= 3 && tableSum >= 15) score += 20; - - // --- Cooperation --- - if (!nextIsOpp) { - // Partner plays next — dumping is safer - score += 40; - - // If we dump a card our partner might capture with... that's actually helpful - // Dump cards that create capturable sums for partner - if (partnerHandSize > 0) score += 15; - } - - // --- Card tracking --- + // --- CARD TRACKING --- if (tracker) { + // Count exact cards that can clear the table const unseen = tracker.getUnseenCards(myHand, afterTable); - - // Check specific scopa threats by value let directThreats = 0; for (const uc of unseen) { - if (uc.value === tableSum && afterTable.length >= 1) { - directThreats++; + const caps = findCaptures(uc, afterTable); + for (const cap of caps) { + if (cap.length === afterTable.length) { directThreats++; break; } } } - score -= directThreats * 50; + score -= directThreats * 65; - // Late game: if we know most cards, we can be more precise - if (phase > 0.6) { - // Fewer unseen cards = more confident threat assessment - const confidence = Math.min(1, tracker.playedCount / 30); - score *= (1 + confidence * 0.3); + if (phase > 0.5) { + const confidence = Math.min(1, tracker.playedCount / 25); + score = Math.round(score * (1 + confidence * 0.3)); } } return score; } -// --------------------------------------------------------------------------- -// MASTER — deep minimax + alpha-beta + determinization + move ordering -// --------------------------------------------------------------------------- +// =========================================================================== +// MASTER — deep minimax, alpha-beta, determinization, endgame solver +// =========================================================================== function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { const myTeam = teamOf(playerIdx); const phase = gamePhase(state); - - // Adaptive search parameters based on game phase and remaining moves const cardsRemaining = state.players.reduce((s, p) => s + p.hand.length, 0); - const NUM_SAMPLES = cardsRemaining <= 12 ? 20 : 16; - const MAX_DEPTH = cardsRemaining <= 8 ? 8 : 6; // deeper search in endgame + + // Adaptive: much deeper in endgame, exact solve when very few cards + const isDeepEndgame = cardsRemaining <= 6; + const isEndgame = cardsRemaining <= 12; + const NUM_SAMPLES = isDeepEndgame ? 1 : isEndgame ? 28 : 24; + const MAX_DEPTH = isDeepEndgame ? cardsRemaining : isEndgame ? 10 : 8; const legalMoves = getLegalMoves(state, playerIdx); if (legalMoves.length === 1) return legalMoves[0]; - // Sort moves by quick heuristic for better pruning + // Quick-eval move ordering for better pruning const quickScored = legalMoves.map(m => ({ move: m, quick: quickEval(m, state, playerIdx, tracker), @@ -470,14 +489,17 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac const moveScores = new Map(); for (const m of sortedMoves) moveScores.set(moveKey(m), 0); - const samples = generateSamples(state, playerIdx, tracker, NUM_SAMPLES); + // Deep endgame: use actual state (perfect info), otherwise sample + const samples = isDeepEndgame + ? [state] + : generateSamples(state, playerIdx, tracker, NUM_SAMPLES); for (const sample of samples) { for (const move of sortedMoves) { const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); const score = alphaBeta( result.nextState, MAX_DEPTH - 1, -Infinity, Infinity, - false, myTeam, playerIdx, phase, tracker, + myTeam, playerIdx, phase, tracker, ); moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score); } @@ -487,52 +509,44 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac let bestScore = -Infinity; for (const move of sortedMoves) { const totalScore = moveScores.get(moveKey(move)) ?? 0; - if (totalScore > bestScore) { - bestScore = totalScore; - bestMove = move; - } + if (totalScore > bestScore) { bestScore = totalScore; bestMove = move; } } return bestMove; } -/** Quick heuristic to order moves for better alpha-beta pruning */ function quickEval(move: AIMove, state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): 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: best - if (move.capture.length > 0 && afterTable.length === 0) score += 1000; + // Scopa + if (move.capture.length > 0 && afterTable.length === 0) score += 1200; - // Settebello capture - if (move.capture.some(c => c.suit === 'denara' && c.value === 7) || - (move.card.suit === 'denara' && move.card.value === 7 && move.capture.length > 0)) { - score += 800; - } + // 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; - // More captures first - score += move.capture.length * 50; + 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; - // Denari captures - score += [move.card, ...move.capture].filter(c => c.suit === 'denara').length * 80; - - // 7s captured (primiera) - score += [move.card, ...move.capture].filter(c => c.value === 7).length * 60; - - // Dump: prefer high face cards if (move.capture.length === 0) { score -= 200; - if (move.card.value >= 8) score += 30; - if (move.card.suit === 'denara') score -= 100; - if (move.card.value === 7) score -= 80; + if (move.card.value >= 8) score += 40; + if (move.card.suit === 'denara') score -= 120; + if (move.card.value === 7) score -= 90; } - // Anti-scopa: penalize leaving clearable table + // Anti-scopa if (afterTable.length > 0) { const sum = afterTable.reduce((s, c) => s + c.value, 0); - if (sum <= 10) score -= 120; - if (afterTable.length === 1) score -= 80; + if (sum <= 10 && nextIsOpp) score -= 150; + if (sum >= 11) score += 50; + if (afterTable.length === 1 && nextIsOpp) score -= 100; } return score; @@ -547,13 +561,10 @@ function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] { const moves: AIMove[] = []; const player = state.players[playerIdx]; const table = state.table; - for (const card of player.hand) { const captures = findCaptures(card, table); if (captures.length > 0) { - for (const captureSet of captures) { - moves.push({ card, capture: captureSet }); - } + for (const captureSet of captures) moves.push({ card, capture: captureSet }); } else { moves.push({ card, capture: [] }); } @@ -565,9 +576,7 @@ function generateSamples( state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, count: number, ): GameState[] { const myHand = state.players[playerIdx].hand; - const partner = partnerOf(playerIdx); const samples: GameState[] = []; - const unseen = tracker ? tracker.getUnseenCards(myHand, state.table) : getUnseenWithoutTracker(state, playerIdx); @@ -575,7 +584,6 @@ function generateSamples( for (let s = 0; s < count; s++) { const sample = JSON.parse(JSON.stringify(state)) as GameState; const shuffled = shuffleArray([...unseen]); - let idx = 0; for (let p = 0; p < 4; p++) { if (p === playerIdx) continue; @@ -583,10 +591,8 @@ function generateSamples( sample.players[p].hand = shuffled.slice(idx, idx + need); idx += need; } - samples.push(sample); } - return samples; } @@ -594,16 +600,9 @@ function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card const known = new Set(); for (const c of state.players[playerIdx].hand) known.add(c.id); for (const c of state.table) known.add(c.id); - for (const p of state.players) { - for (const c of p.pile) known.add(c.id); - } - - const unseen: Card[] = []; + for (const p of state.players) { for (const c of p.pile) known.add(c.id); } const deck = buildDeck(); - for (const c of deck) { - if (!known.has(c.id)) unseen.push(c); - } - return unseen; + return deck.filter(c => !known.has(c.id)); } function shuffleArray(arr: T[]): T[] { @@ -616,7 +615,7 @@ function shuffleArray(arr: T[]): T[] { function alphaBeta( state: GameState, depth: number, alpha: number, beta: number, - maximizing: boolean, myTeam: 0 | 1, rootPlayer: PlayerIndex, + myTeam: 0 | 1, rootPlayer: PlayerIndex, phase: number, tracker?: CardTracker, ): number { if (depth === 0 || state.roundOver) { @@ -629,17 +628,22 @@ function alphaBeta( if (moves.length === 0) return evaluate(state, myTeam, phase); - // Move ordering within alpha-beta for better pruning - if (moves.length > 3) { + // Move ordering for pruning + if (moves.length > 2) { moves.sort((a, b) => { let sa = 0, sb = 0; - if (a.capture.length > 0) sa += 100 + a.capture.length * 10; - if (b.capture.length > 0) sb += 100 + b.capture.length * 10; - // Scopa + if (a.capture.length > 0) sa += 100 + a.capture.length * 15; + if (b.capture.length > 0) sb += 100 + b.capture.length * 15; const aAfter = state.table.filter(c => !a.capture.some(cc => cc.id === c.id)); const bAfter = state.table.filter(c => !b.capture.some(cc => cc.id === c.id)); - if (a.capture.length > 0 && aAfter.length === 0) sa += 500; - if (b.capture.length > 0 && bAfter.length === 0) sb += 500; + if (a.capture.length > 0 && aAfter.length === 0) sa += 600; + if (b.capture.length > 0 && bAfter.length === 0) sb += 600; + // Settebello + if ([a.card, ...a.capture].some(c => c.suit === 'denara' && c.value === 7)) sa += 400; + if ([b.card, ...b.capture].some(c => c.suit === 'denara' && c.value === 7)) sb += 400; + // Anti-scopa in ordering + if (aAfter.length > 0 && aAfter.reduce((s, c) => s + c.value, 0) <= 10) sa -= 80; + if (bAfter.length > 0 && bAfter.reduce((s, c) => s + c.value, 0) <= 10) sb -= 80; return isMyTeam ? sb - sa : sa - sb; }); } @@ -648,7 +652,7 @@ function alphaBeta( let value = -Infinity; for (const move of moves) { const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); - const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, myTeam, rootPlayer, phase, tracker); + const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, tracker); value = Math.max(value, child); alpha = Math.max(alpha, value); if (beta <= alpha) break; @@ -658,7 +662,7 @@ function alphaBeta( let value = Infinity; for (const move of moves) { const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); - const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, myTeam, rootPlayer, phase, tracker); + const child = alphaBeta(result.nextState, depth - 1, alpha, beta, myTeam, rootPlayer, phase, tracker); value = Math.min(value, child); beta = Math.min(beta, value); if (beta <= alpha) break; @@ -679,48 +683,50 @@ function evaluate(state: GameState, myTeam: 0 | 1, phase: number): number { // --- Cards majority (1 point) --- const cardDiff = myPile.length - oppPile.length; - score += cardDiff * (15 + phase * 10); // more weight in endgame + score += cardDiff * (22 + phase * 15); // --- Denari majority (1 point) --- const myDenari = myPile.filter(c => c.suit === 'denara').length; const oppDenari = oppPile.filter(c => c.suit === 'denara').length; - score += (myDenari - oppDenari) * 40; + score += (myDenari - oppDenari) * 55; // --- Settebello (1 point) --- - if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 250; - if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 250; + if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 400; + if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 400; // --- Primiera (1 point) --- const myPrim = calcPrimiera(myPile); const oppPrim = calcPrimiera(oppPile); if (myPrim > 0 && oppPrim > 0) { - score += (myPrim - oppPrim) * 3; + score += (myPrim - oppPrim) * 4; } else if (myPrim > 0) { - score += 120; + score += 150; } else if (oppPrim > 0) { - score -= 120; + score -= 150; } - // Primiera component tracking: count 7s per suit + // Per-suit 7 tracking (key primiera component) for (const suit of SUITS) { const my7 = myPile.some(c => c.suit === suit && c.value === 7); const opp7 = oppPile.some(c => c.suit === suit && c.value === 7); - if (my7 && !opp7) score += 30; - if (opp7 && !my7) score -= 30; + if (my7 && !opp7) score += 40; + if (opp7 && !my7) score -= 40; } - // --- Scope (1 point each, most valuable!) --- + // --- Scope (most valuable! each is a full point) --- const myScope = myPlayers.reduce((s, p) => s + p.scope, 0); const oppScope = oppPlayers.reduce((s, p) => s + p.scope, 0); - score += (myScope - oppScope) * 200; + score += (myScope - oppScope) * 350; // --- Table position (non-terminal) --- if (!state.roundOver && state.table.length > 0) { const tableSum = state.table.reduce((s, c) => s + c.value, 0); const curTeam = teamOf(state.currentPlayer); - // If it's our team's turn and table is capturable, that's good - if (curTeam === myTeam && tableSum <= 10) score += 15; - if (curTeam !== myTeam && tableSum <= 10) score -= 15; + if (curTeam === myTeam && tableSum <= 10) score += 25; + if (curTeam !== myTeam && tableSum <= 10) score -= 25; + // Bonus: table has settebello and it's our turn + if (curTeam === myTeam && state.table.some(c => c.suit === 'denara' && c.value === 7)) score += 80; + if (curTeam !== myTeam && state.table.some(c => c.suit === 'denara' && c.value === 7)) score -= 80; } return score;