import { Card } from '@core/cards/Card'; import { GameState, Phase } from '@core/rules/GameState'; import { attackSeats, currentActor, legalActions, maxTableSlots, unbeatenSlots } from '@core/rules/DurakRules'; /** * Everything that must be true of any reachable state. Called after every single * action in the soak test, so a rule bug is caught at the move that caused it * rather than a hundred moves later. * * Throws with a description of the first violation. */ export function assertInvariants(state: GameState): void { const problems: string[] = []; // Cards are conserved: all 36, each exactly once, somewhere. const seen: Card[] = [ ...state.deck, ...state.discard, ...state.players.flatMap((p) => p.hand), ]; for (const slot of state.table) { seen.push(slot.attack); if (slot.defence) seen.push(slot.defence); } if (seen.length !== 36) { problems.push(`${seen.length} cards in play, expected 36`); } if (new Set(seen).size !== seen.length) { problems.push('the same card exists in two places'); } // The trump card is the bottom of the deck until it is drawn. if (state.deck.length > 0 && state.deck[state.deck.length - 1] !== state.trumpCard) { problems.push('the trump card is not at the bottom of the deck'); } if (state.trumpSuit !== state.trumpCard.suit) { problems.push('the trump suit does not match the trump card'); } // Table shape. if (state.table.length > maxTableSlots(state)) { problems.push(`${state.table.length} attacking cards exceeds the limit`); } for (const slot of state.table) { if (slot.defence && slot.defence === slot.attack) { problems.push('a card beat itself'); } } // Seats. if (state.attacker === state.defender) { problems.push('the attacker is also the defender'); } if (state.phase !== Phase.Finished) { if (state.players[state.attacker].out) problems.push('the attacker has left the game'); if (state.players[state.defender].out) problems.push('the defender has left the game'); } // A player who is out holds nothing; a player who holds nothing while the // deck still has cards should have refilled. for (const player of state.players) { if (player.out && player.hand.length > 0) { problems.push(`${player.id} is out but still holds cards`); } if (!player.out && player.hand.length === 0 && state.phase === Phase.Attack) { problems.push(`${player.id} is at the table with an empty hand`); } } // Phase consistency. switch (state.phase) { case Phase.Attack: if (state.table.length !== 0) problems.push('a turn opened with cards already down'); if (state.defenderTook) problems.push('a turn opened mid-take'); break; case Phase.Defence: if (unbeatenSlots(state).length === 0) problems.push('defending with nothing to beat'); if (state.defenderTook) problems.push('defending after declaring a take'); break; case Phase.Reinforce: if (!state.defenderTook && unbeatenSlots(state).length > 0) { problems.push('adding cards while an attack is unanswered'); } if (attackSeats(state).every((s) => state.passed[s])) { problems.push('every attacker has passed but the turn is still open'); } break; case Phase.Finished: if (state.players.filter((p) => !p.out).length > 1) { problems.push('the game ended with more than one player left'); } break; } // Whoever is on move must have something to do. const actor = currentActor(state); if (state.phase !== Phase.Finished) { if (actor === null) { problems.push('nobody is on move in an unfinished game'); } else if (legalActions(state, actor).length === 0) { problems.push(`${actor} is on move with no legal action`); } } if (problems.length > 0) { throw new Error(`Broken state: ${problems.join('; ')}`); } }