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
@@ -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"
@@ -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<YahtzeeState, YahtzeeMove> {
override fun chooseMove(state: YahtzeeState, seat: Int, legal: List<YahtzeeMove>): YahtzeeMove {
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
val rolls = legal.filterIsInstance<RollDice>()
val writes = legal.filterIsInstance<ScoreIn>()
// 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<Int>): List<Int> {
val run = runIndices(dice)
if (run.size >= 4) return run
val counts = countsOf(dice)
val most = counts.entries
.sortedWith(compareByDescending<Map.Entry<Int, Int>> { 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<Int>): List<Int> {
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<Int>()
return dice.indices.filter { index ->
val face = dice[index]
face in wanted && taken.add(face)
}
}
}
@@ -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<Int?> = 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<Int>,
val rollsUsed: Int,
val cards: List<YahtzeeCard>,
/** Hundreds earned for extra Yahtzees, counted per seat. */
val yahtzeeBonuses: List<Int>,
val log: List<String>,
) {
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<Int> = 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<Int>): Map<Int, Int> = dice.groupingBy { it }.eachCount()
/** The longest run of consecutive faces present, however many of each. */
fun longestRun(dice: List<Int>): 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>): 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<Int>): Boolean =
dice.size == DICE_COUNT && dice.toSet().size == 1
@@ -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<YahtzeeState, YahtzeeMove> {
override val gameId: String = GameCatalog.YAHTZEE
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): YahtzeeState {
val options = json.decodeFromString<YahtzeeOptions>(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<YahtzeeMove> {
if (state.isOver || state.turn != seat) return emptyList()
val moves = mutableListOf<YahtzeeMove>()
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<List<Int>> =
(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<Int>): List<Int> {
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)
}