feat(ai): improve all difficulty levels with race tracking, probabilistic threats, deeper search

SCOPONE-0005 iteration 7

Beginner:
- Random chance 15%→8%, noise ±40%→±25%
- Hard settebello protection, anti-scopa table management

Advanced:
- Race-aware scoring: adapt priorities per scoring category
- Partner scopa setup: +70 bonus for clearable table before partner
- Probabilistic two-opponent threat assessment (hypergeometric)
- Stronger anti-scopa penalties across all table states

Master:
- Samples 16→24 (28 endgame), exact solve ≤6 cards
- Depth 6→8 (10 endgame, full depth ≤6 cards)
- Better evaluation: scope 350, settebello 400, table awareness
- Improved move ordering: settebello + anti-scopa factors
This commit is contained in:
Giancarmine Salucci
2026-03-31 23:26:42 +02:00
parent 0b1c7f6386
commit a045efd798

View File

@@ -11,26 +11,68 @@ export interface AIMove {
// Helpers shared across all difficulty levels // Helpers shared across all difficulty levels
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Who plays after the given player */
function nextPlayer(p: PlayerIndex): PlayerIndex { function nextPlayer(p: PlayerIndex): PlayerIndex {
return ((p + 1) % 4) as PlayerIndex; return ((p + 1) % 4) as PlayerIndex;
} }
/** Partner of a given player (across the table) */
function partnerOf(p: PlayerIndex): PlayerIndex { function partnerOf(p: PlayerIndex): PlayerIndex {
return ((p + 2) % 4) as PlayerIndex; return ((p + 2) % 4) as PlayerIndex;
} }
/** Is `other` an opponent of `me`? */
function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean { function isOpponent(me: PlayerIndex, other: PlayerIndex): boolean {
return teamOf(me) !== teamOf(other); return teamOf(me) !== teamOf(other);
} }
function primieraVal(card: Card): number { function primieraVal(card: Card): number {
return PRIMIERA_VALUES[card.value] ?? 0; 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. * Count scopa threats: how many unseen cards can clear a given table.
* Returns the count and whether the immediate next player can do it. * Uses probabilistic assessment per-player based on hand sizes.
*/ */
function countScopaThreats( function countScopaThreats(
afterTable: Card[], afterTable: Card[],
@@ -38,63 +80,63 @@ function countScopaThreats(
tracker: CardTracker | undefined, tracker: CardTracker | undefined,
state: GameState, state: GameState,
playerIdx: PlayerIndex, playerIdx: PlayerIndex,
): { totalThreats: number; nextOpponentCanScopa: boolean } { ): { totalThreats: number; nextOppCanScopa: boolean; secondOppCanScopa: boolean } {
if (afterTable.length === 0) return { totalThreats: 0, nextOpponentCanScopa: false }; if (afterTable.length === 0) return { totalThreats: 0, nextOppCanScopa: false, secondOppCanScopa: false };
const unseen = tracker const unseen = tracker
? tracker.getUnseenCards(myHand, afterTable) ? tracker.getUnseenCards(myHand, afterTable)
: getUnseenWithoutTracker(state, playerIdx); : getUnseenWithoutTracker(state, playerIdx);
// Count every unseen card that has at least one capture clearing the full table
let totalThreats = 0; 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<number>();
for (const uc of unseen) { for (const uc of unseen) {
// Single-card scopa: card value == table sum and captures all const caps = findCaptures(uc, afterTable);
if (uc.value === tableSum && afterTable.length >= 1) { for (const cap of caps) {
// Check if the card actually captures all table cards if (cap.length === afterTable.length) { totalThreats++; break; }
const caps = findCaptures(uc, afterTable); }
for (const cap of caps) { }
if (cap.length === afterTable.length) {
if (!threatValues.has(uc.value)) { // Probabilistic check for each opponent
totalThreats++; const next = nextPlayer(playerIdx);
threatValues.add(uc.value); 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); if (isOpponent(playerIdx, second)) {
for (const cap of caps) { const hs = state.players[second].hand.length;
if (afterTable.every(tc => cap.some(cc => cc.id === tc.id))) { if (hs > 0) {
totalThreats++; const probNone = hypergeometricNone(unseenCount, totalThreats, hs);
break; secondOppCanScopa = (1 - probNone) > 0.25;
}
} }
} }
} }
// Check specifically the next opponent return { totalThreats, nextOppCanScopa, secondOppCanScopa };
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) */ /** P(0 threat cards drawn) using hypergeometric approx */
function gamePhase(state: GameState): number { function hypergeometricNone(total: number, threats: number, drawn: number): number {
const totalCards = state.players.reduce((s, p) => s + p.hand.length, 0); if (drawn >= total) return threats > 0 ? 0 : 1;
return 1 - totalCards / 40; // 0.0 at start, 1.0 at end 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( 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 { function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
const player = state.players[playerIdx]; const player = state.players[playerIdx];
const table = state.table; 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 // 8% pure random (reduced from 15%)
if (Math.random() < 0.15) { if (Math.random() < 0.08) {
return randomMove(state, playerIdx); return randomMove(state, playerIdx);
} }
@@ -130,20 +175,14 @@ function beginnerMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr
const captures = findCaptures(card, table); const captures = findCaptures(card, table);
if (captures.length > 0) { if (captures.length > 0) {
for (const captureSet of captures) { for (const captureSet of captures) {
const base = scoreCaptureBasic(card, captureSet, table, state, playerIdx, tracker); const base = scoreCaptureBeginner(card, captureSet, table, state, playerIdx, phase, nextIsOpp);
const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.4; const score = base + (Math.random() - 0.5) * Math.max(80, Math.abs(base) * 0.25);
if (score > bestScore) { if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; }
bestScore = score;
bestMove = { card, capture: captureSet };
}
} }
} else { } else {
const base = scoreDumpBasic(card, table, state, playerIdx, tracker); const base = scoreDumpBeginner(card, table, state, playerIdx, phase, nextIsOpp);
const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.4; const score = base + (Math.random() - 0.5) * Math.max(60, Math.abs(base) * 0.25);
if (score > bestScore) { if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; }
bestScore = score;
bestMove = { card, capture: [] };
}
} }
} }
@@ -160,67 +199,73 @@ function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove {
return { card, capture: [] }; return { card, capture: [] };
} }
function scoreCaptureBasic( function scoreCaptureBeginner(
played: Card, captured: Card[], table: Card[], played: Card, captured: Card[], table: Card[],
state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker, state: GameState, playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean,
): number { ): number {
let score = 100; let score = 100;
const allCaptured = [played, ...captured]; const allCaptured = [played, ...captured];
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
const isScopa = afterTable.length === 0; const isScopa = afterTable.length === 0;
if (isScopa) score += 500; if (isScopa) score += 600;
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300; if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 400;
score += allCaptured.filter(c => c.suit === 'denara').length * 50; // If settebello is on table and we DON'T take it — even beginners know this is bad
score += captured.length * 20; if (table.some(c => c.suit === 'denara' && c.value === 7) &&
score += allCaptured.reduce((s, c) => s + primieraVal(c), 0); !allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score -= 200;
// Basic cooperation: if next player is partner, leaving cards is OK score += allCaptured.filter(c => c.suit === 'denara').length * 60;
const next = nextPlayer(playerIdx); score += captured.length * 25;
if (!isOpponent(playerIdx, next) && !isScopa) score += 20; 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 // Basic cooperation
if (!isScopa && isOpponent(playerIdx, next)) { 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); 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; return score;
} }
function scoreDumpBasic( function scoreDumpBeginner(
card: Card, table: Card[], state: GameState, card: Card, table: Card[], state: GameState,
playerIdx: PlayerIndex, tracker?: CardTracker, playerIdx: PlayerIndex, phase: number, nextIsOpp: boolean,
): number { ): number {
let score = 0; let score = 0;
const afterTable = [...table, card]; 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); 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; 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 { function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
const player = state.players[playerIdx]; const player = state.players[playerIdx];
const table = state.table; const table = state.table;
const myTeam = teamOf(playerIdx);
const phase = gamePhase(state); const phase = gamePhase(state);
const race = getRaceState(state, playerIdx);
// Analyze turn context
const next = nextPlayer(playerIdx); const next = nextPlayer(playerIdx);
const nextIsOpp = isOpponent(playerIdx, next); const nextIsOpp = isOpponent(playerIdx, next);
const partner = partnerOf(playerIdx); const partner = partnerOf(playerIdx);
@@ -234,23 +279,17 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr
if (captures.length > 0) { if (captures.length > 0) {
for (const captureSet of captures) { for (const captureSet of captures) {
const score = scoreCaptureAdv( const score = scoreCaptureAdv(
card, captureSet, table, state, playerIdx, myTeam, card, captureSet, table, state, playerIdx, race,
tracker, player.hand, phase, nextIsOpp, partnerHandSize, tracker, player.hand, phase, nextIsOpp, partnerHandSize,
); );
if (score > bestScore) { if (score > bestScore) { bestScore = score; bestMove = { card, capture: captureSet }; }
bestScore = score;
bestMove = { card, capture: captureSet };
}
} }
} else { } else {
const score = scoreDumpAdv( const score = scoreDumpAdv(
card, table, state, playerIdx, myTeam, card, table, state, playerIdx, race,
tracker, player.hand, phase, nextIsOpp, partnerHandSize, tracker, player.hand, phase, nextIsOpp, partnerHandSize,
); );
if (score > bestScore) { if (score > bestScore) { bestScore = score; bestMove = { card, capture: [] }; }
bestScore = score;
bestMove = { card, capture: [] };
}
} }
} }
@@ -259,7 +298,7 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr
function scoreCaptureAdv( function scoreCaptureAdv(
played: Card, captured: Card[], table: Card[], state: GameState, 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, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number,
): number { ): number {
let score = 100; let score = 100;
@@ -267,95 +306,87 @@ function scoreCaptureAdv(
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id)); const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
const isScopa = afterTable.length === 0; const isScopa = afterTable.length === 0;
// --- Core scoring --- // --- SCOPA ---
if (isScopa) score += 900 + phase * 300;
// Scopa (huge bonus, scales with game phase) // --- SETTEBELLO ---
if (isScopa) score += 700 + phase * 200;
// Settebello: absolute must-capture
const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7); const capturesSettebello = allCaptured.some(c => c.suit === 'denara' && c.value === 7);
if (capturesSettebello) score += 500; if (capturesSettebello) score += 700;
// If settebello is on table and we DON'T capture it, penalty if (table.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) score -= 500;
if (table.some(c => c.suit === 'denara' && c.value === 7) && !capturesSettebello) score -= 300;
// Denari // --- DENARI (race-aware: prioritize more when behind) ---
const denariCount = allCaptured.filter(c => c.suit === 'denara').length; const denariCount = allCaptured.filter(c => c.suit === 'denara').length;
score += denariCount * 70; score += denariCount * (race.behindInDenari ? 100 : 60);
// Card count — more important in endgame // --- CARD COUNT (race-aware) ---
score += captured.length * (25 + phase * 15); 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) { 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) { if (!isScopa) {
const tableSum = afterTable.reduce((s, c) => s + c.value, 0); 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 (threats.nextOppCanScopa) score -= 550;
if (tableSum <= 10) { if (threats.secondOppCanScopa) score -= 250;
const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); score -= threats.totalThreats * 75;
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 += 100;
if (tableSum >= 11) score += 80; else if (tableSum <= 3) score -= 120;
else if (tableSum >= 8) score += 20; else if (tableSum <= 7) score -= 50;
else score -= 40;
// Avoid leaving exactly one card (easy scopa for opponent) // Single card on table = trivial scopa
if (afterTable.length === 1 && nextIsOpp) score -= 150; 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 --- // --- PARTNER COOPERATION ---
if (!isScopa && !nextIsOpp) { const next = nextPlayer(playerIdx);
// Next player is partner — leaving cards for them is good if (!isScopa && !isOpponent(playerIdx, next)) {
// Partner might be able to scopa or capture denari // Partner plays next — leaving cards for them
score += 30; 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 // Partner scopa setup: if after-table is clearable and partner is next
if (afterTable.some(c => c.suit === 'denara') && partnerHandSize > 0) { const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
score += 20; 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 opponent is next and after-table clearable, but after THAT is partner...
if (partnerHandSize === 0) { // Consider if the table after opponent's play might be good for partner (complex, skip)
score += captured.length * 15;
}
// --- Card tracking threats --- // Endgame: partner finished, maximize own captures
if (partnerHandSize === 0) score += captured.length * 25;
// --- CARD TRACKER REFINEMENTS ---
if (tracker && !isScopa) { if (tracker && !isScopa) {
const unseen = tracker.getUnseenCards(myHand, afterTable);
// Check settebello still in play — protect it
if (tracker.isSettebelloUnseen() && !capturesSettebello) { if (tracker.isSettebelloUnseen() && !capturesSettebello) {
// Don't leave table where opponent could capture settebello + table
if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) { 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 // Late game: more confident — sharpen penalties
const myPile = state.players[playerIdx].pile.concat( if (phase > 0.5) {
state.players[partnerOf(playerIdx)].pile const confidence = Math.min(1, tracker.playedCount / 25);
); const afterSum = afterTable.reduce((s, c) => s + c.value, 0);
const allTeamCards = [...myPile, ...allCaptured]; if (afterTable.length > 0 && afterSum <= 10) {
for (const suit of SUITS) { score -= Math.round(confidence * 100);
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;
}
} }
} }
} }
@@ -365,101 +396,89 @@ function scoreCaptureAdv(
function scoreDumpAdv( function scoreDumpAdv(
card: Card, table: Card[], state: GameState, 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, myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number,
): number { ): number {
let score = 0; let score = 0;
const afterTable = [...table, card]; const afterTable = [...table, card];
const tableSum = afterTable.reduce((s, c) => s + c.value, 0); const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
// --- Card value protection --- // --- HARD RULES ---
// NEVER dump settebello
if (card.suit === 'denara' && card.value === 7) return -10000; if (card.suit === 'denara' && card.value === 7) return -10000;
// Avoid dumping denari // --- CARD PROTECTION (race-aware) ---
if (card.suit === 'denara') score -= 80; if (card.suit === 'denara') score -= (race.behindInDenari ? 120 : 70);
if (card.value === 7) score -= (race.need7s ? 110 : 65);
// Avoid dumping 7s (primiera), especially in endgame if (card.value === 6) score -= 50;
if (card.value === 7) score -= 80 - phase * 20; if (card.value === 1) score -= 40;
if (card.value >= 8) score += 25 + card.value * 3;
// 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) ---
// --- ANTI-SCOPA ---
if (tableSum >= 11) { if (tableSum >= 11) {
score += 100; // excellent — no single card can scopa score += 130;
} else if (tableSum <= 10) { } else {
const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx); const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx);
if (nextIsOpp && threats.nextOpponentCanScopa) { if (threats.nextOppCanScopa) score -= 650;
score -= 500; // DANGER — next opponent can likely scopa 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.) // --- CARD TRACKING ---
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) { if (tracker) {
// Count exact cards that can clear the table
const unseen = tracker.getUnseenCards(myHand, afterTable); const unseen = tracker.getUnseenCards(myHand, afterTable);
// Check specific scopa threats by value
let directThreats = 0; let directThreats = 0;
for (const uc of unseen) { for (const uc of unseen) {
if (uc.value === tableSum && afterTable.length >= 1) { const caps = findCaptures(uc, afterTable);
directThreats++; 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.5) {
if (phase > 0.6) { const confidence = Math.min(1, tracker.playedCount / 25);
// Fewer unseen cards = more confident threat assessment score = Math.round(score * (1 + confidence * 0.3));
const confidence = Math.min(1, tracker.playedCount / 30);
score *= (1 + confidence * 0.3);
} }
} }
return score; 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 { function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
const myTeam = teamOf(playerIdx); const myTeam = teamOf(playerIdx);
const phase = gamePhase(state); 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 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); const legalMoves = getLegalMoves(state, playerIdx);
if (legalMoves.length === 1) return legalMoves[0]; 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 => ({ const quickScored = legalMoves.map(m => ({
move: m, move: m,
quick: quickEval(m, state, playerIdx, tracker), quick: quickEval(m, state, playerIdx, tracker),
@@ -470,14 +489,17 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
const moveScores = new Map<string, number>(); const moveScores = new Map<string, number>();
for (const m of sortedMoves) moveScores.set(moveKey(m), 0); 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 sample of samples) {
for (const move of sortedMoves) { for (const move of sortedMoves) {
const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined); const result = applyMove(sample, playerIdx, move.card, move.capture.length > 0 ? move.capture : undefined);
const score = alphaBeta( const score = alphaBeta(
result.nextState, MAX_DEPTH - 1, -Infinity, Infinity, 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); 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; let bestScore = -Infinity;
for (const move of sortedMoves) { for (const move of sortedMoves) {
const totalScore = moveScores.get(moveKey(move)) ?? 0; const totalScore = moveScores.get(moveKey(move)) ?? 0;
if (totalScore > bestScore) { if (totalScore > bestScore) { bestScore = totalScore; bestMove = move; }
bestScore = totalScore;
bestMove = move;
}
} }
return bestMove; return bestMove;
} }
/** Quick heuristic to order moves for better alpha-beta pruning */
function quickEval(move: AIMove, state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): number { function quickEval(move: AIMove, state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): number {
let score = 0; let score = 0;
const table = state.table; const table = state.table;
const afterTable = table.filter(c => !move.capture.some(cc => cc.id === c.id)); 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 // Scopa
if (move.capture.length > 0 && afterTable.length === 0) score += 1000; if (move.capture.length > 0 && afterTable.length === 0) score += 1200;
// Settebello capture // Settebello
if (move.capture.some(c => c.suit === 'denara' && c.value === 7) || if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 900;
(move.card.suit === 'denara' && move.card.value === 7 && move.capture.length > 0)) { if (move.capture.length === 0 && move.card.suit === 'denara' && move.card.value === 7) score -= 5000;
score += 800;
}
// More captures first score += move.capture.length * 60;
score += move.capture.length * 50; 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) { if (move.capture.length === 0) {
score -= 200; score -= 200;
if (move.card.value >= 8) score += 30; if (move.card.value >= 8) score += 40;
if (move.card.suit === 'denara') score -= 100; if (move.card.suit === 'denara') score -= 120;
if (move.card.value === 7) score -= 80; if (move.card.value === 7) score -= 90;
} }
// Anti-scopa: penalize leaving clearable table // Anti-scopa
if (afterTable.length > 0) { if (afterTable.length > 0) {
const sum = afterTable.reduce((s, c) => s + c.value, 0); const sum = afterTable.reduce((s, c) => s + c.value, 0);
if (sum <= 10) score -= 120; if (sum <= 10 && nextIsOpp) score -= 150;
if (afterTable.length === 1) score -= 80; if (sum >= 11) score += 50;
if (afterTable.length === 1 && nextIsOpp) score -= 100;
} }
return score; return score;
@@ -547,13 +561,10 @@ function getLegalMoves(state: GameState, playerIdx: PlayerIndex): AIMove[] {
const moves: AIMove[] = []; const moves: AIMove[] = [];
const player = state.players[playerIdx]; const player = state.players[playerIdx];
const table = state.table; const table = state.table;
for (const card of player.hand) { for (const card of player.hand) {
const captures = findCaptures(card, table); const captures = findCaptures(card, table);
if (captures.length > 0) { if (captures.length > 0) {
for (const captureSet of captures) { for (const captureSet of captures) moves.push({ card, capture: captureSet });
moves.push({ card, capture: captureSet });
}
} else { } else {
moves.push({ card, capture: [] }); moves.push({ card, capture: [] });
} }
@@ -565,9 +576,7 @@ function generateSamples(
state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, count: number, state: GameState, playerIdx: PlayerIndex, tracker: CardTracker | undefined, count: number,
): GameState[] { ): GameState[] {
const myHand = state.players[playerIdx].hand; const myHand = state.players[playerIdx].hand;
const partner = partnerOf(playerIdx);
const samples: GameState[] = []; const samples: GameState[] = [];
const unseen = tracker const unseen = tracker
? tracker.getUnseenCards(myHand, state.table) ? tracker.getUnseenCards(myHand, state.table)
: getUnseenWithoutTracker(state, playerIdx); : getUnseenWithoutTracker(state, playerIdx);
@@ -575,7 +584,6 @@ function generateSamples(
for (let s = 0; s < count; s++) { for (let s = 0; s < count; s++) {
const sample = JSON.parse(JSON.stringify(state)) as GameState; const sample = JSON.parse(JSON.stringify(state)) as GameState;
const shuffled = shuffleArray([...unseen]); const shuffled = shuffleArray([...unseen]);
let idx = 0; let idx = 0;
for (let p = 0; p < 4; p++) { for (let p = 0; p < 4; p++) {
if (p === playerIdx) continue; if (p === playerIdx) continue;
@@ -583,10 +591,8 @@ function generateSamples(
sample.players[p].hand = shuffled.slice(idx, idx + need); sample.players[p].hand = shuffled.slice(idx, idx + need);
idx += need; idx += need;
} }
samples.push(sample); samples.push(sample);
} }
return samples; return samples;
} }
@@ -594,16 +600,9 @@ function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card
const known = new Set<string>(); const known = new Set<string>();
for (const c of state.players[playerIdx].hand) known.add(c.id); for (const c of state.players[playerIdx].hand) known.add(c.id);
for (const c of state.table) known.add(c.id); for (const c of state.table) known.add(c.id);
for (const p of state.players) { for (const p of state.players) { for (const c of p.pile) known.add(c.id); }
for (const c of p.pile) known.add(c.id);
}
const unseen: Card[] = [];
const deck = buildDeck(); const deck = buildDeck();
for (const c of deck) { return deck.filter(c => !known.has(c.id));
if (!known.has(c.id)) unseen.push(c);
}
return unseen;
} }
function shuffleArray<T>(arr: T[]): T[] { function shuffleArray<T>(arr: T[]): T[] {
@@ -616,7 +615,7 @@ function shuffleArray<T>(arr: T[]): T[] {
function alphaBeta( function alphaBeta(
state: GameState, depth: number, alpha: number, beta: number, state: GameState, depth: number, alpha: number, beta: number,
maximizing: boolean, myTeam: 0 | 1, rootPlayer: PlayerIndex, myTeam: 0 | 1, rootPlayer: PlayerIndex,
phase: number, tracker?: CardTracker, phase: number, tracker?: CardTracker,
): number { ): number {
if (depth === 0 || state.roundOver) { if (depth === 0 || state.roundOver) {
@@ -629,17 +628,22 @@ function alphaBeta(
if (moves.length === 0) return evaluate(state, myTeam, phase); if (moves.length === 0) return evaluate(state, myTeam, phase);
// Move ordering within alpha-beta for better pruning // Move ordering for pruning
if (moves.length > 3) { if (moves.length > 2) {
moves.sort((a, b) => { moves.sort((a, b) => {
let sa = 0, sb = 0; let sa = 0, sb = 0;
if (a.capture.length > 0) sa += 100 + a.capture.length * 10; if (a.capture.length > 0) sa += 100 + a.capture.length * 15;
if (b.capture.length > 0) sb += 100 + b.capture.length * 10; if (b.capture.length > 0) sb += 100 + b.capture.length * 15;
// Scopa
const aAfter = state.table.filter(c => !a.capture.some(cc => cc.id === c.id)); 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)); 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 (a.capture.length > 0 && aAfter.length === 0) sa += 600;
if (b.capture.length > 0 && bAfter.length === 0) sb += 500; 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; return isMyTeam ? sb - sa : sa - sb;
}); });
} }
@@ -648,7 +652,7 @@ function alphaBeta(
let value = -Infinity; let value = -Infinity;
for (const move of moves) { for (const move of moves) {
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); 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); value = Math.max(value, child);
alpha = Math.max(alpha, value); alpha = Math.max(alpha, value);
if (beta <= alpha) break; if (beta <= alpha) break;
@@ -658,7 +662,7 @@ function alphaBeta(
let value = Infinity; let value = Infinity;
for (const move of moves) { for (const move of moves) {
const result = applyMove(state, cur, move.card, move.capture.length > 0 ? move.capture : undefined); 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); value = Math.min(value, child);
beta = Math.min(beta, value); beta = Math.min(beta, value);
if (beta <= alpha) break; if (beta <= alpha) break;
@@ -679,48 +683,50 @@ function evaluate(state: GameState, myTeam: 0 | 1, phase: number): number {
// --- Cards majority (1 point) --- // --- Cards majority (1 point) ---
const cardDiff = myPile.length - oppPile.length; const cardDiff = myPile.length - oppPile.length;
score += cardDiff * (15 + phase * 10); // more weight in endgame score += cardDiff * (22 + phase * 15);
// --- Denari majority (1 point) --- // --- Denari majority (1 point) ---
const myDenari = myPile.filter(c => c.suit === 'denara').length; const myDenari = myPile.filter(c => c.suit === 'denara').length;
const oppDenari = oppPile.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) --- // --- Settebello (1 point) ---
if (myPile.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 -= 250; if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 400;
// --- Primiera (1 point) --- // --- Primiera (1 point) ---
const myPrim = calcPrimiera(myPile); const myPrim = calcPrimiera(myPile);
const oppPrim = calcPrimiera(oppPile); const oppPrim = calcPrimiera(oppPile);
if (myPrim > 0 && oppPrim > 0) { if (myPrim > 0 && oppPrim > 0) {
score += (myPrim - oppPrim) * 3; score += (myPrim - oppPrim) * 4;
} else if (myPrim > 0) { } else if (myPrim > 0) {
score += 120; score += 150;
} else if (oppPrim > 0) { } 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) { for (const suit of SUITS) {
const my7 = myPile.some(c => c.suit === suit && c.value === 7); const my7 = myPile.some(c => c.suit === suit && c.value === 7);
const opp7 = oppPile.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 (my7 && !opp7) score += 40;
if (opp7 && !my7) score -= 30; 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 myScope = myPlayers.reduce((s, p) => s + p.scope, 0);
const oppScope = oppPlayers.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) --- // --- Table position (non-terminal) ---
if (!state.roundOver && state.table.length > 0) { if (!state.roundOver && state.table.length > 0) {
const tableSum = state.table.reduce((s, c) => s + c.value, 0); const tableSum = state.table.reduce((s, c) => s + c.value, 0);
const curTeam = teamOf(state.currentPlayer); 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 += 25;
if (curTeam === myTeam && tableSum <= 10) score += 15; if (curTeam !== myTeam && tableSum <= 10) score -= 25;
if (curTeam !== myTeam && tableSum <= 10) score -= 15; // 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; return score;