Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
- 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
342 lines
11 KiB
TypeScript
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 };
|
|
}
|