feat(SCOPONE-0005): iteration 6 — rewrite AI with team cooperation and deeper search
This commit is contained in:
532
src/game/ai.ts
532
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 { findCaptures, canCapture, calcPrimiera, teamOf, applyMove, buildDeck } from './engine';
|
||||||
import { CardTracker } from './card-tracker';
|
import { CardTracker } from './card-tracker';
|
||||||
|
|
||||||
@@ -7,6 +7,92 @@ export interface AIMove {
|
|||||||
capture: Card[];
|
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<number>();
|
||||||
|
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
|
// Main entry point — dispatches by difficulty
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -18,23 +104,22 @@ export function chooseMove(
|
|||||||
tracker?: CardTracker,
|
tracker?: CardTracker,
|
||||||
): AIMove {
|
): AIMove {
|
||||||
switch (difficulty) {
|
switch (difficulty) {
|
||||||
case 'beginner': return beginnerMove(state, playerIdx);
|
case 'beginner': return beginnerMove(state, playerIdx, tracker);
|
||||||
case 'advanced': return advancedMove(state, playerIdx, tracker);
|
case 'advanced': return advancedMove(state, playerIdx, tracker);
|
||||||
case 'master': return masterMove(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 player = state.players[playerIdx];
|
||||||
const table = state.table;
|
const table = state.table;
|
||||||
const myTeam = teamOf(playerIdx);
|
|
||||||
|
|
||||||
// 20% chance to pick a completely random legal move
|
// 15% chance to pick a completely random legal move
|
||||||
if (Math.random() < 0.2) {
|
if (Math.random() < 0.15) {
|
||||||
return randomMove(state, playerIdx);
|
return randomMove(state, playerIdx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,17 +130,16 @@ function beginnerMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
|||||||
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) {
|
||||||
// Weakened heuristic: half weights + random noise
|
const base = scoreCaptureBasic(card, captureSet, table, state, playerIdx, tracker);
|
||||||
const base = scoreCaptureBasic(card, captureSet, table);
|
const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.4;
|
||||||
const score = base * 0.5 + (Math.random() - 0.5) * base * 0.6;
|
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMove = { card, capture: captureSet };
|
bestMove = { card, capture: captureSet };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const base = scoreDumpBasic(card);
|
const base = scoreDumpBasic(card, table, state, playerIdx, tracker);
|
||||||
const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.6;
|
const score = base * 0.5 + (Math.random() - 0.5) * Math.abs(base) * 0.4;
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMove = { card, capture: [] };
|
bestMove = { card, capture: [] };
|
||||||
@@ -76,48 +160,71 @@ function randomMove(state: GameState, playerIdx: PlayerIndex): AIMove {
|
|||||||
return { card, capture: [] };
|
return { card, capture: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic scoring (no cheating, no card counting)
|
function scoreCaptureBasic(
|
||||||
function scoreCaptureBasic(played: Card, captured: Card[], table: Card[]): number {
|
played: Card, captured: Card[], table: Card[],
|
||||||
|
state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker,
|
||||||
|
): 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;
|
||||||
|
|
||||||
if (afterTable.length === 0) score += 500;
|
if (isScopa) score += 500;
|
||||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300;
|
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300;
|
||||||
score += allCaptured.filter(c => c.suit === 'denara').length * 50;
|
score += allCaptured.filter(c => c.suit === 'denara').length * 50;
|
||||||
score += captured.length * 20;
|
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;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreDumpBasic(card: Card): number {
|
function scoreDumpBasic(
|
||||||
|
card: Card, table: Card[], state: GameState,
|
||||||
|
playerIdx: PlayerIndex, tracker?: CardTracker,
|
||||||
|
): number {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
const afterTable = [...table, card];
|
||||||
|
|
||||||
if (card.suit !== 'denara') score += 30;
|
if (card.suit !== 'denara') score += 30;
|
||||||
if (card.suit === 'denara') score -= 40;
|
if (card.suit === 'denara') score -= 40;
|
||||||
if (card.suit === 'denara' && card.value === 7) score -= 300;
|
if (card.suit === 'denara' && card.value === 7) score -= 300;
|
||||||
if (card.value >= 8) score += 10;
|
if (card.value >= 8) score += 10;
|
||||||
if (card.value === 7) score -= 50;
|
if (card.value === 7) score -= 50;
|
||||||
if (card.value === 1) score -= 30;
|
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;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
function primieraValue(card: Card): number {
|
|
||||||
const vals: Record<number, number> = { 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 {
|
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 myTeam = teamOf(playerIdx);
|
||||||
const allyIdx = ((playerIdx + 2) % 4) as PlayerIndex;
|
const phase = gamePhase(state);
|
||||||
const allyIsNext = state.currentPlayer === playerIdx &&
|
|
||||||
((playerIdx + 1) % 4) === allyIdx;
|
// 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 bestMove: AIMove | null = null;
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
@@ -126,14 +233,20 @@ function advancedMove(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 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) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMove = { card, capture: captureSet };
|
bestMove = { card, capture: captureSet };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
bestMove = { card, capture: [] };
|
bestMove = { card, capture: [] };
|
||||||
@@ -144,155 +257,235 @@ function advancedMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTr
|
|||||||
return bestMove!;
|
return bestMove!;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreCaptureAdvanced(
|
function scoreCaptureAdv(
|
||||||
played: Card, captured: Card[], table: Card[], state: GameState,
|
played: Card, captured: Card[], table: Card[], state: GameState,
|
||||||
myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[],
|
playerIdx: PlayerIndex, myTeam: 0 | 1, tracker: CardTracker | undefined,
|
||||||
allyIsNext: boolean,
|
myHand: Card[], phase: number, nextIsOpp: boolean, partnerHandSize: number,
|
||||||
): 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;
|
||||||
|
|
||||||
// Scopa bonus
|
// --- Core scoring ---
|
||||||
if (isScopa) score += 600;
|
|
||||||
|
|
||||||
// Settebello — highest priority
|
// Scopa (huge bonus, scales with game phase)
|
||||||
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 400;
|
if (isScopa) score += 700 + phase * 200;
|
||||||
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
|
|
||||||
captured.some(c => c.suit === 'denara' && c.value === 7)) score += 250;
|
|
||||||
|
|
||||||
// Denari cards
|
// Settebello: absolute must-capture
|
||||||
score += allCaptured.filter(c => c.suit === 'denara').length * 60;
|
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)
|
// Denari
|
||||||
score += captured.length * 25;
|
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) {
|
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
|
// --- Anti-scopa defense ---
|
||||||
if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) score -= 200;
|
|
||||||
|
|
||||||
// Anti-scopa: prefer leaving table total ≥ 11
|
|
||||||
if (!isScopa) {
|
if (!isScopa) {
|
||||||
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;
|
// 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)
|
// --- Cooperation with partner ---
|
||||||
if (tracker) {
|
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);
|
const unseen = tracker.getUnseenCards(myHand, afterTable);
|
||||||
// Check if any unseen card could scopa the remaining table
|
|
||||||
for (const uc of unseen) {
|
// Check settebello still in play — protect it
|
||||||
const caps = findCaptures(uc, afterTable);
|
if (tracker.isSettebelloUnseen() && !capturesSettebello) {
|
||||||
for (const cap of caps) {
|
// Don't leave table where opponent could capture settebello + table
|
||||||
if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) {
|
if (afterTable.some(c => c.suit === 'denara' && c.value === 7)) {
|
||||||
score -= 200; // potential opponent scopa
|
score -= 250;
|
||||||
break;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreDumpAdvanced(
|
function scoreDumpAdv(
|
||||||
card: Card, table: Card[], state: GameState,
|
card: Card, table: Card[], state: GameState,
|
||||||
myTeam: 0 | 1, tracker: CardTracker | undefined, myHand: Card[],
|
playerIdx: PlayerIndex, myTeam: 0 | 1, tracker: CardTracker | undefined,
|
||||||
allyIsNext: boolean,
|
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);
|
||||||
|
|
||||||
// Never dump settebello
|
// --- Card value protection ---
|
||||||
if (card.suit === 'denara' && card.value === 7) score -= 500;
|
|
||||||
|
// NEVER dump settebello
|
||||||
|
if (card.suit === 'denara' && card.value === 7) return -10000;
|
||||||
|
|
||||||
// Avoid dumping denari
|
// Avoid dumping denari
|
||||||
if (card.suit === 'denara') score -= 50;
|
if (card.suit === 'denara') score -= 80;
|
||||||
if (card.suit !== 'denara') score += 30;
|
|
||||||
|
|
||||||
// Avoid dumping 7s (primiera)
|
// Avoid dumping 7s (primiera), especially in endgame
|
||||||
if (card.value === 7) score -= 60;
|
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;
|
if (card.value === 1) score -= 35;
|
||||||
|
|
||||||
// Prefer dumping face cards
|
// Prefer dumping face cards (8, 9, 10) — low primiera value
|
||||||
if (card.value >= 8) score += 15;
|
if (card.value >= 8) score += 20 + card.value * 2;
|
||||||
|
|
||||||
// Anti-scopa: prefer leaving table total ≥ 11
|
// --- Anti-scopa defense (CRITICAL) ---
|
||||||
const tableSum = afterTable.reduce((s, c) => s + c.value, 0);
|
|
||||||
if (tableSum >= 11) score += 50;
|
|
||||||
if (tableSum < 5) score -= 40;
|
|
||||||
|
|
||||||
// Card tracking: check if unseen cards could scopa after this dump
|
if (tableSum >= 11) {
|
||||||
if (tracker) {
|
score += 100; // excellent — no single card can scopa
|
||||||
const unseen = tracker.getUnseenCards(myHand, afterTable);
|
} else if (tableSum <= 10) {
|
||||||
let scopaThreat = 0;
|
const threats = countScopaThreats(afterTable, myHand, tracker, state, playerIdx);
|
||||||
for (const uc of unseen) {
|
if (nextIsOpp && threats.nextOpponentCanScopa) {
|
||||||
const caps = findCaptures(uc, afterTable);
|
score -= 500; // DANGER — next opponent can likely scopa
|
||||||
for (const cap of caps) {
|
|
||||||
if (afterTable.filter(c => !cap.some(cc => cc.id === c.id)).length === 0) {
|
|
||||||
scopaThreat++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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
|
// Prefer making tableSum awkward (e.g., large primes: 11, 13, etc.)
|
||||||
if (allyIsNext) {
|
if (tableSum >= 11) score += 30;
|
||||||
score += 10;
|
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;
|
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 {
|
function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTracker): AIMove {
|
||||||
const player = state.players[playerIdx];
|
|
||||||
const table = state.table;
|
|
||||||
const myTeam = teamOf(playerIdx);
|
const myTeam = teamOf(playerIdx);
|
||||||
const NUM_SAMPLES = 12;
|
const phase = gamePhase(state);
|
||||||
const MAX_DEPTH = 4; // 4 plies = one full rotation
|
|
||||||
|
// 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);
|
const legalMoves = getLegalMoves(state, playerIdx);
|
||||||
if (legalMoves.length === 1) return legalMoves[0];
|
if (legalMoves.length === 1) return legalMoves[0];
|
||||||
|
|
||||||
// Score accumulator for each move
|
// Sort moves by quick heuristic for better pruning
|
||||||
const moveScores = new Map<string, number>();
|
const quickScored = legalMoves.map(m => ({
|
||||||
for (const m of legalMoves) {
|
move: m,
|
||||||
moveScores.set(moveKey(m), 0);
|
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<string, number>();
|
||||||
|
for (const m of sortedMoves) moveScores.set(moveKey(m), 0);
|
||||||
|
|
||||||
// Determinization: sample possible opponent hand assignments
|
|
||||||
const samples = generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
|
const samples = generateSamples(state, playerIdx, tracker, NUM_SAMPLES);
|
||||||
|
|
||||||
for (const sample of 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 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);
|
moveScores.set(moveKey(move), (moveScores.get(moveKey(move)) ?? 0) + score);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick move with highest total score across samples
|
let bestMove = sortedMoves[0];
|
||||||
let bestMove = legalMoves[0];
|
|
||||||
let bestScore = -Infinity;
|
let bestScore = -Infinity;
|
||||||
for (const move of legalMoves) {
|
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;
|
bestScore = totalScore;
|
||||||
@@ -303,6 +496,48 @@ function masterMove(state: GameState, playerIdx: PlayerIndex, tracker?: CardTrac
|
|||||||
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 {
|
||||||
|
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 {
|
function moveKey(move: AIMove): string {
|
||||||
const capIds = move.capture.map(c => c.id).sort().join(',');
|
const capIds = move.capture.map(c => c.id).sort().join(',');
|
||||||
return `${move.card.id}|${capIds}`;
|
return `${move.card.id}|${capIds}`;
|
||||||
@@ -330,9 +565,9 @@ 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[] = [];
|
||||||
|
|
||||||
// Cards we know about: our hand + table + tracked
|
|
||||||
const unseen = tracker
|
const unseen = tracker
|
||||||
? tracker.getUnseenCards(myHand, state.table)
|
? tracker.getUnseenCards(myHand, state.table)
|
||||||
: getUnseenWithoutTracker(state, playerIdx);
|
: getUnseenWithoutTracker(state, playerIdx);
|
||||||
@@ -341,7 +576,6 @@ function generateSamples(
|
|||||||
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]);
|
||||||
|
|
||||||
// Distribute unseen cards among other players proportionally to their hand sizes
|
|
||||||
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;
|
||||||
@@ -357,11 +591,9 @@ function generateSamples(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] {
|
function getUnseenWithoutTracker(state: GameState, playerIdx: PlayerIndex): Card[] {
|
||||||
// Without tracker, we only know our own hand and the table
|
|
||||||
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);
|
||||||
// Also know cards in all players' piles (they've been captured visibly)
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -385,22 +617,38 @@ 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,
|
maximizing: boolean, myTeam: 0 | 1, rootPlayer: PlayerIndex,
|
||||||
|
phase: number, tracker?: CardTracker,
|
||||||
): number {
|
): number {
|
||||||
if (depth === 0 || state.roundOver) {
|
if (depth === 0 || state.roundOver) {
|
||||||
return evaluate(state, myTeam);
|
return evaluate(state, myTeam, phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cur = state.currentPlayer;
|
const cur = state.currentPlayer;
|
||||||
const isMyTeam = teamOf(cur) === myTeam;
|
const isMyTeam = teamOf(cur) === myTeam;
|
||||||
const moves = getLegalMoves(state, cur);
|
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) {
|
if (isMyTeam) {
|
||||||
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);
|
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, 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;
|
||||||
@@ -410,7 +658,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);
|
const child = alphaBeta(result.nextState, depth - 1, alpha, beta, !isMyTeam, 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;
|
||||||
@@ -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 oppTeam = myTeam === 0 ? 1 : 0;
|
||||||
const myPlayers = myTeam === 0 ? [state.players[0], state.players[2]] : [state.players[1], state.players[3]];
|
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]];
|
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;
|
let score = 0;
|
||||||
|
|
||||||
// Cards (20 = majority)
|
// --- Cards majority (1 point) ---
|
||||||
score += (myPile.length - oppPile.length) * 10;
|
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 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) * 30;
|
score += (myDenari - oppDenari) * 40;
|
||||||
|
|
||||||
// Settebello
|
// --- Settebello (1 point) ---
|
||||||
if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 200;
|
if (myPile.some(c => c.suit === 'denara' && c.value === 7)) score += 250;
|
||||||
if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 200;
|
if (oppPile.some(c => c.suit === 'denara' && c.value === 7)) score -= 250;
|
||||||
|
|
||||||
// Primiera
|
// --- 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) * 2;
|
score += (myPrim - oppPrim) * 3;
|
||||||
} else if (myPrim > 0) {
|
} else if (myPrim > 0) {
|
||||||
score += 100;
|
score += 120;
|
||||||
} else if (oppPrim > 0) {
|
} 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 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) * 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;
|
return score;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user