Files
scopone/src/game/engine.ts
2026-04-08 21:50:40 +02:00

404 lines
12 KiB
TypeScript

import {
Card, Suit, SUITS, Player, PlayerIndex, GameState,
TeamScore, ScoreBreakdown, PRIMIERA_VALUES, Capture, DealerRelativeRole
} 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[][] {
// Each direct-match card is a separate single-card capture option
const directMatches = table.filter(c => c.value === played.value);
if (directMatches.length > 0) {
return directMatches.map((directMatch): Card[] => [directMatch]);
}
const results: Card[][] = [];
// Only sum captures are legal when no direct match is available.
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 nextPlayer(playerIdx: PlayerIndex, steps = 1): PlayerIndex {
return ((playerIdx + steps) % 4) as PlayerIndex;
}
export function getOpeningPlayerForDealer(dealer: PlayerIndex): PlayerIndex {
return nextPlayer(dealer);
}
export function getDealerRelativeOrder(
dealer: PlayerIndex
): [PlayerIndex, PlayerIndex, PlayerIndex, PlayerIndex] {
const firstHand = getOpeningPlayerForDealer(dealer);
const secondHand = nextPlayer(firstHand);
const thirdHand = nextPlayer(secondHand);
return [firstHand, secondHand, thirdHand, dealer];
}
export function getDealerRelativeRole(
dealer: PlayerIndex,
playerIdx: PlayerIndex
): DealerRelativeRole {
const [firstHand, secondHand, thirdHand] = getDealerRelativeOrder(dealer);
if (playerIdx === firstHand) return 'first-hand';
if (playerIdx === secondHand) return 'second-hand';
if (playerIdx === thirdHand) return 'third-hand';
return 'dealer';
}
export function createInitialState(dealer: PlayerIndex = 3): GameState {
const deck = shuffle(buildDeck());
const startingPlayer = getOpeningPlayerForDealer(dealer);
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,
matchStartingPlayer: startingPlayer,
dealer,
currentPlayer: startingPlayer,
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 = cloneState(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 (but NOT on the last play of the round)
if (state2.table.length === 0) {
const allHandsEmptyNow = state2.players.every(p => p.hand.length === 0);
if (!allHandsEmptyNow) {
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 = nextPlayer(playerIdx);
// 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);
}
export function getMatchOutcome(teamScores: [TeamScore, TeamScore]): {
winner: 0 | 1 | null;
continueMatch: boolean;
} {
const [team0, team1] = teamScores;
const thresholdReached = team0.totalPoints >= 11 || team1.totalPoints >= 11;
if (!thresholdReached) {
return {
winner: null,
continueMatch: true,
};
}
if (team0.totalPoints === team1.totalPoints) {
return {
winner: null,
continueMatch: true,
};
}
return {
winner: team0.totalPoints > team1.totalPoints ? 0 : 1,
continueMatch: false,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function cloneCard(c: Card): Card {
return { suit: c.suit, value: c.value, id: c.id };
}
function clonePlayer(p: Player): Player {
return {
index: p.index,
hand: p.hand.map(cloneCard),
pile: p.pile.map(cloneCard),
scope: p.scope,
isHuman: p.isHuman,
name: p.name,
};
}
function cloneTeamScore(ts: TeamScore): TeamScore {
return { ...ts };
}
export function cloneState(state: GameState): GameState {
return {
players: [
clonePlayer(state.players[0]),
clonePlayer(state.players[1]),
clonePlayer(state.players[2]),
clonePlayer(state.players[3]),
],
table: state.table.map(cloneCard),
matchStartingPlayer: state.matchStartingPlayer,
dealer: state.dealer,
currentPlayer: state.currentPlayer,
roundOver: state.roundOver,
gameOver: state.gameOver,
teamScores: [cloneTeamScore(state.teamScores[0]), cloneTeamScore(state.teamScores[1])],
lastCapturTeam: state.lastCapturTeam,
roundNumber: state.roundNumber,
};
}
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;
}