chore(SCOPONE-0006): complete iteration 0 — fix scopa rule and improve AI intelligence

This commit is contained in:
Giancarmine Salucci
2026-04-01 09:44:15 +02:00
parent 185f7c36c7
commit 30897c5eb3
2 changed files with 395 additions and 137 deletions

View File

@@ -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;

View File

@@ -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