Merge pull request #18: Hearts, peg solitaire, and a seat for one
Two new games and the first single-player one. GameRules needed no change
for a one-seat table; CatalogTest's floor of two players is the only
invariant that moved.
Verified by ci.yml run 86 on 480f0ba: build, unit tests and lint all green.
This commit is contained in:
@@ -34,6 +34,14 @@ object GameCatalog {
|
|||||||
const val WIZARD = "wizard"
|
const val WIZARD = "wizard"
|
||||||
const val CRAZY_EIGHTS = "crazy8s"
|
const val CRAZY_EIGHTS = "crazy8s"
|
||||||
const val CRIBBAGE = "cribbage"
|
const val CRIBBAGE = "cribbage"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hearts has a rules engine and an AI but no screen yet, so it is an id
|
||||||
|
* without a [GameDescriptor]: nothing to tap, and nothing to crash. It joins
|
||||||
|
* [all] when its screen lands.
|
||||||
|
*/
|
||||||
|
const val HEARTS = "hearts"
|
||||||
|
const val PEG_SOLITAIRE = "pegsolitaire"
|
||||||
const val SEQUENCE = "sequence"
|
const val SEQUENCE = "sequence"
|
||||||
const val MASTERMIND = "mastermind"
|
const val MASTERMIND = "mastermind"
|
||||||
const val BACKGAMMON = "backgammon"
|
const val BACKGAMMON = "backgammon"
|
||||||
@@ -116,6 +124,17 @@ object GameCatalog {
|
|||||||
"twice round the board to 121.",
|
"twice round the board to 121.",
|
||||||
available = true,
|
available = true,
|
||||||
),
|
),
|
||||||
|
GameDescriptor(
|
||||||
|
id = HEARTS,
|
||||||
|
title = "Hearts",
|
||||||
|
category = GameCategory.CARD,
|
||||||
|
minPlayers = 4,
|
||||||
|
maxPlayers = 4,
|
||||||
|
teamBased = false,
|
||||||
|
blurb = "Take no trick worth taking. Every heart costs a point, the queen " +
|
||||||
|
"of spades costs thirteen, and taking all of them costs everybody else.",
|
||||||
|
available = true,
|
||||||
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = SEQUENCE,
|
id = SEQUENCE,
|
||||||
title = "Sequence",
|
title = "Sequence",
|
||||||
@@ -177,6 +196,19 @@ object GameCatalog {
|
|||||||
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
blurb = "Three in a row takes a piece. Reduce them to two and the game is yours.",
|
||||||
available = true,
|
available = true,
|
||||||
),
|
),
|
||||||
|
GameDescriptor(
|
||||||
|
id = PEG_SOLITAIRE,
|
||||||
|
title = "Peg Solitaire",
|
||||||
|
category = GameCategory.BOARD,
|
||||||
|
// The first game here played alone. Everything downstream reads the
|
||||||
|
// seat count from these two numbers, so one seat is all it takes.
|
||||||
|
minPlayers = 1,
|
||||||
|
maxPlayers = 1,
|
||||||
|
teamBased = false,
|
||||||
|
blurb = "The tee game from the table, and four larger boards. Jump a peg, " +
|
||||||
|
"take the one you passed, and try to finish with one standing.",
|
||||||
|
available = true,
|
||||||
|
),
|
||||||
GameDescriptor(
|
GameDescriptor(
|
||||||
id = PIRATES,
|
id = PIRATES,
|
||||||
title = "Pirates and Bulgars",
|
title = "Pirates and Bulgars",
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package org.prolibertate.games.game.hearts
|
||||||
|
|
||||||
|
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.GameAi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cautious Hearts player.
|
||||||
|
*
|
||||||
|
* It plays to avoid points rather than to shoot the moon: it sheds the queen of
|
||||||
|
* spades and the guards above her at the first opportunity, ducks under whoever
|
||||||
|
* is winning a trick when it can, and takes a trick as cheaply as possible when
|
||||||
|
* it cannot get out of the way. That is enough to make it an opponent worth
|
||||||
|
* beating without pretending to read the table.
|
||||||
|
*/
|
||||||
|
object HeartsAi : GameAi<HeartsState, HeartsMove> {
|
||||||
|
|
||||||
|
override fun chooseMove(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||||
|
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||||
|
return when (state.phase) {
|
||||||
|
HeartsPhase.PASSING -> choosePass(state, seat, legal)
|
||||||
|
else -> choosePlay(state, seat, legal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun choosePass(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
val wanted = hand.sortedByDescending { passDanger(it, hand) }.take(PASS_SIZE).toSet()
|
||||||
|
// Prefer the exact bundle the heuristic asked for, but fall back rather
|
||||||
|
// than fail: the caller's list is what the host will validate against.
|
||||||
|
return legal.firstOrNull { it is PassCards && it.cards.toSet() == wanted } ?: legal.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much a card wants to be somebody else's problem.
|
||||||
|
*
|
||||||
|
* The queen is the whole game, and the ace and king of spades are only
|
||||||
|
* dangerous because they can be forced to capture her — so a long spade
|
||||||
|
* holding, which gives room to duck, makes all three far less urgent.
|
||||||
|
*/
|
||||||
|
private fun passDanger(card: Card, hand: List<Card>): Int {
|
||||||
|
val spades = hand.count { it.suit == Suit.SPADES }
|
||||||
|
val guarded = spades >= 5
|
||||||
|
return when {
|
||||||
|
card == QUEEN_OF_SPADES -> if (guarded) 45 else 100
|
||||||
|
card.suit == Suit.SPADES && card.rank.order > Rank.QUEEN.order ->
|
||||||
|
if (guarded) 30 else 90
|
||||||
|
|
||||||
|
card.suit == Suit.HEARTS -> 40 + card.rank.order
|
||||||
|
else -> card.rank.order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun choosePlay(state: HeartsState, seat: Int, legal: List<HeartsMove>): HeartsMove {
|
||||||
|
val cards = legal.filterIsInstance<PlayCard>().map { it.card }
|
||||||
|
if (cards.isEmpty()) return legal.first()
|
||||||
|
val chosen = when {
|
||||||
|
state.trick.isEmpty() -> lead(cards)
|
||||||
|
else -> follow(state, cards)
|
||||||
|
}
|
||||||
|
return PlayCard(chosen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leads low, and keeps off spades while the queen is still unaccounted for —
|
||||||
|
* leading a spade under her is how a seat ends up holding her.
|
||||||
|
*/
|
||||||
|
private fun lead(cards: List<Card>): Card {
|
||||||
|
val offSpades = cards.filter { it.suit != Suit.SPADES }
|
||||||
|
val pool = if (offSpades.isNotEmpty()) offSpades else cards
|
||||||
|
return pool.minBy { it.rank.order }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun follow(state: HeartsState, cards: List<Card>): Card {
|
||||||
|
val led = state.trick.first().card.suit
|
||||||
|
val best = state.trick.maxOf { trickStrength(it.card, led) }
|
||||||
|
val pot = pointsIn(state.trick.map { it.card })
|
||||||
|
val last = state.trick.size == state.options.playerCount - 1
|
||||||
|
|
||||||
|
val ducking = cards.filter { trickStrength(it, led) < best }
|
||||||
|
if (ducking.isNotEmpty()) {
|
||||||
|
// Safe under the current winner, so shed the most expensive card
|
||||||
|
// that still loses.
|
||||||
|
return ducking.maxBy { it.rank.order + pointsOf(it) * 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
val void = cards.none { it.suit == led }
|
||||||
|
if (void) return discard(cards)
|
||||||
|
|
||||||
|
// Every card left would take the trick. Playing last with nothing in the
|
||||||
|
// pot means it costs nothing, so there is no reason to waste a high card
|
||||||
|
// on it; otherwise take it as cheaply as possible.
|
||||||
|
return if (last && pot == 0) cards.maxBy { it.rank.order } else cards.minBy { it.rank.order }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Void in the led suit and free to throw anything, so throw the worst of it. */
|
||||||
|
private fun discard(cards: List<Card>): Card {
|
||||||
|
cards.firstOrNull { it == QUEEN_OF_SPADES }?.let { return it }
|
||||||
|
cards.filter { it.suit == Suit.SPADES && it.rank.order > Rank.QUEEN.order }
|
||||||
|
.maxByOrNull { it.rank.order }
|
||||||
|
?.let { return it }
|
||||||
|
cards.filter { it.suit == Suit.HEARTS }
|
||||||
|
.maxByOrNull { it.rank.order }
|
||||||
|
?.let { return it }
|
||||||
|
return cards.maxBy { it.rank.order }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package org.prolibertate.games.game.hearts
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Rank
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HeartsOptions(
|
||||||
|
/**
|
||||||
|
* Four hands of thirteen, which is the game as it is normally played. Three
|
||||||
|
* and five handed Hearts exist, but they need cards struck from the deck to
|
||||||
|
* divide evenly, and a seat that holds a different number of cards from its
|
||||||
|
* neighbours changes which discards are safe — a different game to bid and
|
||||||
|
* play, not a seat count.
|
||||||
|
*/
|
||||||
|
val playerCount: Int = 4,
|
||||||
|
/** The round in which somebody crosses this ends the game. */
|
||||||
|
val targetScore: Int = 100,
|
||||||
|
/**
|
||||||
|
* Taking all twenty-six points scores nothing and gives everyone else the
|
||||||
|
* lot. Switching this off scores a moon like any other hand, which is to say
|
||||||
|
* it becomes the worst result on the table rather than the best.
|
||||||
|
*/
|
||||||
|
val allowShootTheMoon: Boolean = true,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(playerCount == 4) { "Hearts is dealt four handed" }
|
||||||
|
require(targetScore > 0) { "targetScore must be positive" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the three passed cards go. The cycle repeats every fourth round, and
|
||||||
|
* the hold round is what stops a seat from planning around a known direction
|
||||||
|
* forever.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class PassDirection(val label: String) {
|
||||||
|
LEFT("left"),
|
||||||
|
RIGHT("right"),
|
||||||
|
ACROSS("across"),
|
||||||
|
HOLD("nobody"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Rounds run left, right, across, hold, and back to left. */
|
||||||
|
fun forRound(round: Int): PassDirection = entries[round % entries.size]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class HeartsPhase { PASSING, PLAYING, ROUND_OVER, GAME_OVER }
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlayedCard(val seat: Int, val card: Card)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HeartsState(
|
||||||
|
val options: HeartsOptions,
|
||||||
|
val seed: Long,
|
||||||
|
/** Zero-based. Only used to pick the pass direction and to number the log. */
|
||||||
|
val round: Int,
|
||||||
|
val phase: HeartsPhase,
|
||||||
|
val hands: List<List<Card>>,
|
||||||
|
/** Survives redaction so a client can draw the backs of other hands. */
|
||||||
|
val handCounts: List<Int>,
|
||||||
|
/** What each seat has chosen to pass, before the swap happens. */
|
||||||
|
val passSelections: List<List<Card>>,
|
||||||
|
val turn: Int,
|
||||||
|
val leader: Int,
|
||||||
|
/**
|
||||||
|
* Which trick of the round is being played, counting from zero. Carried
|
||||||
|
* rather than derived because the first trick is the one that bans point
|
||||||
|
* cards, and a rule that important should not depend on reading it back out
|
||||||
|
* of how many cards happen to have been taken.
|
||||||
|
*/
|
||||||
|
val trickNumber: Int,
|
||||||
|
val trick: List<PlayedCard>,
|
||||||
|
/** Held on the table so a finished trick can be read before it is swept. */
|
||||||
|
val completedTrick: List<PlayedCard> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Every card each seat has taken in tricks this round. Kept whole rather
|
||||||
|
* than as a running count because shooting the moon is decided on the
|
||||||
|
* twenty-six penalty cards together, not on a total that cannot tell an
|
||||||
|
* unbroken moon from thirteen hearts and a lucky queen elsewhere.
|
||||||
|
*/
|
||||||
|
val taken: List<List<Card>>,
|
||||||
|
val heartsBroken: Boolean,
|
||||||
|
val scores: List<Int>,
|
||||||
|
val roundScores: List<Int>,
|
||||||
|
val log: List<String>,
|
||||||
|
) {
|
||||||
|
val passDirection: PassDirection get() = PassDirection.forRound(round)
|
||||||
|
|
||||||
|
/** Thirteen, four handed: every card dealt is a card played. */
|
||||||
|
val tricksPerRound: Int get() = DECK_SIZE / options.playerCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ordinary pack, which Hearts uses whole. */
|
||||||
|
const val DECK_SIZE: Int = 52
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface HeartsMove
|
||||||
|
|
||||||
|
/** The three cards this seat is giving away. Order does not matter. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pass")
|
||||||
|
data class PassCards(val cards: List<Card>) : HeartsMove
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("play")
|
||||||
|
data class PlayCard(val card: Card) : HeartsMove
|
||||||
|
|
||||||
|
/** How many cards change hands in a pass. */
|
||||||
|
const val PASS_SIZE: Int = 3
|
||||||
|
|
||||||
|
val QUEEN_OF_SPADES: Card = Card(Rank.QUEEN, Suit.SPADES)
|
||||||
|
|
||||||
|
/** The two of clubs always leads the first trick of a round. */
|
||||||
|
val TWO_OF_CLUBS: Card = Card(Rank.TWO, Suit.CLUBS)
|
||||||
|
|
||||||
|
/** Every heart costs a point; the queen of spades costs thirteen. */
|
||||||
|
fun pointsOf(card: Card): Int = when {
|
||||||
|
card == QUEEN_OF_SPADES -> 13
|
||||||
|
card.suit == Suit.HEARTS -> 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole twenty-six, which is what a moon has to consist of. */
|
||||||
|
const val ALL_POINTS: Int = 26
|
||||||
|
|
||||||
|
fun pointsIn(cards: List<Card>): Int = cards.sumOf { pointsOf(it) }
|
||||||
|
|
||||||
|
/** Which seat receives what [from] passes, given the round's direction. */
|
||||||
|
fun passTarget(from: Int, playerCount: Int, direction: PassDirection): Int = when (direction) {
|
||||||
|
PassDirection.LEFT -> (from + 1) % playerCount
|
||||||
|
PassDirection.RIGHT -> (from + playerCount - 1) % playerCount
|
||||||
|
PassDirection.ACROSS -> (from + playerCount / 2) % playerCount
|
||||||
|
PassDirection.HOLD -> from
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranks a card within a trick. Nothing trumps in Hearts, so a card that did not
|
||||||
|
* follow the led suit cannot win however high it is.
|
||||||
|
*/
|
||||||
|
fun trickStrength(card: Card, ledSuit: Suit): Int =
|
||||||
|
if (card.suit == ledSuit) card.rank.order else -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a round's takings into the points each seat adds to its score.
|
||||||
|
*
|
||||||
|
* A seat that took all twenty-six has shot the moon: it scores nothing and
|
||||||
|
* every other seat takes the lot. That is the one case where a round's scores
|
||||||
|
* do not sum to twenty-six.
|
||||||
|
*/
|
||||||
|
fun scoreRound(taken: List<List<Card>>, allowShootTheMoon: Boolean): List<Int> {
|
||||||
|
val raw = taken.map { pointsIn(it) }
|
||||||
|
if (!allowShootTheMoon) return raw
|
||||||
|
val shooter = raw.indexOfFirst { it == ALL_POINTS }
|
||||||
|
if (shooter < 0) return raw
|
||||||
|
return raw.indices.map { if (it == shooter) 0 else ALL_POINTS }
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package org.prolibertate.games.game.hearts
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.cards.Decks
|
||||||
|
import org.prolibertate.games.game.cards.Suit
|
||||||
|
import org.prolibertate.games.game.cards.shuffledWith
|
||||||
|
import org.prolibertate.games.game.engine.GameRules
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hearts as a pure state machine. See RULES-hearts.md.
|
||||||
|
*/
|
||||||
|
object HeartsRules : GameRules<HeartsState, HeartsMove> {
|
||||||
|
|
||||||
|
override val gameId: String = GameCatalog.HEARTS
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initialState(config: TableConfig): HeartsState {
|
||||||
|
val options = json.decodeFromString<HeartsOptions>(config.optionsJson)
|
||||||
|
require(config.seats.size == options.playerCount) {
|
||||||
|
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||||
|
}
|
||||||
|
return deal(options, config.seed, 0, List(options.playerCount) { 0 }, listOf("Round 1."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deal(
|
||||||
|
options: HeartsOptions,
|
||||||
|
seed: Long,
|
||||||
|
round: Int,
|
||||||
|
scores: List<Int>,
|
||||||
|
log: List<String>,
|
||||||
|
): HeartsState {
|
||||||
|
// A fresh generator per round, keyed on the round, so the whole game is
|
||||||
|
// reproducible from the one seed the host shipped with the table.
|
||||||
|
val random = Random(seed + round)
|
||||||
|
val deck = Decks.standard52().shuffledWith(random)
|
||||||
|
val perHand = deck.size / options.playerCount
|
||||||
|
val hands = (0 until options.playerCount).map { seat ->
|
||||||
|
deck.subList(seat * perHand, (seat + 1) * perHand).sortedWith(handOrder)
|
||||||
|
}
|
||||||
|
val direction = PassDirection.forRound(round)
|
||||||
|
val passing = direction != PassDirection.HOLD
|
||||||
|
// A hold round has nothing to swap, so it opens straight onto the lead.
|
||||||
|
val opener = if (passing) 0 else holderOfTwoOfClubs(hands)
|
||||||
|
return HeartsState(
|
||||||
|
options = options,
|
||||||
|
seed = seed,
|
||||||
|
round = round,
|
||||||
|
phase = if (passing) HeartsPhase.PASSING else HeartsPhase.PLAYING,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
passSelections = List(options.playerCount) { emptyList() },
|
||||||
|
turn = opener,
|
||||||
|
leader = opener,
|
||||||
|
trickNumber = 0,
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = emptyList(),
|
||||||
|
taken = List(options.playerCount) { emptyList() },
|
||||||
|
heartsBroken = false,
|
||||||
|
scores = scores,
|
||||||
|
roundScores = List(options.playerCount) { 0 },
|
||||||
|
log = log + if (passing) "Passing ${direction.label}." else "No passing this round.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorted by suit then rank, which is how a hand is held rather than dealt. */
|
||||||
|
private val handOrder = compareBy<Card>({ it.suit.ordinal }, { it.rank.order })
|
||||||
|
|
||||||
|
private fun holderOfTwoOfClubs(hands: List<List<Card>>): Int =
|
||||||
|
hands.indexOfFirst { it.contains(TWO_OF_CLUBS) }
|
||||||
|
|
||||||
|
override fun currentSeat(state: HeartsState): Int? = when (state.phase) {
|
||||||
|
// Passing is simultaneous at the table, but the engine hands out one
|
||||||
|
// seat at a time. The lowest seat that has not chosen goes next, and
|
||||||
|
// the swap happens once the last one has.
|
||||||
|
HeartsPhase.PASSING -> state.passSelections.indexOfFirst { it.isEmpty() }.takeIf { it >= 0 }
|
||||||
|
HeartsPhase.PLAYING -> state.turn
|
||||||
|
HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun legalMoves(state: HeartsState, seat: Int): List<HeartsMove> =
|
||||||
|
when (state.phase) {
|
||||||
|
HeartsPhase.PASSING ->
|
||||||
|
if (state.passSelections[seat].isNotEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
combinations(state.hands[seat], PASS_SIZE).map { PassCards(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
HeartsPhase.PLAYING ->
|
||||||
|
if (state.turn != seat) emptyList() else playableCards(state, seat).map { PlayCard(it) }
|
||||||
|
|
||||||
|
HeartsPhase.ROUND_OVER, HeartsPhase.GAME_OVER -> emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cards [seat] may legally play into the trick as it stands.
|
||||||
|
*
|
||||||
|
* Four restrictions, in the order they bite: the two of clubs opens the
|
||||||
|
* round, a seat that can follow the led suit must, no point card may be
|
||||||
|
* discarded on the first trick, and hearts cannot be led until one has been
|
||||||
|
* played. Each of the last three lifts when a seat holds nothing else.
|
||||||
|
*/
|
||||||
|
fun playableCards(state: HeartsState, seat: Int): List<Card> {
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
if (hand.isEmpty()) return emptyList()
|
||||||
|
val firstTrick = state.trickNumber == 0
|
||||||
|
|
||||||
|
if (state.trick.isEmpty()) {
|
||||||
|
if (firstTrick) return listOf(TWO_OF_CLUBS).filter { hand.contains(it) }
|
||||||
|
val offHearts = hand.filter { it.suit != Suit.HEARTS }
|
||||||
|
// Hearts may only be led once broken — unless that is all there is
|
||||||
|
// left to lead, in which case the restriction cannot be honoured.
|
||||||
|
return if (state.heartsBroken || offHearts.isEmpty()) hand else offHearts
|
||||||
|
}
|
||||||
|
|
||||||
|
val led = state.trick.first().card.suit
|
||||||
|
val following = hand.filter { it.suit == led }
|
||||||
|
if (following.isNotEmpty()) return following
|
||||||
|
|
||||||
|
// Void in the led suit, so anything goes — except that the first trick
|
||||||
|
// takes no points while the seat has a card that costs none.
|
||||||
|
if (firstTrick) {
|
||||||
|
val harmless = hand.filter { pointsOf(it) == 0 }
|
||||||
|
if (harmless.isNotEmpty()) return harmless
|
||||||
|
}
|
||||||
|
return hand
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyMove(state: HeartsState, seat: Int, move: HeartsMove): HeartsState =
|
||||||
|
when (move) {
|
||||||
|
is PassCards -> applyPass(state, seat, move)
|
||||||
|
is PlayCard -> applyPlay(state, seat, move)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPass(state: HeartsState, seat: Int, move: PassCards): HeartsState {
|
||||||
|
require(state.phase == HeartsPhase.PASSING) { "Not passing" }
|
||||||
|
require(state.passSelections[seat].isEmpty()) { "Seat $seat has already passed" }
|
||||||
|
require(move.cards.size == PASS_SIZE) { "A pass is $PASS_SIZE cards" }
|
||||||
|
require(move.cards.distinct().size == PASS_SIZE) { "Cannot pass the same card twice" }
|
||||||
|
val hand = state.hands[seat]
|
||||||
|
require(move.cards.all { hand.contains(it) }) { "Cannot pass a card not held" }
|
||||||
|
|
||||||
|
val selections = state.passSelections.toMutableList()
|
||||||
|
selections[seat] = move.cards
|
||||||
|
if (selections.any { it.isEmpty() }) {
|
||||||
|
return state.copy(passSelections = selections)
|
||||||
|
}
|
||||||
|
return completePass(state.copy(passSelections = selections))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everyone has chosen, so the three-card bundles change hands at once. */
|
||||||
|
private fun completePass(state: HeartsState): HeartsState {
|
||||||
|
val direction = state.passDirection
|
||||||
|
val count = state.options.playerCount
|
||||||
|
val incoming = MutableList(count) { emptyList<Card>() }
|
||||||
|
for (from in 0 until count) {
|
||||||
|
incoming[passTarget(from, count, direction)] = state.passSelections[from]
|
||||||
|
}
|
||||||
|
val hands = state.hands.mapIndexed { seat, hand ->
|
||||||
|
(hand - state.passSelections[seat].toSet() + incoming[seat]).sortedWith(handOrder)
|
||||||
|
}
|
||||||
|
val opener = holderOfTwoOfClubs(hands)
|
||||||
|
return state.copy(
|
||||||
|
phase = HeartsPhase.PLAYING,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
turn = opener,
|
||||||
|
leader = opener,
|
||||||
|
log = state.log + "Cards passed ${direction.label}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPlay(state: HeartsState, seat: Int, move: PlayCard): HeartsState {
|
||||||
|
require(state.phase == HeartsPhase.PLAYING) { "Not playing" }
|
||||||
|
require(state.turn == seat) { "Seat $seat is not on the clock" }
|
||||||
|
require(playableCards(state, seat).contains(move.card)) { "${move.card} is not playable" }
|
||||||
|
|
||||||
|
val hands = state.hands.toMutableList()
|
||||||
|
hands[seat] = hands[seat] - move.card
|
||||||
|
val trick = state.trick + PlayedCard(seat, move.card)
|
||||||
|
val broken = state.heartsBroken || move.card.suit == Suit.HEARTS
|
||||||
|
|
||||||
|
if (trick.size < state.options.playerCount) {
|
||||||
|
return state.copy(
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
trick = trick,
|
||||||
|
completedTrick = emptyList(),
|
||||||
|
turn = (seat + 1) % state.options.playerCount,
|
||||||
|
heartsBroken = broken,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val led = trick.first().card.suit
|
||||||
|
val winner = trick.maxBy { trickStrength(it.card, led) }.seat
|
||||||
|
val taken = state.taken.toMutableList()
|
||||||
|
taken[winner] = taken[winner] + trick.map { it.card }
|
||||||
|
val swept = state.copy(
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
trick = emptyList(),
|
||||||
|
completedTrick = trick,
|
||||||
|
taken = taken,
|
||||||
|
heartsBroken = broken,
|
||||||
|
turn = winner,
|
||||||
|
leader = winner,
|
||||||
|
trickNumber = state.trickNumber + 1,
|
||||||
|
)
|
||||||
|
return if (hands.all { it.isEmpty() }) finishRound(swept) else swept
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishRound(state: HeartsState): HeartsState {
|
||||||
|
val gained = scoreRound(state.taken, state.options.allowShootTheMoon)
|
||||||
|
val scores = state.scores.mapIndexed { seat, score -> score + gained[seat] }
|
||||||
|
val moon = state.taken.indexOfFirst { pointsIn(it) == ALL_POINTS }
|
||||||
|
val over = scores.any { it >= state.options.targetScore }
|
||||||
|
val notes = buildList {
|
||||||
|
if (moon >= 0 && state.options.allowShootTheMoon) add("Seat $moon shot the moon.")
|
||||||
|
add("Round ${state.round + 1} scored.")
|
||||||
|
if (over) add("Game over — lowest score wins.")
|
||||||
|
}
|
||||||
|
return state.copy(
|
||||||
|
phase = if (over) HeartsPhase.GAME_OVER else HeartsPhase.ROUND_OVER,
|
||||||
|
scores = scores,
|
||||||
|
roundScores = gained,
|
||||||
|
log = state.log + notes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advances from [HeartsPhase.ROUND_OVER] into the next deal. */
|
||||||
|
fun startNextRound(state: HeartsState): HeartsState {
|
||||||
|
check(state.phase == HeartsPhase.ROUND_OVER) { "Round is not over" }
|
||||||
|
val next = state.round + 1
|
||||||
|
return deal(state.options, state.seed, next, state.scores, state.log + "Round ${next + 1}.")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isFinished(state: HeartsState): Boolean = state.phase == HeartsPhase.GAME_OVER
|
||||||
|
|
||||||
|
override fun summary(state: HeartsState): String = when (state.phase) {
|
||||||
|
HeartsPhase.PASSING -> "Passing ${state.passDirection.label}"
|
||||||
|
HeartsPhase.PLAYING -> "Trick ${state.trickNumber + 1} of ${state.tricksPerRound}"
|
||||||
|
HeartsPhase.ROUND_OVER -> "Round ${state.round + 1}: " + state.roundScores.joinToString(", ")
|
||||||
|
HeartsPhase.GAME_OVER -> {
|
||||||
|
val best = state.scores.min()
|
||||||
|
"Winner: seat ${state.scores.indexOf(best)} on $best"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other hands go, and so do the cards they have chosen to pass — a pass is
|
||||||
|
* secret until it lands, and a client that could read the selections would
|
||||||
|
* know exactly what is coming.
|
||||||
|
*/
|
||||||
|
override fun viewFor(state: HeartsState, seat: Int): HeartsState = state.copy(
|
||||||
|
hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() },
|
||||||
|
handCounts = state.handCounts,
|
||||||
|
passSelections = state.passSelections.mapIndexed { index, cards ->
|
||||||
|
if (index == seat) cards else emptyList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun encodeState(state: HeartsState): String = json.encodeToString(state)
|
||||||
|
override fun decodeState(json: String): HeartsState = this.json.decodeFromString(json)
|
||||||
|
override fun encodeMove(move: HeartsMove): String = json.encodeToString(move)
|
||||||
|
override fun decodeMove(json: String): HeartsMove = this.json.decodeFromString(json)
|
||||||
|
|
||||||
|
/** Every distinct [size]-card selection from [cards], order ignored. */
|
||||||
|
private fun combinations(cards: List<Card>, size: Int): List<List<Card>> {
|
||||||
|
if (size == 0) return listOf(emptyList())
|
||||||
|
if (cards.size < size) return emptyList()
|
||||||
|
val result = mutableListOf<List<Card>>()
|
||||||
|
fun walk(start: Int, chosen: List<Card>) {
|
||||||
|
if (chosen.size == size) {
|
||||||
|
result.add(chosen)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Stop early once too few cards remain to finish the selection.
|
||||||
|
for (i in start..cards.size - (size - chosen.size)) {
|
||||||
|
walk(i + 1, chosen + cards[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(0, emptyList())
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.prolibertate.games.game.pegsolitaire
|
||||||
|
|
||||||
|
import org.prolibertate.games.game.engine.GameAi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the board for the one seat.
|
||||||
|
*
|
||||||
|
* Nothing normally asks it to: the single seat belongs to the person holding the
|
||||||
|
* phone, so this never runs during an ordinary game. It exists because a seat
|
||||||
|
* that could be left empty has to be playable by something, and because a
|
||||||
|
* one-move-deep chooser is exactly what a "suggest a jump" button would want.
|
||||||
|
*
|
||||||
|
* The heuristic is the one every good peg player uses by hand: keep the pegs
|
||||||
|
* together. A jump that leaves more jumps behind it has not painted the board
|
||||||
|
* into a corner, and pulling toward the middle stops a stranded peg being left
|
||||||
|
* out on an arm where nothing can ever reach it.
|
||||||
|
*/
|
||||||
|
object PegSolitaireAi : GameAi<PegSolitaireState, PegSolitaireMove> {
|
||||||
|
|
||||||
|
override fun chooseMove(
|
||||||
|
state: PegSolitaireState,
|
||||||
|
seat: Int,
|
||||||
|
legal: List<PegSolitaireMove>,
|
||||||
|
): PegSolitaireMove {
|
||||||
|
require(legal.isNotEmpty()) { "No jump available" }
|
||||||
|
val jumps = legal.filterIsInstance<PegJump>()
|
||||||
|
if (jumps.isEmpty()) return legal.first()
|
||||||
|
return jumps.maxBy { score(state, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun score(state: PegSolitaireState, jump: PegJump): Int {
|
||||||
|
val after = state.copy(pegs = state.pegs - jump.from - jump.over + jump.to)
|
||||||
|
// Solving outright beats every other consideration.
|
||||||
|
if (after.solved) return Int.MAX_VALUE
|
||||||
|
val followUps = PegSolitaireRules.legalJumps(after).size
|
||||||
|
return followUps * 10 - spread(after)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the pegs sit from their own centre, in whole holes.
|
||||||
|
*
|
||||||
|
* A smaller number is a tighter cluster. Manhattan distance is the right
|
||||||
|
* measure on both board shapes here, because a jump moves two steps along one
|
||||||
|
* of the grid's own directions either way.
|
||||||
|
*/
|
||||||
|
private fun spread(state: PegSolitaireState): Int {
|
||||||
|
if (state.pegs.isEmpty()) return 0
|
||||||
|
val meanRow = state.pegs.sumOf { it.row } / state.pegs.size
|
||||||
|
val meanCol = state.pegs.sumOf { it.col } / state.pegs.size
|
||||||
|
return state.pegs.sumOf {
|
||||||
|
kotlin.math.abs(it.row - meanRow) + kotlin.math.abs(it.col - meanCol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package org.prolibertate.games.game.pegsolitaire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hole in the board, by row and column.
|
||||||
|
*
|
||||||
|
* Both geometries the boards come in are indexed this way, which is what lets
|
||||||
|
* one jump rule serve them all: a triangular row is simply shorter than the one
|
||||||
|
* below it, and a cross is a square with pieces bitten out of the corners.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Hole(val row: Int, val col: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boards this plays on.
|
||||||
|
*
|
||||||
|
* Two shapes of grid, and the difference between them is only how many
|
||||||
|
* directions a peg can jump in. On a triangle a hole has six neighbours, because
|
||||||
|
* the rows are staggered; on a square board it has four, because they are not.
|
||||||
|
* Everything else — the jump, the goal, the dead end — is the same game.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class PegBoard(val label: String, val holeCount: Int) {
|
||||||
|
/** The tee game found on a restaurant table: five rows, fifteen holes. */
|
||||||
|
TRIANGLE_15("Triangle of 15", 15),
|
||||||
|
TRIANGLE_21("Triangle of 21", 21),
|
||||||
|
|
||||||
|
/** The classic cross: a seven by seven square with two-by-two corners cut. */
|
||||||
|
ENGLISH_CROSS("English cross", 33),
|
||||||
|
|
||||||
|
/** The continental board, with a shallower cut that leaves four more holes. */
|
||||||
|
FRENCH_CROSS("French cross", 37),
|
||||||
|
DIAMOND_41("Diamond of 41", 41),
|
||||||
|
;
|
||||||
|
|
||||||
|
val isTriangular: Boolean get() = this == TRIANGLE_15 || this == TRIANGLE_21
|
||||||
|
|
||||||
|
/** Every hole on this board. */
|
||||||
|
fun holes(): Set<Hole> = when (this) {
|
||||||
|
TRIANGLE_15 -> triangle(5)
|
||||||
|
TRIANGLE_21 -> triangle(6)
|
||||||
|
// 49 less the four two-by-two corners.
|
||||||
|
ENGLISH_CROSS -> square(7) { r, c -> r in 2..4 || c in 2..4 }
|
||||||
|
// 49 less a three-hole triangle at each corner.
|
||||||
|
FRENCH_CROSS -> square(7) { r, c ->
|
||||||
|
val fromEdgeRow = minOf(r, 6 - r)
|
||||||
|
val fromEdgeCol = minOf(c, 6 - c)
|
||||||
|
fromEdgeRow + fromEdgeCol >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
DIAMOND_41 -> square(9) { r, c -> kotlin.math.abs(r - 4) + kotlin.math.abs(c - 4) <= 4 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directions a jump may run in, as row and column steps.
|
||||||
|
*
|
||||||
|
* Diagonals are not among the four on a square board: a peg moves along a
|
||||||
|
* rank or a file and nowhere else, which is what makes the cross boards hard.
|
||||||
|
*/
|
||||||
|
fun directions(): List<Pair<Int, Int>> = if (isTriangular) {
|
||||||
|
listOf(0 to -1, 0 to 1, -1 to -1, -1 to 0, 1 to 0, 1 to 1)
|
||||||
|
} else {
|
||||||
|
listOf(0 to -1, 0 to 1, -1 to 0, 1 to 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the one empty hole sits when nothing else is chosen. */
|
||||||
|
fun defaultStart(): Hole = when {
|
||||||
|
// The apex, which is how the tee game is set out on the table.
|
||||||
|
isTriangular -> Hole(0, 0)
|
||||||
|
this == DIAMOND_41 -> Hole(4, 4)
|
||||||
|
else -> Hole(3, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triangle(rows: Int): Set<Hole> =
|
||||||
|
(0 until rows).flatMap { r -> (0..r).map { c -> Hole(r, c) } }.toSet()
|
||||||
|
|
||||||
|
private fun square(size: Int, keep: (Int, Int) -> Boolean): Set<Hole> =
|
||||||
|
(0 until size).flatMap { r ->
|
||||||
|
(0 until size).mapNotNull { c -> Hole(r, c).takeIf { keep(r, c) } }
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What counts as having won.
|
||||||
|
*
|
||||||
|
* Leaving one peg anywhere is the game as it is usually played. Leaving it in
|
||||||
|
* the hole the board started empty is the harder problem, and on the English
|
||||||
|
* cross it is the one the puzzle is famous for.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class PegGoal(val label: String) {
|
||||||
|
ONE_PEG("Leave one peg"),
|
||||||
|
ONE_PEG_IN_START("Leave one peg, in the starting hole"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PegSolitaireOptions(
|
||||||
|
val board: PegBoard = PegBoard.TRIANGLE_15,
|
||||||
|
val goal: PegGoal = PegGoal.ONE_PEG,
|
||||||
|
/**
|
||||||
|
* The hole left empty at the start. Null takes the board's own default,
|
||||||
|
* which keeps a saved table valid if the default ever moves.
|
||||||
|
*/
|
||||||
|
val startEmpty: Hole? = null,
|
||||||
|
) {
|
||||||
|
/** The empty hole this table actually opens with. */
|
||||||
|
fun start(): Hole = startEmpty ?: board.defaultStart()
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(board.holes().contains(start())) {
|
||||||
|
"${start()} is not a hole on the ${board.label}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PegSolitaireState(
|
||||||
|
val options: PegSolitaireOptions,
|
||||||
|
/** Which holes still hold a peg. Everything else on the board is empty. */
|
||||||
|
val pegs: Set<Hole>,
|
||||||
|
val jumps: Int,
|
||||||
|
/** Held so the screen can show what just happened. */
|
||||||
|
val lastJump: PegJump? = null,
|
||||||
|
val log: List<String>,
|
||||||
|
) {
|
||||||
|
val board: PegBoard get() = options.board
|
||||||
|
|
||||||
|
val remaining: Int get() = pegs.size
|
||||||
|
|
||||||
|
/** True once the board matches what the chosen goal asked for. */
|
||||||
|
val solved: Boolean
|
||||||
|
get() = remaining == 1 && when (options.goal) {
|
||||||
|
PegGoal.ONE_PEG -> true
|
||||||
|
PegGoal.ONE_PEG_IN_START -> pegs.first() == options.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed interface PegSolitaireMove
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A peg jumps a neighbour and lands two holes away, and the peg it jumped is
|
||||||
|
* taken off. [over] is not carried because a jump is always exactly two steps in
|
||||||
|
* one direction, so the hole between the ends is the only one it can be —
|
||||||
|
* storing it would be a second copy of the same fact, free to disagree.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("jump")
|
||||||
|
data class PegJump(val from: Hole, val to: Hole) : PegSolitaireMove {
|
||||||
|
val over: Hole get() = Hole((from.row + to.row) / 2, (from.col + to.col) / 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package org.prolibertate.games.game.pegsolitaire
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peg solitaire as a pure state machine. See RULES-pegsolitaire.md.
|
||||||
|
*
|
||||||
|
* The first game here played by one person, which the engine takes in its
|
||||||
|
* stride: there is one seat, it is always on the clock until the board runs out
|
||||||
|
* of jumps, and [viewFor] has nothing to hide because there is nobody to hide it
|
||||||
|
* from.
|
||||||
|
*/
|
||||||
|
object PegSolitaireRules : GameRules<PegSolitaireState, PegSolitaireMove> {
|
||||||
|
|
||||||
|
override val gameId: String = GameCatalog.PEG_SOLITAIRE
|
||||||
|
|
||||||
|
/** The one seat. Named rather than written as a bare 0 at each use. */
|
||||||
|
const val SOLO_SEAT: Int = 0
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initialState(config: TableConfig): PegSolitaireState {
|
||||||
|
val options = json.decodeFromString<PegSolitaireOptions>(config.optionsJson)
|
||||||
|
require(config.seats.size == 1) {
|
||||||
|
"Peg solitaire seats one, not ${config.seats.size}"
|
||||||
|
}
|
||||||
|
val start = options.start()
|
||||||
|
return PegSolitaireState(
|
||||||
|
options = options,
|
||||||
|
pegs = options.board.holes() - start,
|
||||||
|
jumps = 0,
|
||||||
|
lastJump = null,
|
||||||
|
log = listOf("${options.board.label}, opening at row ${start.row + 1}."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every jump available on the board as it stands.
|
||||||
|
*
|
||||||
|
* A jump needs three holes in a line: a peg to move, a peg beside it to take,
|
||||||
|
* and an empty hole beyond. Checking that the landing hole is on the board is
|
||||||
|
* what keeps a jump from running off a triangle's short row or out through a
|
||||||
|
* cross's missing corner.
|
||||||
|
*/
|
||||||
|
fun legalJumps(state: PegSolitaireState): List<PegJump> {
|
||||||
|
val holes = state.board.holes()
|
||||||
|
val directions = state.board.directions()
|
||||||
|
return state.pegs.flatMap { from ->
|
||||||
|
directions.mapNotNull { (dr, dc) ->
|
||||||
|
val over = Hole(from.row + dr, from.col + dc)
|
||||||
|
val to = Hole(from.row + 2 * dr, from.col + 2 * dc)
|
||||||
|
// `over` holding a peg already implies it is a hole, since a peg
|
||||||
|
// can only ever sit in one.
|
||||||
|
val ok = state.pegs.contains(over) &&
|
||||||
|
holes.contains(to) &&
|
||||||
|
!state.pegs.contains(to)
|
||||||
|
if (ok) PegJump(from, to) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun currentSeat(state: PegSolitaireState): Int? =
|
||||||
|
if (isFinished(state)) null else SOLO_SEAT
|
||||||
|
|
||||||
|
override fun legalMoves(state: PegSolitaireState, seat: Int): List<PegSolitaireMove> =
|
||||||
|
if (seat != SOLO_SEAT) emptyList() else legalJumps(state)
|
||||||
|
|
||||||
|
override fun applyMove(
|
||||||
|
state: PegSolitaireState,
|
||||||
|
seat: Int,
|
||||||
|
move: PegSolitaireMove,
|
||||||
|
): PegSolitaireState {
|
||||||
|
require(seat == SOLO_SEAT) { "Peg solitaire has one seat" }
|
||||||
|
val jump = move as PegJump
|
||||||
|
require(legalJumps(state).contains(jump)) { "$jump is not a legal jump" }
|
||||||
|
val pegs = state.pegs - jump.from - jump.over + jump.to
|
||||||
|
val next = state.copy(
|
||||||
|
pegs = pegs,
|
||||||
|
jumps = state.jumps + 1,
|
||||||
|
lastJump = jump,
|
||||||
|
)
|
||||||
|
val note = when {
|
||||||
|
next.solved -> "Solved in ${next.jumps} jumps."
|
||||||
|
legalJumps(next).isEmpty() -> "No jumps left, ${next.remaining} pegs standing."
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return if (note == null) next else next.copy(log = next.log + note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The board is finished when nothing can jump, whether or not it was solved. */
|
||||||
|
override fun isFinished(state: PegSolitaireState): Boolean = legalJumps(state).isEmpty()
|
||||||
|
|
||||||
|
override fun summary(state: PegSolitaireState): String = when {
|
||||||
|
state.solved -> "Solved in ${state.jumps} jumps"
|
||||||
|
isFinished(state) -> "Stuck with ${state.remaining} pegs"
|
||||||
|
else -> "${state.remaining} pegs, ${legalJumps(state).size} jumps available"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing to strip. One person plays, the whole board is face up in front of
|
||||||
|
* them, and there is no second seat that could be told less than the truth.
|
||||||
|
*/
|
||||||
|
override fun viewFor(state: PegSolitaireState, seat: Int): PegSolitaireState = state
|
||||||
|
|
||||||
|
override fun encodeState(state: PegSolitaireState): String = json.encodeToString(state)
|
||||||
|
override fun decodeState(json: String): PegSolitaireState = this.json.decodeFromString(json)
|
||||||
|
override fun encodeMove(move: PegSolitaireMove): String = json.encodeToString(move)
|
||||||
|
override fun decodeMove(json: String): PegSolitaireMove = this.json.decodeFromString(json)
|
||||||
|
|
||||||
|
/** Deals the same board again, so a lost game can be retried from the start. */
|
||||||
|
fun restart(state: PegSolitaireState): PegSolitaireState {
|
||||||
|
val start = state.options.start()
|
||||||
|
return state.copy(
|
||||||
|
pegs = state.options.board.holes() - start,
|
||||||
|
jumps = 0,
|
||||||
|
lastJump = null,
|
||||||
|
log = state.log + "Board reset.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -297,14 +297,12 @@ private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
|
|||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (game.available) {
|
text = when {
|
||||||
if (game.minPlayers == game.maxPlayers) {
|
!game.available -> "Coming soon"
|
||||||
"${game.minPlayers} players"
|
// A puzzle for one, rather than "1 players".
|
||||||
} else {
|
game.maxPlayers == 1 -> "On your own"
|
||||||
"${game.minPlayers}–${game.maxPlayers} players"
|
game.minPlayers == game.maxPlayers -> "${game.minPlayers} players"
|
||||||
}
|
else -> "${game.minPlayers}–${game.maxPlayers} players"
|
||||||
} else {
|
|
||||||
"Coming soon"
|
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
|||||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||||
import org.prolibertate.games.game.golf.GolfOptions
|
import org.prolibertate.games.game.golf.GolfOptions
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsOptions
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||||
@@ -88,6 +92,8 @@ fun GameSetupScreen(
|
|||||||
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
var backgammon by remember { mutableStateOf(BackgammonOptions()) }
|
||||||
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
var mastermind by remember { mutableStateOf(MastermindOptions()) }
|
||||||
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
var pirates by remember { mutableStateOf(PiratesOptions()) }
|
||||||
|
var hearts by remember { mutableStateOf(HeartsOptions()) }
|
||||||
|
var pegs by remember { mutableStateOf(PegSolitaireOptions()) }
|
||||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||||
// always White, so choosing Black means sitting in seat 1.
|
// always White, so choosing Black means sitting in seat 1.
|
||||||
var playWhite by remember { mutableStateOf(true) }
|
var playWhite by remember { mutableStateOf(true) }
|
||||||
@@ -111,6 +117,8 @@ fun GameSetupScreen(
|
|||||||
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
GameCatalog.BACKGAMMON -> setupJson.encodeToString(backgammon)
|
||||||
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
GameCatalog.MASTERMIND -> setupJson.encodeToString(mastermind)
|
||||||
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
GameCatalog.PIRATES -> setupJson.encodeToString(pirates)
|
||||||
|
GameCatalog.HEARTS -> setupJson.encodeToString(hearts)
|
||||||
|
GameCatalog.PEG_SOLITAIRE -> setupJson.encodeToString(pegs)
|
||||||
else -> "{}"
|
else -> "{}"
|
||||||
}
|
}
|
||||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||||
@@ -162,15 +170,27 @@ fun GameSetupScreen(
|
|||||||
onSide = { playPirates = it },
|
onSide = { playPirates = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
GameCatalog.HEARTS -> HeartsOptionsEditor(hearts) { hearts = it }
|
||||||
|
|
||||||
|
GameCatalog.PEG_SOLITAIRE -> PegSolitaireOptionsEditor(pegs) { pegs = it }
|
||||||
|
|
||||||
else -> Text("No options yet for this game.")
|
else -> Text("No options yet for this game.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
Text("Opponents", fontWeight = FontWeight.Bold)
|
// A one-seat game has nobody to play against and no seat anyone
|
||||||
|
// could join, so it is offered as itself rather than as a table
|
||||||
|
// with every other chair filled by the computer.
|
||||||
|
val solo = seatCount == 1
|
||||||
|
Text(if (solo) "On your own" else "Opponents", fontWeight = FontWeight.Bold)
|
||||||
Text(
|
Text(
|
||||||
text = "Play now and every other seat is taken by the computer. Host a game " +
|
text = if (solo) {
|
||||||
"instead and people can claim those seats as they join — whatever is " +
|
"A puzzle for one. There is nobody to wait for and nothing to host."
|
||||||
"still empty when you start stays computer-controlled.",
|
} else {
|
||||||
|
"Play now and every other seat is taken by the computer. Host a game " +
|
||||||
|
"instead and people can claim those seats as they join — whatever is " +
|
||||||
|
"still empty when you start stays computer-controlled."
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,14 +202,16 @@ fun GameSetupScreen(
|
|||||||
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
descriptor.id == GameCatalog.PIRATES && playPirates -> 1
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
PrimaryAction(text = "Play against the computer") {
|
PrimaryAction(text = if (solo) "Play" else "Play against the computer") {
|
||||||
onPlayOffline(
|
onPlayOffline(
|
||||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||||
localSeat,
|
localSeat,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PrimaryAction(text = "Host a game for others to join") {
|
if (!solo) {
|
||||||
onHostOnline(optionsJson)
|
PrimaryAction(text = "Host a game for others to join") {
|
||||||
|
onHostOnline(optionsJson)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -944,3 +966,49 @@ private fun ToggleRow(
|
|||||||
Switch(checked = checked, onCheckedChange = onChange)
|
Switch(checked = checked, onCheckedChange = onChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeartsOptionsEditor(options: HeartsOptions, onChange: (HeartsOptions) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Game ends at",
|
||||||
|
values = listOf(50, 100),
|
||||||
|
selected = options.targetScore,
|
||||||
|
display = { "$it points" },
|
||||||
|
onSelect = { onChange(options.copy(targetScore = it)) },
|
||||||
|
)
|
||||||
|
ToggleRow(
|
||||||
|
title = "Shooting the moon",
|
||||||
|
subtitle = "Take all twenty-six and score nothing, while everyone else takes " +
|
||||||
|
"the lot. Switched off, a moon is simply the worst hand at the table.",
|
||||||
|
checked = options.allowShootTheMoon,
|
||||||
|
onChange = { onChange(options.copy(allowShootTheMoon = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PegSolitaireOptionsEditor(
|
||||||
|
options: PegSolitaireOptions,
|
||||||
|
onChange: (PegSolitaireOptions) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
ChipRow(
|
||||||
|
label = "Board",
|
||||||
|
values = PegBoard.entries.toList(),
|
||||||
|
selected = options.board,
|
||||||
|
display = { it.label },
|
||||||
|
// The opening hole is cleared as well: a hole that exists on the old
|
||||||
|
// board may be a missing corner on the new one, and the options
|
||||||
|
// refuse to be built that way.
|
||||||
|
onSelect = { onChange(options.copy(board = it, startEmpty = null)) },
|
||||||
|
)
|
||||||
|
ChipRow(
|
||||||
|
label = "Finish",
|
||||||
|
values = PegGoal.entries.toList(),
|
||||||
|
selected = options.goal,
|
||||||
|
display = { it.label },
|
||||||
|
onSelect = { onChange(options.copy(goal = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import org.prolibertate.games.game.GameCatalog
|
import org.prolibertate.games.game.GameCatalog
|
||||||
import org.prolibertate.games.game.chess.ChessAi
|
import org.prolibertate.games.game.chess.ChessAi
|
||||||
@@ -26,6 +29,15 @@ import org.prolibertate.games.game.euchre.EuchreMove
|
|||||||
import org.prolibertate.games.game.euchre.EuchrePhase
|
import org.prolibertate.games.game.euchre.EuchrePhase
|
||||||
import org.prolibertate.games.game.euchre.EuchreRules
|
import org.prolibertate.games.game.euchre.EuchreRules
|
||||||
import org.prolibertate.games.game.euchre.EuchreState
|
import org.prolibertate.games.game.euchre.EuchreState
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsAi
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsMove
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsPhase
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsRules
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsState
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireAi
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireRules
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
|
||||||
import org.prolibertate.games.game.golf.GolfAi
|
import org.prolibertate.games.game.golf.GolfAi
|
||||||
import org.prolibertate.games.game.golf.GolfMove
|
import org.prolibertate.games.game.golf.GolfMove
|
||||||
import org.prolibertate.games.game.golf.GolfPhase
|
import org.prolibertate.games.game.golf.GolfPhase
|
||||||
@@ -81,11 +93,13 @@ import org.prolibertate.games.ui.game.CheckersScreen
|
|||||||
import org.prolibertate.games.ui.game.ChessScreen
|
import org.prolibertate.games.ui.game.ChessScreen
|
||||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||||
import org.prolibertate.games.ui.game.CribbageScreen
|
import org.prolibertate.games.ui.game.CribbageScreen
|
||||||
|
import org.prolibertate.games.ui.game.HeartsScreen
|
||||||
import org.prolibertate.games.ui.game.EuchreScreen
|
import org.prolibertate.games.ui.game.EuchreScreen
|
||||||
import org.prolibertate.games.ui.game.GolfScreen
|
import org.prolibertate.games.ui.game.GolfScreen
|
||||||
import org.prolibertate.games.ui.game.KaiserScreen
|
import org.prolibertate.games.ui.game.KaiserScreen
|
||||||
import org.prolibertate.games.ui.game.MastermindScreen
|
import org.prolibertate.games.ui.game.MastermindScreen
|
||||||
import org.prolibertate.games.ui.game.MorrisScreen
|
import org.prolibertate.games.ui.game.MorrisScreen
|
||||||
|
import org.prolibertate.games.ui.game.PegSolitaireScreen
|
||||||
import org.prolibertate.games.ui.game.PiratesScreen
|
import org.prolibertate.games.ui.game.PiratesScreen
|
||||||
import org.prolibertate.games.ui.game.PresidentScreen
|
import org.prolibertate.games.ui.game.PresidentScreen
|
||||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||||
@@ -430,6 +444,67 @@ fun PlayScreen(
|
|||||||
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
PiratesScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GameCatalog.HEARTS -> {
|
||||||
|
val controller = remember(route) {
|
||||||
|
MatchController<HeartsState, HeartsMove>(
|
||||||
|
rules = HeartsRules,
|
||||||
|
ai = HeartsAi,
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
advanceIdle = { state ->
|
||||||
|
if (state.phase == HeartsPhase.ROUND_OVER) {
|
||||||
|
HeartsRules.startNextRound(state)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// What a round cost is read before the next deal.
|
||||||
|
awaitsConfirmation = { state -> state.phase == HeartsPhase.ROUND_OVER },
|
||||||
|
aiThinkingMillis = { settings.scaled(700L) },
|
||||||
|
holdBeforeNextMove = { state ->
|
||||||
|
if (state.completedTrick.isNotEmpty()) {
|
||||||
|
settings.scaled(TRICK_HOLD_MILLIS)
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
HeartsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCatalog.PEG_SOLITAIRE -> {
|
||||||
|
// Playing again means a fresh board rather than a fresh round, and
|
||||||
|
// it cannot go through advanceIdle: driveIdleSeats returns as soon
|
||||||
|
// as rules.isFinished is true, and a peg board with no jumps left
|
||||||
|
// genuinely is finished. Rebuilding the controller is the honest
|
||||||
|
// way to deal again, and the board is deterministic, so the retry
|
||||||
|
// is the same puzzle.
|
||||||
|
var generation by remember(route) { mutableIntStateOf(0) }
|
||||||
|
val controller = remember(route, generation) {
|
||||||
|
MatchController<PegSolitaireState, PegSolitaireMove>(
|
||||||
|
rules = PegSolitaireRules,
|
||||||
|
// Never consulted while a person holds the only seat.
|
||||||
|
ai = PegSolitaireAi,
|
||||||
|
config = route.config,
|
||||||
|
scope = scope,
|
||||||
|
role = role,
|
||||||
|
localSeats = setOf(route.localSeat),
|
||||||
|
primarySeat = route.localSeat,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StartMatch(env, controller, route)
|
||||||
|
PegSolitaireScreen(
|
||||||
|
controller = controller,
|
||||||
|
onRestart = { generation++ },
|
||||||
|
onExit = onExit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
GameCatalog.MORRIS -> {
|
GameCatalog.MORRIS -> {
|
||||||
val controller = remember(route) {
|
val controller = remember(route) {
|
||||||
MatchController<MorrisState, MorrisMove>(
|
MatchController<MorrisState, MorrisMove>(
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.prolibertate.games.game.cards.Card
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsMove
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsPhase
|
||||||
|
import org.prolibertate.games.game.hearts.HeartsState
|
||||||
|
import org.prolibertate.games.game.hearts.PASS_SIZE
|
||||||
|
import org.prolibertate.games.game.hearts.PassCards
|
||||||
|
import org.prolibertate.games.game.hearts.PlayCard
|
||||||
|
import org.prolibertate.games.game.hearts.pointsOf
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.common.PlayingCardView
|
||||||
|
|
||||||
|
private const val TITLE = "Hearts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Hearts table.
|
||||||
|
*
|
||||||
|
* Two quite different things happen at this screen, so it reads the phase and
|
||||||
|
* shows one or the other: choosing three cards to give away, which is a
|
||||||
|
* multiple selection nothing else in the app does, and then playing one card at
|
||||||
|
* a time, which every trick-taking screen here does the same way.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun HeartsScreen(
|
||||||
|
controller: MatchController<HeartsState, HeartsMove>,
|
||||||
|
localSeat: Int,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val abandoned by controller.abandoned.collectAsState()
|
||||||
|
val awaiting by controller.awaitingConfirmation.collectAsState()
|
||||||
|
|
||||||
|
val chosen = remember { mutableStateListOf<Card>() }
|
||||||
|
var confirmingEnd by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val endGameAction: @Composable () -> Unit = {
|
||||||
|
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
if (current == null) {
|
||||||
|
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") }
|
||||||
|
abandoned?.let { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val hand = current.hands.getOrElse(localSeat) { emptyList() }
|
||||||
|
val passing = current.phase == HeartsPhase.PASSING
|
||||||
|
val alreadyPassed = current.passSelections.getOrElse(localSeat) { emptyList() }.isNotEmpty()
|
||||||
|
val playable = legal.filterIsInstance<PlayCard>().map { it.card }.toSet()
|
||||||
|
|
||||||
|
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
Column(
|
||||||
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
current.phase == HeartsPhase.GAME_OVER -> "Game over — lowest score wins."
|
||||||
|
current.phase == HeartsPhase.ROUND_OVER -> "Round scored."
|
||||||
|
passing && alreadyPassed -> "Waiting for the others to choose…"
|
||||||
|
passing -> "Choose three to pass ${current.passDirection.label}"
|
||||||
|
playable.isEmpty() -> "Waiting…"
|
||||||
|
else -> "Your turn"
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Every heart costs a point and the queen of spades costs thirteen. " +
|
||||||
|
"Take none of them.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
TrickView(current)
|
||||||
|
ScoreRow(current, localSeat)
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = if (passing && !alreadyPassed) {
|
||||||
|
"Passing ${current.passDirection.label} — ${chosen.size} of $PASS_SIZE chosen"
|
||||||
|
} else {
|
||||||
|
"Your hand"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
hand.forEach { card ->
|
||||||
|
val selectable = passing && !alreadyPassed
|
||||||
|
PlayingCardView(
|
||||||
|
card = card,
|
||||||
|
width = 52.dp,
|
||||||
|
selected = chosen.contains(card),
|
||||||
|
enabled = if (selectable) true else playable.contains(card),
|
||||||
|
caption = pointsOf(card).takeIf { it > 0 }?.let { "−$it" },
|
||||||
|
onClick = {
|
||||||
|
if (selectable) {
|
||||||
|
toggle(chosen, card)
|
||||||
|
} else if (playable.contains(card)) {
|
||||||
|
controller.submit(PlayCard(card))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passing && !alreadyPassed) {
|
||||||
|
Button(
|
||||||
|
enabled = chosen.size == PASS_SIZE,
|
||||||
|
onClick = {
|
||||||
|
controller.submit(PassCards(chosen.toList()))
|
||||||
|
chosen.clear()
|
||||||
|
},
|
||||||
|
) { Text("Pass these three") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (awaiting) {
|
||||||
|
Button(onClick = controller::confirmAdvance) { Text("Next round") }
|
||||||
|
}
|
||||||
|
|
||||||
|
current.log.takeLast(3).forEach {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abandoned?.let { LeftHeartsDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndHeartsDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds or removes [card], never letting the selection run past three. */
|
||||||
|
private fun toggle(chosen: SnapshotStateList<Card>, card: Card) {
|
||||||
|
if (chosen.contains(card)) {
|
||||||
|
chosen.remove(card)
|
||||||
|
} else if (chosen.size < PASS_SIZE) {
|
||||||
|
chosen.add(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TrickView(state: HeartsState) {
|
||||||
|
// The trick just swept is held on the table for a moment, so show that when
|
||||||
|
// there is nothing in front of the players yet.
|
||||||
|
val showing = state.trick.ifEmpty { state.completedTrick }
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = if (state.trick.isEmpty() && showing.isNotEmpty()) {
|
||||||
|
"Last trick — taken by seat ${state.leader}"
|
||||||
|
} else {
|
||||||
|
"Trick ${state.trickNumber + 1} of ${state.tricksPerRound}"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
if (showing.isEmpty()) {
|
||||||
|
Text("Nothing played yet.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
showing.forEach { played ->
|
||||||
|
PlayingCardView(
|
||||||
|
card = played.card,
|
||||||
|
width = 46.dp,
|
||||||
|
caption = "seat ${played.seat}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScoreRow(state: HeartsState, localSeat: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
state.scores.forEachIndexed { seat, score ->
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = if (seat == localSeat) "You" else "Seat $seat",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = if (seat == localSeat) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
Text("$score", fontWeight = FontWeight.Bold)
|
||||||
|
// What this round has cost so far, which is the number anybody
|
||||||
|
// actually watches while a hand is being played.
|
||||||
|
Text(
|
||||||
|
text = "+${state.roundScores.getOrElse(seat) { 0 }}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EndHeartsDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onKeepPlaying,
|
||||||
|
title = { Text("End this game?") },
|
||||||
|
text = { Text("The game ends here. Anyone else at the table is told you have left.") },
|
||||||
|
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||||
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeftHeartsDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onStay,
|
||||||
|
title = { Text("The game has ended") },
|
||||||
|
text = { Text(notice) },
|
||||||
|
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||||
|
dismissButton = { TextButton(onClick = onStay) { Text("Look at the table") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package org.prolibertate.games.ui.game
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.Hole
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegJump
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireMove
|
||||||
|
import org.prolibertate.games.game.pegsolitaire.PegSolitaireState
|
||||||
|
import org.prolibertate.games.net.MatchController
|
||||||
|
import org.prolibertate.games.ui.ScreenScaffold
|
||||||
|
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||||
|
import org.prolibertate.games.ui.theme.Parchment
|
||||||
|
import org.prolibertate.games.ui.theme.WallaceGold
|
||||||
|
import org.prolibertate.games.ui.theme.WallaceRed
|
||||||
|
|
||||||
|
private const val TITLE = "Peg Solitaire"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The peg board.
|
||||||
|
*
|
||||||
|
* One layout serves both board shapes. Each hole is placed at a position in
|
||||||
|
* hole-widths rather than pixels, and a triangle simply offsets its rows by half
|
||||||
|
* a hole so they stagger — which is the same thing that gives a triangular hole
|
||||||
|
* six neighbours instead of four. Scaling that grid to whatever width the phone
|
||||||
|
* has is then the only sizing decision left.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PegSolitaireScreen(
|
||||||
|
controller: MatchController<PegSolitaireState, PegSolitaireMove>,
|
||||||
|
onRestart: () -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by controller.state.collectAsState()
|
||||||
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val abandoned by controller.abandoned.collectAsState()
|
||||||
|
|
||||||
|
var selected by remember { mutableStateOf<Hole?>(null) }
|
||||||
|
var confirmingEnd by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val endGameAction: @Composable () -> Unit = {
|
||||||
|
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = state
|
||||||
|
if (current == null) {
|
||||||
|
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||||
|
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val jumps = legal.filterIsInstance<PegJump>()
|
||||||
|
val movable = jumps.map { it.from }.toSet()
|
||||||
|
val landings = selected?.let { from -> jumps.filter { it.from == from } }.orEmpty()
|
||||||
|
val destinations = landings.map { it.to }.toSet()
|
||||||
|
val finished = jumps.isEmpty()
|
||||||
|
|
||||||
|
ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
|
Column(
|
||||||
|
modifier = modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
current.solved -> "Solved, in ${current.jumps} jumps."
|
||||||
|
finished -> "Stuck with ${current.remaining} pegs."
|
||||||
|
selected != null -> "Tap a ringed hole to jump into it."
|
||||||
|
else -> "Tap a peg that can jump."
|
||||||
|
},
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${current.board.label}. ${current.remaining} pegs left, " +
|
||||||
|
"${current.jumps} jumps made.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
Board(
|
||||||
|
state = current,
|
||||||
|
selected = selected,
|
||||||
|
movable = movable,
|
||||||
|
destinations = destinations,
|
||||||
|
onTap = { hole ->
|
||||||
|
selected = when {
|
||||||
|
// Completing a jump takes priority: the tapped hole is
|
||||||
|
// one the selected peg was offered.
|
||||||
|
selected != null && destinations.contains(hole) -> {
|
||||||
|
controller.submit(PegJump(from = selected!!, to = hole))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
movable.contains(hole) -> hole
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (finished) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onRestart) { Text("Play again") }
|
||||||
|
TextButton(onClick = onExit) { Text("Leave") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current.log.takeLast(3).forEach {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abandoned?.let { LeftBoardDialog(it, onExit, controller::dismissAbandoned) }
|
||||||
|
if (confirmingEnd) EndBoardDialog(onExit) { confirmingEnd = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Board(
|
||||||
|
state: PegSolitaireState,
|
||||||
|
selected: Hole?,
|
||||||
|
movable: Set<Hole>,
|
||||||
|
destinations: Set<Hole>,
|
||||||
|
onTap: (Hole) -> Unit,
|
||||||
|
) {
|
||||||
|
val board = state.board
|
||||||
|
val holes = remember(board) { board.holes().toList() }
|
||||||
|
val places = remember(board) { holes.associateWith { placeOf(board, it) } }
|
||||||
|
val xs = places.values.map { it.first }
|
||||||
|
val ys = places.values.map { it.second }
|
||||||
|
val minX = xs.min()
|
||||||
|
val minY = ys.min()
|
||||||
|
val spanX = xs.max() - minX
|
||||||
|
val spanY = ys.max() - minY
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// One extra hole-width so the last column has somewhere to sit.
|
||||||
|
val cell: Dp = maxWidth / (spanX + 1f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(cell * (spanY + 1f))
|
||||||
|
.background(FeltGreenDark),
|
||||||
|
) {
|
||||||
|
holes.forEach { hole ->
|
||||||
|
val (x, y) = places.getValue(hole)
|
||||||
|
HoleView(
|
||||||
|
filled = state.pegs.contains(hole),
|
||||||
|
selected = hole == selected,
|
||||||
|
canMove = movable.contains(hole),
|
||||||
|
isDestination = destinations.contains(hole),
|
||||||
|
size = cell,
|
||||||
|
modifier = Modifier.offset(x = cell * (x - minX), y = cell * (y - minY)),
|
||||||
|
onTap = { onTap(hole) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a hole sits, in hole-widths from the board's top left.
|
||||||
|
*
|
||||||
|
* The half-hole shift on a triangle is the whole difference between the two
|
||||||
|
* geometries as far as drawing is concerned.
|
||||||
|
*/
|
||||||
|
private fun placeOf(board: PegBoard, hole: Hole): Pair<Float, Float> =
|
||||||
|
if (board.isTriangular) {
|
||||||
|
(hole.col - hole.row / 2f) to hole.row.toFloat()
|
||||||
|
} else {
|
||||||
|
hole.col.toFloat() to hole.row.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HoleView(
|
||||||
|
filled: Boolean,
|
||||||
|
selected: Boolean,
|
||||||
|
canMove: Boolean,
|
||||||
|
isDestination: Boolean,
|
||||||
|
size: Dp,
|
||||||
|
modifier: Modifier,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
|
||||||
|
val diameter = size * 0.72f
|
||||||
|
val ring = when {
|
||||||
|
selected -> WallaceGold
|
||||||
|
isDestination -> WallaceGold
|
||||||
|
else -> Parchment.copy(alpha = 0.35f)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(diameter)
|
||||||
|
.background(
|
||||||
|
color = if (filled) WallaceRed else FeltGreenDark,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
// A destination is drawn as an empty ring rather than filled,
|
||||||
|
// so it never reads as a peg already sitting there.
|
||||||
|
border = BorderStroke(if (selected || isDestination) 3.dp else 1.dp, ring),
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.clickable(enabled = filled && canMove || isDestination) { onTap() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EndBoardDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onKeepPlaying,
|
||||||
|
title = { Text("End this game?") },
|
||||||
|
text = { Text("The board is left as it stands.") },
|
||||||
|
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||||
|
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeftBoardDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onStay,
|
||||||
|
title = { Text("The game has ended") },
|
||||||
|
text = { Text(notice) },
|
||||||
|
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||||
|
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ class CatalogTest {
|
|||||||
GameCatalog.KAISER,
|
GameCatalog.KAISER,
|
||||||
GameCatalog.CRAZY_EIGHTS,
|
GameCatalog.CRAZY_EIGHTS,
|
||||||
GameCatalog.CRIBBAGE,
|
GameCatalog.CRIBBAGE,
|
||||||
|
GameCatalog.HEARTS,
|
||||||
|
GameCatalog.PEG_SOLITAIRE,
|
||||||
GameCatalog.WIZARD,
|
GameCatalog.WIZARD,
|
||||||
GameCatalog.CHESS,
|
GameCatalog.CHESS,
|
||||||
GameCatalog.TAYU,
|
GameCatalog.TAYU,
|
||||||
@@ -87,7 +89,11 @@ class CatalogTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `player counts are sane`() {
|
fun `player counts are sane`() {
|
||||||
GameCatalog.all.forEach { game ->
|
GameCatalog.all.forEach { game ->
|
||||||
assertTrue("${game.id} min players", game.minPlayers >= 2)
|
// One, not two. Peg solitaire is played alone, and the engine took
|
||||||
|
// that without changes — one seat, always on the clock, nothing to
|
||||||
|
// redact. Everything downstream reads the seat count from here, so
|
||||||
|
// this is the only place that had to allow it.
|
||||||
|
assertTrue("${game.id} min players", game.minPlayers >= 1)
|
||||||
assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers)
|
assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package org.prolibertate.games.game.hearts
|
||||||
|
|
||||||
|
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.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
|
||||||
|
|
||||||
|
class HeartsRulesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(options: HeartsOptions = HeartsOptions(), seed: Long = 7L) = TableConfig(
|
||||||
|
gameId = "hearts",
|
||||||
|
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 c(rank: Rank, suit: Suit) = Card(rank, suit)
|
||||||
|
|
||||||
|
/** A state part way through a round, with passing already done. */
|
||||||
|
private fun playing(
|
||||||
|
hands: List<List<Card>>,
|
||||||
|
turn: Int = 0,
|
||||||
|
leader: Int = 0,
|
||||||
|
trick: List<PlayedCard> = emptyList(),
|
||||||
|
trickNumber: Int = 1,
|
||||||
|
heartsBroken: Boolean = false,
|
||||||
|
taken: List<List<Card>> = List(hands.size) { emptyList() },
|
||||||
|
options: HeartsOptions = HeartsOptions(),
|
||||||
|
) = HeartsState(
|
||||||
|
options = options,
|
||||||
|
seed = 1L,
|
||||||
|
round = 0,
|
||||||
|
phase = HeartsPhase.PLAYING,
|
||||||
|
hands = hands,
|
||||||
|
handCounts = hands.map { it.size },
|
||||||
|
passSelections = List(hands.size) { emptyList() },
|
||||||
|
turn = turn,
|
||||||
|
leader = leader,
|
||||||
|
trickNumber = trickNumber,
|
||||||
|
trick = trick,
|
||||||
|
taken = taken,
|
||||||
|
heartsBroken = heartsBroken,
|
||||||
|
scores = List(hands.size) { 0 },
|
||||||
|
roundScores = List(hands.size) { 0 },
|
||||||
|
log = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a deal gives four hands of thirteen from one pack`() {
|
||||||
|
val state = HeartsRules.initialState(config())
|
||||||
|
assertEquals(4, state.hands.size)
|
||||||
|
state.hands.forEach { assertEquals(13, it.size) }
|
||||||
|
val all = state.hands.flatten()
|
||||||
|
assertEquals(52, all.size)
|
||||||
|
assertEquals("no card is dealt twice", 52, all.distinct().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the pass direction cycles and the fourth round holds`() {
|
||||||
|
assertEquals(PassDirection.LEFT, PassDirection.forRound(0))
|
||||||
|
assertEquals(PassDirection.RIGHT, PassDirection.forRound(1))
|
||||||
|
assertEquals(PassDirection.ACROSS, PassDirection.forRound(2))
|
||||||
|
assertEquals(PassDirection.HOLD, PassDirection.forRound(3))
|
||||||
|
assertEquals(PassDirection.LEFT, PassDirection.forRound(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a hold round opens straight onto the lead`() {
|
||||||
|
// Round three holds, so there is nothing to swap and play begins at once
|
||||||
|
// with whoever holds the two of clubs.
|
||||||
|
var state = HeartsRules.initialState(config())
|
||||||
|
// Walk to round three through the public entry point rather than
|
||||||
|
// reaching into the private deal.
|
||||||
|
repeat(3) {
|
||||||
|
state = HeartsRules.startNextRound(state.copy(phase = HeartsPhase.ROUND_OVER))
|
||||||
|
}
|
||||||
|
assertEquals(3, state.round)
|
||||||
|
assertEquals(PassDirection.HOLD, state.passDirection)
|
||||||
|
assertEquals(HeartsPhase.PLAYING, state.phase)
|
||||||
|
assertTrue(state.hands[state.turn].contains(TWO_OF_CLUBS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `passing left moves three cards to the next seat`() {
|
||||||
|
var state = HeartsRules.initialState(config())
|
||||||
|
assertEquals(HeartsPhase.PASSING, state.phase)
|
||||||
|
val given = (0 until 4).map { state.hands[it].take(PASS_SIZE) }
|
||||||
|
for (seat in 0 until 4) {
|
||||||
|
assertEquals(seat, HeartsRules.currentSeat(state))
|
||||||
|
state = HeartsRules.applyMove(state, seat, PassCards(given[seat]))
|
||||||
|
}
|
||||||
|
assertEquals(HeartsPhase.PLAYING, state.phase)
|
||||||
|
for (seat in 0 until 4) {
|
||||||
|
val receiver = (seat + 1) % 4
|
||||||
|
assertTrue(
|
||||||
|
"seat $receiver should hold what seat $seat passed",
|
||||||
|
state.hands[receiver].containsAll(given[seat]),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
"seat $seat should not still hold what it passed",
|
||||||
|
state.hands[seat].any { given[seat].contains(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.hands.forEach { assertEquals(13, it.size) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pass must be three distinct cards from the hand`() {
|
||||||
|
val state = HeartsRules.initialState(config())
|
||||||
|
val hand = state.hands[0]
|
||||||
|
assertThrows { HeartsRules.applyMove(state, 0, PassCards(hand.take(2))) }
|
||||||
|
assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(hand[0], hand[0], hand[1]))) }
|
||||||
|
// A card this seat does not hold — it is in somebody else's thirteen.
|
||||||
|
val notHeld = state.hands[1].first()
|
||||||
|
assertThrows { HeartsRules.applyMove(state, 0, PassCards(listOf(notHeld) + hand.take(2))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the two of clubs opens the round`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.TWO, Suit.CLUBS), c(Rank.KING, Suit.SPADES)),
|
||||||
|
listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.FOUR, Suit.HEARTS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS), c(Rank.SIX, Suit.DIAMONDS)),
|
||||||
|
listOf(c(Rank.SEVEN, Suit.CLUBS), c(Rank.EIGHT, Suit.DIAMONDS)),
|
||||||
|
)
|
||||||
|
val state = playing(hands, trickNumber = 0)
|
||||||
|
assertEquals(listOf(TWO_OF_CLUBS), HeartsRules.playableCards(state, 0))
|
||||||
|
assertThrows { HeartsRules.applyMove(state, 0, PlayCard(c(Rank.KING, Suit.SPADES))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a seat holding the led suit must follow it`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.THREE, Suit.CLUBS), c(Rank.ACE, Suit.HEARTS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
val state = playing(
|
||||||
|
hands,
|
||||||
|
turn = 1,
|
||||||
|
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
|
||||||
|
)
|
||||||
|
assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(state, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no point card may be discarded on the first trick`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||||
|
// Void in clubs, holding the queen, a heart, and one harmless card.
|
||||||
|
listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS), c(Rank.NINE, Suit.DIAMONDS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
val state = playing(
|
||||||
|
hands,
|
||||||
|
turn = 1,
|
||||||
|
trickNumber = 0,
|
||||||
|
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
|
||||||
|
)
|
||||||
|
assertEquals(listOf(c(Rank.NINE, Suit.DIAMONDS)), HeartsRules.playableCards(state, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the first trick ban lifts when a seat holds nothing but points`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||||
|
listOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
val state = playing(
|
||||||
|
hands,
|
||||||
|
turn = 1,
|
||||||
|
trickNumber = 0,
|
||||||
|
trick = listOf(PlayedCard(0, c(Rank.TWO, Suit.CLUBS))),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
setOf(QUEEN_OF_SPADES, c(Rank.ACE, Suit.HEARTS)),
|
||||||
|
HeartsRules.playableCards(state, 1).toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hearts cannot be led until one has been played`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.THREE, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.FOUR, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.SIX, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
val unbroken = playing(hands, heartsBroken = false)
|
||||||
|
assertEquals(listOf(c(Rank.THREE, Suit.CLUBS)), HeartsRules.playableCards(unbroken, 0))
|
||||||
|
|
||||||
|
val broken = playing(hands, heartsBroken = true)
|
||||||
|
assertEquals(2, HeartsRules.playableCards(broken, 0).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hearts may be led when they are all that is left`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.ACE, Suit.HEARTS), c(Rank.TWO, Suit.HEARTS)),
|
||||||
|
listOf(c(Rank.FOUR, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.SIX, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
val state = playing(hands, heartsBroken = false)
|
||||||
|
assertEquals(2, HeartsRules.playableCards(state, 0).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the highest card of the led suit takes the trick`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.KING, Suit.CLUBS)),
|
||||||
|
// An ace off suit cannot win, however high.
|
||||||
|
listOf(c(Rank.ACE, Suit.DIAMONDS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
var state = playing(hands)
|
||||||
|
state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS)))
|
||||||
|
state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.KING, Suit.CLUBS)))
|
||||||
|
state = HeartsRules.applyMove(state, 2, PlayCard(c(Rank.ACE, Suit.DIAMONDS)))
|
||||||
|
state = HeartsRules.applyMove(state, 3, PlayCard(c(Rank.FIVE, Suit.CLUBS)))
|
||||||
|
assertEquals("king of clubs takes it", 1, state.leader)
|
||||||
|
assertEquals(4, state.taken[1].size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `playing a heart breaks them`() {
|
||||||
|
val hands = listOf(
|
||||||
|
listOf(c(Rank.TWO, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.ACE, Suit.HEARTS)),
|
||||||
|
listOf(c(Rank.FIVE, Suit.CLUBS)),
|
||||||
|
listOf(c(Rank.SEVEN, Suit.CLUBS)),
|
||||||
|
)
|
||||||
|
var state = playing(hands)
|
||||||
|
assertFalse(state.heartsBroken)
|
||||||
|
state = HeartsRules.applyMove(state, 0, PlayCard(c(Rank.TWO, Suit.CLUBS)))
|
||||||
|
state = HeartsRules.applyMove(state, 1, PlayCard(c(Rank.ACE, Suit.HEARTS)))
|
||||||
|
assertTrue(state.heartsBroken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hearts cost a point each and the queen costs thirteen`() {
|
||||||
|
assertEquals(1, pointsOf(c(Rank.TWO, Suit.HEARTS)))
|
||||||
|
assertEquals(1, pointsOf(c(Rank.ACE, Suit.HEARTS)))
|
||||||
|
assertEquals(13, pointsOf(QUEEN_OF_SPADES))
|
||||||
|
assertEquals(0, pointsOf(c(Rank.KING, Suit.SPADES)))
|
||||||
|
assertEquals(0, pointsOf(c(Rank.ACE, Suit.DIAMONDS)))
|
||||||
|
// The whole pack is worth twenty-six and nothing more.
|
||||||
|
assertEquals(ALL_POINTS, org.prolibertate.games.game.cards.Decks.standard52().sumOf { pointsOf(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an ordinary round shares out twenty-six points`() {
|
||||||
|
val hearts = Rank.standard.map { Card(it, Suit.HEARTS) }
|
||||||
|
val taken = listOf(
|
||||||
|
hearts.take(6),
|
||||||
|
hearts.drop(6) + QUEEN_OF_SPADES,
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
val scored = scoreRound(taken, allowShootTheMoon = true)
|
||||||
|
assertEquals(listOf(6, 20, 0, 0), scored)
|
||||||
|
assertEquals(ALL_POINTS, scored.sum())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `shooting the moon scores everyone else instead`() {
|
||||||
|
val everything = Rank.standard.map { Card(it, Suit.HEARTS) } + QUEEN_OF_SPADES
|
||||||
|
val taken = listOf(emptyList(), everything, emptyList<Card>(), emptyList())
|
||||||
|
assertEquals(listOf(26, 0, 26, 26), scoreRound(taken, allowShootTheMoon = true))
|
||||||
|
// Switched off, a moon is simply the worst hand at the table.
|
||||||
|
assertEquals(listOf(0, 26, 0, 0), scoreRound(taken, allowShootTheMoon = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a client sees neither other hands nor what they are passing`() {
|
||||||
|
var state = HeartsRules.initialState(config())
|
||||||
|
state = HeartsRules.applyMove(state, 0, PassCards(state.hands[0].take(PASS_SIZE)))
|
||||||
|
val view = HeartsRules.viewFor(state, 0)
|
||||||
|
assertEquals(state.hands[0], view.hands[0])
|
||||||
|
assertTrue("other hands are stripped", view.hands.drop(1).all { it.isEmpty() })
|
||||||
|
assertEquals("counts survive so backs can be drawn", state.handCounts, view.handCounts)
|
||||||
|
assertEquals(state.passSelections[0], view.passSelections[0])
|
||||||
|
assertTrue(view.passSelections.drop(1).all { it.isEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state and moves survive a round trip`() {
|
||||||
|
val state = HeartsRules.initialState(config())
|
||||||
|
val decoded = HeartsRules.decodeState(HeartsRules.encodeState(state))
|
||||||
|
assertEquals(state, decoded)
|
||||||
|
|
||||||
|
val pass: HeartsMove = PassCards(state.hands[0].take(PASS_SIZE))
|
||||||
|
assertEquals(pass, HeartsRules.decodeMove(HeartsRules.encodeMove(pass)))
|
||||||
|
val play: HeartsMove = PlayCard(TWO_OF_CLUBS)
|
||||||
|
assertEquals(play, HeartsRules.decodeMove(HeartsRules.encodeMove(play)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nobody is on the clock once the game is over`() {
|
||||||
|
val state = HeartsRules.initialState(config()).copy(phase = HeartsPhase.GAME_OVER)
|
||||||
|
assertNull(HeartsRules.currentSeat(state))
|
||||||
|
assertTrue(HeartsRules.legalMoves(state, 0).isEmpty())
|
||||||
|
assertTrue(HeartsRules.isFinished(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertThrows(block: () -> Unit) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
throw AssertionError("expected the move to be rejected")
|
||||||
|
} catch (expected: IllegalArgumentException) {
|
||||||
|
// The rules rejected it, which is the point.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package org.prolibertate.games.game.pegsolitaire
|
||||||
|
|
||||||
|
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 PegSolitaireRulesTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
private fun config(options: PegSolitaireOptions = PegSolitaireOptions()) = TableConfig(
|
||||||
|
gameId = "pegsolitaire",
|
||||||
|
seats = listOf(PlayerSlot(seat = 0, name = "P0", kind = PlayerKind.HUMAN_LOCAL, team = 0)),
|
||||||
|
optionsJson = json.encodeToString(options),
|
||||||
|
seed = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every board has the number of holes it claims`() {
|
||||||
|
PegBoard.entries.forEach { board ->
|
||||||
|
assertEquals(board.label, board.holeCount, board.holes().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a triangle staggers its rows and a cross does not`() {
|
||||||
|
// Six ways off a hole on a triangle, four on a square grid. This is the
|
||||||
|
// only thing that differs between the two shapes.
|
||||||
|
assertEquals(6, PegBoard.TRIANGLE_15.directions().size)
|
||||||
|
assertEquals(6, PegBoard.TRIANGLE_21.directions().size)
|
||||||
|
assertEquals(4, PegBoard.ENGLISH_CROSS.directions().size)
|
||||||
|
assertEquals(4, PegBoard.FRENCH_CROSS.directions().size)
|
||||||
|
assertEquals(4, PegBoard.DIAMOND_41.directions().size)
|
||||||
|
// No diagonal creeps into a cross board.
|
||||||
|
assertTrue(PegBoard.ENGLISH_CROSS.directions().all { (dr, dc) -> dr == 0 || dc == 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the cross boards are cut where they should be`() {
|
||||||
|
val english = PegBoard.ENGLISH_CROSS.holes()
|
||||||
|
assertFalse("a two-by-two corner is gone", english.contains(Hole(0, 0)))
|
||||||
|
assertFalse(english.contains(Hole(1, 1)))
|
||||||
|
assertTrue(english.contains(Hole(0, 3)))
|
||||||
|
assertTrue(english.contains(Hole(3, 3)))
|
||||||
|
|
||||||
|
val french = PegBoard.FRENCH_CROSS.holes()
|
||||||
|
// The shallower cut takes three holes per corner, so (1,1) survives.
|
||||||
|
assertFalse(french.contains(Hole(0, 0)))
|
||||||
|
assertFalse(french.contains(Hole(0, 1)))
|
||||||
|
assertFalse(french.contains(Hole(1, 0)))
|
||||||
|
assertTrue("the French cut leaves this one", french.contains(Hole(1, 1)))
|
||||||
|
|
||||||
|
val diamond = PegBoard.DIAMOND_41.holes()
|
||||||
|
assertTrue(diamond.contains(Hole(0, 4)))
|
||||||
|
assertTrue(diamond.contains(Hole(4, 0)))
|
||||||
|
assertFalse(diamond.contains(Hole(0, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a board opens one peg short`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
assertEquals(PegBoard.TRIANGLE_15.holeCount - 1, state.remaining)
|
||||||
|
assertFalse("the opening hole is empty", state.pegs.contains(Hole(0, 0)))
|
||||||
|
assertEquals(0, state.jumps)
|
||||||
|
assertNull(state.lastJump)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the tee game opens with exactly two jumps, both into the apex`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
val jumps = PegSolitaireRules.legalJumps(state)
|
||||||
|
assertEquals(2, jumps.size)
|
||||||
|
assertTrue("every opening jump lands in the empty hole", jumps.all { it.to == Hole(0, 0) })
|
||||||
|
assertEquals(setOf(Hole(2, 0), Hole(2, 2)), jumps.map { it.from }.toSet())
|
||||||
|
// The jumped peg is the one between the ends, and it is never invented.
|
||||||
|
assertEquals(setOf(Hole(1, 0), Hole(1, 1)), jumps.map { it.over }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a jump moves one peg and takes the one it passed`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
val jump = PegJump(from = Hole(2, 0), to = Hole(0, 0))
|
||||||
|
val after = PegSolitaireRules.applyMove(state, 0, jump)
|
||||||
|
assertFalse("the mover left", after.pegs.contains(Hole(2, 0)))
|
||||||
|
assertFalse("the jumped peg was taken", after.pegs.contains(Hole(1, 0)))
|
||||||
|
assertTrue("the mover arrived", after.pegs.contains(Hole(0, 0)))
|
||||||
|
assertEquals(state.remaining - 1, after.remaining)
|
||||||
|
assertEquals(1, after.jumps)
|
||||||
|
assertEquals(jump, after.lastJump)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a jump needs a peg to pass and an empty hole to land in`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
// Landing hole already occupied.
|
||||||
|
assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(4, 0), Hole(2, 0))) }
|
||||||
|
// Nothing to jump over: (1,0) is empty only after a jump, but (0,0) is
|
||||||
|
// the opening hole, so a jump starting there has no peg to move.
|
||||||
|
assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(0, 0), Hole(2, 0))) }
|
||||||
|
// Straight off the board.
|
||||||
|
assertThrows { PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(2, 4))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the one seat may play`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
assertEquals(0, PegSolitaireRules.currentSeat(state))
|
||||||
|
assertTrue(PegSolitaireRules.legalMoves(state, 1).isEmpty())
|
||||||
|
assertThrows { PegSolitaireRules.applyMove(state, 1, PegJump(Hole(2, 0), Hole(0, 0))) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a table for anything but one seat is refused`() {
|
||||||
|
val twoSeats = TableConfig(
|
||||||
|
gameId = "pegsolitaire",
|
||||||
|
seats = (0..1).map {
|
||||||
|
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||||
|
},
|
||||||
|
optionsJson = json.encodeToString(PegSolitaireOptions()),
|
||||||
|
seed = 1L,
|
||||||
|
)
|
||||||
|
assertThrows { PegSolitaireRules.initialState(twoSeats) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one peg left is solved, and the stricter goal asks where`() {
|
||||||
|
val loose = PegSolitaireOptions(goal = PegGoal.ONE_PEG)
|
||||||
|
val strict = PegSolitaireOptions(goal = PegGoal.ONE_PEG_IN_START)
|
||||||
|
val base = PegSolitaireRules.initialState(config(loose))
|
||||||
|
|
||||||
|
val elsewhere = base.copy(pegs = setOf(Hole(4, 2)))
|
||||||
|
assertTrue(elsewhere.solved)
|
||||||
|
assertFalse("the strict goal wants it back where it started", elsewhere.copy(options = strict).solved)
|
||||||
|
|
||||||
|
val atStart = base.copy(options = strict, pegs = setOf(Hole(0, 0)))
|
||||||
|
assertTrue("the survivor is in the opening hole", atStart.solved)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a board with no jumps left is finished, solved or not`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
// Two pegs far apart cannot reach each other, so nothing can jump.
|
||||||
|
val stuck = state.copy(pegs = setOf(Hole(4, 0), Hole(4, 4)))
|
||||||
|
assertTrue(PegSolitaireRules.isFinished(stuck))
|
||||||
|
assertFalse(stuck.solved)
|
||||||
|
assertNull(PegSolitaireRules.currentSeat(stuck))
|
||||||
|
assertTrue(PegSolitaireRules.summary(stuck).contains("Stuck"))
|
||||||
|
|
||||||
|
val won = state.copy(pegs = setOf(Hole(2, 1)))
|
||||||
|
assertTrue(PegSolitaireRules.isFinished(won))
|
||||||
|
assertTrue(won.solved)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an opening hole has to be a hole on the board`() {
|
||||||
|
assertThrows { PegSolitaireOptions(board = PegBoard.TRIANGLE_15, startEmpty = Hole(9, 9)) }
|
||||||
|
// A hole that exists on one board but not another.
|
||||||
|
assertThrows {
|
||||||
|
PegSolitaireOptions(board = PegBoard.ENGLISH_CROSS, startEmpty = Hole(0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every board can be opened and played`() {
|
||||||
|
// Each board deals, offers jumps, and runs to a finish without the rules
|
||||||
|
// throwing. This is what catches a geometry that produces a board nobody
|
||||||
|
// can move on at all.
|
||||||
|
PegBoard.entries.forEach { board ->
|
||||||
|
val state = PegSolitaireRules.initialState(
|
||||||
|
config(PegSolitaireOptions(board = board)),
|
||||||
|
)
|
||||||
|
assertEquals(board.holeCount - 1, state.remaining)
|
||||||
|
assertTrue(
|
||||||
|
"${board.label} should have an opening jump",
|
||||||
|
PegSolitaireRules.legalJumps(state).isNotEmpty(),
|
||||||
|
)
|
||||||
|
val end = playOut(state)
|
||||||
|
assertTrue(PegSolitaireRules.isFinished(end))
|
||||||
|
assertTrue("${board.label} should take pegs off", end.remaining < state.remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `restart puts every peg back`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
val played = PegSolitaireRules.applyMove(state, 0, PegJump(Hole(2, 0), Hole(0, 0)))
|
||||||
|
val again = PegSolitaireRules.restart(played)
|
||||||
|
assertEquals(state.pegs, again.pegs)
|
||||||
|
assertEquals(0, again.jumps)
|
||||||
|
assertNull(again.lastJump)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing is hidden from the only player`() {
|
||||||
|
val state = PegSolitaireRules.initialState(config())
|
||||||
|
assertEquals(state, PegSolitaireRules.viewFor(state, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `state and moves survive a round trip`() {
|
||||||
|
PegBoard.entries.forEach { board ->
|
||||||
|
val state = PegSolitaireRules.initialState(
|
||||||
|
config(PegSolitaireOptions(board = board, goal = PegGoal.ONE_PEG_IN_START)),
|
||||||
|
)
|
||||||
|
assertEquals(state, PegSolitaireRules.decodeState(PegSolitaireRules.encodeState(state)))
|
||||||
|
}
|
||||||
|
val move: PegSolitaireMove = PegJump(Hole(2, 0), Hole(0, 0))
|
||||||
|
assertEquals(move, PegSolitaireRules.decodeMove(PegSolitaireRules.encodeMove(move)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a board to a standstill using the AI, which never has to guess. */
|
||||||
|
private fun playOut(from: PegSolitaireState): PegSolitaireState {
|
||||||
|
var state = from
|
||||||
|
// Each jump takes exactly one peg off, so the board cannot outlast its
|
||||||
|
// own peg count — a loop that did would be a bug in applyMove.
|
||||||
|
var guard = from.remaining + 1
|
||||||
|
while (!PegSolitaireRules.isFinished(state) && guard-- > 0) {
|
||||||
|
val legal = PegSolitaireRules.legalMoves(state, 0)
|
||||||
|
state = PegSolitaireRules.applyMove(state, 0, PegSolitaireAi.chooseMove(state, 0, legal))
|
||||||
|
}
|
||||||
|
assertTrue("playing out should terminate", guard > 0)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
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