chore: initial commit
This commit is contained in:
163
src/game/ai.ts
Normal file
163
src/game/ai.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user