From 0b1c7f6386616fdb06deea5b0df123bcf07bf614 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Tue, 31 Mar 2026 23:15:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(SCOPONE-0005):=20iteration=206=20=E2=80=94?= =?UTF-8?q?=20rewrite=20AI=20with=20team=20cooperation=20and=20deeper=20se?= =?UTF-8?q?arch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/ai.ts | 532 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 399 insertions(+), 133 deletions(-) diff --git a/src/game/ai.ts b/src/game/ai.ts index 1df8b3b..9bd7501 100644 --- a/src/game/ai.ts +++ b/src/game/ai.ts @@ -1,4 +1,4 @@ -import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES } from './types'; +import { Card, GameState, PlayerIndex, Difficulty, PRIMIERA_VALUES, Suit, SUITS } from './types'; import { findCaptures, canCapture, calcPrimiera, teamOf, applyMove, buildDeck } from './engine'; import { CardTracker } from './card-tracker'; @@ -7,6 +7,92 @@ export interface AIMove { capture: Card[]; } +// --------------------------------------------------------------------------- +// 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; +} + +/** + * Count how many unseen cards can scopa a given table layout. + * Returns the count and whether the immediate next player can do it. + */ +function countScopaThreats( + afterTable: Card[], + myHand: Card[], + tracker: CardTracker | undefined, + state: GameState, + playerIdx: PlayerIndex, +): { totalThreats: number; nextOpponentCanScopa: boolean } { + if (afterTable.length === 0) return { totalThreats: 0, nextOpponentCanScopa: false }; + + const unseen = tracker + ? tracker.getUnseenCards(myHand, afterTable) + : getUnseenWithoutTracker(state, playerIdx); + + 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); + } + } + } + } else { + const caps = findCaptures(uc, afterTable); + for (const cap of caps) { + if (afterTable.every(tc => cap.some(cc => cc.id === tc.id))) { + totalThreats++; + break; + } + } + } + } + + // 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 }; +} + +/** 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 +} + // --------------------------------------------------------------------------- // Main entry point — dispatches by difficulty // --------------------------------------------------------------------------- @@ -18,23 +104,22 @@ export function chooseMove( tracker?: CardTracker, ): AIMove { switch (difficulty) { - case 'beginner': return beginnerMove(state, playerIdx); + case 'beginner': return beginnerMove(state, playerIdx, tracker); case 'advanced': return advancedMove(state, playerIdx, tracker); case 'master': return masterMove(state, playerIdx, tracker); } } // --------------------------------------------------------------------------- -// BEGINNER — weakened heuristic with random noise +// BEGINNER — weakened heuristic with random noise + basic cooperation // --------------------------------------------------------------------------- -function beginnerMove(state: GameState, playerIdx: PlayerIndex): AIMove { +function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { const player = state.players[playerIdx]; const table = state.table; - const myTeam = teamOf(playerIdx); - // 20% chance to pick a completely random legal move - if (Math.random() < 0.2) { + // 15% chance to pick a completely random legal move + if (Math.random() < 0.15) { return randomMove(state, playerIdx); } @@ -45,17 +130,16 @@ function beginnerMove(state: GameState, playerIdx: PlayerIndex): AIMove { const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) { - // Weakened heuristic: half weights + random noise - const base = scoreCaptureBasic(card, captureSet, table); - const score = base * 0.5 + (Math.random() - 0.5) * base * 0.6; + 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 }; } } } else { - const base = scoreDumpBasic(card); - const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.6; + 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: [] }; @@ -76,48 +160,71 @@ function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove { return { card, capture: [] }; } -// Basic scoring (no cheating, no card counting) -function scoreCaptureBasic(played: Card, captured: Card[], table: Card[]): number { +function scoreCaptureBasic( + played: Card, captured: Card[], table: Card[], + state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker, +): 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 (afterTable.length === 0) score += 500; + 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 + primieraValue(c), 0); + score += allCaptured.reduce((s, c) => s + primieraVal(c), 0); + + // Basic cooperation: if next player is partner, leaving cards is OK + const next = nextPlayer(playerIdx); + if (!isOpponent(playerIdx, next) && !isScopa) score += 20; + + // Don't leave easy scopa for next opponent + if (!isScopa && isOpponent(playerIdx, next)) { + const tableSum = afterTable.reduce((s, c) => s + c.value, 0); + if (tableSum <= 10) score -= 40; + } return score; } -function scoreDumpBasic(card: Card): number { +function scoreDumpBasic( + card: Card, table: Card[], state: GameState, + playerIdx: PlayerIndex, tracker?: CardTracker, +): 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; + return score; } -function primieraValue(card: Card): number { - const vals: Record = { 7: 8, 6: 6, 1: 5, 5: 4, 4: 3, 3: 2, 2: 1 }; - return vals[card.value] ?? 0; -} - // --------------------------------------------------------------------------- -// ADVANCED — improved heuristic with card counting +// ADVANCED — strong heuristic with card counting + real cooperation // --------------------------------------------------------------------------- function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { const player = state.players[playerIdx]; const table = state.table; const myTeam = teamOf(playerIdx); - const allyIdx = ((playerIdx + 2) % 4) as PlayerIndex; - const allyIsNext = state.currentPlayer === playerIdx && - ((playerIdx + 1) % 4) === allyIdx; + const phase = gamePhase(state); + + // Analyze turn context + const next = nextPlayer(playerIdx); + const nextIsOpp = isOpponent(playerIdx, next); + const partner = partnerOf(playerIdx); + const partnerHandSize = state.players[partner].hand.length; let bestMove: AIMove | null = null; let bestScore = -Infinity; @@ -126,14 +233,20 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr const captures = findCaptures(card, table); if (captures.length > 0) { for (const captureSet of captures) { - const score = scoreCaptureAdvanced(card, captureSet, table, state, myTeam, tracker, player.hand, allyIsNext); + const score = scoreCaptureAdv( + card, captureSet, table, state, playerIdx, myTeam, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, + ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; } } } else { - const score = scoreDumpAdvanced(card, table, state, myTeam, tracker, player.hand, allyIsNext); + const score = scoreDumpAdv( + card, table, state, playerIdx, myTeam, + tracker, player.hand, phase, nextIsOpp, partnerHandSize, + ); if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; @@ -144,155 +257,235 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr return bestMove!; } -function scoreCaptureAdvanced( +function scoreCaptureAdv( played: Card, captured: Card[], table: Card[], state: GameState, - myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[], - allyIsNext: boolean, + playerIdx: PlayerIndex, myTeam: 0 | 1, tracker: CardTracker | undefined, + myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number, ): 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 bonus - if (isScopa) score += 600; + // --- Core scoring --- - // Settebello — highest priority - if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 400; - if (table.some(c => c.suit === 'denara' && c.value === 7) && - captured.some(c => c.suit === 'denara' && c.value === 7)) score += 250; + // Scopa (huge bonus, scales with game phase) + if (isScopa) score += 700 + phase * 200; - // Denari cards - score += allCaptured.filter(c => c.suit === 'denara').length * 60; + // Settebello: absolute must-capture + 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; - // Card count (carte majority) - score += captured.length * 25; + // Denari + const denariCount = allCaptured.filter(c => c.suit === 'denara').length; + score += denariCount * 70; - // Primiera — prefer capturing 7s, then 6s, then aces + // Card count — more important in endgame + score += captured.length * (25 + phase * 15); + + // Primiera — 7s are critical, then 6s, aces for (const c of allCaptured) { - score += PRIMIERA_VALUES[c.value] * 2; + score += primieraVal(c) * 3; } + // Extra bonus for 7s specifically (primiera control) + const sevensCapt = allCaptured.filter(c => c.value === 7).length; + score += sevensCapt * 40; - // Card counting: avoid leaving settebello on table - if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) score -= 200; - - // Anti-scopa: prefer leaving table total ≥ 11 + // --- Anti-scopa defense --- if (!isScopa) { const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (tableSum >= 11) score += 40; - if (tableSum < 5) score -= 30; + + // 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; + } + + // Prefer leaving table sum >= 11 (unclearable by one card) + if (tableSum >= 11) score += 80; + else if (tableSum >= 8) score += 20; + else score -= 40; + + // Avoid leaving exactly one card (easy scopa for opponent) + if (afterTable.length === 1 && nextIsOpp) score -= 150; } - // Card tracking threat assessment (no cheating) - if (tracker) { + // --- 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; + + // If table has denari and partner can still play, leaving is OK + if (afterTable.some(c => c.suit === 'denara') && partnerHandSize > 0) { + score += 20; + } + } + + // Endgame: if partner has no cards left, capture everything you can + if (partnerHandSize === 0) { + score += captured.length * 15; + } + + // --- Card tracking threats --- + if (tracker && !isScopa) { const unseen = tracker.getUnseenCards(myHand, afterTable); - // Check if any unseen card could scopa the remaining table - for (const uc of unseen) { - const caps = findCaptures(uc, afterTable); - for (const cap of caps) { - if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) { - score -= 200; // potential opponent scopa - break; + + // 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; + } + } + + // 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; } } } } - // Cooperative: if ally is next, allow leaving cards ally can capture - if (allyIsNext && !isScopa) { - score += 15; // slight preference if we leave cards and ally plays next - } - return score; } -function scoreDumpAdvanced( +function scoreDumpAdv( card: Card, table: Card[], state: GameState, - myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[], - allyIsNext: boolean, + playerIdx: PlayerIndex, myTeam: 0 | 1, 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); - // Never dump settebello - if (card.suit === 'denara' && card.value === 7) score -= 500; + // --- Card value protection --- + + // NEVER dump settebello + if (card.suit === 'denara' && card.value === 7) return -10000; // Avoid dumping denari - if (card.suit === 'denara') score -= 50; - if (card.suit !== 'denara') score += 30; + if (card.suit === 'denara') score -= 80; - // Avoid dumping 7s (primiera) - if (card.value === 7) score -= 60; + // 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 - if (card.value >= 8) score += 15; + // Prefer dumping face cards (8, 9, 10) — low primiera value + if (card.value >= 8) score += 20 + card.value * 2; - // Anti-scopa: prefer leaving table total ≥ 11 - const tableSum = afterTable.reduce((s, c) => s + c.value, 0); - if (tableSum >= 11) score += 50; - if (tableSum < 5) score -= 40; + // --- Anti-scopa defense (CRITICAL) --- - // Card tracking: check if unseen cards could scopa after this dump - if (tracker) { - const unseen = tracker.getUnseenCards(myHand, afterTable); - let scopaThreat = 0; - for (const uc of unseen) { - const caps = findCaptures(uc, afterTable); - for (const cap of caps) { - if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) { - scopaThreat++; - break; - } - } + if (tableSum >= 11) { + score += 100; // excellent — no single card can scopa + } else if (tableSum <= 10) { + const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); + if (nextIsOpp && threats.nextOpponentCanScopa) { + score -= 500; // DANGER — next opponent can likely scopa } - score -= scopaThreat * 80; + score -= threats.totalThreats * 80; + + // Especially bad: leaving table sum that matches a common value + if (tableSum <= 3) score -= 60; } - // Cooperative: if ally is next, leaving captures for ally is OK - if (allyIsNext) { - score += 10; + // 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 --- + if (tracker) { + 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++; + } + } + score -= directThreats * 50; + + // 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); + } } return score; } // --------------------------------------------------------------------------- -// MASTER — minimax with alpha-beta pruning and determinization +// MASTER — deep minimax + alpha-beta + determinization + move ordering // --------------------------------------------------------------------------- function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove { - const player = state.players[playerIdx]; - const table = state.table; const myTeam = teamOf(playerIdx); - const NUM_SAMPLES = 12; - const MAX_DEPTH = 4; // 4 plies = one full rotation + 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 - // Generate all legal moves for this player const legalMoves = getLegalMoves(state, playerIdx); if (legalMoves.length === 1) return legalMoves[0]; - // Score accumulator for each move - const moveScores = new Map(); - for (const m of legalMoves) { - moveScores.set(moveKey(m), 0); - } + // Sort moves by quick heuristic for better pruning + const quickScored = legalMoves.map(m => ({ + move: m, + quick: quickEval(m, state, playerIdx, tracker), + })); + quickScored.sort((a, b) => b.quick - a.quick); + const sortedMoves = quickScored.map(qs => qs.move); + + const moveScores = new Map(); + for (const m of sortedMoves) moveScores.set(moveKey(m), 0); - // Determinization: sample possible opponent hand assignments const samples = generateSamples(state, playerIdx, tracker, NUM_SAMPLES); for (const sample of samples) { - for (const move of legalMoves) { + 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); + const score = alphaBeta( + result.nextState, MAX_DEPTH - 1, -Infinity, Infinity, + false, myTeam, playerIdx, phase, tracker, + ); moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score); } } - // Pick move with highest total score across samples - let bestMove = legalMoves[0]; + let bestMove = sortedMoves[0]; let bestScore = -Infinity; - for (const move of legalMoves) { + for (const move of sortedMoves) { const totalScore = moveScores.get(moveKey(move)) ?? 0; if (totalScore > bestScore) { bestScore = totalScore; @@ -303,6 +496,48 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac 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)); + + // Scopa: best + if (move.capture.length > 0 && afterTable.length === 0) score += 1000; + + // 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; + } + + // More captures first + score += move.capture.length * 50; + + // 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; + } + + // Anti-scopa: penalize leaving clearable table + 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; + } + + return score; +} + function moveKey(move: AIMove): string { const capIds = move.capture.map(c => c.id).sort().join(','); return `${move.card.id}|${capIds}`; @@ -330,9 +565,9 @@ 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[] = []; - // Cards we know about: our hand + table + tracked const unseen = tracker ? tracker.getUnseenCards(myHand, state.table) : getUnseenWithoutTracker(state, playerIdx); @@ -341,7 +576,6 @@ function generateSamples( const sample = JSON.parse(JSON.stringify(state)) as GameState; const shuffled = shuffleArray([...unseen]); - // Distribute unseen cards among other players proportionally to their hand sizes let idx = 0; for (let p = 0; p < 4; p++) { if (p === playerIdx) continue; @@ -357,11 +591,9 @@ function generateSamples( } function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] { - // Without tracker, we only know our own hand and the table 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); - // Also know cards in all players' piles (they've been captured visibly) for (const p of state.players) { for (const c of p.pile) known.add(c.id); } @@ -385,22 +617,38 @@ function shuffleArray(arr: T[]): T[] { function alphaBeta( state: GameState, depth: number, alpha: number, beta: number, maximizing: boolean, myTeam: 0 | 1, rootPlayer: PlayerIndex, + phase: number, tracker?: CardTracker, ): number { if (depth === 0 || state.roundOver) { - return evaluate(state, myTeam); + return evaluate(state, myTeam, phase); } const cur = state.currentPlayer; const isMyTeam = teamOf(cur) === myTeam; const moves = getLegalMoves(state, cur); - if (moves.length === 0) return evaluate(state, myTeam); + if (moves.length === 0) return evaluate(state, myTeam, phase); + + // Move ordering within alpha-beta for better pruning + if (moves.length > 3) { + 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 + 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; + return isMyTeam ? sb - sa : sa - sb; + }); + } if (isMyTeam) { 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); + const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, myTeam, rootPlayer, phase, tracker); value = Math.max(value, child); alpha = Math.max(alpha, value); if (beta <= alpha) break; @@ -410,7 +658,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); + const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, myTeam, rootPlayer, phase, tracker); value = Math.min(value, child); beta = Math.min(beta, value); if (beta <= alpha) break; @@ -419,7 +667,7 @@ function alphaBeta( } } -function evaluate(state: GameState, myTeam: 0 | 1): number { +function evaluate(state: GameState, myTeam: 0 | 1, phase: number): number { const oppTeam = myTeam === 0 ? 1 : 0; const myPlayers = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; const oppPlayers = oppTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]]; @@ -429,33 +677,51 @@ function evaluate(state: GameState, myTeam: 0 | 1): number { let score = 0; - // Cards (20 = majority) - score += (myPile.length - oppPile.length) * 10; + // --- Cards majority (1 point) --- + const cardDiff = myPile.length - oppPile.length; + score += cardDiff * (15 + phase * 10); // more weight in endgame - // Denari + // --- 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) * 30; + score += (myDenari - oppDenari) * 40; - // Settebello - if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 200; - if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 200; + // --- 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; - // Primiera + // --- Primiera (1 point) --- const myPrim = calcPrimiera(myPile); const oppPrim = calcPrimiera(oppPile); if (myPrim > 0 && oppPrim > 0) { - score += (myPrim - oppPrim) * 2; + score += (myPrim - oppPrim) * 3; } else if (myPrim > 0) { - score += 100; + score += 120; } else if (oppPrim > 0) { - score -= 100; + score -= 120; } - // Scope + // Primiera component tracking: count 7s per suit + 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; + } + + // --- Scope (1 point each, most valuable!) --- const myScope = myPlayers.reduce((s, p) => s + p.scope, 0); const oppScope = oppPlayers.reduce((s, p) => s + p.scope, 0); - score += (myScope - oppScope) * 150; + score += (myScope - oppScope) * 200; + + // --- 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; + } return score; }