diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 840b2fe..c38d813 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -42,6 +42,13 @@ object GameCatalog { */ const val HEARTS = "hearts" const val PEG_SOLITAIRE = "pegsolitaire" + + /** + * An id ahead of its screen: the engine is written and tested, but a + * catalogue entry with nothing behind it would crash on the first tap, so it + * joins [all] when its screen lands. + */ + const val YAHTZEE = "yahtzee" const val SEQUENCE = "sequence" const val MASTERMIND = "mastermind" const val BACKGAMMON = "backgammon" diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt new file mode 100644 index 0000000..b6ecc05 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeAi.kt @@ -0,0 +1,86 @@ +package org.prolibertate.games.game.yahtzee + +import org.prolibertate.games.game.engine.GameAi + +/** + * Plays the dice by eye rather than by arithmetic. + * + * It deliberately does not try each way of keeping and look at what came back. + * The throw is a pure function of the state and what is held, so an AI that + * enumerated the thirty-two subsets and applied them would be reading the roll + * before making it — beating the player by seeing the future rather than by + * playing well. Everything below decides from the dice already on the table. + */ +object YahtzeeAi : GameAi { + + override fun chooseMove(state: YahtzeeState, seat: Int, legal: List): YahtzeeMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val rolls = legal.filterIsInstance() + val writes = legal.filterIsInstance() + + // Nothing on the table yet, so there is only one thing to do. + if (!state.hasRolled) return rolls.firstOrNull() ?: writes.first() + + val best = writes.maxByOrNull { scoreOf(it.category, state.dice) } + val bestScore = best?.let { scoreOf(it.category, state.dice) } ?: 0 + + // A hand already worth taking, or no rolls left, ends the turn. + if (rolls.isEmpty()) return best ?: writes.first() + if (bestScore >= WORTH_KEEPING) return best!! + + val keep = whatToKeep(state.dice) + return rolls.firstOrNull { it.keep.sorted() == keep.sorted() } + ?: rolls.first() + } + + /** + * Good enough to stop throwing. Set at a full house, which is the point + * where a hand is worth more than an average reroll of it. + */ + private const val WORTH_KEEPING = 25 + + /** + * What to hold on to: a run if one is nearly there, otherwise the face there + * is most of. Holding the biggest group is what turns three of a kind into + * four, and it is the whole of ordinary dice play. + */ + private fun whatToKeep(dice: List): List { + val run = runIndices(dice) + if (run.size >= 4) return run + + val counts = countsOf(dice) + val most = counts.entries + .sortedWith(compareByDescending> { it.value }.thenByDescending { it.key }) + .first() + if (most.value >= 2) { + return dice.indices.filter { dice[it] == most.key } + } + // Five different faces and no run worth keeping: hold the big ones and + // throw the rest. + return dice.indices.filter { dice[it] >= 5 } + } + + /** Positions making up the longest run of consecutive faces, one die per face. */ + private fun runIndices(dice: List): List { + val faces = dice.toSortedSet().toList() + var bestStart = 0 + var bestLength = 0 + var start = 0 + for (i in faces.indices) { + if (i > 0 && faces[i] != faces[i - 1] + 1) start = i + val length = i - start + 1 + if (length > bestLength) { + bestLength = length + bestStart = start + } + } + if (bestLength < 4) return emptyList() + val wanted = faces.subList(bestStart, bestStart + bestLength).toSet() + // One die per face: a second five adds nothing to a straight. + val taken = mutableSetOf() + return dice.indices.filter { index -> + val face = dice[index] + face in wanted && taken.add(face) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt new file mode 100644 index 0000000..4e14cc3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeModel.kt @@ -0,0 +1,192 @@ +package org.prolibertate.games.game.yahtzee + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Five dice, thirteen boxes, and thirteen turns to fill them. */ +const val DICE_COUNT: Int = 5 +const val ROLLS_PER_TURN: Int = 3 + +@Serializable +enum class YahtzeeSection { UPPER, LOWER } + +/** + * The thirteen boxes on the card, in the order they are printed. + * + * The upper six score only the die they name, which is what the bonus is for: + * three of each is exactly sixty-three, so the bonus rewards a card that did not + * give up on any of them. + */ +@Serializable +enum class YahtzeeCategory(val label: String, val section: YahtzeeSection) { + ONES("Ones", YahtzeeSection.UPPER), + TWOS("Twos", YahtzeeSection.UPPER), + THREES("Threes", YahtzeeSection.UPPER), + FOURS("Fours", YahtzeeSection.UPPER), + FIVES("Fives", YahtzeeSection.UPPER), + SIXES("Sixes", YahtzeeSection.UPPER), + THREE_OF_A_KIND("Three of a kind", YahtzeeSection.LOWER), + FOUR_OF_A_KIND("Four of a kind", YahtzeeSection.LOWER), + FULL_HOUSE("Full house", YahtzeeSection.LOWER), + SMALL_STRAIGHT("Small straight", YahtzeeSection.LOWER), + LARGE_STRAIGHT("Large straight", YahtzeeSection.LOWER), + YAHTZEE("Yahtzee", YahtzeeSection.LOWER), + CHANCE("Chance", YahtzeeSection.LOWER), + ; + + /** The face this box counts, for the upper six. */ + val face: Int? get() = if (section == YahtzeeSection.UPPER) ordinal + 1 else null +} + +/** Three of each of the upper six, which is what the bonus asks for. */ +const val UPPER_BONUS_AT: Int = 63 +const val UPPER_BONUS: Int = 35 +const val YAHTZEE_SCORE: Int = 50 +const val YAHTZEE_BONUS: Int = 100 + +@Serializable +data class YahtzeeOptions( + /** One plays it as a puzzle against the card; up to six round a table. */ + val playerCount: Int = 2, + /** + * A second Yahtzee is worth a hundred on top, once the Yahtzee box itself + * holds fifty. Switched off, extra Yahtzees are only worth whatever box they + * are written into. + */ + val yahtzeeBonus: Boolean = true, +) { + init { + require(playerCount in 1..6) { "Yahtzee seats one to six" } + } +} + +/** + * One player's card. + * + * Boxes are held as a list indexed by [YahtzeeCategory.ordinal] rather than a + * map, so what crosses the wire is thirteen slots in a fixed order and a box + * that has never been filled is plainly null rather than absent. + */ +@Serializable +data class YahtzeeCard( + val boxes: List = List(YahtzeeCategory.entries.size) { null }, +) { + operator fun get(category: YahtzeeCategory): Int? = boxes[category.ordinal] + + fun isFilled(category: YahtzeeCategory): Boolean = this[category] != null + + val isComplete: Boolean get() = boxes.none { it == null } + + fun with(category: YahtzeeCategory, score: Int): YahtzeeCard { + require(!isFilled(category)) { "${category.label} is already written in" } + return copy(boxes = boxes.toMutableList().also { it[category.ordinal] = score }) + } + + val upperSubtotal: Int + get() = YahtzeeCategory.entries + .filter { it.section == YahtzeeSection.UPPER } + .sumOf { this[it] ?: 0 } + + val upperBonus: Int get() = if (upperSubtotal >= UPPER_BONUS_AT) UPPER_BONUS else 0 + + val lowerSubtotal: Int + get() = YahtzeeCategory.entries + .filter { it.section == YahtzeeSection.LOWER } + .sumOf { this[it] ?: 0 } + + /** Does not include Yahtzee bonuses, which are held on the state. */ + val total: Int get() = upperSubtotal + upperBonus + lowerSubtotal +} + +@Serializable +data class YahtzeeState( + val options: YahtzeeOptions, + val seed: Long, + /** Zero-based. Thirteen rounds fill thirteen boxes. */ + val round: Int, + val turn: Int, + /** Empty until the seat has rolled; five dice after that. */ + val dice: List, + val rollsUsed: Int, + val cards: List, + /** Hundreds earned for extra Yahtzees, counted per seat. */ + val yahtzeeBonuses: List, + val log: List, +) { + val rollsLeft: Int get() = ROLLS_PER_TURN - rollsUsed + + /** Nothing can be written in until the dice have been thrown at least once. */ + val hasRolled: Boolean get() = rollsUsed > 0 + + fun totalFor(seat: Int): Int = cards[seat].total + yahtzeeBonuses[seat] + + val isOver: Boolean get() = cards.all { it.isComplete } +} + +@Serializable +sealed interface YahtzeeMove + +/** + * Throws the dice, keeping the ones at [keep]. + * + * Positions rather than faces: two fives are two different dice, and a player + * who keeps one of them and rerolls the other is doing something a set of faces + * could not express. + */ +@Serializable +@SerialName("roll") +data class RollDice(val keep: List = emptyList()) : YahtzeeMove + +@Serializable +@SerialName("score") +data class ScoreIn(val category: YahtzeeCategory) : YahtzeeMove + +/** How many of each face, indexed one to six. */ +fun countsOf(dice: List): Map = dice.groupingBy { it }.eachCount() + +/** The longest run of consecutive faces present, however many of each. */ +fun longestRun(dice: List): Int { + val present = dice.toSortedSet() + var best = 0 + var run = 0 + var previous: Int? = null + for (face in present) { + run = if (previous != null && face == previous + 1) run + 1 else 1 + best = maxOf(best, run) + previous = face + } + return best +} + +/** + * What [category] is worth for [dice]. + * + * A full house is strictly three of one and two of another, so five of a kind + * does not pay for it — that is the printed rule, and the joker variations that + * change it are not played here. + */ +fun scoreOf(category: YahtzeeCategory, dice: List): Int { + val counts = countsOf(dice) + val sum = dice.sum() + return when (category) { + YahtzeeCategory.ONES, YahtzeeCategory.TWOS, YahtzeeCategory.THREES, + YahtzeeCategory.FOURS, YahtzeeCategory.FIVES, YahtzeeCategory.SIXES, + -> { + val face = category.face!! + face * (counts[face] ?: 0) + } + + YahtzeeCategory.THREE_OF_A_KIND -> if (counts.values.any { it >= 3 }) sum else 0 + YahtzeeCategory.FOUR_OF_A_KIND -> if (counts.values.any { it >= 4 }) sum else 0 + YahtzeeCategory.FULL_HOUSE -> + if (counts.values.sorted() == listOf(2, 3)) 25 else 0 + + YahtzeeCategory.SMALL_STRAIGHT -> if (longestRun(dice) >= 4) 30 else 0 + YahtzeeCategory.LARGE_STRAIGHT -> if (longestRun(dice) >= 5) 40 else 0 + YahtzeeCategory.YAHTZEE -> if (counts.values.any { it == DICE_COUNT }) YAHTZEE_SCORE else 0 + YahtzeeCategory.CHANCE -> sum + } +} + +fun isYahtzee(dice: List): Boolean = + dice.size == DICE_COUNT && dice.toSet().size == 1 diff --git a/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt new file mode 100644 index 0000000..3e82893 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/yahtzee/YahtzeeRules.kt @@ -0,0 +1,175 @@ +package org.prolibertate.games.game.yahtzee + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Yahtzee as a pure state machine. See RULES-yahtzee.md. + * + * The dice are thrown from the state rather than from a generator held on the + * side: [applyMove] has to return the same thing every time it is handed the + * same state and move, or the host and its clients would part company on the + * first reroll. Seeding on the round, the seat, the rolls used and what was kept + * gives a throw that is unpredictable to play against and identical to replay. + */ +object YahtzeeRules : GameRules { + + override val gameId: String = GameCatalog.YAHTZEE + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): YahtzeeState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == options.playerCount) { + "Seat count ${config.seats.size} does not match ${options.playerCount} players" + } + return YahtzeeState( + options = options, + seed = config.seed, + round = 0, + turn = 0, + dice = emptyList(), + rollsUsed = 0, + cards = List(options.playerCount) { YahtzeeCard() }, + yahtzeeBonuses = List(options.playerCount) { 0 }, + log = listOf("Round 1."), + ) + } + + override fun currentSeat(state: YahtzeeState): Int? = + if (state.isOver) null else state.turn + + override fun legalMoves(state: YahtzeeState, seat: Int): List { + if (state.isOver || state.turn != seat) return emptyList() + val moves = mutableListOf() + if (state.rollsUsed < ROLLS_PER_TURN) { + // The first throw of a turn is all five dice: there is nothing on + // the table yet to keep. + moves += if (!state.hasRolled) { + listOf(RollDice(emptyList())) + } else { + keepSubsets().map { RollDice(it) } + } + } + if (state.hasRolled) { + moves += YahtzeeCategory.entries + .filterNot { state.cards[seat].isFilled(it) } + .map { ScoreIn(it) } + } + return moves + } + + /** Every set of dice positions a player might hold back — thirty-two of them. */ + private fun keepSubsets(): List> = + (0 until (1 shl DICE_COUNT)).map { mask -> + (0 until DICE_COUNT).filter { mask and (1 shl it) != 0 } + } + + override fun applyMove(state: YahtzeeState, seat: Int, move: YahtzeeMove): YahtzeeState { + require(!state.isOver) { "The game is over" } + require(state.turn == seat) { "Seat $seat is not on the clock" } + return when (move) { + is RollDice -> applyRoll(state, move) + is ScoreIn -> applyScore(state, seat, move) + } + } + + private fun applyRoll(state: YahtzeeState, move: RollDice): YahtzeeState { + require(state.rollsUsed < ROLLS_PER_TURN) { "No rolls left this turn" } + require(move.keep.distinct().size == move.keep.size) { "Cannot keep a die twice" } + require(move.keep.all { it in 0 until DICE_COUNT }) { "No such die" } + require(state.hasRolled || move.keep.isEmpty()) { "Nothing to keep before the first roll" } + return state.copy( + dice = throwDice(state, move.keep), + rollsUsed = state.rollsUsed + 1, + ) + } + + private fun throwDice(state: YahtzeeState, keep: List): List { + val random = Random( + state.seed + + state.round * 10_000L + + state.turn * 1_000L + + state.rollsUsed * 100L + + keep.sorted().hashCode(), + ) + return (0 until DICE_COUNT).map { index -> + if (keep.contains(index) && index < state.dice.size) { + state.dice[index] + } else { + random.nextInt(1, 7) + } + } + } + + private fun applyScore(state: YahtzeeState, seat: Int, move: ScoreIn): YahtzeeState { + require(state.hasRolled) { "Roll before writing anything in" } + val card = state.cards[seat] + require(!card.isFilled(move.category)) { "${move.category.label} is already written in" } + + val written = scoreOf(move.category, state.dice) + // A second Yahtzee pays a hundred on top, but only once the box itself + // holds the fifty — a zero written there earlier forfeits the bonus, as + // the printed rule has it. + val earnsBonus = state.options.yahtzeeBonus && + isYahtzee(state.dice) && + card[YahtzeeCategory.YAHTZEE] == YAHTZEE_SCORE + val bonuses = if (!earnsBonus) { + state.yahtzeeBonuses + } else { + state.yahtzeeBonuses.toMutableList().also { it[seat] = it[seat] + YAHTZEE_BONUS } + } + + val cards = state.cards.toMutableList().also { it[seat] = card.with(move.category, written) } + val nextTurn = (seat + 1) % state.options.playerCount + val wrapped = nextTurn == 0 + val round = if (wrapped) state.round + 1 else state.round + val notes = buildList { + add("Seat $seat wrote $written in ${move.category.label}.") + if (earnsBonus) add("Seat $seat rolled another Yahtzee: $YAHTZEE_BONUS more.") + if (cards.all { it.isComplete }) add("Cards full.") else if (wrapped) add("Round ${round + 1}.") + } + return state.copy( + round = round, + turn = nextTurn, + // The dice go back in the cup for whoever is next. + dice = emptyList(), + rollsUsed = 0, + cards = cards, + yahtzeeBonuses = bonuses, + log = state.log + notes, + ) + } + + override fun isFinished(state: YahtzeeState): Boolean = state.isOver + + override fun summary(state: YahtzeeState): String = when { + state.isOver -> { + val totals = state.cards.indices.map { state.totalFor(it) } + val best = totals.max() + "Winner: seat ${totals.indexOf(best)} on $best" + } + + !state.hasRolled -> "Seat ${state.turn} to throw" + else -> "Seat ${state.turn}, ${state.rollsLeft} rolls left" + } + + /** + * Nothing is hidden. The dice are on the table and every card is face up — + * Yahtzee is a game of what you do with a throw everyone watched, so there + * is no private state to strip. + */ + override fun viewFor(state: YahtzeeState, seat: Int): YahtzeeState = state + + override fun encodeState(state: YahtzeeState): String = json.encodeToString(state) + override fun decodeState(json: String): YahtzeeState = this.json.decodeFromString(json) + override fun encodeMove(move: YahtzeeMove): String = json.encodeToString(move) + override fun decodeMove(json: String): YahtzeeMove = this.json.decodeFromString(json) +} diff --git a/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt new file mode 100644 index 0000000..7ab59b6 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/yahtzee/YahtzeeRulesTest.kt @@ -0,0 +1,294 @@ +package org.prolibertate.games.game.yahtzee + +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.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +class YahtzeeRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: YahtzeeOptions = YahtzeeOptions(), seed: Long = 11L) = TableConfig( + gameId = "yahtzee", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + // ---- scoring ----------------------------------------------------------- + + @Test + fun `the upper six count only the die they name`() { + val dice = listOf(3, 3, 5, 1, 3) + assertEquals(1, scoreOf(YahtzeeCategory.ONES, dice)) + assertEquals(0, scoreOf(YahtzeeCategory.TWOS, dice)) + assertEquals(9, scoreOf(YahtzeeCategory.THREES, dice)) + assertEquals(5, scoreOf(YahtzeeCategory.FIVES, dice)) + assertEquals(0, scoreOf(YahtzeeCategory.SIXES, dice)) + } + + @Test + fun `three and four of a kind pay the whole throw`() { + val three = listOf(4, 4, 4, 2, 6) + assertEquals(20, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, three)) + assertEquals(0, scoreOf(YahtzeeCategory.FOUR_OF_A_KIND, three)) + + val four = listOf(4, 4, 4, 4, 6) + assertEquals(22, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, four)) + assertEquals(22, scoreOf(YahtzeeCategory.FOUR_OF_A_KIND, four)) + + assertEquals(0, scoreOf(YahtzeeCategory.THREE_OF_A_KIND, listOf(1, 2, 3, 4, 5))) + } + + @Test + fun `a full house is three of one and two of another, and nothing else`() { + assertEquals(25, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(2, 2, 5, 5, 5))) + assertEquals(0, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(2, 2, 2, 5, 6))) + // Five of a kind is not a full house on the printed card, whatever the + // joker variations do with it. + assertEquals(0, scoreOf(YahtzeeCategory.FULL_HOUSE, listOf(4, 4, 4, 4, 4))) + } + + @Test + fun `straights want consecutive faces, and a repeat does not break them`() { + assertEquals(4, longestRun(listOf(1, 2, 3, 4, 4))) + assertEquals(5, longestRun(listOf(2, 3, 4, 5, 6))) + assertEquals(1, longestRun(listOf(3, 3, 3, 3, 3))) + + assertEquals(30, scoreOf(YahtzeeCategory.SMALL_STRAIGHT, listOf(1, 2, 3, 4, 4))) + assertEquals(0, scoreOf(YahtzeeCategory.LARGE_STRAIGHT, listOf(1, 2, 3, 4, 4))) + assertEquals(40, scoreOf(YahtzeeCategory.LARGE_STRAIGHT, listOf(3, 4, 5, 6, 2))) + // A large straight is a small one as well. + assertEquals(30, scoreOf(YahtzeeCategory.SMALL_STRAIGHT, listOf(3, 4, 5, 6, 2))) + } + + @Test + fun `five of a kind is fifty, and chance is whatever is showing`() { + assertEquals(50, scoreOf(YahtzeeCategory.YAHTZEE, listOf(6, 6, 6, 6, 6))) + assertEquals(0, scoreOf(YahtzeeCategory.YAHTZEE, listOf(6, 6, 6, 6, 1))) + assertEquals(19, scoreOf(YahtzeeCategory.CHANCE, listOf(6, 6, 4, 2, 1))) + assertTrue(isYahtzee(listOf(2, 2, 2, 2, 2))) + assertFalse(isYahtzee(listOf(2, 2, 2, 2, 3))) + } + + @Test + fun `the upper bonus arrives at sixty-three`() { + var card = YahtzeeCard() + // Exactly three of each is sixty-three, which is what the bonus is for. + YahtzeeCategory.entries.filter { it.section == YahtzeeSection.UPPER }.forEach { + card = card.with(it, it.face!! * 3) + } + assertEquals(63, card.upperSubtotal) + assertEquals(35, card.upperBonus) + + var short = YahtzeeCard() + YahtzeeCategory.entries.filter { it.section == YahtzeeSection.UPPER }.forEach { + short = short.with(it, it.face!! * 2) + } + assertEquals(42, short.upperSubtotal) + assertEquals(0, short.upperBonus) + } + + @Test + fun `a box can only be written in once`() { + val card = YahtzeeCard().with(YahtzeeCategory.CHANCE, 20) + assertTrue(card.isFilled(YahtzeeCategory.CHANCE)) + assertFalse(card.isFilled(YahtzeeCategory.ONES)) + try { + card.with(YahtzeeCategory.CHANCE, 5) + throw AssertionError("expected a rejection") + } catch (expected: IllegalArgumentException) { + // As intended. + } + } + + // ---- the state machine ------------------------------------------------- + + @Test + fun `a turn opens with one thing to do, and it is throwing`() { + val state = YahtzeeRules.initialState(config()) + assertEquals(0, YahtzeeRules.currentSeat(state)) + val legal = YahtzeeRules.legalMoves(state, 0) + assertEquals(listOf(RollDice(emptyList())), legal) + // Nothing may be written in before the dice have been thrown. + assertThrows { YahtzeeRules.applyMove(state, 0, ScoreIn(YahtzeeCategory.CHANCE)) } + } + + @Test + fun `a throw puts five dice on the table`() { + val state = YahtzeeRules.initialState(config()) + val rolled = YahtzeeRules.applyMove(state, 0, RollDice()) + assertEquals(DICE_COUNT, rolled.dice.size) + assertTrue(rolled.dice.all { it in 1..6 }) + assertEquals(1, rolled.rollsUsed) + assertEquals(2, rolled.rollsLeft) + } + + @Test + fun `the same state and move always throw the same dice`() { + // The host and every client run applyMove separately; a throw that + // differed between them would split the game in two. + val state = YahtzeeRules.initialState(config()) + val once = YahtzeeRules.applyMove(state, 0, RollDice()) + val again = YahtzeeRules.applyMove(state, 0, RollDice()) + assertEquals(once.dice, again.dice) + + val keptOnce = YahtzeeRules.applyMove(once, 0, RollDice(listOf(0, 1))) + val keptAgain = YahtzeeRules.applyMove(once, 0, RollDice(listOf(0, 1))) + assertEquals(keptOnce.dice, keptAgain.dice) + } + + @Test + fun `kept dice stay put and the rest are thrown again`() { + val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice()) + val kept = YahtzeeRules.applyMove(state, 0, RollDice(listOf(0, 2, 4))) + assertEquals(state.dice[0], kept.dice[0]) + assertEquals(state.dice[2], kept.dice[2]) + assertEquals(state.dice[4], kept.dice[4]) + assertEquals(2, kept.rollsUsed) + } + + @Test + fun `there are three throws in a turn and no more`() { + var state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice()) + state = YahtzeeRules.applyMove(state, 0, RollDice()) + state = YahtzeeRules.applyMove(state, 0, RollDice()) + assertEquals(ROLLS_PER_TURN, state.rollsUsed) + assertEquals(0, state.rollsLeft) + assertThrows { YahtzeeRules.applyMove(state, 0, RollDice()) } + // Only writing it down is left. + assertTrue(YahtzeeRules.legalMoves(state, 0).all { it is ScoreIn }) + } + + @Test + fun `nothing may be kept before the first throw`() { + val state = YahtzeeRules.initialState(config()) + assertThrows { YahtzeeRules.applyMove(state, 0, RollDice(listOf(0))) } + } + + @Test + fun `writing a box passes the dice on and the round turns over`() { + val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice()) + val written = YahtzeeRules.applyMove(state, 0, ScoreIn(YahtzeeCategory.CHANCE)) + assertEquals("the dice go back in the cup", emptyList(), written.dice) + assertEquals(0, written.rollsUsed) + assertEquals(1, written.turn) + assertEquals("still the first round", 0, written.round) + assertEquals(state.dice.sum(), written.cards[0][YahtzeeCategory.CHANCE]) + + val second = YahtzeeRules.applyMove(written, 1, RollDice()) + val wrapped = YahtzeeRules.applyMove(second, 1, ScoreIn(YahtzeeCategory.CHANCE)) + assertEquals(0, wrapped.turn) + assertEquals("round turns over once everyone has written", 1, wrapped.round) + } + + @Test + fun `a seat that is not on the clock cannot move`() { + val state = YahtzeeRules.initialState(config()) + assertTrue(YahtzeeRules.legalMoves(state, 1).isEmpty()) + assertThrows { YahtzeeRules.applyMove(state, 1, RollDice()) } + } + + @Test + fun `a second Yahtzee pays a hundred, but only over a fifty`() { + val base = YahtzeeRules.initialState(config()) + val fives = listOf(5, 5, 5, 5, 5) + + // The box already holds the fifty, so the bonus is earned. + val earned = base.copy( + dice = fives, + rollsUsed = 1, + cards = listOf( + YahtzeeCard().with(YahtzeeCategory.YAHTZEE, YAHTZEE_SCORE), + YahtzeeCard(), + ), + ) + val after = YahtzeeRules.applyMove(earned, 0, ScoreIn(YahtzeeCategory.FIVES)) + assertEquals(YAHTZEE_BONUS, after.yahtzeeBonuses[0]) + assertEquals(25, after.cards[0][YahtzeeCategory.FIVES]) + + // A zero written there earlier forfeits it. + val forfeited = base.copy( + dice = fives, + rollsUsed = 1, + cards = listOf(YahtzeeCard().with(YahtzeeCategory.YAHTZEE, 0), YahtzeeCard()), + ) + assertEquals( + 0, + YahtzeeRules.applyMove(forfeited, 0, ScoreIn(YahtzeeCategory.FIVES)).yahtzeeBonuses[0], + ) + + // And it can be switched off at the table. + val off = earned.copy(options = YahtzeeOptions(playerCount = 2, yahtzeeBonus = false)) + assertEquals( + 0, + YahtzeeRules.applyMove(off, 0, ScoreIn(YahtzeeCategory.FIVES)).yahtzeeBonuses[0], + ) + } + + @Test + fun `a full card ends the game`() { + var state = YahtzeeRules.initialState(config(YahtzeeOptions(playerCount = 1))) + // Thirteen turns, each filling one box. + var guard = 200 + while (!YahtzeeRules.isFinished(state) && guard-- > 0) { + val legal = YahtzeeRules.legalMoves(state, state.turn) + state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal)) + } + assertTrue("the game should end on its own", guard > 0) + assertTrue(YahtzeeRules.isFinished(state)) + assertTrue(state.cards[0].isComplete) + assertEquals(YahtzeeCategory.entries.size, state.round) + assertNull(YahtzeeRules.currentSeat(state)) + assertTrue(YahtzeeRules.summary(state).contains("Winner")) + } + + @Test + fun `two can play it through as well`() { + var state = YahtzeeRules.initialState(config(YahtzeeOptions(playerCount = 2))) + var guard = 400 + while (!YahtzeeRules.isFinished(state) && guard-- > 0) { + val legal = YahtzeeRules.legalMoves(state, state.turn) + state = YahtzeeRules.applyMove(state, state.turn, YahtzeeAi.chooseMove(state, state.turn, legal)) + } + assertTrue(guard > 0) + assertTrue(state.cards.all { it.isComplete }) + state.cards.indices.forEach { assertTrue(state.totalFor(it) >= 0) } + } + + @Test + fun `nothing is hidden from anyone`() { + val state = YahtzeeRules.initialState(config()) + assertEquals(state, YahtzeeRules.viewFor(state, 0)) + assertEquals(state, YahtzeeRules.viewFor(state, 1)) + } + + @Test + fun `state and moves survive a round trip`() { + val state = YahtzeeRules.applyMove(YahtzeeRules.initialState(config()), 0, RollDice()) + assertEquals(state, YahtzeeRules.decodeState(YahtzeeRules.encodeState(state))) + + val roll: YahtzeeMove = RollDice(listOf(0, 3)) + assertEquals(roll, YahtzeeRules.decodeMove(YahtzeeRules.encodeMove(roll))) + val write: YahtzeeMove = ScoreIn(YahtzeeCategory.LARGE_STRAIGHT) + assertEquals(write, YahtzeeRules.decodeMove(YahtzeeRules.encodeMove(write))) + } + + private fun assertThrows(block: () -> Unit) { + try { + block() + throw AssertionError("expected the rules to reject that") + } catch (expected: IllegalArgumentException) { + // Rejected, which is the point. + } + } +}