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:
Claude
2026-08-05 22:37:05 +00:00
parent 4312616efa
commit 487c68419e
54 changed files with 6487 additions and 0 deletions
@@ -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(),
)
}
}