feat(SCOPONE-0013): PIMC AI rewrite + Gitea Android CI pipeline
Some checks failed
Android Build & Publish / android (push) Failing after 2m10s
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
This commit is contained in:
341
src/game/ai-strategy.ts
Normal file
341
src/game/ai-strategy.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user