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(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(obj: T): T { return JSON.parse(JSON.stringify(obj)); } export function teamOf(playerIdx: PlayerIndex): 0 | 1 { return (playerIdx === 0 || playerIdx === 2) ? 0 : 1; }