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