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;
}

307
src/game/engine.ts Normal file
View File

@@ -0,0 +1,307 @@
import {
Card, Suit, SUITS, Player, PlayerIndex, GameState,
TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture
} from './types';
// ---------------------------------------------------------------------------
// Deck
// ---------------------------------------------------------------------------
export function buildDeck(): Card[] {
const deck: Card[] = [];
for (const suit of SUITS) {
for (let v = 1; v <= 10; v++) {
deck.push({ suit, value: v, id: `${suit}_${v}` });
}
}
return deck;
}
export function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// ---------------------------------------------------------------------------
// Capture logic
// ---------------------------------------------------------------------------
/**
* Find all valid capture combinations for a played card against the table.
* Rules:
* - If the table contains a card of the same value, you MUST take it (and only it)
* unless the table has multiple cards of that value (take exactly one)
* - If no direct match exists, you may take any subset of table cards that sums to the played value
* Returns array of capture sets (each is a list of cards taken from table).
*/
export function findCaptures(played: Card, table: Card[]): Card[][] {
const results: Card[][] = [];
// Check for direct value matches
const directMatches = table.filter(c => c.value === played.value);
if (directMatches.length > 0) {
// Must capture exactly one matching card (Italian rules: take one direct match)
// Actually: if there's one direct match take it; if multiple, still take all that match
// Standard Italian rule: take ALL cards of matching value
results.push([...directMatches]);
return results; // direct match takes priority, no sum captures allowed
}
// No direct matches — find subsets that sum to played.value
const subsets = getSubsets(table);
for (const subset of subsets) {
if (subset.length >= 2) {
const sum = subset.reduce((acc, c) => acc + c.value, 0);
if (sum === played.value) {
results.push(subset);
}
}
}
return results;
}
function getSubsets(cards: Card[]): Card[][] {
const result: Card[][] = [[]];
for (const card of cards) {
const newSubsets = result.map(s => [...s, card]);
result.push(...newSubsets);
}
return result;
}
export function canCapture(played: Card, table: Card[]): boolean {
return findCaptures(played, table).length > 0;
}
// ---------------------------------------------------------------------------
// Game state initialisation
// ---------------------------------------------------------------------------
export function createInitialState(): GameState {
const deck = shuffle(buildDeck());
const players: [Player, Player, Player, Player] = [
{ index: 0, hand: [], pile: [], scope: 0, isHuman: true, name: 'Tu' },
{ index: 1, hand: [], pile: [], scope: 0, isHuman: false, name: 'AI Ovest' },
{ index: 2, hand: [], pile: [], scope: 0, isHuman: false, name: 'Compagno' },
{ index: 3, hand: [], pile: [], scope: 0, isHuman: false, name: 'AI Est' },
];
// Deal 10 cards each — Scopone Scientifico deals all at once
for (let i = 0; i < 4; i++) {
players[i].hand = deck.splice(0, 10);
}
// No initial table cards in Scopone Scientifico
const table: Card[] = [];
const emptyTeamScore = (): TeamScore => ({
cards: 0, scope: 0, denari: 0, settebello: false, primiera: 0, roundPoints: 0, totalPoints: 0,
});
return {
players,
table,
currentPlayer: 0,
roundOver: false,
gameOver: false,
teamScores: [emptyTeamScore(), emptyTeamScore()],
lastCapturTeam: null,
roundNumber: 1,
};
}
// ---------------------------------------------------------------------------
// Play a card
// ---------------------------------------------------------------------------
/**
* Apply a move to the game state (immutably).
* If captureChoice is provided, use that capture set; otherwise use the first valid capture.
* Returns the new state and what was captured (null if nothing).
*/
export function applyMove(
state: GameState,
playerIdx: PlayerIndex,
card: Card,
captureChoice?: Card[]
): { nextState: GameState; capture: Capture | null; isScopa: boolean } {
const state2 = deepClone(state);
const player = state2.players[playerIdx];
// Remove card from hand
player.hand = player.hand.filter(c => c.id !== card.id);
const captures = findCaptures(card, state2.table);
let capturedCards: Card[] = [];
let isScopa = false;
if (captures.length > 0) {
const chosen = captureChoice ?? captures[0];
capturedCards = chosen;
// Remove captured cards from table
const capturedIds = new Set(chosen.map(c => c.id));
state2.table = state2.table.filter(c => !capturedIds.has(c.id));
// Add played card + captured to player's pile
player.pile.push(card, ...capturedCards);
// Scopa: cleared the table
if (state2.table.length === 0) {
player.scope += 1;
isScopa = true;
}
// Track which team made last capture
state2.lastCapturTeam = (playerIdx === 0 || playerIdx === 2) ? 0 : 1;
} else {
// No capture — add to table
state2.table.push(card);
}
// Advance turn
state2.currentPlayer = ((playerIdx + 1) % 4) as PlayerIndex;
// Check if round is over (all hands empty)
const allHandsEmpty = state2.players.every(p => p.hand.length === 0);
if (allHandsEmpty) {
// Remaining table cards go to last capturing team
if (state2.table.length > 0 && state2.lastCapturTeam !== null) {
const lastTeamPlayers = state2.lastCapturTeam === 0 ? [0, 2] : [1, 3];
// Give to first player of that team
state2.players[lastTeamPlayers[0]].pile.push(...state2.table);
state2.table = [];
}
state2.roundOver = true;
calculateScores(state2);
}
return {
nextState: state2,
capture: capturedCards.length > 0 ? { played: card, captured: capturedCards } : null,
isScopa,
};
}
// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------
function calculateScores(state: GameState): void {
const team0 = [state.players[0], state.players[2]];
const team1 = [state.players[1], state.players[3]];
const breakdown: ScoreBreakdown = scoreRound(team0, team1);
const t0 = state.teamScores[0];
const t1 = state.teamScores[1];
// Cards
t0.cards = team0.reduce((s, p) => s + p.pile.length, 0);
t1.cards = team1.reduce((s, p) => s + p.pile.length, 0);
// Denari
const denari0 = team0.flatMap(p => p.pile).filter(c => c.suit === 'denara');
const denari1 = team1.flatMap(p => p.pile).filter(c => c.suit === 'denara');
t0.denari = denari0.length;
t1.denari = denari1.length;
// Settebello
t0.settebello = team0.flatMap(p => p.pile).some(c => c.suit === 'denara' && c.value === 7);
t1.settebello = !t0.settebello;
// Scope
t0.scope = team0.reduce((s, p) => s + p.scope, 0);
t1.scope = team1.reduce((s, p) => s + p.scope, 0);
// Primiera
t0.primiera = calcPrimiera(team0.flatMap(p => p.pile));
t1.primiera = calcPrimiera(team1.flatMap(p => p.pile));
// Points this round
let p0 = 0;
let p1 = 0;
if (breakdown.cartePoint === 0) p0++;
else if (breakdown.cartePoint === 1) p1++;
if (breakdown.denariPoint === 0) p0++;
else if (breakdown.denariPoint === 1) p1++;
if (breakdown.settebelloPoint === 0) p0++;
else p1++;
if (breakdown.primieraPoint === 0) p0++;
else if (breakdown.primieraPoint === 1) p1++;
p0 += breakdown.scopeTeam0;
p1 += breakdown.scopeTeam1;
t0.roundPoints = p0;
t1.roundPoints = p1;
t0.totalPoints += p0;
t1.totalPoints += p1;
}
function scoreRound(team0: Player[], team1: Player[]): ScoreBreakdown {
const pile0 = team0.flatMap(p => p.pile);
const pile1 = team1.flatMap(p => p.pile);
const cards0 = pile0.length;
const cards1 = pile1.length;
const denari0 = pile0.filter(c => c.suit === 'denara').length;
const denari1 = pile1.filter(c => c.suit === 'denara').length;
const hasSette0 = pile0.some(c => c.suit === 'denara' && c.value === 7);
const prim0 = calcPrimiera(pile0);
const prim1 = calcPrimiera(pile1);
const scope0 = team0.reduce((s, p) => s + p.scope, 0);
const scope1 = team1.reduce((s, p) => s + p.scope, 0);
return {
cartePoint: cards0 > cards1 ? 0 : cards1 > cards0 ? 1 : null,
denariPoint: denari0 > denari1 ? 0 : denari1 > denari0 ? 1 : null,
settebelloPoint: hasSette0 ? 0 : 1,
primieraPoint: prim0 > prim1 ? 0 : prim1 > prim0 ? 1 : null,
scopeTeam0: scope0,
scopeTeam1: scope1,
};
}
export function calcPrimiera(pile: Card[]): number {
// Best card per suit, using primiera values
let total = 0;
for (const suit of SUITS) {
const cards = pile.filter(c => c.suit === suit);
if (cards.length === 0) return 0; // can't score primiera without all 4 suits
const best = Math.max(...cards.map(c => PRIMIERA_VALUES[c.value]));
total += best;
}
return total;
}
export function getScoreBreakdown(state: GameState): ScoreBreakdown {
const team0 = [state.players[0], state.players[2]];
const team1 = [state.players[1], state.players[3]];
return scoreRound(team0, team1);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
export function teamOf(playerIdx: PlayerIndex): 0 | 1 {
return (playerIdx === 0 || playerIdx === 2) ? 0 : 1;
}

69
src/game/types.ts Normal file
View File

@@ -0,0 +1,69 @@
export type Suit = 'bastoni' | 'coppe' | 'denara' | 'spade';
export const SUITS: Suit[] = ['bastoni', 'coppe', 'denara', 'spade'];
export interface Card {
suit: Suit;
value: number; // 1-10
id: string; // e.g. "denara_7"
}
export interface Capture {
played: Card;
captured: Card[];
}
export type PlayerIndex = 0 | 1 | 2 | 3;
export interface Player {
index: PlayerIndex;
hand: Card[];
pile: Card[]; // captured cards
scope: number; // number of scope achieved
isHuman: boolean;
name: string;
}
export interface GameState {
players: [Player, Player, Player, Player];
table: Card[];
currentPlayer: PlayerIndex;
roundOver: boolean;
gameOver: boolean;
teamScores: [TeamScore, TeamScore]; // Team 0: players 0+2, Team 1: players 1+3
lastCapturTeam: 0 | 1 | null; // which team made the last capture (gets remaining table cards)
roundNumber: number;
}
export interface TeamScore {
cards: number;
scope: number;
denari: number;
settebello: boolean;
primiera: number;
// final points for this round
roundPoints: number;
totalPoints: number;
}
export interface ScoreBreakdown {
cartePoint: 0 | 1 | null; // null = tie
denariPoint: 0 | 1 | null;
settebelloPoint: 0 | 1;
primieraPoint: 0 | 1 | null;
scopeTeam0: number;
scopeTeam1: number;
}
// Primiera values per card value
export const PRIMIERA_VALUES: Record<number, number> = {
7: 21,
6: 18,
1: 16,
5: 15,
4: 14,
3: 13,
2: 12,
8: 10,
9: 10,
10: 10,
};