Add President and Golf
Two more games on the existing framework — no changes were needed to the lobby, networking, settings or update layers, which is what the shared GameRules/GameAi contract was for. President - Threes low, twos high. Lead any number of one rank; follow with the same count of a higher rank, or pass. When everyone has passed the pile comes down and the last player to play leads again. - Toggles: player count, rounds, twos clear the pile, four of a kind bombs, and the between-round card exchange. - Plays are offered as whole sets rather than by selecting cards. A set is the unit of the game, so choosing from the legal sets is both easier to operate and impossible to get wrong. Golf - 4, 6 or 9 card grids. Draw from stock or discard, then either swap the card in or — only if it came off the stock — throw it and turn one of your own over. A card taken from the discard pile has to be used. - Matching columns cancel to nothing; on the 3x3 board rows do too, but not on the shallower grids where three cards are not a line. - Face-down cards are hidden from everyone including their owner, which is the point of the game: viewFor masks them with a fixed placeholder so the real card never reaches the device that has not turned it over. Both engines are pure Kotlin and unit tested — 71 tests now, covering ranking, legal moves, pile and hole resolution, scoring, redaction, wire round-trips, and full random and AI playthroughs. The Golf test also checks the AI actually beats random play rather than merely being legal. Also widened the setup screen's option chips to wrap: the reveal-count row carries up to ten, and a chip off the edge of the screen is a setting that cannot be changed — the same failure as the Euchre Pass button. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
@@ -50,7 +50,12 @@ class CatalogTest {
|
||||
@Test
|
||||
fun `playable games are the ones with engines wired up`() {
|
||||
assertEquals(
|
||||
setOf(GameCatalog.EUCHRE, GameCatalog.SEQUENCE),
|
||||
setOf(
|
||||
GameCatalog.EUCHRE,
|
||||
GameCatalog.SEQUENCE,
|
||||
GameCatalog.PRESIDENT,
|
||||
GameCatalog.GOLF,
|
||||
),
|
||||
GameCatalog.playable.map { it.id }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package org.prolibertate.games.game.golf
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
class GolfRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: GolfOptions = GolfOptions(), seed: Long = 3L) = TableConfig(
|
||||
gameId = "golf",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun card(rank: Rank, suit: Suit = Suit.CLUBS) = Card(rank, suit)
|
||||
|
||||
// -- Scoring ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `card values follow the golf table`() {
|
||||
assertEquals(0, golfValue(card(Rank.KING)))
|
||||
assertEquals(1, golfValue(card(Rank.ACE)))
|
||||
assertEquals(-2, golfValue(card(Rank.TWO)))
|
||||
assertEquals(10, golfValue(card(Rank.JACK)))
|
||||
assertEquals(10, golfValue(card(Rank.QUEEN)))
|
||||
assertEquals(7, golfValue(card(Rank.SEVEN)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a matching column cancels to nothing`() {
|
||||
val options = GolfOptions(gridSize = 6)
|
||||
// 2x3: columns are (0,3) (1,4) (2,5).
|
||||
val grid = listOf(
|
||||
card(Rank.NINE), card(Rank.FOUR), card(Rank.KING),
|
||||
card(Rank.NINE, Suit.HEARTS), card(Rank.SEVEN), card(Rank.KING, Suit.HEARTS),
|
||||
)
|
||||
// Nines cancel, kings are worth nothing anyway, 4 + 7 remain.
|
||||
assertEquals(11, scoreGrid(grid, options))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmatched grid scores the sum of its cards`() {
|
||||
val options = GolfOptions(gridSize = 4)
|
||||
val grid = listOf(
|
||||
card(Rank.THREE), card(Rank.FOUR),
|
||||
card(Rank.FIVE), card(Rank.SIX),
|
||||
)
|
||||
assertEquals(18, scoreGrid(grid, options))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `twos pull a score below zero`() {
|
||||
val options = GolfOptions(gridSize = 4)
|
||||
val grid = listOf(
|
||||
card(Rank.TWO), card(Rank.KING),
|
||||
card(Rank.THREE), card(Rank.KING, Suit.HEARTS),
|
||||
)
|
||||
assertEquals(1, scoreGrid(grid, options))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows cancel only on the nine card board`() {
|
||||
val threeByThree = GolfOptions(gridSize = 9)
|
||||
val grid = listOf(
|
||||
card(Rank.EIGHT), card(Rank.EIGHT, Suit.HEARTS), card(Rank.EIGHT, Suit.SPADES),
|
||||
card(Rank.THREE), card(Rank.FOUR), card(Rank.FIVE),
|
||||
card(Rank.SIX), card(Rank.SEVEN), card(Rank.NINE),
|
||||
)
|
||||
// The row of eights is wiped out, leaving 3+4+5+6+7+9.
|
||||
assertEquals(34, scoreGrid(grid, threeByThree))
|
||||
|
||||
// The same three-in-a-line on a 2x3 board is not a line at all.
|
||||
val twoByThree = GolfOptions(gridSize = 6)
|
||||
val flat = listOf(
|
||||
card(Rank.EIGHT), card(Rank.EIGHT, Suit.HEARTS), card(Rank.EIGHT, Suit.SPADES),
|
||||
card(Rank.THREE), card(Rank.FOUR), card(Rank.FIVE),
|
||||
)
|
||||
assertEquals(36, scoreGrid(flat, twoByThree))
|
||||
}
|
||||
|
||||
// -- Dealing ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `everyone gets a grid and two cards face up`() {
|
||||
val state = GolfRules.initialState(config(GolfOptions(playerCount = 3, gridSize = 6)))
|
||||
assertEquals(3, state.grids.size)
|
||||
state.grids.forEach { assertEquals(6, it.size) }
|
||||
state.revealed.forEach { assertEquals(2, it.count { shown -> shown }) }
|
||||
assertEquals(1, state.discard.size)
|
||||
assertEquals(GolfPhase.DRAW, state.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a big table uses two decks so the stock survives`() {
|
||||
val state = GolfRules.initialState(config(GolfOptions(playerCount = 6, gridSize = 9)))
|
||||
val dealt = 6 * 9
|
||||
assertTrue("stock must not be empty at the deal", state.stock.isNotEmpty())
|
||||
assertEquals(104, dealt + state.stock.size + state.discard.size)
|
||||
}
|
||||
|
||||
// -- Turn structure -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a turn is draw then place`() {
|
||||
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
assertEquals(setOf(DrawFromStock, DrawFromDiscard), GolfRules.legalMoves(state, 0).toSet())
|
||||
|
||||
state = GolfRules.applyMove(state, 0, DrawFromStock)
|
||||
assertEquals(GolfPhase.PLACE, state.phase)
|
||||
assertTrue(state.drawn != null)
|
||||
|
||||
val moves = GolfRules.legalMoves(state, 0)
|
||||
assertTrue("can place it anywhere", moves.filterIsInstance<ReplaceCard>().size == 6)
|
||||
assertTrue("or throw it and turn one over", moves.any { it is DiscardAndFlip })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a card taken from the discard pile must be used`() {
|
||||
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
state = GolfRules.applyMove(state, 0, DrawFromDiscard)
|
||||
val moves = GolfRules.legalMoves(state, 0)
|
||||
assertTrue("no throwing it back", moves.none { it is DiscardAndFlip })
|
||||
assertTrue(moves.all { it is ReplaceCard })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacing a card turns that slot face up and discards the old one`() {
|
||||
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
state = GolfRules.applyMove(state, 0, DrawFromStock)
|
||||
val drawn = state.drawn!!
|
||||
val replacedCard = state.grids[0][5]
|
||||
|
||||
state = GolfRules.applyMove(state, 0, ReplaceCard(5))
|
||||
assertEquals(drawn, state.grids[0][5])
|
||||
assertTrue(state.revealed[0][5])
|
||||
assertEquals(replacedCard, state.discard.last())
|
||||
assertEquals("turn passes", 1, state.turn)
|
||||
assertEquals(GolfPhase.DRAW, state.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throwing the drawn card turns one of your own over`() {
|
||||
var state = GolfRules.initialState(config(GolfOptions(playerCount = 2)))
|
||||
state = GolfRules.applyMove(state, 0, DrawFromStock)
|
||||
val drawn = state.drawn!!
|
||||
assertFalse(state.revealed[0][4])
|
||||
|
||||
state = GolfRules.applyMove(state, 0, DiscardAndFlip(4))
|
||||
assertTrue(state.revealed[0][4])
|
||||
assertEquals(drawn, state.discard.last())
|
||||
}
|
||||
|
||||
// -- Closing and scoring ------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `turning the last card over gives everyone else one more turn`() {
|
||||
val options = GolfOptions(playerCount = 3, gridSize = 4, startingReveals = 3, holes = 1)
|
||||
var state = GolfRules.initialState(config(options))
|
||||
// Seat 0 has one card left face down; use it to close.
|
||||
state = GolfRules.applyMove(state, 0, DrawFromStock)
|
||||
state = GolfRules.applyMove(state, 0, DiscardAndFlip(3))
|
||||
|
||||
assertEquals(0, state.closedBy)
|
||||
assertEquals("one turn each for the other two", 2, state.finalTurnsLeft)
|
||||
assertFalse(GolfRules.isFinished(state))
|
||||
|
||||
// Those two turns play out, then the hole is scored.
|
||||
repeat(2) {
|
||||
val seat = GolfRules.currentSeat(state)!!
|
||||
state = GolfRules.applyMove(state, seat, GolfRules.legalMoves(state, seat).first())
|
||||
state = GolfRules.applyMove(state, seat, GolfRules.legalMoves(state, seat).first())
|
||||
}
|
||||
assertTrue("single-hole game is over", GolfRules.isFinished(state))
|
||||
assertTrue("everything is turned up to count", state.revealed.all { row -> row.all { it } })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scores accumulate across holes`() {
|
||||
val options = GolfOptions(playerCount = 2, gridSize = 4, startingReveals = 4, holes = 3)
|
||||
var state = GolfRules.initialState(config(options))
|
||||
var guard = 0
|
||||
while (state.hole == 0 && !GolfRules.isFinished(state) && guard++ < 400) {
|
||||
val seat = GolfRules.currentSeat(state) ?: break
|
||||
state = GolfRules.applyMove(state, seat, GolfRules.legalMoves(state, seat).first())
|
||||
}
|
||||
assertEquals(GolfPhase.HOLE_OVER, state.phase)
|
||||
val afterFirst = state.scores.toList()
|
||||
|
||||
state = GolfRules.nextHole(state)
|
||||
assertEquals(1, state.hole)
|
||||
assertEquals("running totals carry over", afterFirst, state.scores)
|
||||
}
|
||||
|
||||
// -- Full games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always finishes`() {
|
||||
repeat(15) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = GolfRules.initialState(
|
||||
config(GolfOptions(playerCount = 3, gridSize = 6, holes = 2), seed = iteration.toLong())
|
||||
)
|
||||
var guard = 0
|
||||
while (!GolfRules.isFinished(state) && guard++ < 8000) {
|
||||
val seat = GolfRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = GolfRules.nextHole(state)
|
||||
continue
|
||||
}
|
||||
val legal = GolfRules.legalMoves(state, seat)
|
||||
assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty())
|
||||
state = GolfRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("game $iteration did not finish", GolfRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai play always finishes and beats random play on average`() {
|
||||
val ai = GolfAi()
|
||||
var aiTotal = 0
|
||||
var randomTotal = 0
|
||||
val games = 12
|
||||
|
||||
repeat(games) { iteration ->
|
||||
val random = Random(9000L + iteration)
|
||||
var state = GolfRules.initialState(
|
||||
config(GolfOptions(playerCount = 2, gridSize = 6, holes = 3), seed = 700L + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!GolfRules.isFinished(state) && guard++ < 8000) {
|
||||
val seat = GolfRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = GolfRules.nextHole(state)
|
||||
continue
|
||||
}
|
||||
val legal = GolfRules.legalMoves(state, seat)
|
||||
// Seat 0 plays properly, seat 1 flails.
|
||||
val move = if (seat == 0) {
|
||||
ai.chooseMove(state, seat, legal)
|
||||
} else {
|
||||
legal[random.nextInt(legal.size)]
|
||||
}
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = GolfRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue(GolfRules.isFinished(state))
|
||||
aiTotal += state.scores[0]
|
||||
randomTotal += state.scores[1]
|
||||
}
|
||||
|
||||
// Golf is won by the lowest score, so the AI should be under random.
|
||||
assertTrue(
|
||||
"ai scored $aiTotal against random's $randomTotal — no better than flailing",
|
||||
aiTotal < randomTotal,
|
||||
)
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a view hides face-down cards including your own`() {
|
||||
val state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6)))
|
||||
val view = GolfRules.viewFor(state, seat = 0)
|
||||
|
||||
for (index in 0 until 6) {
|
||||
if (state.revealed[0][index]) {
|
||||
assertEquals(state.grids[0][index], view.grids[0][index])
|
||||
} else {
|
||||
assertEquals("face-down cards are masked", HIDDEN_CARD, view.grids[0][index])
|
||||
}
|
||||
}
|
||||
assertTrue("the stock order is hidden too", view.stock.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `masking does not leak the real card`() {
|
||||
// A grid deliberately built with no twos of spades in it, so if the
|
||||
// mask ever showed through it would be obvious.
|
||||
val state = GolfRules.initialState(config(GolfOptions(playerCount = 2, gridSize = 6), seed = 42L))
|
||||
val view = GolfRules.viewFor(state, seat = 1)
|
||||
val hiddenIndices = (0 until 6).filter { !state.revealed[1][it] }
|
||||
hiddenIndices.forEach { index ->
|
||||
val real = state.grids[1][index]
|
||||
if (real != HIDDEN_CARD) {
|
||||
assertNotEquals("the real card must not survive redaction", real, view.grids[1][index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = GolfRules.initialState(config())
|
||||
assertEquals(state, GolfRules.decodeState(GolfRules.encodeState(state)))
|
||||
listOf(DrawFromStock, DrawFromDiscard, ReplaceCard(2), DiscardAndFlip(5)).forEach {
|
||||
assertEquals(it, GolfRules.decodeMove(GolfRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package org.prolibertate.games.game.president
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.cards.Card
|
||||
import org.prolibertate.games.game.cards.Rank
|
||||
import org.prolibertate.games.game.cards.Suit
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
class PresidentRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(
|
||||
options: PresidentOptions = PresidentOptions(),
|
||||
seed: Long = 11L,
|
||||
) = TableConfig(
|
||||
gameId = "president",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
// -- Ranking ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `threes are low and twos are high`() {
|
||||
assertTrue(presidentRank(Card(Rank.THREE, Suit.CLUBS)) < presidentRank(Card(Rank.TEN, Suit.CLUBS)))
|
||||
assertTrue(presidentRank(Card(Rank.KING, Suit.CLUBS)) < presidentRank(Card(Rank.ACE, Suit.CLUBS)))
|
||||
assertTrue(
|
||||
"a two outranks an ace",
|
||||
presidentRank(Card(Rank.TWO, Suit.CLUBS)) > presidentRank(Card(Rank.ACE, Suit.CLUBS)),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Dealing ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the whole deck is dealt out`() {
|
||||
val state = PresidentRules.initialState(config())
|
||||
assertEquals(52, state.hands.sumOf { it.size })
|
||||
assertEquals(52, state.hands.flatten().distinct().size)
|
||||
assertTrue("hands differ by at most one card",
|
||||
state.hands.maxOf { it.size } - state.hands.minOf { it.size } <= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lowest card leads the first round`() {
|
||||
val state = PresidentRules.initialState(config())
|
||||
val lowestHolder = state.hands.indices.minByOrNull { seat ->
|
||||
state.hands[seat].minOf { presidentRank(it) }
|
||||
}
|
||||
assertEquals(lowestHolder, state.turn)
|
||||
}
|
||||
|
||||
// -- Leading and following ----------------------------------------------
|
||||
|
||||
private fun playingState(
|
||||
hands: List<List<Card>>,
|
||||
setSize: Int = 0,
|
||||
setRank: Int = 0,
|
||||
turn: Int = 0,
|
||||
options: PresidentOptions = PresidentOptions(playerCount = 4),
|
||||
pile: List<Card> = emptyList(),
|
||||
) = PresidentState(
|
||||
options = options,
|
||||
seed = 1L,
|
||||
roundNumber = 0,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
pile = pile,
|
||||
setSize = setSize,
|
||||
setRank = setRank,
|
||||
turn = turn,
|
||||
passed = List(options.playerCount) { false },
|
||||
finishedOrder = emptyList(),
|
||||
previousFinish = List(options.playerCount) { -1 },
|
||||
scores = List(options.playerCount) { 0 },
|
||||
phase = PresidentPhase.PLAYING,
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
private fun hand(vararg cards: Pair<Rank, Suit>) = cards.map { Card(it.first, it.second) }
|
||||
|
||||
@Test
|
||||
fun `leading offers every set size of every rank held`() {
|
||||
val h = hand(
|
||||
Rank.FIVE to Suit.CLUBS, Rank.FIVE to Suit.HEARTS, Rank.FIVE to Suit.SPADES,
|
||||
Rank.NINE to Suit.CLUBS,
|
||||
)
|
||||
val state = playingState(listOf(h, emptyList(), emptyList(), emptyList()))
|
||||
val plays = PresidentRules.legalMoves(state, 0).filterIsInstance<PlayCards>()
|
||||
assertEquals("singles, pairs and triples of fives, plus the nine", 4, plays.size)
|
||||
assertTrue(plays.any { it.cards.size == 3 })
|
||||
assertFalse("nothing to pass on when leading",
|
||||
PresidentRules.legalMoves(state, 0).contains(PassTurn))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `following must match the set size and beat the rank`() {
|
||||
val h = hand(
|
||||
Rank.FOUR to Suit.CLUBS, Rank.FOUR to Suit.HEARTS,
|
||||
Rank.KING to Suit.CLUBS, Rank.KING to Suit.HEARTS,
|
||||
Rank.SEVEN to Suit.SPADES,
|
||||
)
|
||||
val state = playingState(
|
||||
hands = listOf(h, emptyList(), emptyList(), emptyList()),
|
||||
setSize = 2,
|
||||
setRank = presidentRank(Card(Rank.NINE, Suit.CLUBS)),
|
||||
)
|
||||
val plays = PresidentRules.legalMoves(state, 0).filterIsInstance<PlayCards>()
|
||||
assertTrue("only the kings beat a pair of nines", plays.all { it.cards.first().rank == Rank.KING })
|
||||
assertTrue(plays.all { it.cards.size == 2 })
|
||||
assertTrue("passing is allowed", PresidentRules.legalMoves(state, 0).contains(PassTurn))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a two beats anything and clears the pile`() {
|
||||
val h = hand(Rank.TWO to Suit.CLUBS, Rank.FOUR to Suit.HEARTS)
|
||||
var state = playingState(
|
||||
hands = listOf(h, hand(Rank.SIX to Suit.CLUBS), emptyList(), emptyList()),
|
||||
setSize = 1,
|
||||
setRank = presidentRank(Card(Rank.ACE, Suit.SPADES)),
|
||||
pile = hand(Rank.ACE to Suit.SPADES),
|
||||
)
|
||||
val two = PlayCards(hand(Rank.TWO to Suit.CLUBS))
|
||||
assertTrue(PresidentRules.legalMoves(state, 0).contains(two))
|
||||
|
||||
state = PresidentRules.applyMove(state, 0, two)
|
||||
assertEquals("pile is cleared", 0, state.setSize)
|
||||
assertTrue(state.pile.isEmpty())
|
||||
assertEquals("and the same player leads again", 0, state.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with twos clear disabled a two is just the highest card`() {
|
||||
val options = PresidentOptions(playerCount = 4, twosClear = false)
|
||||
val h = hand(Rank.TWO to Suit.CLUBS)
|
||||
var state = playingState(
|
||||
hands = listOf(h, hand(Rank.SIX to Suit.CLUBS), emptyList(), emptyList()),
|
||||
setSize = 1,
|
||||
setRank = presidentRank(Card(Rank.ACE, Suit.SPADES)),
|
||||
options = options,
|
||||
pile = hand(Rank.ACE to Suit.SPADES),
|
||||
)
|
||||
state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.TWO to Suit.CLUBS)))
|
||||
assertEquals("the pile stands", 1, state.setSize)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when everyone else passes the pile is taken down`() {
|
||||
val hands = listOf(
|
||||
hand(Rank.FIVE to Suit.CLUBS, Rank.SIX to Suit.CLUBS),
|
||||
hand(Rank.FOUR to Suit.HEARTS),
|
||||
hand(Rank.THREE to Suit.SPADES),
|
||||
)
|
||||
val options = PresidentOptions(playerCount = 3)
|
||||
var state = playingState(hands = hands, options = options)
|
||||
|
||||
state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS)))
|
||||
assertEquals(1, state.turn)
|
||||
state = PresidentRules.applyMove(state, 1, PassTurn)
|
||||
state = PresidentRules.applyMove(state, 2, PassTurn)
|
||||
|
||||
assertEquals("pile cleared", 0, state.setSize)
|
||||
assertEquals("the last player to play leads again", 0, state.turn)
|
||||
assertTrue(state.passed.none { it })
|
||||
}
|
||||
|
||||
// -- Going out and scoring ----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `going out records a finishing position`() {
|
||||
val hands = listOf(
|
||||
hand(Rank.FIVE to Suit.CLUBS),
|
||||
hand(Rank.SIX to Suit.HEARTS, Rank.SEVEN to Suit.HEARTS),
|
||||
hand(Rank.EIGHT to Suit.SPADES, Rank.NINE to Suit.SPADES),
|
||||
)
|
||||
val options = PresidentOptions(playerCount = 3)
|
||||
var state = playingState(hands = hands, options = options)
|
||||
state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS)))
|
||||
assertEquals(listOf(0), state.finishedOrder)
|
||||
assertTrue(state.isOut(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a round ends when only one player still holds cards`() {
|
||||
val options = PresidentOptions(playerCount = 3, roundsToPlay = 2)
|
||||
var state = playingState(
|
||||
hands = listOf(
|
||||
hand(Rank.FIVE to Suit.CLUBS),
|
||||
hand(Rank.SIX to Suit.HEARTS),
|
||||
hand(Rank.EIGHT to Suit.SPADES, Rank.NINE to Suit.SPADES),
|
||||
),
|
||||
options = options,
|
||||
)
|
||||
state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS)))
|
||||
state = PresidentRules.applyMove(state, 1, PlayCards(hand(Rank.SIX to Suit.HEARTS)))
|
||||
|
||||
assertEquals(PresidentPhase.ROUND_OVER, state.phase)
|
||||
assertEquals("the straggler takes last place", listOf(0, 1, 2), state.finishedOrder)
|
||||
assertEquals("President scores most", 2, state.scores[0])
|
||||
assertEquals(1, state.scores[1])
|
||||
assertEquals(0, state.scores[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last round ends the game`() {
|
||||
val options = PresidentOptions(playerCount = 3, roundsToPlay = 1)
|
||||
var state = playingState(
|
||||
hands = listOf(
|
||||
hand(Rank.FIVE to Suit.CLUBS),
|
||||
hand(Rank.SIX to Suit.HEARTS),
|
||||
hand(Rank.EIGHT to Suit.SPADES, Rank.NINE to Suit.SPADES),
|
||||
),
|
||||
options = options,
|
||||
)
|
||||
state = PresidentRules.applyMove(state, 0, PlayCards(hand(Rank.FIVE to Suit.CLUBS)))
|
||||
state = PresidentRules.applyMove(state, 1, PlayCards(hand(Rank.SIX to Suit.HEARTS)))
|
||||
assertTrue(PresidentRules.isFinished(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `titles run from President down to Scum`() {
|
||||
assertEquals("President", titleFor(0, 4))
|
||||
assertEquals("Vice President", titleFor(1, 4))
|
||||
assertEquals("Vice Scum", titleFor(2, 4))
|
||||
assertEquals("Scum", titleFor(3, 4))
|
||||
assertEquals("Citizen", titleFor(2, 6))
|
||||
}
|
||||
|
||||
// -- Full games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always reaches a winner`() {
|
||||
repeat(20) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = PresidentRules.initialState(
|
||||
config(PresidentOptions(playerCount = 4, roundsToPlay = 3), seed = iteration.toLong())
|
||||
)
|
||||
var guard = 0
|
||||
while (!PresidentRules.isFinished(state) && guard++ < 6000) {
|
||||
val seat = PresidentRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = PresidentRules.nextRound(state)
|
||||
continue
|
||||
}
|
||||
val legal = PresidentRules.legalMoves(state, seat)
|
||||
assertTrue("seat $seat had no legal move", legal.isNotEmpty())
|
||||
state = PresidentRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("game $iteration did not finish", PresidentRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai play always reaches a winner`() {
|
||||
val ai = PresidentAi()
|
||||
repeat(10) { iteration ->
|
||||
var state = PresidentRules.initialState(
|
||||
config(PresidentOptions(playerCount = 5, roundsToPlay = 2), seed = 400L + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!PresidentRules.isFinished(state) && guard++ < 6000) {
|
||||
val seat = PresidentRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = PresidentRules.nextRound(state)
|
||||
continue
|
||||
}
|
||||
val legal = PresidentRules.legalMoves(state, seat)
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = PresidentRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue("ai game did not finish", PresidentRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `card exchange moves the best cards to the president`() {
|
||||
val options = PresidentOptions(playerCount = 4, roundsToPlay = 3)
|
||||
var state = PresidentRules.initialState(config(options, seed = 5L))
|
||||
var guard = 0
|
||||
while (state.phase == PresidentPhase.PLAYING && guard++ < 6000) {
|
||||
val seat = PresidentRules.currentSeat(state)!!
|
||||
val legal = PresidentRules.legalMoves(state, seat)
|
||||
state = PresidentRules.applyMove(state, seat, legal.first())
|
||||
}
|
||||
assertEquals(PresidentPhase.ROUND_OVER, state.phase)
|
||||
|
||||
val president = state.finishedOrder.first()
|
||||
val scum = state.finishedOrder.last()
|
||||
val next = PresidentRules.nextRound(state)
|
||||
|
||||
assertEquals("still a full deck", 52, next.hands.sumOf { it.size })
|
||||
assertEquals("the scum leads the new round", scum, next.turn)
|
||||
assertTrue(
|
||||
"the president holds a high card after the swap",
|
||||
next.hands[president].maxOf { presidentRank(it) } >= 13,
|
||||
)
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides every other hand`() {
|
||||
val state = PresidentRules.initialState(config())
|
||||
val view = PresidentRules.viewFor(state, seat = 1)
|
||||
assertEquals(state.hands[1], view.hands[1])
|
||||
assertTrue(view.hands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() })
|
||||
assertEquals(state.handCounts, view.handCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = PresidentRules.initialState(config())
|
||||
assertEquals(state, PresidentRules.decodeState(PresidentRules.encodeState(state)))
|
||||
listOf(
|
||||
PassTurn,
|
||||
PlayCards(hand(Rank.FIVE to Suit.CLUBS, Rank.FIVE to Suit.HEARTS)),
|
||||
).forEach {
|
||||
assertEquals(it, PresidentRules.decodeMove(PresidentRules.encodeMove(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user