Add app shell with Euchre and Sequence, LAN/Bluetooth play
First vertical slice: the whole shell plus two games playable end to end, so every layer is exercised before the other nine games are built on it. Games - Euchre: bowers, ordering up, dealer discard, going alone, stick the dealer, and full scoring. Rule variants (deck size, points to win, stick the dealer, going alone) are toggles in game setup. - Sequence: two-eyed and one-eyed jacks, free corners, locked sequences, the shared-chip limit, and dead-card exchange. Teams, players per team and the sequence target are toggles. - The board layout in SequenceBoard is generated, not the commercial board — it holds every structural property the rules need, but does not match a physical board. Documented in docs/RULES-sequence.md. Multiplayer - One transport abstraction over newline-delimited JSON, implemented twice: LAN via NSD discovery and TCP, Bluetooth via Classic RFCOMM. A lobby advertises on both at once, so a table can mix them. - Host-authoritative: clients send move intents, the host re-validates every one against the rules, and each client receives a state redacted for its own seat so hidden information never crosses the wire. - Seats start as AI and are handed to people as they join, which is what makes any mix of humans and AI work without a separate code path. Shell - Main menu lists all eleven games; the nine without engines are marked coming soon and are driven entirely by GameCatalog. - Settings: sound, animation speed (one multiplier drives card movement and AI thinking time), and OTA updates from the latest GitHub release. - Layouts measure available space rather than assuming an orientation. The game package is free of Android types, so the rules engines and AI run under plain JUnit; 43 tests cover ranking, following suit, scoring, sequence detection, redaction, wire round-trips and full random and AI playthroughs. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
package org.prolibertate.games.game.euchre
|
||||
|
||||
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.Decks
|
||||
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 EuchreRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(options: EuchreOptions = EuchreOptions(), seed: Long = 42L) = TableConfig(
|
||||
gameId = "euchre",
|
||||
seats = (0 until 4).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
// -- Deck ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `24 card deck runs nine to ace`() {
|
||||
val deck = Decks.euchre(24)
|
||||
assertEquals(24, deck.size)
|
||||
assertEquals(24, deck.distinct().size)
|
||||
assertTrue(deck.none { it.rank.order < Rank.NINE.order })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `32 card deck runs seven to ace`() {
|
||||
val deck = Decks.euchre(32)
|
||||
assertEquals(32, deck.size)
|
||||
assertTrue(deck.any { it.rank == Rank.SEVEN })
|
||||
}
|
||||
|
||||
// -- Bower ranking ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `left bower counts as trump not its printed suit`() {
|
||||
val trump = Suit.SPADES
|
||||
val leftBower = Card(Rank.JACK, Suit.CLUBS)
|
||||
assertTrue(isLeftBower(leftBower, trump))
|
||||
assertEquals(Suit.SPADES, effectiveSuit(leftBower, trump))
|
||||
assertTrue(isTrumpCard(leftBower, trump))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right bower outranks left bower which outranks the ace of trump`() {
|
||||
val trump = Suit.SPADES
|
||||
val right = trickStrength(Card(Rank.JACK, Suit.SPADES), trump, trump)
|
||||
val left = trickStrength(Card(Rank.JACK, Suit.CLUBS), trump, trump)
|
||||
val aceTrump = trickStrength(Card(Rank.ACE, Suit.SPADES), trump, trump)
|
||||
assertTrue("right > left", right > left)
|
||||
assertTrue("left > ace of trump", left > aceTrump)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `off suit card cannot win a trick`() {
|
||||
val trump = Suit.SPADES
|
||||
val ledSuit = Suit.HEARTS
|
||||
val offSuit = trickStrength(Card(Rank.ACE, Suit.DIAMONDS), trump, ledSuit)
|
||||
assertEquals(0, offSuit)
|
||||
}
|
||||
|
||||
// -- Following suit -----------------------------------------------------
|
||||
|
||||
private fun playingState(
|
||||
hands: List<List<Card>>,
|
||||
trump: Suit,
|
||||
trick: List<PlayedCard> = emptyList(),
|
||||
turn: Int = 0,
|
||||
) = EuchreState(
|
||||
options = EuchreOptions(),
|
||||
seed = 1L,
|
||||
handNumber = 0,
|
||||
dealer = 3,
|
||||
phase = EuchrePhase.PLAYING,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
upCard = null,
|
||||
turnedDown = null,
|
||||
trump = trump,
|
||||
maker = 0,
|
||||
aloneSeat = null,
|
||||
turn = turn,
|
||||
passes = 0,
|
||||
trick = trick,
|
||||
leader = 0,
|
||||
tricksWon = listOf(0, 0, 0, 0),
|
||||
scores = listOf(0, 0),
|
||||
lastTrickWinner = null,
|
||||
log = emptyList(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `left bower must be played when trump is led`() {
|
||||
val trump = Suit.SPADES
|
||||
val hand = listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.KING, Suit.HEARTS))
|
||||
val state = playingState(
|
||||
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||
trump = trump,
|
||||
trick = listOf(PlayedCard(0, Card(Rank.NINE, Suit.SPADES))),
|
||||
turn = 1,
|
||||
)
|
||||
val playable = EuchreRules.playableCards(state, 1)
|
||||
assertEquals(listOf(Card(Rank.JACK, Suit.CLUBS)), playable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left bower does not satisfy following its printed suit`() {
|
||||
val trump = Suit.SPADES
|
||||
// Clubs is led, and the only club held is the left bower — which is a
|
||||
// spade for this hand, so the player is void in clubs and may play
|
||||
// anything.
|
||||
val hand = listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.KING, Suit.HEARTS))
|
||||
val state = playingState(
|
||||
hands = listOf(emptyList(), hand, emptyList(), emptyList()),
|
||||
trump = trump,
|
||||
trick = listOf(PlayedCard(0, Card(Rank.NINE, Suit.CLUBS))),
|
||||
turn = 1,
|
||||
)
|
||||
assertEquals(hand.toSet(), EuchreRules.playableCards(state, 1).toSet())
|
||||
}
|
||||
|
||||
// -- Bidding ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `ordering up sets trump and sends the dealer to discard`() {
|
||||
val state = EuchreRules.initialState(config())
|
||||
val upSuit = state.upCard!!.suit
|
||||
val next = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false))
|
||||
assertEquals(EuchrePhase.DEALER_DISCARD, next.phase)
|
||||
assertEquals(upSuit, next.trump)
|
||||
assertEquals(next.dealer, next.turn)
|
||||
assertEquals(6, next.hands[next.dealer].size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dealer discard returns the hand to five cards and starts play`() {
|
||||
var state = EuchreRules.initialState(config())
|
||||
state = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false))
|
||||
val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard
|
||||
state = EuchreRules.applyMove(state, state.dealer, discard)
|
||||
assertEquals(EuchrePhase.PLAYING, state.phase)
|
||||
assertEquals(5, state.hands[state.dealer].size)
|
||||
assertEquals((state.dealer + 1) % 4, state.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four passes turn the card down and open the second round`() {
|
||||
var state = EuchreRules.initialState(config())
|
||||
val up = state.upCard!!
|
||||
repeat(4) { state = EuchreRules.applyMove(state, state.turn, Pass) }
|
||||
assertEquals(EuchrePhase.BID_ROUND_2, state.phase)
|
||||
assertEquals(up, state.turnedDown)
|
||||
assertTrue("turned-down suit cannot be named",
|
||||
EuchreRules.legalMoves(state, state.turn)
|
||||
.filterIsInstance<CallTrump>()
|
||||
.none { it.suit == up.suit })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stick the dealer removes the dealers option to pass`() {
|
||||
var state = EuchreRules.initialState(config(EuchreOptions(stickTheDealer = true)))
|
||||
repeat(4) { state = EuchreRules.applyMove(state, state.turn, Pass) }
|
||||
repeat(3) { state = EuchreRules.applyMove(state, state.turn, Pass) }
|
||||
assertEquals(state.dealer, state.turn)
|
||||
val moves = EuchreRules.legalMoves(state, state.dealer)
|
||||
assertFalse("dealer must name a suit", moves.contains(Pass))
|
||||
assertTrue(moves.all { it is CallTrump })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without stick the dealer a passed out hand is redealt`() {
|
||||
var state = EuchreRules.initialState(config(EuchreOptions(stickTheDealer = false)))
|
||||
val firstDealer = state.dealer
|
||||
repeat(8) { state = EuchreRules.applyMove(state, state.turn, Pass) }
|
||||
assertEquals(EuchrePhase.BID_ROUND_1, state.phase)
|
||||
assertEquals((firstDealer + 1) % 4, state.dealer)
|
||||
}
|
||||
|
||||
// -- Going alone --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `going alone sits the partner out`() {
|
||||
var state = EuchreRules.initialState(config())
|
||||
val bidder = state.turn
|
||||
state = EuchreRules.applyMove(state, bidder, OrderUp(alone = true))
|
||||
val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard
|
||||
state = EuchreRules.applyMove(state, state.dealer, discard)
|
||||
assertEquals(bidder, state.aloneSeat)
|
||||
assertEquals(partnerOf(bidder), state.sittingOut)
|
||||
assertEquals(3, state.activeSeatCount)
|
||||
assertTrue(state.hands[partnerOf(bidder)].isEmpty())
|
||||
assertTrue("the sitting-out seat never gets the turn", state.turn != state.sittingOut)
|
||||
}
|
||||
|
||||
// -- Scoring ------------------------------------------------------------
|
||||
|
||||
/** Plays a hand out with a fixed strategy so scoring can be asserted. */
|
||||
private fun playOutHand(state: EuchreState, pick: (EuchreState, Int) -> EuchreMove): EuchreState {
|
||||
var current = state
|
||||
var guard = 0
|
||||
while (current.phase == EuchrePhase.PLAYING && guard++ < 100) {
|
||||
val seat = EuchreRules.currentSeat(current)!!
|
||||
current = EuchreRules.applyMove(current, seat, pick(current, seat))
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `taking all five tricks scores two, or four when alone`() {
|
||||
// Team 0 holds every trump; team 1 cannot win a trick.
|
||||
val trump = Suit.SPADES
|
||||
val strong = listOf(
|
||||
Card(Rank.JACK, Suit.SPADES), Card(Rank.JACK, Suit.CLUBS),
|
||||
Card(Rank.ACE, Suit.SPADES), Card(Rank.KING, Suit.SPADES),
|
||||
Card(Rank.QUEEN, Suit.SPADES),
|
||||
)
|
||||
val weak0 = listOf(
|
||||
Card(Rank.NINE, Suit.HEARTS), Card(Rank.TEN, Suit.HEARTS),
|
||||
Card(Rank.QUEEN, Suit.HEARTS), Card(Rank.KING, Suit.HEARTS),
|
||||
Card(Rank.ACE, Suit.HEARTS),
|
||||
)
|
||||
val weak1 = listOf(
|
||||
Card(Rank.NINE, Suit.DIAMONDS), Card(Rank.TEN, Suit.DIAMONDS),
|
||||
Card(Rank.QUEEN, Suit.DIAMONDS), Card(Rank.KING, Suit.DIAMONDS),
|
||||
Card(Rank.ACE, Suit.DIAMONDS),
|
||||
)
|
||||
val weak2 = listOf(
|
||||
Card(Rank.NINE, Suit.CLUBS), Card(Rank.TEN, Suit.CLUBS),
|
||||
Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS),
|
||||
Card(Rank.ACE, Suit.CLUBS),
|
||||
)
|
||||
|
||||
val state = playingState(
|
||||
hands = listOf(strong, weak0, weak1, weak2),
|
||||
trump = trump,
|
||||
).copy(maker = 0, leader = 0, turn = 0)
|
||||
|
||||
val finished = playOutHand(state) { s, seat ->
|
||||
PlayCard(EuchreRules.playableCards(s, seat).first())
|
||||
}
|
||||
assertEquals(5, finished.tricksWon[0])
|
||||
assertEquals(2, finished.scores[0])
|
||||
assertEquals(0, finished.scores[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failing to take three tricks hands the defenders two points`() {
|
||||
val trump = Suit.SPADES
|
||||
// Seat 0 makes it but holds nothing; seat 1 holds all the trump.
|
||||
val makerHand = listOf(
|
||||
Card(Rank.NINE, Suit.HEARTS), Card(Rank.TEN, Suit.HEARTS),
|
||||
Card(Rank.QUEEN, Suit.HEARTS), Card(Rank.KING, Suit.HEARTS),
|
||||
Card(Rank.ACE, Suit.HEARTS),
|
||||
)
|
||||
val trumpHand = listOf(
|
||||
Card(Rank.JACK, Suit.SPADES), Card(Rank.JACK, Suit.CLUBS),
|
||||
Card(Rank.ACE, Suit.SPADES), Card(Rank.KING, Suit.SPADES),
|
||||
Card(Rank.QUEEN, Suit.SPADES),
|
||||
)
|
||||
val filler1 = listOf(
|
||||
Card(Rank.NINE, Suit.DIAMONDS), Card(Rank.TEN, Suit.DIAMONDS),
|
||||
Card(Rank.QUEEN, Suit.DIAMONDS), Card(Rank.KING, Suit.DIAMONDS),
|
||||
Card(Rank.ACE, Suit.DIAMONDS),
|
||||
)
|
||||
val filler2 = listOf(
|
||||
Card(Rank.NINE, Suit.CLUBS), Card(Rank.TEN, Suit.CLUBS),
|
||||
Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS),
|
||||
Card(Rank.ACE, Suit.CLUBS),
|
||||
)
|
||||
|
||||
val state = playingState(
|
||||
hands = listOf(makerHand, trumpHand, filler1, filler2),
|
||||
trump = trump,
|
||||
).copy(maker = 0, leader = 0, turn = 0)
|
||||
|
||||
// Seat 1 always trumps in; everyone else follows or throws off.
|
||||
val finished = playOutHand(state) { s, seat ->
|
||||
val playable = EuchreRules.playableCards(s, seat)
|
||||
val card = if (seat == 1) {
|
||||
playable.maxByOrNull { trumpStrength(it, trump) }!!
|
||||
} else {
|
||||
playable.first()
|
||||
}
|
||||
PlayCard(card)
|
||||
}
|
||||
assertEquals(EuchrePhase.HAND_OVER, finished.phase)
|
||||
assertTrue("makers took fewer than three", finished.tricksWon[0] + finished.tricksWon[2] < 3)
|
||||
assertEquals(2, finished.scores[1])
|
||||
assertEquals(0, finished.scores[0])
|
||||
}
|
||||
|
||||
// -- Full games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play always reaches a winner`() {
|
||||
repeat(25) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = EuchreRules.initialState(config(seed = iteration.toLong()))
|
||||
var guard = 0
|
||||
while (!EuchreRules.isFinished(state) && guard++ < 5000) {
|
||||
val seat = EuchreRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = EuchreRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = EuchreRules.legalMoves(state, seat)
|
||||
assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty())
|
||||
state = EuchreRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("game $iteration did not finish", EuchreRules.isFinished(state))
|
||||
assertTrue(state.scores.max() >= state.options.pointsToWin)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai play always reaches a winner`() {
|
||||
val ai = EuchreAi()
|
||||
repeat(10) { iteration ->
|
||||
var state = EuchreRules.initialState(config(seed = 500L + iteration))
|
||||
var guard = 0
|
||||
while (!EuchreRules.isFinished(state) && guard++ < 5000) {
|
||||
val seat = EuchreRules.currentSeat(state)
|
||||
if (seat == null) {
|
||||
state = EuchreRules.nextHand(state)
|
||||
continue
|
||||
}
|
||||
val legal = EuchreRules.legalMoves(state, seat)
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = EuchreRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue("ai game $iteration did not finish", EuchreRules.isFinished(state))
|
||||
}
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides every other hand but keeps the counts`() {
|
||||
val state = EuchreRules.initialState(config())
|
||||
val view = EuchreRules.viewFor(state, seat = 2)
|
||||
assertEquals(state.hands[2], view.hands[2])
|
||||
assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() })
|
||||
assertEquals(listOf(5, 5, 5, 5), view.handCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = EuchreRules.initialState(config())
|
||||
val decoded = EuchreRules.decodeState(EuchreRules.encodeState(state))
|
||||
assertEquals(state, decoded)
|
||||
|
||||
val moves = listOf(
|
||||
Pass,
|
||||
OrderUp(alone = true),
|
||||
CallTrump(Suit.HEARTS, alone = false),
|
||||
Discard(Card(Rank.NINE, Suit.CLUBS)),
|
||||
PlayCard(Card(Rank.ACE, Suit.SPADES)),
|
||||
)
|
||||
moves.forEach { move ->
|
||||
assertEquals(move, EuchreRules.decodeMove(EuchreRules.encodeMove(move)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `illegal moves are rejected`() {
|
||||
val state = EuchreRules.initialState(config())
|
||||
val wrongSeat = (state.turn + 1) % 4
|
||||
var threw = false
|
||||
try {
|
||||
EuchreRules.applyMove(state, wrongSeat, Pass)
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
threw = true
|
||||
}
|
||||
assertTrue("playing out of turn must be rejected", threw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package org.prolibertate.games.game.sequence
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
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 SequenceRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(
|
||||
options: SequenceOptions = SequenceOptions(),
|
||||
seed: Long = 7L,
|
||||
) = TableConfig(
|
||||
gameId = "sequence",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(
|
||||
seat = it,
|
||||
name = "P$it",
|
||||
kind = PlayerKind.AI,
|
||||
team = it % options.teamCount,
|
||||
)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
// -- Board --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `board holds every non-jack card twice plus four free corners`() {
|
||||
assertEquals(BOARD_CELLS, SequenceBoard.cells.size)
|
||||
val cards = SequenceBoard.cells.filterNotNull()
|
||||
assertEquals(96, cards.size)
|
||||
assertEquals(4, SequenceBoard.cells.count { it == null })
|
||||
|
||||
val counts = cards.groupingBy { it }.eachCount()
|
||||
assertEquals("48 distinct cards", 48, counts.size)
|
||||
assertTrue("each appears exactly twice", counts.values.all { it == 2 })
|
||||
assertTrue("no jacks on the board", cards.none { it.rank == Rank.JACK })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the four corners are the free squares`() {
|
||||
listOf(
|
||||
SequenceBoard.cellAt(0, 0),
|
||||
SequenceBoard.cellAt(0, 9),
|
||||
SequenceBoard.cellAt(9, 0),
|
||||
SequenceBoard.cellAt(9, 9),
|
||||
).forEach { assertTrue(SequenceBoard.isCorner(it)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every non-jack card maps to exactly two squares`() {
|
||||
val nonJacks = Suit.entries.flatMap { suit ->
|
||||
Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) }
|
||||
}
|
||||
nonJacks.forEach { card ->
|
||||
assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jack classification splits one-eyed from two-eyed`() {
|
||||
assertTrue(isTwoEyedJack(Card(Rank.JACK, Suit.DIAMONDS)))
|
||||
assertTrue(isTwoEyedJack(Card(Rank.JACK, Suit.CLUBS)))
|
||||
assertTrue(isOneEyedJack(Card(Rank.JACK, Suit.HEARTS)))
|
||||
assertTrue(isOneEyedJack(Card(Rank.JACK, Suit.SPADES)))
|
||||
assertFalse(isOneEyedJack(Card(Rank.ACE, Suit.SPADES)))
|
||||
}
|
||||
|
||||
// -- Dealing ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `hand sizes follow the player count`() {
|
||||
assertEquals(7, handSizeFor(2))
|
||||
assertEquals(6, handSizeFor(3))
|
||||
assertEquals(6, handSizeFor(4))
|
||||
assertEquals(5, handSizeFor(6))
|
||||
assertEquals(4, handSizeFor(8))
|
||||
assertEquals(3, handSizeFor(12))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial deal gives everyone a hand from a 104 card deck`() {
|
||||
val options = SequenceOptions(teamCount = 2, playersPerTeam = 2)
|
||||
val state = SequenceRules.initialState(config(options))
|
||||
assertEquals(4, state.playerCount)
|
||||
state.hands.forEach { assertEquals(6, it.size) }
|
||||
assertEquals(104 - 4 * 6, state.drawPile.size)
|
||||
assertTrue(state.chips.all { it == NO_TEAM })
|
||||
}
|
||||
|
||||
// -- Placement ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `placing a chip claims the square and passes the turn`() {
|
||||
var state = SequenceRules.initialState(config())
|
||||
val move = SequenceRules.legalMoves(state, 0).filterIsInstance<PlaceChip>().first()
|
||||
state = SequenceRules.applyMove(state, 0, move)
|
||||
assertEquals(state.teams[0], state.chips[move.cell])
|
||||
assertEquals(1, state.turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a card whose squares are both taken becomes a dead card`() {
|
||||
val card = Card(Rank.ACE, Suit.HEARTS)
|
||||
val squares = SequenceBoard.squaresByCard.getValue(card)
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
squares.forEach { chips[it] = 1 }
|
||||
|
||||
val state = baseState(chips = chips, hand0 = listOf(card))
|
||||
val moves = SequenceRules.legalMoves(state, 0)
|
||||
assertTrue("no placement is possible", moves.none { it is PlaceChip })
|
||||
assertEquals(listOf(ExchangeDeadCard(card)), moves)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exchanging a dead card keeps the turn`() {
|
||||
val card = Card(Rank.ACE, Suit.HEARTS)
|
||||
val squares = SequenceBoard.squaresByCard.getValue(card)
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
squares.forEach { chips[it] = 1 }
|
||||
|
||||
var state = baseState(chips = chips, hand0 = listOf(card))
|
||||
state = SequenceRules.applyMove(state, 0, ExchangeDeadCard(card))
|
||||
assertEquals("turn does not pass", 0, state.turn)
|
||||
assertTrue(state.exchangedThisTurn)
|
||||
assertFalse("the dead card is gone", state.hands[0].contains(card))
|
||||
}
|
||||
|
||||
// -- Jacks --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a two-eyed jack can be placed on any empty square`() {
|
||||
val jack = Card(Rank.JACK, Suit.DIAMONDS)
|
||||
val state = baseState(hand0 = listOf(jack))
|
||||
val placements = SequenceRules.legalMoves(state, 0).filterIsInstance<PlaceChip>()
|
||||
// Every square except the four corners is available.
|
||||
assertEquals(BOARD_CELLS - 4, placements.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-eyed jack removes an opposing chip but not a locked one`() {
|
||||
val jack = Card(Rank.JACK, Suit.SPADES)
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
val target = SequenceBoard.cellAt(5, 5)
|
||||
val protectedCell = SequenceBoard.cellAt(6, 6)
|
||||
chips[target] = 1
|
||||
chips[protectedCell] = 1
|
||||
val locked = MutableList(BOARD_CELLS) { false }
|
||||
locked[protectedCell] = true
|
||||
|
||||
val state = baseState(chips = chips, locked = locked, hand0 = listOf(jack))
|
||||
val removals = SequenceRules.legalMoves(state, 0).filterIsInstance<RemoveChip>()
|
||||
assertEquals(listOf(target), removals.map { it.cell })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-eyed jack cannot remove your own teams chip`() {
|
||||
val jack = Card(Rank.JACK, Suit.SPADES)
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
chips[SequenceBoard.cellAt(4, 4)] = 0 // seat 0 is on team 0
|
||||
|
||||
val state = baseState(chips = chips, hand0 = listOf(jack))
|
||||
assertTrue(SequenceRules.legalMoves(state, 0).none { it is RemoveChip })
|
||||
}
|
||||
|
||||
// -- Sequences ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `five in a row completes a sequence and locks the squares`() {
|
||||
// Four chips already down in row 4; the card for the fifth is in hand.
|
||||
val fifth = SequenceBoard.cellAt(4, 4)
|
||||
val card = SequenceBoard.cells[fifth]!!
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 }
|
||||
|
||||
var state = baseState(chips = chips, hand0 = listOf(card))
|
||||
state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth))
|
||||
|
||||
assertEquals(1, state.sequencesByTeam[0])
|
||||
(0..4).forEach { col ->
|
||||
assertTrue("(4,$col) locked", state.locked[SequenceBoard.cellAt(4, col)])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a corner counts as a free square for the run`() {
|
||||
// Corner (0,0) plus (0,1)..(0,3) held, needing only (0,4).
|
||||
val fifth = SequenceBoard.cellAt(0, 4)
|
||||
val card = SequenceBoard.cells[fifth]!!
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
listOf(1, 2, 3).forEach { chips[SequenceBoard.cellAt(0, it)] = 0 }
|
||||
|
||||
var state = baseState(chips = chips, hand0 = listOf(card))
|
||||
state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth))
|
||||
assertEquals("the free corner completed the five", 1, state.sequencesByTeam[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a diagonal run counts`() {
|
||||
val fifth = SequenceBoard.cellAt(4, 4)
|
||||
val card = SequenceBoard.cells[fifth]!!
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(it, it)] = 0 }
|
||||
|
||||
var state = baseState(chips = chips, hand0 = listOf(card))
|
||||
state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth))
|
||||
assertEquals(1, state.sequencesByTeam[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an opposing chip breaks the run`() {
|
||||
val fifth = SequenceBoard.cellAt(4, 4)
|
||||
val card = SequenceBoard.cells[fifth]!!
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
listOf(0, 1, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 }
|
||||
chips[SequenceBoard.cellAt(4, 2)] = 1
|
||||
|
||||
var state = baseState(chips = chips, hand0 = listOf(card))
|
||||
state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth))
|
||||
assertEquals(0, state.sequencesByTeam[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two sequences may share at most one chip`() {
|
||||
// A full row of nine for team 0. Placing the tenth cannot manufacture
|
||||
// an extra sequence out of heavily overlapping windows.
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
val locked = MutableList(BOARD_CELLS) { false }
|
||||
(0..4).forEach {
|
||||
chips[SequenceBoard.cellAt(6, it)] = 0
|
||||
locked[SequenceBoard.cellAt(6, it)] = true
|
||||
}
|
||||
(5..8).forEach { chips[SequenceBoard.cellAt(6, it)] = 0 }
|
||||
|
||||
val fifth = SequenceBoard.cellAt(6, 9)
|
||||
val card = SequenceBoard.cells[fifth]!!
|
||||
var state = baseState(chips = chips, locked = locked, hand0 = listOf(card))
|
||||
.copy(sequencesByTeam = listOf(1, 0))
|
||||
state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth))
|
||||
|
||||
// Squares 5..9 contain no already-locked chip, so this is a clean second.
|
||||
assertEquals(2, state.sequencesByTeam[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reaching the sequence target wins the game`() {
|
||||
val chips = MutableList(BOARD_CELLS) { NO_TEAM }
|
||||
listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 }
|
||||
val fifth = SequenceBoard.cellAt(4, 4)
|
||||
val card = SequenceBoard.cells[fifth]!!
|
||||
|
||||
var state = baseState(
|
||||
chips = chips,
|
||||
hand0 = listOf(card),
|
||||
options = SequenceOptions(teamCount = 2, sequencesToWin = 1),
|
||||
)
|
||||
state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth))
|
||||
assertTrue(SequenceRules.isFinished(state))
|
||||
assertEquals(0, state.winner)
|
||||
assertEquals(SequencePhase.GAME_OVER, state.phase)
|
||||
}
|
||||
|
||||
// -- Full games ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `random legal play terminates without an illegal state`() {
|
||||
repeat(10) { iteration ->
|
||||
val random = Random(iteration.toLong())
|
||||
var state = SequenceRules.initialState(config(seed = iteration.toLong()))
|
||||
var guard = 0
|
||||
while (!SequenceRules.isFinished(state) && guard++ < 4000) {
|
||||
val seat = SequenceRules.currentSeat(state) ?: break
|
||||
val legal = SequenceRules.legalMoves(state, seat)
|
||||
if (legal.isEmpty()) break // a blocked table is a legitimate draw
|
||||
state = SequenceRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("ran away", guard < 4000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai play produces only legal moves and reaches a result`() {
|
||||
val ai = SequenceAi()
|
||||
repeat(5) { iteration ->
|
||||
var state = SequenceRules.initialState(
|
||||
config(SequenceOptions(teamCount = 2, playersPerTeam = 1), seed = 900L + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!SequenceRules.isFinished(state) && guard++ < 4000) {
|
||||
val seat = SequenceRules.currentSeat(state) ?: break
|
||||
val legal = SequenceRules.legalMoves(state, seat)
|
||||
if (legal.isEmpty()) break
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = SequenceRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue("ai game ran away", guard < 4000)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides other hands and the deck order`() {
|
||||
val state = SequenceRules.initialState(config(SequenceOptions(playersPerTeam = 2)))
|
||||
val view = SequenceRules.viewFor(state, seat = 1)
|
||||
assertEquals(state.hands[1], view.hands[1])
|
||||
assertTrue(view.hands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() })
|
||||
assertTrue("deck order is hidden", view.drawPile.isEmpty())
|
||||
assertEquals(state.handCounts, view.handCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
val state = SequenceRules.initialState(config())
|
||||
assertEquals(state, SequenceRules.decodeState(SequenceRules.encodeState(state)))
|
||||
|
||||
val moves = listOf(
|
||||
PlaceChip(Card(Rank.ACE, Suit.HEARTS), 12),
|
||||
RemoveChip(Card(Rank.JACK, Suit.SPADES), 40),
|
||||
ExchangeDeadCard(Card(Rank.TWO, Suit.CLUBS)),
|
||||
)
|
||||
moves.forEach { assertEquals(it, SequenceRules.decodeMove(SequenceRules.encodeMove(it))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `summary reports the running sequence count`() {
|
||||
val state = SequenceRules.initialState(config())
|
||||
assertNotNull(SequenceRules.summary(state))
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------------
|
||||
|
||||
private fun baseState(
|
||||
chips: List<Int> = List(BOARD_CELLS) { NO_TEAM },
|
||||
locked: List<Boolean> = List(BOARD_CELLS) { false },
|
||||
hand0: List<Card>,
|
||||
options: SequenceOptions = SequenceOptions(),
|
||||
): SequenceState {
|
||||
val hands = listOf(hand0) + List(options.playerCount - 1) { emptyList<Card>() }
|
||||
return SequenceState(
|
||||
options = options,
|
||||
seed = 1L,
|
||||
teams = (0 until options.playerCount).map { it % options.teamCount },
|
||||
chips = chips,
|
||||
locked = locked,
|
||||
hands = hands,
|
||||
handCounts = hands.map { it.size },
|
||||
drawPile = emptyList(),
|
||||
discardPile = emptyList(),
|
||||
turn = 0,
|
||||
sequencesByTeam = List(options.teamCount) { 0 },
|
||||
winner = null,
|
||||
phase = SequencePhase.PLAYING,
|
||||
exchangedThisTurn = false,
|
||||
lastPlacedCell = null,
|
||||
log = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user