chore: initial commit

This commit is contained in:
Giancarmine Salucci
2026-03-31 18:38:34 +02:00
commit 3d1f3e5eb4
79 changed files with 6659 additions and 0 deletions

163
src/game/ai.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Card, GameState, PlayerIndex } from './types';
import { findCaptures, canCapture, calcPrimiera, teamOf } from './engine';
export interface AIMove {
card: Card;
capture: Card[];
}
/**
* Heuristic AI for Scopone Scientifico.
* Evaluates moves by scoring the value of captured cards.
*/
export function chooseMove(state: GameState, playerIdx: PlayerIndex): AIMove {
const player = state.players[playerIdx];
const hand = player.hand;
const table = state.table;
const myTeam = teamOf(playerIdx);
let bestMove: AIMove | null = null;
let bestScore = -Infinity;
for (const card of hand) {
const captures = findCaptures(card, table);
if (captures.length > 0) {
for (const captureSet of captures) {
const score = scoreCapture(card, captureSet, table, state, myTeam);
if (score > bestScore) {
bestScore = score;
bestMove = { card, capture: captureSet };
}
}
} else {
// No capture — score the "dump" move
const score = scoreDump(card, table, state, myTeam);
if (score > bestScore) {
bestScore = score;
bestMove = { card, capture: [] };
}
}
}
return bestMove!;
}
function scoreCapture(
played: Card,
captured: Card[],
table: Card[],
state: GameState,
myTeam: 0 | 1
): number {
let score = 100; // base for capturing anything
const allCaptured = [played, ...captured];
const afterTable = table.filter(c => !captured.some(cc => cc.id === c.id));
const isScopa = afterTable.length === 0;
// Scopa is very valuable
if (isScopa) score += 500;
// Settebello
if (allCaptured.some(c => c.suit === 'denara' && c.value === 7)) score += 300;
// Table has settebello and this capture takes it
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
captured.some(c => c.suit === 'denara' && c.value === 7)) score += 200;
// Denari cards
score += allCaptured.filter(c => c.suit === 'denara').length * 50;
// More cards = better
score += captured.length * 20;
// High-value primiera cards
score += allCaptured.reduce((s, c) => s + primieraScore(c), 0);
// Avoid leaving settebello on table for opponent
const settebelloOnTable = afterTable.some(c => c.suit === 'denara' && c.value === 7);
if (settebelloOnTable) score -= 150;
// Opponent could easily capture remaining table
score -= opponentThreatScore(afterTable, state, myTeam) * 30;
return score;
}
function scoreDump(
card: Card,
table: Card[],
state: GameState,
myTeam: 0 | 1
): number {
let score = 0;
// Don't dump cards that complete opponent captures
const afterTable = [...table, card];
// Check if opponent can make a scopa after this dump
const opponentTeam = myTeam === 0 ? 1 : 0;
const opponentPlayers = state.players.filter(p => teamOf(p.index) === opponentTeam);
for (const opp of opponentPlayers) {
for (const oppCard of opp.hand) {
const caps = findCaptures(oppCard, afterTable);
for (const cap of caps) {
const leftAfter = afterTable.filter(c => !cap.some(cc => cc.id === c.id));
if (leftAfter.length === 0) score -= 400; // would give opponent scopa
}
}
}
// Prefer to dump low-value non-denari cards
if (card.suit !== 'denara') score += 30;
if (card.suit === 'denara') score -= 40;
if (card.suit === 'denara' && card.value === 7) score -= 300; // never dump settebello
// Prefer dumping face cards (10, 9, 8) that are less useful
if (card.value >= 8) score += 10;
// Don't dump 7s (primiera value)
if (card.value === 7) score -= 50;
// Don't dump 1s (primiera value)
if (card.value === 1) score -= 30;
// Penalty for making easy captures for opponents
const capturable = state.players
.filter(p => teamOf(p.index) !== myTeam)
.flatMap(p => p.hand)
.some(oppCard => canCapture(oppCard, afterTable));
if (capturable) score -= 20;
return score;
}
function primieraScore(card: Card): number {
// Reward for capturing high-primiera cards
const vals: Record<number, number> = { 7: 8, 6: 6, 1: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
return vals[card.value] ?? 0;
}
function opponentThreatScore(
table: Card[],
state: GameState,
myTeam: 0 | 1
): number {
const opponentTeam = myTeam === 0 ? 1 : 0;
let threat = 0;
for (const player of state.players) {
if (teamOf(player.index) !== opponentTeam) continue;
for (const card of player.hand) {
const caps = findCaptures(card, table);
if (caps.length > 0) {
threat += caps[0].length;
// Extra threat if settebello is capturable
if (table.some(c => c.suit === 'denara' && c.value === 7) &&
caps.some(cap => cap.some(c => c.suit === 'denara' && c.value === 7))) {
threat += 5;
}
}
}
}
return threat;
}