Share the ace-low convention rather than copy it

Klondike had its own patienceOrder, acceptsOnFoundation and buildsDown. FreeCell
builds the same way, Spider builds in suit, and Pyramid needs an ace worth one
to pair to thirteen -- four separate definitions of "the ace is low" would be
four chances to disagree, and the one that already went wrong cost a CI round
trip to find.

game.solitaire.Patience now holds the ordering and the build rules, and
Klondike reads them from there. buildsDown stays as a name in the Klondike
package because that is what this game builds with; it just no longer defines
what it means.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
This commit is contained in:
Claude
2026-08-08 15:44:53 +00:00
parent 831b2fc925
commit 7569f0b891
4 changed files with 50 additions and 17 deletions
@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
import org.prolibertate.games.game.cards.Card import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.solitaire.buildsDownAlternating
const val TABLEAU_PILES: Int = 7 const val TABLEAU_PILES: Int = 7
const val FOUNDATIONS: Int = 4 const val FOUNDATIONS: Int = 4
@@ -121,25 +122,13 @@ data class MoveCards(val from: Spot, val to: Spot, val count: Int = 1) : Klondik
fun foundationFor(card: Card): Int = card.suit.ordinal fun foundationFor(card: Card): Int = card.suit.ordinal
/** /**
* Where a rank sits when the ace is low. * Down by one and in the other colour.
* *
* [Rank.order] puts the ace at fourteen, because the games this deck was built * Named here because it is what this game builds with, but the rule itself is
* for are trick-taking ones where it is the highest card there is. Patience * shared: FreeCell builds the same way, Spider builds in suit, and all of them
* counts it the other way: foundations run ace upwards and columns run down to * need the same ace-low ordering underneath.
* an ace, so every comparison here has to use this and not the raw order.
*/ */
val Rank.patienceOrder: Int get() = if (this == Rank.ACE) 1 else order fun buildsDown(lower: Card, onto: Card): Boolean = buildsDownAlternating(lower, onto)
/** Ace first, then strictly upwards in the same suit. */
fun acceptsOnFoundation(foundation: List<Card>, card: Card): Boolean {
val top = foundation.lastOrNull()
?: return card.rank == Rank.ACE
return card.suit == top.suit && card.rank.patienceOrder == top.rank.patienceOrder + 1
}
/** Down by one, and in the other colour. */
fun buildsDown(lower: Card, onto: Card): Boolean =
lower.rank.patienceOrder == onto.rank.patienceOrder - 1 && lower.suit.isRed != onto.suit.isRed
/** /**
* Whether [cards] is a run that can travel as one: each card a step below the * Whether [cards] is a run that can travel as one: each card a step below the
@@ -8,6 +8,7 @@ import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.shuffledWith import org.prolibertate.games.game.cards.shuffledWith
import org.prolibertate.games.game.engine.GameRules import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.solitaire.acceptsOnFoundation
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -0,0 +1,41 @@
package org.prolibertate.games.game.solitaire
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Rank
/**
* Where a rank sits when the ace is low.
*
* [Rank.order] puts the ace at fourteen, which is right for the games this deck
* was built for: in Euchre, Kaiser, Wizard and the rest it is the highest card
* there is. Every patience in this app counts it the other way — foundations run
* ace upwards, columns run down to an ace, and Pyramid needs it to be worth one
* when a pair has to make thirteen.
*
* It lives here rather than on [Rank] because ace high is correct everywhere
* outside these games, and it is shared rather than copied because four
* separate definitions of "the ace is low" would be four chances to disagree.
*/
val Rank.patienceOrder: Int get() = if (this == Rank.ACE) 1 else order
/** Down by one, and in the other colour: the ordinary tableau build. */
fun buildsDownAlternating(lower: Card, onto: Card): Boolean =
lower.rank.patienceOrder == onto.rank.patienceOrder - 1 &&
lower.suit.isRed != onto.suit.isRed
/** Down by one in the same suit, which is what Spider pays for. */
fun buildsDownInSuit(lower: Card, onto: Card): Boolean =
lower.rank.patienceOrder == onto.rank.patienceOrder - 1 && lower.suit == onto.suit
/** Ace first, then strictly upwards in the same suit. */
fun acceptsOnFoundation(foundation: List<Card>, card: Card): Boolean {
val top = foundation.lastOrNull() ?: return card.rank == Rank.ACE
return card.suit == top.suit && card.rank.patienceOrder == top.rank.patienceOrder + 1
}
/**
* Whether [cards] is a run that travels as one, by whatever rule the game
* builds with.
*/
fun isRun(cards: List<Card>, builds: (Card, Card) -> Boolean): Boolean =
cards.isNotEmpty() && cards.zipWithNext().all { (upper, lower) -> builds(lower, upper) }
@@ -13,6 +13,8 @@ import org.prolibertate.games.game.cards.Suit
import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.game.solitaire.acceptsOnFoundation
import org.prolibertate.games.game.solitaire.patienceOrder
class KlondikeRulesTest { class KlondikeRulesTest {