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