Files
scopone/src/game/ai-strategy.ts
Giancarmine Salucci 3f74c57665
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
- Replace minimax with PIMC (Perfect Information Monte Carlo) search
- Add PIMC_SCOPE_BOOST=150 → effective scopa value 540 (was 390)
  → Master win rate: 67.5% → 72.5% vs legacy AI (target ≥60%)
  → Advanced win rate: 97.5% vs beginner AI (target ≥55%)
  → Scope gap in losses: 6.54 → 3.00 scopa/match
- Add card inference engine for probabilistic hand tracking
- Add ai-strategy, ai-legacy evaluation bridge
- Add .gitea/workflows/android-build.yml: build debug + unsigned
  release APK and publish to Gitea generic package registry
2026-05-24 16:29:04 +02:00

342 lines
11 KiB
TypeScript

// ---------------------------------------------------------------------------
// AI Strategy — table parity, spariglio, mulinello, category states, endgame
// ---------------------------------------------------------------------------
import { Card, GameState, PlayerIndex, Suit, SUITS, PRIMIERA_VALUES } from './types';
import { AIMove } from './types';
import { teamOf } from './engine';
import { CardInferenceEngine } from './card-inference';
// ---------------------------------------------------------------------------
// Exported interfaces
// ---------------------------------------------------------------------------
export interface ParityState {
pairedRanks: number[];
unpairedRanks: number[];
spariglioDegree: number;
isEvenParity: boolean;
}
export interface SpariglioPotential {
card: Card;
spariglioDelta: number; // how spariglioDegree changes if this card is dumped
isSpariglio3Card: boolean; // true if this is a 3-card spariglio (highest priority)
}
export interface MulinelloState {
active: boolean;
favorableFor: 'us' | 'them' | null;
breakingMoves: AIMove[];
}
export interface CategoryEntry {
state: 'secured' | 'lost' | 'contested';
closeness: number; // 0-1, higher = closer to winning
}
export interface PrimieraCategoryEntry {
perSuit: Record<Suit, CategoryEntry>;
overallCloseness: number;
}
export interface CategoryStates {
denari: CategoryEntry;
carte: CategoryEntry;
primiera: PrimieraCategoryEntry;
scope: 'always_contested';
settebello: 'always_contested';
}
export interface PrimieraRaceState {
teamLeadsBySuit: Record<Suit, boolean | null>; // true=we lead, false=they lead, null=tied/unknown
contestedSuits: Suit[];
unseenPrimieraCards: Card[]; // unseen 7s, 6s, 1s (in primiera value order)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function canCardCapture(card: Card, table: Card[]): boolean {
// Direct match
if (table.some(c => c.value === card.value)) return true;
// Sum capture (subsets of 2+)
if (table.length < 2) return false;
for (let mask = 1; mask < (1 << table.length); mask++) {
const subset = table.filter((_, i) => mask & (1 << i));
if (subset.length >= 2 && subset.reduce((s, c) => s + c.value, 0) === card.value) return true;
}
return false;
}
function bestPrimieraValue(pile: Card[], suit: Suit): number {
const cards = pile.filter(c => c.suit === suit);
if (cards.length === 0) return 0;
return Math.max(...cards.map(c => PRIMIERA_VALUES[c.value] ?? 10));
}
function buildPrimieraCategoryEntry(
state: GameState,
team: 0 | 1,
myPile: Card[],
oppPile: Card[],
): PrimieraCategoryEntry {
const perSuit: Record<Suit, CategoryEntry> = {} as Record<Suit, CategoryEntry>;
let totalCloseness = 0;
for (const suit of SUITS) {
const myBest = bestPrimieraValue(myPile, suit);
const oppBest = bestPrimieraValue(oppPile, suit);
let entry: CategoryEntry;
if (myBest > oppBest && myBest >= PRIMIERA_VALUES[6]) {
// We have 7 or 6 in this suit and it's the best
entry = { state: 'secured', closeness: 1 };
} else if (oppBest > myBest && oppBest >= PRIMIERA_VALUES[6]) {
entry = { state: 'lost', closeness: 0 };
} else {
// Contested: closer to 1 if we have a better card
const closeness = Math.max(0, Math.min(1, (myBest - oppBest + 10) / 20));
entry = { state: 'contested', closeness };
}
perSuit[suit] = entry;
totalCloseness += entry.closeness;
}
return {
perSuit,
overallCloseness: totalCloseness / 4,
};
}
// ---------------------------------------------------------------------------
// Exported functions
// ---------------------------------------------------------------------------
export function analyzeTableParity(table: Card[]): ParityState {
const countByValue = new Map<number, number>();
for (const card of table) {
countByValue.set(card.value, (countByValue.get(card.value) ?? 0) + 1);
}
const pairedRanks: number[] = [];
const unpairedRanks: number[] = [];
for (const [value, count] of countByValue) {
if (count % 2 === 0) pairedRanks.push(value);
else unpairedRanks.push(value);
}
return {
pairedRanks,
unpairedRanks,
spariglioDegree: unpairedRanks.length,
isEvenParity: unpairedRanks.length === 0,
};
}
export function rankDumpsBySpariglio(
hand: Card[],
table: Card[],
isNonDealerTeam: boolean,
): SpariglioPotential[] {
const current = analyzeTableParity(table);
const potentials: SpariglioPotential[] = [];
for (const card of hand) {
if (canCardCapture(card, table)) continue; // only dump moves
const tableAfter = [...table, card];
const after = analyzeTableParity(tableAfter);
const spariglioDelta = after.spariglioDegree - current.spariglioDegree;
potentials.push({
card,
spariglioDelta,
isSpariglio3Card: spariglioDelta >= 2,
});
}
// Non-dealer wants highest positive delta first; dealer wants lowest (most negative) first
if (isNonDealerTeam) {
potentials.sort((a, b) => b.spariglioDelta - a.spariglioDelta);
} else {
potentials.sort((a, b) => a.spariglioDelta - b.spariglioDelta);
}
return potentials;
}
export function detectMulinello(
state: GameState,
playerIdx: PlayerIndex,
inference: CardInferenceEngine,
): MulinelloState {
const table = state.table;
if (table.length === 0 || table.length > 4) {
return { active: false, favorableFor: null, breakingMoves: [] };
}
const myHand = state.players[playerIdx].hand;
const myCaptures = myHand.filter(c => canCardCapture(c, table));
if (myCaptures.length === 0 && table.length <= 2) {
// We can't capture — potential mulinello against us
return {
active: true,
favorableFor: 'them',
breakingMoves: myHand.map(c => ({ card: c, capture: [] })),
};
}
return { active: false, favorableFor: null, breakingMoves: [] };
}
export function getPhase(state: GameState): 'opening' | 'midgame' | 'endgame' {
const totalCardsInHands = state.players.reduce((sum, p) => sum + p.hand.length, 0);
const totalPlayed = 40 - totalCardsInHands - state.table.length;
if (totalPlayed <= 12) return 'opening';
if (totalCardsInHands <= 16) return 'endgame';
return 'midgame';
}
export function getCategoryStates(state: GameState, team: 0 | 1): CategoryStates {
const myPile: Card[] = [];
const oppPile: Card[] = [];
for (let i = 0; i < 4; i++) {
const p = state.players[i];
if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile);
else oppPile.push(...p.pile);
}
const myCoins = myPile.filter(c => c.suit === 'denara').length;
const oppCoins = oppPile.filter(c => c.suit === 'denara').length;
const totalCards = myPile.length + oppPile.length + state.table.length;
const unseenCoins = 10 - myCoins - oppCoins - state.table.filter(c => c.suit === 'denara').length;
const unseenCards = 40 - totalCards;
// Denari
const denari: CategoryEntry = myCoins >= 6
? { state: 'secured', closeness: 1 }
: oppCoins >= 6
? { state: 'lost', closeness: 0 }
: {
state: 'contested',
closeness: Math.max(0, Math.min(1, (myCoins - oppCoins + 1) / Math.max(1, unseenCoins + 1))),
};
// Carte
const myCards = myPile.length;
const oppCards = oppPile.length;
const carte: CategoryEntry = myCards >= 21
? { state: 'secured', closeness: 1 }
: oppCards >= 21
? { state: 'lost', closeness: 0 }
: {
state: 'contested',
closeness: Math.max(0, Math.min(1, (myCards - oppCards + 1) / Math.max(1, unseenCards + 1))),
};
// Primiera: per suit
const primiera = buildPrimieraCategoryEntry(state, team, myPile, oppPile);
return {
denari,
carte,
primiera,
scope: 'always_contested',
settebello: 'always_contested',
};
}
export function solveEndgame(
state: GameState,
playerIdx: PlayerIndex,
inference: CardInferenceEngine,
legalMoves: AIMove[],
): AIMove | null {
// Only attempt when very few cards remain
const totalRemaining = state.players.reduce((sum, p) => sum + p.hand.length, 0);
if (totalRemaining > 8) return null;
const myHand = state.players[playerIdx].hand;
for (let i = 0; i < 4; i++) {
if (i === playerIdx) continue;
const p = state.players[i];
if (p.hand.length === 0) continue;
const constrained = inference.getConstrainedUnseen(i as PlayerIndex, myHand, state.table);
if (constrained.length < p.hand.length) {
// Can't fully determine this player's hand
return null;
}
// If constrained pool equals exactly handSize, we know exactly what they have
if (constrained.length > p.hand.length) return null; // still ambiguous
}
// We know all hands — pick the best move by greedy evaluation
let bestMove: AIMove | null = null;
let bestScore = -Infinity;
for (const move of legalMoves) {
let score = 0;
// Scopa
const tableAfter = state.table.filter(c => !move.capture.some(cap => cap.id === c.id));
if (move.capture.length > 0 && tableAfter.length === 0) score += 100;
// Settebello
if (move.capture.some(c => c.id === 'denara_7')) score += 80;
// 7s
score += move.capture.filter(c => c.value === 7).length * 40;
// Coins
score += move.capture.filter(c => c.suit === 'denara').length * 15;
// Cards
score += move.capture.length * 3;
if (score > bestScore) {
bestScore = score;
bestMove = move;
}
}
return bestMove;
}
export function analyzePrimieraRace(state: GameState, team: 0 | 1): PrimieraRaceState {
const myPile: Card[] = [];
const oppPile: Card[] = [];
for (let i = 0; i < 4; i++) {
const p = state.players[i];
if (teamOf(i as PlayerIndex) === team) myPile.push(...p.pile);
else oppPile.push(...p.pile);
}
const allKnown = new Set([...myPile, ...oppPile, ...state.table].map(c => c.id));
const unseenPrimieraCards: Card[] = [];
const PRIMIERA_ORDER = [7, 6, 1, 5, 4, 3, 2, 8, 9, 10];
for (const value of PRIMIERA_ORDER) {
for (const suit of SUITS) {
const id = `${suit}_${value}`;
if (!allKnown.has(id) && (value === 7 || value === 6 || value === 1)) {
unseenPrimieraCards.push({ suit, value, id });
}
}
}
const teamLeadsBySuit: Record<Suit, boolean | null> = {} as Record<Suit, boolean | null>;
const contestedSuits: Suit[] = [];
for (const suit of SUITS) {
const myBest = bestPrimieraValue(myPile, suit);
const oppBest = bestPrimieraValue(oppPile, suit);
if (myBest > oppBest) teamLeadsBySuit[suit] = true;
else if (oppBest > myBest) teamLeadsBySuit[suit] = false;
else {
teamLeadsBySuit[suit] = null;
contestedSuits.push(suit);
}
}
return { teamLeadsBySuit, contestedSuits, unseenPrimieraCards };
}