Hearts, as rules and an AI

The trick-taking games already here all ask a seat to win tricks it named.
Hearts inverts that: every heart costs a point, the queen of spades costs
thirteen, and the hand is played to take none of them.

Four restrictions decide what may be played, and they lift in that order: the
two of clubs opens the round, a seat holding the led suit must follow it, no
point card may be discarded on the first trick, and hearts cannot be led until
one has been played. Each of the last three lifts when a seat holds nothing
else, which is why playableCards checks them in sequence rather than as one
condition.

Passing is simultaneous at the table but the engine hands out one seat at a
time, so currentSeat returns the lowest seat that has not chosen and the swap
runs once the last one has. viewFor strips the selections as well as the hands:
a pass a client could read in advance is not a pass.

Taking all twenty-six is the one case where a round does not sum to twenty-six,
so scoreRound works from the cards taken rather than a running total -- a count
cannot tell an unbroken moon from thirteen hearts and a queen picked up
elsewhere.

The AI plays to avoid points rather than to shoot: it sheds the queen and her
guards early, ducks under whoever is winning, and takes a trick as cheaply as
it can when it cannot get out of the way.

Not in GameCatalog yet. The engine is complete and tested, but tapping a
catalogue entry with no screen behind it would crash, so it is added with the
screen rather than before it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
This commit is contained in:
Claude
2026-08-08 05:11:00 +00:00
parent c208afc213
commit 88948eac9e
4 changed files with 898 additions and 0 deletions
@@ -0,0 +1,108 @@
package org.prolibertate.games.game.hearts
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.GameAi
/**
* A cautious Hearts player.
*
* It plays to avoid points rather than to shoot the moon: it sheds the queen of
* spades and the guards above her at the first opportunity, ducks under whoever
* is winning a trick when it can, and takes a trick as cheaply as possible when
* it cannot get out of the way. That is enough to make it an opponent worth
* beating without pretending to read the table.
*/
object HeartsAi : GameAi<HeartsState, HeartsMove> {
override fun chooseMove(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
return when (state.phase) {
HeartsPhase.PASSING -> choosePass(state, seat, legal)
else -> choosePlay(state, seat, legal)
}
}
private fun choosePass(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
val hand = state.hands[seat]
val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
// Prefer the exact bundle the heuristic asked for, but fall back rather
// than fail: the caller's list is what the host will validate against.
return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first()
}
/**
* How much a card wants to be somebody else's problem.
*
* The queen is the whole game, and the ace and king of spades are only
* dangerous because they can be forced to capture her — so a long spade
* holding, which gives room to duck, makes all three far less urgent.
*/
private fun passDanger(card: Card, hand: List<Card>): Int {
val spades = hand.count { it.suit == Suit.SPADES }
val guarded = spades >= 5
return when {
card == QUEEN_OF_SPADES -> if (guarded) 45 else 100
card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order ->
if (guarded) 30 else 90
card.suit == Suit.HEARTS -> 40 + card.rank.order
else -> card.rank.order
}
}
private fun choosePlay(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
if (cards.isEmpty()) return legal.first()
val chosen = when {
state.trick.isEmpty() -> lead(cards)
else -> follow(state, cards)
}
return PlayCard(chosen)
}
/**
* Leads low, and keeps off spades while the queen is still unaccounted for —
* leading a spade under her is how a seat ends up holding her.
*/
private fun lead(cards: List<Card>): Card {
val offSpades = cards.filter { it.suit != Suit.SPADES }
val pool = if (offSpades.isNotEmpty()) offSpades else cards
return pool.minBy { it.rank.order }
}
private fun follow(state: HeartsState, cards: List<Card>): Card {
val led = state.trick.first().card.suit
val best = state.trick.maxOf { trickStrength(it.card, led) }
val pot = pointsIn(state.trick.map { it.card })
val last = state.trick.size == state.options.playerCount - 1
val ducking = cards.filter { trickStrength(it, led) < best }
if (ducking.isNotEmpty()) {
// Safe under the current winner, so shed the most expensive card
// that still loses.
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
}
val void = cards.none { it.suit == led }
if (void) return discard(cards)
// Every card left would take the trick. Playing last with nothing in the
// pot means it costs nothing, so there is no reason to waste a high card
// on it; otherwise take it as cheaply as possible.
return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
}
/** Void in the led suit and free to throw anything, so throw the worst of it. */
private fun discard(cards: List<Card>): Card {
cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it }
cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order }
.maxByOrNull { it.rank.order }
?.let { return it }
cards.filter { it.suit == Suit.HEARTS }
.maxByOrNull { it.rank.order }
?.let { return it }
return cards.maxBy { it.rank.order }
}
}
@@ -0,0 +1,164 @@
package org.prolibertate.games.game.hearts
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
@Serializable
data class HeartsOptions(
/**
* Four hands of thirteen, which is the game as it is normally played. Three
* and five handed Hearts exist, but they need cards struck from the deck to
* divide evenly, and a seat that holds a different number of cards from its
* neighbours changes which discards are safe — a different game to bid and
* play, not a seat count.
*/
val playerCount: Int = 4,
/** The round in which somebody crosses this ends the game. */
val targetScore: Int = 100,
/**
* Taking all twenty-six points scores nothing and gives everyone else the
* lot. Switching this off scores a moon like any other hand, which is to say
* it becomes the worst result on the table rather than the best.
*/
val allowShootTheMoon: Boolean = true,
) {
init {
require(playerCount == 4) { "Hearts is dealt four handed" }
require(targetScore > 0) { "targetScore must be positive" }
}
}
/**
* Where the three passed cards go. The cycle repeats every fourth round, and
* the hold round is what stops a seat from planning around a known direction
* forever.
*/
@Serializable
enum class PassDirection(val label: String) {
LEFT("left"),
RIGHT("right"),
ACROSS("across"),
HOLD("nobody"),
;
companion object {
/** Rounds run left, right, across, hold, and back to left. */
fun forRound(round: Int): PassDirection = entries[round % entries.size]
}
}
@Serializable
enum class HeartsPhase { PASSING, PLAYING, ROUND_OVER, GAME_OVER }
@Serializable
data class PlayedCard(val seat: Int, val card: Card)
@Serializable
data class HeartsState(
val options: HeartsOptions,
val seed: Long,
/** Zero-based. Only used to pick the pass direction and to number the log. */
val round: Int,
val phase: HeartsPhase,
val hands: List<List<Card>>,
/** Survives redaction so a client can draw the backs of other hands. */
val handCounts: List<Int>,
/** What each seat has chosen to pass, before the swap happens. */
val passSelections: List<List<Card>>,
val turn: Int,
val leader: Int,
/**
* Which trick of the round is being played, counting from zero. Carried
* rather than derived because the first trick is the one that bans point
* cards, and a rule that important should not depend on reading it back out
* of how many cards happen to have been taken.
*/
val trickNumber: Int,
val trick: List<PlayedCard>,
/** Held on the table so a finished trick can be read before it is swept. */
val completedTrick: List<PlayedCard> = emptyList(),
/**
* Every card each seat has taken in tricks this round. Kept whole rather
* than as a running count because shooting the moon is decided on the
* twenty-six penalty cards together, not on a total that cannot tell an
* unbroken moon from thirteen hearts and a lucky queen elsewhere.
*/
val taken: List<List<Card>>,
val heartsBroken: Boolean,
val scores: List<Int>,
val roundScores: List<Int>,
val log: List<String>,
) {
val passDirection: PassDirection get() = PassDirection.forRound(round)
/** Thirteen, four handed: every card dealt is a card played. */
val tricksPerRound: Int get() = DECK_SIZE / options.playerCount
}
/** An ordinary pack, which Hearts uses whole. */
const val DECK_SIZE: Int = 52
@Serializable
sealed interface HeartsMove
/** The three cards this seat is giving away. Order does not matter. */
@Serializable
@SerialName("pass")
data class PassCards(val cards: List<Card>) : HeartsMove
@Serializable
@SerialName("play")
data class PlayCard(val card: Card) : HeartsMove
/** How many cards change hands in a pass. */
const val PASS_SIZE: Int = 3
val QUEEN_OF_SPADES: Card = Card(Rank.QUEEN, Suit.SPADES)
/** The two of clubs always leads the first trick of a round. */
val TWO_OF_CLUBS: Card = Card(Rank.TWO, Suit.CLUBS)
/** Every heart costs a point; the queen of spades costs thirteen. */
fun pointsOf(card: Card): Int = when {
card == QUEEN_OF_SPADES -> 13
card.suit == Suit.HEARTS -> 1
else -> 0
}
/** The whole twenty-six, which is what a moon has to consist of. */
const val ALL_POINTS: Int = 26
fun pointsIn(cards: List<Card>): Int = cards.sumOf { pointsOf(it) }
/** Which seat receives what [from] passes, given the round's direction. */
fun passTarget(from: Int, playerCount: Int, direction: PassDirection): Int = when (direction) {
PassDirection.LEFT -> (from + 1) % playerCount
PassDirection.RIGHT -> (from + playerCount - 1) % playerCount
PassDirection.ACROSS -> (from + playerCount / 2) % playerCount
PassDirection.HOLD -> from
}
/**
* Ranks a card within a trick. Nothing trumps in Hearts, so a card that did not
* follow the led suit cannot win however high it is.
*/
fun trickStrength(card: Card, ledSuit: Suit): Int =
if (card.suit == ledSuit) card.rank.order else -1
/**
* Turns a round's takings into the points each seat adds to its score.
*
* A seat that took all twenty-six has shot the moon: it scores nothing and
* every other seat takes the lot. That is the one case where a round's scores
* do not sum to twenty-six.
*/
fun scoreRound(taken: List<List<Card>>, allowShootTheMoon: Boolean): List<Int> {
val raw = taken.map { pointsIn(it) }
if (!allowShootTheMoon) return raw
val shooter = raw.indexOfFirst { it == ALL_POINTS }
if (shooter < 0) return raw
return raw.indices.map { if (it == shooter) 0 else ALL_POINTS }
}
@@ -0,0 +1,294 @@
package org.prolibertate.games.game.hearts
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
/**
* Hearts as a pure state machine. See RULES-hearts.md.
*/
object HeartsRules : GameRules<HeartsState, HeartsMove> {
override val gameId: String = GameCatalog.HEARTS
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): HeartsState {
val options = json.decodeFromString<HeartsOptions>(config.optionsJson)
require(config.seats.size == options.playerCount) {
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
}
return deal(options, config.seed, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
}
private fun deal(
options: HeartsOptions,
seed: Long,
round: Int,
scores: List<Int>,
log: List<String>,
): HeartsState {
// A fresh generator per round, keyed on the round, so the whole game is
// reproducible from the one seed the host shipped with the table.
val random = Random(seed + round)
val deck = Decks.standard52().shuffledWith(random)
val perHand = deck.size / options.playerCount
val hands = (0 until options.playerCount).map { seat ->
deck.subList(seat * perHand, (seat + 1) * perHand).sortedWith(handOrder)
}
val direction = PassDirection.forRound(round)
val passing = direction != PassDirection.HOLD
// A hold round has nothing to swap, so it opens straight onto the lead.
val opener = if (passing) 0 else holderOfTwoOfClubs(hands)
return HeartsState(
options = options,
seed = seed,
round = round,
phase = if (passing) HeartsPhase.PASSING else HeartsPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
passSelections = List(options.playerCount) { emptyList() },
turn = opener,
leader = opener,
trickNumber = 0,
trick = emptyList(),
completedTrick = emptyList(),
taken = List(options.playerCount) { emptyList() },
heartsBroken = false,
scores = scores,
roundScores = List(options.playerCount) { 0 },
log = log + if (passing) "Passing ${direction.label}." else "No passing this round.",
)
}
/** Sorted by suit then rank, which is how a hand is held rather than dealt. */
private val handOrder = compareBy<Card>({ it.suit.ordinal }, { it.rank.order })
private fun holderOfTwoOfClubs(hands: List<List<Card>>): Int =
hands.indexOfFirst { it.contains(TWO_OF_CLUBS) }
override fun currentSeat(state: HeartsState): Int? = when (state.phase) {
// Passing is simultaneous at the table, but the engine hands out one
// seat at a time. The lowest seat that has not chosen goes next, and
// the swap happens once the last one has.
HeartsPhase.PASSING -> state.passSelections.indexOfFirst { it.isEmpty() }.takeIf { it >= 0 }
HeartsPhase.PLAYING -> state.turn
HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> null
}
override fun legalMoves(state: HeartsState, seat: Int): List<HeartsMove> =
when (state.phase) {
HeartsPhase.PASSING ->
if (state.passSelections[seat].isNotEmpty()) {
emptyList()
} else {
combinations(state.hands[seat], PASS_SIZE).map { PassCards(it) }
}
HeartsPhase.PLAYING ->
if (state.turn != seat) emptyList() else playableCards(state, seat).map { PlayCard(it) }
HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> emptyList()
}
/**
* The cards [seat] may legally play into the trick as it stands.
*
* Four restrictions, in the order they bite: the two of clubs opens the
* round, a seat that can follow the led suit must, no point card may be
* discarded on the first trick, and hearts cannot be led until one has been
* played. Each of the last three lifts when a seat holds nothing else.
*/
fun playableCards(state: HeartsState, seat: Int): List<Card> {
val hand = state.hands[seat]
if (hand.isEmpty()) return emptyList()
val firstTrick = state.trickNumber == 0
if (state.trick.isEmpty()) {
if (firstTrick) return listOf(TWO_OF_CLUBS).filter { hand.contains(it) }
val offHearts = hand.filter { it.suit != Suit.HEARTS }
// Hearts may only be led once broken — unless that is all there is
// left to lead, in which case the restriction cannot be honoured.
return if (state.heartsBroken || offHearts.isEmpty()) hand else offHearts
}
val led = state.trick.first().card.suit
val following = hand.filter { it.suit == led }
if (following.isNotEmpty()) return following
// Void in the led suit, so anything goes — except that the first trick
// takes no points while the seat has a card that costs none.
if (firstTrick) {
val harmless = hand.filter { pointsOf(it) == 0 }
if (harmless.isNotEmpty()) return harmless
}
return hand
}
override fun applyMove(state: HeartsState, seat: Int, move: HeartsMove): HeartsState =
when (move) {
is PassCards -> applyPass(state, seat, move)
is PlayCard -> applyPlay(state, seat, move)
}
private fun applyPass(state: HeartsState, seat: Int, move: PassCards): HeartsState {
require(state.phase == HeartsPhase.PASSING) { "Not passing" }
require(state.passSelections[seat].isEmpty()) { "Seat $seat has already passed" }
require(move.cards.size == PASS_SIZE) { "A pass is $PASS_SIZE cards" }
require(move.cards.distinct().size == PASS_SIZE) { "Cannot pass the same card twice" }
val hand = state.hands[seat]
require(move.cards.all { hand.contains(it) }) { "Cannot pass a card not held" }
val selections = state.passSelections.toMutableList()
selections[seat] = move.cards
if (selections.any { it.isEmpty() }) {
return state.copy(passSelections = selections)
}
return completePass(state.copy(passSelections = selections))
}
/** Everyone has chosen, so the three-card bundles change hands at once. */
private fun completePass(state: HeartsState): HeartsState {
val direction = state.passDirection
val count = state.options.playerCount
val incoming = MutableList(count) { emptyList<Card>() }
for (from in 0 until count) {
incoming[passTarget(from, count, direction)] = state.passSelections[from]
}
val hands = state.hands.mapIndexed { seat, hand ->
(hand - state.passSelections[seat].toSet() + incoming[seat]).sortedWith(handOrder)
}
val opener = holderOfTwoOfClubs(hands)
return state.copy(
phase = HeartsPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
turn = opener,
leader = opener,
log = state.log + "Cards passed ${direction.label}.",
)
}
private fun applyPlay(state: HeartsState, seat: Int, move: PlayCard): HeartsState {
require(state.phase == HeartsPhase.PLAYING) { "Not playing" }
require(state.turn == seat) { "Seat $seat is not on the clock" }
require(playableCards(state, seat).contains(move.card)) { "${move.card} is not playable" }
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - move.card
val trick = state.trick + PlayedCard(seat, move.card)
val broken = state.heartsBroken || move.card.suit == Suit.HEARTS
if (trick.size < state.options.playerCount) {
return state.copy(
hands = hands,
handCounts = hands.map { it.size },
trick = trick,
completedTrick = emptyList(),
turn = (seat + 1) % state.options.playerCount,
heartsBroken = broken,
)
}
val led = trick.first().card.suit
val winner = trick.maxBy { trickStrength(it.card, led) }.seat
val taken = state.taken.toMutableList()
taken[winner] = taken[winner] + trick.map { it.card }
val swept = state.copy(
hands = hands,
handCounts = hands.map { it.size },
trick = emptyList(),
completedTrick = trick,
taken = taken,
heartsBroken = broken,
turn = winner,
leader = winner,
trickNumber = state.trickNumber + 1,
)
return if (hands.all { it.isEmpty() }) finishRound(swept) else swept
}
private fun finishRound(state: HeartsState): HeartsState {
val gained = scoreRound(state.taken, state.options.allowShootTheMoon)
val scores = state.scores.mapIndexed { seat, score -> score + gained[seat] }
val moon = state.taken.indexOfFirst { pointsIn(it) == ALL_POINTS }
val over = scores.any { it >= state.options.targetScore }
val notes = buildList {
if (moon >= 0 && state.options.allowShootTheMoon) add("Seat $moon shot the moon.")
add("Round ${state.round + 1} scored.")
if (over) add("Game over — lowest score wins.")
}
return state.copy(
phase = if (over) HeartsPhase.GAME_OVER else HeartsPhase.ROUND_OVER,
scores = scores,
roundScores = gained,
log = state.log + notes,
)
}
/** Advances from [HeartsPhase.ROUND_OVER] into the next deal. */
fun startNextRound(state: HeartsState): HeartsState {
check(state.phase == HeartsPhase.ROUND_OVER) { "Round is not over" }
val next = state.round + 1
return deal(state.options, state.seed, next, state.scores, state.log + "Round ${next + 1}.")
}
override fun isFinished(state: HeartsState): Boolean = state.phase == HeartsPhase.GAME_OVER
override fun summary(state: HeartsState): String = when (state.phase) {
HeartsPhase.PASSING -> "Passing ${state.passDirection.label}"
HeartsPhase.PLAYING -> "Trick ${state.trickNumber + 1} of ${state.tricksPerRound}"
HeartsPhase.ROUND_OVER -> "Round ${state.round + 1}: " + state.roundScores.joinToString(", ")
HeartsPhase.GAME_OVER -> {
val best = state.scores.min()
"Winner: seat ${state.scores.indexOf(best)} on $best"
}
}
/**
* Other hands go, and so do the cards they have chosen to pass — a pass is
* secret until it lands, and a client that could read the selections would
* know exactly what is coming.
*/
override fun viewFor(state: HeartsState, seat: Int): HeartsState = state.copy(
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
handCounts = state.handCounts,
passSelections = state.passSelections.mapIndexed { index, cards ->
if (index == seat) cards else emptyList()
},
)
override fun encodeState(state: HeartsState): String = json.encodeToString(state)
override fun decodeState(json: String): HeartsState = this.json.decodeFromString(json)
override fun encodeMove(move: HeartsMove): String = json.encodeToString(move)
override fun decodeMove(json: String): HeartsMove = this.json.decodeFromString(json)
/** Every distinct [size]-card selection from [cards], order ignored. */
private fun combinations(cards: List<Card>, size: Int): List<List<Card>> {
if (size == 0) return listOf(emptyList())
if (cards.size < size) return emptyList()
val result = mutableListOf<List<Card>>()
fun walk(start: Int, chosen: List<Card>) {
if (chosen.size == size) {
result.add(chosen)
return
}
// Stop early once too few cards remain to finish the selection.
for (i in start..cards.size - (size - chosen.size)) {
walk(i + 1, chosen + cards[i])
}
}
walk(0, emptyList())
return result
}
}
@@ -0,0 +1,332 @@
package org.prolibertate.games.game.hearts
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
class HeartsRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: HeartsOptions = HeartsOptions(), seed: Long = 7L) = TableConfig(
gameId = "hearts",
seats = (0 until options.playerCount).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private fun c(rank: Rank, suit: Suit) = Card(rank, suit)
/** A state part way through a round, with passing already done. */
private fun playing(
hands: List<List<Card>>,
turn: Int = 0,
leader: Int = 0,
trick: List<PlayedCard> = emptyList(),
trickNumber: Int = 1,
heartsBroken: Boolean = false,
taken: List<List<Card>> = List(hands.size) { emptyList() },
options: HeartsOptions = HeartsOptions(),
) = HeartsState(
options = options,
seed = 1L,
round = 0,
phase = HeartsPhase.PLAYING,
hands = hands,
handCounts = hands.map { it.size },
passSelections = List(hands.size) { emptyList() },
turn = turn,
leader = leader,
trickNumber = trickNumber,
trick = trick,
taken = taken,
heartsBroken = heartsBroken,
scores = List(hands.size) { 0 },
roundScores = List(hands.size) { 0 },
log = emptyList(),
)
@Test
fun `a deal gives four hands of thirteen from one pack`() {
val state = HeartsRules.initialState(config())
assertEquals(4, state.hands.size)
state.hands.forEach { assertEquals(13, it.size) }
val all = state.hands.flatten()
assertEquals(52, all.size)
assertEquals("no card is dealt twice", 52, all.distinct().size)
}
@Test
fun `the pass direction cycles and the fourth round holds`() {
assertEquals(PassDirection.LEFT, PassDirection.forRound(0))
assertEquals(PassDirection.RIGHT, PassDirection.forRound(1))
assertEquals(PassDirection.ACROSS, PassDirection.forRound(2))
assertEquals(PassDirection.HOLD, PassDirection.forRound(3))
assertEquals(PassDirection.LEFT, PassDirection.forRound(4))
}
@Test
fun `a hold round opens straight onto the lead`() {
// Round three holds, so there is nothing to swap and play begins at once
// with whoever holds the two of clubs.
var state = HeartsRules.initialState(config())
// Walk to round three through the public entry point rather than
// reaching into the private deal.
repeat(3) {
state = HeartsRules.startNextRound(state.copy(phase = HeartsPhase.ROUND_OVER))
}
assertEquals(3, state.round)
assertEquals(PassDirection.HOLD, state.passDirection)
assertEquals(HeartsPhase.PLAYING, state.phase)
assertTrue(state.hands[state.turn].contains(TWO_OF_CLUBS))
}
@Test
fun `passing left moves three cards to the next seat`() {
var state = HeartsRules.initialState(config())
assertEquals(HeartsPhase.PASSING, state.phase)
val given = (0 until 4).map { state.hands[it].take(PASS_SIZE) }
for (seat in 0 until 4) {
assertEquals(seat, HeartsRules.currentSeat(state))
state = HeartsRules.applyMove(state, seat, PassCards(given[seat]))
}
assertEquals(HeartsPhase.PLAYING, state.phase)
for (seat in 0 until 4) {
val receiver = (seat + 1) % 4
assertTrue(
"seat $receiver should hold what seat $seat passed",
state.hands[receiver].containsAll(given[seat]),
)
assertFalse(
"seat $seat should not still hold what it passed",
state.hands[seat].any { given[seat].contains(it) },
)
}
state.hands.forEach { assertEquals(13, it.size) }
}
@Test
fun `a pass must be three distinct cards from the hand`() {
val state = HeartsRules.initialState(config())
val hand = state.hands[0]
assertThrows { HeartsRules.applyMove(state, 0, PassCards(hand.take(2))) }
assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(hand[0], hand[0], hand[1]))) }
// A card this seat does not hold — it is in somebody else's thirteen.
val notHeld = state.hands[1].first()
assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(notHeld) + hand.take(2))) }
}
@Test
fun `the two of clubs opens the round`() {
val hands = listOf(
listOf(c(Rank.TWO, Suit.CLUBS), c(Rank.KING, Suit.SPADES)),
listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.FOUR, Suit.HEARTS)),
listOf(c(Rank.FIVE, Suit.CLUBS), c(Rank.SIX, Suit.DIAMONDS)),
listOf(c(Rank.SEVEN, Suit.CLUBS), c(Rank.EIGHT, Suit.DIAMONDS)),
)
val state = playing(hands, trickNumber = 0)
assertEquals(listOf(TWO_OF_CLUBS), HeartsRules.playableCards(state, 0))
assertThrows { HeartsRules.applyMove(state, 0, PlayCard(c(Rank.KING, Suit.SPADES))) }
}
@Test
fun `a seat holding the led suit must follow it`() {
val hands = listOf(
listOf(c(Rank.TWO, Suit.CLUBS)),
listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.ACE, Suit.HEARTS)),
listOf(c(Rank.FIVE, Suit.CLUBS)),
listOf(c(Rank.SEVEN, Suit.CLUBS)),
)
val state = playing(
hands,
turn = 1,
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
)
assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(state, 1))
}
@Test
fun `no point card may be discarded on the first trick`() {
val hands = listOf(
listOf(c(Rank.TWO, Suit.CLUBS)),
// Void in clubs, holding the queen, a heart, and one harmless card.
listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS), c(Rank.NINE, Suit.DIAMONDS)),
listOf(c(Rank.FIVE, Suit.CLUBS)),
listOf(c(Rank.SEVEN, Suit.CLUBS)),
)
val state = playing(
hands,
turn = 1,
trickNumber = 0,
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
)
assertEquals(listOf(c(Rank.NINE, Suit.DIAMONDS)), HeartsRules.playableCards(state, 1))
}
@Test
fun `the first trick ban lifts when a seat holds nothing but points`() {
val hands = listOf(
listOf(c(Rank.TWO, Suit.CLUBS)),
listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)),
listOf(c(Rank.FIVE, Suit.CLUBS)),
listOf(c(Rank.SEVEN, Suit.CLUBS)),
)
val state = playing(
hands,
turn = 1,
trickNumber = 0,
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
)
assertEquals(
setOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)),
HeartsRules.playableCards(state, 1).toSet(),
)
}
@Test
fun `hearts cannot be led until one has been played`() {
val hands = listOf(
listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.THREE, Suit.CLUBS)),
listOf(c(Rank.FOUR, Suit.CLUBS)),
listOf(c(Rank.FIVE, Suit.CLUBS)),
listOf(c(Rank.SIX, Suit.CLUBS)),
)
val unbroken = playing(hands, heartsBroken = false)
assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(unbroken, 0))
val broken = playing(hands, heartsBroken = true)
assertEquals(2, HeartsRules.playableCards(broken, 0).size)
}
@Test
fun `hearts may be led when they are all that is left`() {
val hands = listOf(
listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.HEARTS)),
listOf(c(Rank.FOUR, Suit.CLUBS)),
listOf(c(Rank.FIVE, Suit.CLUBS)),
listOf(c(Rank.SIX, Suit.CLUBS)),
)
val state = playing(hands, heartsBroken = false)
assertEquals(2, HeartsRules.playableCards(state, 0).size)
}
@Test
fun `the highest card of the led suit takes the trick`() {
val hands = listOf(
listOf(c(Rank.TWO, Suit.CLUBS)),
listOf(c(Rank.KING, Suit.CLUBS)),
// An ace off suit cannot win, however high.
listOf(c(Rank.ACE, Suit.DIAMONDS)),
listOf(c(Rank.FIVE, Suit.CLUBS)),
)
var state = playing(hands)
state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS)))
state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.KING, Suit.CLUBS)))
state = HeartsRules.applyMove(state, 2, PlayCard(c(Rank.ACE, Suit.DIAMONDS)))
state = HeartsRules.applyMove(state, 3, PlayCard(c(Rank.FIVE, Suit.CLUBS)))
assertEquals("king of clubs takes it", 1, state.leader)
assertEquals(4, state.taken[1].size)
}
@Test
fun `playing a heart breaks them`() {
val hands = listOf(
listOf(c(Rank.TWO, Suit.CLUBS)),
listOf(c(Rank.ACE, Suit.HEARTS)),
listOf(c(Rank.FIVE, Suit.CLUBS)),
listOf(c(Rank.SEVEN, Suit.CLUBS)),
)
var state = playing(hands)
assertFalse(state.heartsBroken)
state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS)))
state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.ACE, Suit.HEARTS)))
assertTrue(state.heartsBroken)
}
@Test
fun `hearts cost a point each and the queen costs thirteen`() {
assertEquals(1, pointsOf(c(Rank.TWO, Suit.HEARTS)))
assertEquals(1, pointsOf(c(Rank.ACE, Suit.HEARTS)))
assertEquals(13, pointsOf(QUEEN_OF_SPADES))
assertEquals(0, pointsOf(c(Rank.KING, Suit.SPADES)))
assertEquals(0, pointsOf(c(Rank.ACE, Suit.DIAMONDS)))
// The whole pack is worth twenty-six and nothing more.
assertEquals(ALL_POINTS, org.prolibertate.games.game.cards.Decks.standard52().sumOf { pointsOf(it) })
}
@Test
fun `an ordinary round shares out twenty-six points`() {
val hearts = Rank.standard.map { Card(it, Suit.HEARTS) }
val taken = listOf(
hearts.take(6),
hearts.drop(6) + QUEEN_OF_SPADES,
emptyList(),
emptyList(),
)
val scored = scoreRound(taken, allowShootTheMoon = true)
assertEquals(listOf(6, 20, 0, 0), scored)
assertEquals(ALL_POINTS, scored.sum())
}
@Test
fun `shooting the moon scores everyone else instead`() {
val everything = Rank.standard.map { Card(it, Suit.HEARTS) } + QUEEN_OF_SPADES
val taken = listOf(emptyList(), everything, emptyList<Card>(), emptyList())
assertEquals(listOf(26, 0, 26, 26), scoreRound(taken, allowShootTheMoon = true))
// Switched off, a moon is simply the worst hand at the table.
assertEquals(listOf(0, 26, 0, 0), scoreRound(taken, allowShootTheMoon = false))
}
@Test
fun `a client sees neither other hands nor what they are passing`() {
var state = HeartsRules.initialState(config())
state = HeartsRules.applyMove(state, 0, PassCards(state.hands[0].take(PASS_SIZE)))
val view = HeartsRules.viewFor(state, 0)
assertEquals(state.hands[0], view.hands[0])
assertTrue("other hands are stripped", view.hands.drop(1).all { it.isEmpty() })
assertEquals("counts survive so backs can be drawn", state.handCounts, view.handCounts)
assertEquals(state.passSelections[0], view.passSelections[0])
assertTrue(view.passSelections.drop(1).all { it.isEmpty() })
}
@Test
fun `state and moves survive a round trip`() {
val state = HeartsRules.initialState(config())
val decoded = HeartsRules.decodeState(HeartsRules.encodeState(state))
assertEquals(state, decoded)
val pass: HeartsMove = PassCards(state.hands[0].take(PASS_SIZE))
assertEquals(pass, HeartsRules.decodeMove(HeartsRules.encodeMove(pass)))
val play: HeartsMove = PlayCard(TWO_OF_CLUBS)
assertEquals(play, HeartsRules.decodeMove(HeartsRules.encodeMove(play)))
}
@Test
fun `nobody is on the clock once the game is over`() {
val state = HeartsRules.initialState(config()).copy(phase = HeartsPhase.GAME_OVER)
assertNull(HeartsRules.currentSeat(state))
assertTrue(HeartsRules.legalMoves(state, 0).isEmpty())
assertTrue(HeartsRules.isFinished(state))
}
private fun assertThrows(block: () -> Unit) {
try {
block()
throw AssertionError("expected the move to be rejected")
} catch (expected: IllegalArgumentException) {
// The rules rejected it, which is the point.
}
}
}