diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt new file mode 100644 index 0000000..a9e1ca9 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsAi.kt @@ -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 { + + override fun chooseMove(state: HeartsState, seat: Int, legal: List): 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 { + 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): 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 { + val cards = legal.filterIsInstance().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 { + 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 { + 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 { + 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 } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt new file mode 100644 index 0000000..63c56ef --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsModel.kt @@ -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>, + /** Survives redaction so a client can draw the backs of other hands. */ + val handCounts: List, + /** What each seat has chosen to pass, before the swap happens. */ + val passSelections: List>, + 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, + /** Held on the table so a finished trick can be read before it is swept. */ + val completedTrick: List = 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>, + val heartsBroken: Boolean, + val scores: List, + val roundScores: List, + val log: List, +) { + 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) : 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): 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>, allowShootTheMoon: Boolean): List { + 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 } +} diff --git a/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt new file mode 100644 index 0000000..4185381 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/hearts/HeartsRules.kt @@ -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 { + + override val gameId: String = GameCatalog.HEARTS + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): HeartsState { + val options = json.decodeFromString(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, + log: List, + ): 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({ it.suit.ordinal }, { it.rank.order }) + + private fun holderOfTwoOfClubs(hands: List>): 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 = + 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 { + 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() } + 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, size: Int): List> { + if (size == 0) return listOf(emptyList()) + if (cards.size < size) return emptyList() + val result = mutableListOf>() + fun walk(start: Int, chosen: List) { + 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 + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt new file mode 100644 index 0000000..db8b5d7 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/hearts/HeartsRulesTest.kt @@ -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>, + turn: Int = 0, + leader: Int = 0, + trick: List = emptyList(), + trickNumber: Int = 1, + heartsBroken: Boolean = false, + taken: List> = 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(), 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. + } + } +}