Yahtzee, as rules and an AI

Five dice, thirteen boxes, three throws a turn. The first dice game in the
app -- backgammon rolls them, but as a race rather than for what the faces
themselves are worth.

The throw is derived from the state rather than from a generator held on the
side. applyMove has to return the same thing every time it is given the same
state and move, because the host and every client run it separately, and a
throw that differed between them would split the game in two. Seeding on the
round, the seat, the rolls used and what was kept gives dice that are
unpredictable to play against and identical to replay.

RollDice carries positions rather than faces. Two fives are two different dice,
and keeping one while rerolling the other is something a set of faces cannot
say.

A card is thirteen slots in printed order rather than a map, so an unfilled box
crosses the wire as a plain null instead of going missing. A full house is
strictly three and two, so five of a kind does not pay for it -- that is what is
printed on the card, and the joker variations that say otherwise are not played
here. The second-Yahtzee hundred is only earned over a fifty: a zero written in
that box earlier forfeits it.

The AI does not try each way of keeping and look at what came back. Since the
throw is a pure function of the state and the dice held, that would be reading
the roll before making it -- winning by seeing the future rather than by playing
well. It decides from what is already on the table: a run if one is nearly
there, otherwise the face there is most of.

Not in GameCatalog yet, for the same reason as the others: the id exists so the
rules compile, and the entry lands with the screen.

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 15:14:16 +00:00
parent 3afae95067
commit 68f8da0379
5 changed files with 754 additions and 0 deletions
@@ -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<Int>(), 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.
}
}
}