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:
@@ -42,6 +42,13 @@ object GameCatalog {
|
|||||||
*/
|
*/
|
||||||
const val HEARTS = "hearts"
|
const val HEARTS = "hearts"
|
||||||
const val PEG_SOLITAIRE = "pegsolitaire"
|
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 SEQUENCE = "sequence"
|
||||||
const val MASTERMIND = "mastermind"
|
const val MASTERMIND = "mastermind"
|
||||||
const val BACKGAMMON = "backgammon"
|
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)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user