Merge remote-tracking branch 'origin/dev' into claude/locked-phone-turn-issue-x9pvbv

This commit is contained in:
Claude
2026-08-17 15:33:41 +00:00
17 changed files with 1515 additions and 143 deletions
@@ -250,6 +250,39 @@ class KaiserRulesTest {
assertEquals("all four held for display", 4, state.completedTrick.size)
}
/**
* Kaiser scores in points, not tricks, so nothing in the rules reads this
* count — the table does, to draw the cards each player has taken. That is
* exactly why it is worth a test: a field no rule depends on is a field
* that can quietly stop being maintained.
*/
@Test
fun `tricks are counted to the seat that took them`() {
// Everything is clubs and nothing is trump, so each trick simply goes
// to the highest card in it: the ace, then the king.
val hands = listOf(
listOf(Card(Rank.ACE, Suit.CLUBS), Card(Rank.SEVEN, Suit.CLUBS)),
listOf(Card(Rank.EIGHT, Suit.CLUBS), Card(Rank.NINE, Suit.CLUBS)),
listOf(Card(Rank.TEN, Suit.CLUBS), Card(Rank.JACK, Suit.CLUBS)),
listOf(Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS)),
)
var state = playingState(hands, trump = Suit.SPADES)
assertEquals("nothing taken at the deal", listOf(0, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the ace takes the first", listOf(1, 0, 0, 0), state.tricksWon)
repeat(4) {
val seat = KaiserRules.currentSeat(state)!!
state = KaiserRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertEquals("the king takes the second", listOf(1, 0, 0, 1), state.tricksWon)
assertEquals("and they add up", 2, state.tricksWon.sum())
}
// -- Scoring ------------------------------------------------------------
@Test
@@ -0,0 +1,247 @@
package org.prolibertate.games.ui.game
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.cards.Decks
import org.prolibertate.games.game.cards.Rank
import org.prolibertate.games.game.cards.Suit
/**
* The scatter under the card animations.
*
* Two properties matter and neither is visible in a screenshot. It has to be
* *bounded*, because the whole difference between cards on a table and cards
* thrown down a flight of stairs is the size of the numbers; and it has to be
* *stable*, because a card that picks a new angle on every recomposition
* twitches, and a card whose angle depends on where it sits in a list jumps
* whenever a card is played next to it.
*/
class CardPhysicsTest {
private val deck = Decks.standard52()
@Test
fun `noise stays inside minus one to one`() {
for (seed in -500..500) {
for (salt in 0..8) {
val n = unitNoise(seed, salt)
assertTrue("$seed/$salt gave $n", n >= -1f && n < 1f)
}
}
}
@Test
fun `noise is the same every time it is asked`() {
assertEquals(unitNoise(12345, 3), unitNoise(12345, 3), 0f)
assertEquals(unitNoise(-7, 1), unitNoise(-7, 1), 0f)
}
/**
* The salt is what lets one seed produce a tilt, two drifts, a spin and a
* queue position that are not all the same number. If it stopped separating
* them, every card would drift exactly as far as it tilted.
*/
@Test
fun `salts do not agree with each other`() {
val fromOneSeed = (0..8).map { unitNoise(4242, it) }
assertEquals(fromOneSeed.size, fromOneSeed.toSet().size)
}
/** Sequential seeds are the normal case: adjacent cards in a hand. */
@Test
fun `neighbouring seeds do not give neighbouring values`() {
val runs = (0 until 200).count { abs(unitNoise(it, 1) - unitNoise(it + 1, 1)) < 0.05f }
assertTrue("$runs of 200 neighbouring seeds landed together", runs < 30)
}
@Test
fun `every card in a deck rests within the stated bounds`() {
deck.forEach { card ->
val rest = cardRest(cardSeed(card))
assertTrue(abs(rest.tiltDegrees) <= REST_TILT_DEGREES)
assertTrue(abs(rest.driftX) <= REST_DRIFT_FRACTION)
assertTrue(abs(rest.driftY) <= REST_DRIFT_FRACTION)
assertTrue(abs(rest.spinDegrees) <= THROW_SPIN_DEGREES)
assertTrue(rest.lateness in 0f..1f)
assertTrue(abs(handTilt(cardSeed(card))) <= HAND_TILT_DEGREES)
}
}
@Test
fun `a card lies the same way however often it is asked`() {
val card = Card(Rank.QUEEN, Suit.SPADES)
assertEquals(cardRest(cardSeed(card)), cardRest(cardSeed(card)))
assertEquals(cardRest(cardSeed(card, 3)), cardRest(cardSeed(card, 3)))
}
/**
* Two cards lying at the same angle in the same place is the one thing that
* makes a scattered pile look printed rather than dealt.
*/
@Test
fun `no two cards in a deck share a seed`() {
val seeds = deck.map { cardSeed(it) }
assertEquals(seeds.size, seeds.toSet().size)
}
/**
* A pile can hold the same card twice — Golf and Spider deal more than one
* deck — so where a card is lying has to be part of its seed.
*/
@Test
fun `the same card in two places lies two ways`() {
val card = Card(Rank.SEVEN, Suit.HEARTS)
assertNotEquals(cardRest(cardSeed(card, 0)), cardRest(cardSeed(card, 1)))
}
/**
* A heap is untidier than a card laid in front of a player, and has to be:
* at the tight bounds five cards on a discard pile are hidden to within a
* twentieth of a card width of each other and the pile looks like one card.
*/
@Test
fun `a heap lies looser than a table but still within bounds`() {
deck.forEach { card ->
val seed = cardSeed(card)
val heap = heapRest(seed)
val table = cardRest(seed)
assertTrue(abs(heap.tiltDegrees) <= HEAP_TILT_DEGREES)
assertTrue(abs(heap.driftX) <= HEAP_DRIFT_FRACTION)
assertTrue(abs(heap.driftY) <= HEAP_DRIFT_FRACTION)
assertTrue(abs(heap.tiltDegrees) >= abs(table.tiltDegrees))
assertTrue(abs(heap.driftX) >= abs(table.driftX))
}
}
/**
* The two have to agree about everything except how far the card strayed,
* or a card would jump when it was drawn one way rather than the other.
*/
@Test
fun `a heap and a table agree about the throw`() {
deck.take(6).forEach { card ->
val seed = cardSeed(card)
assertEquals(cardRest(seed).spinDegrees, heapRest(seed).spinDegrees, 0f)
assertEquals(cardRest(seed).lateness, heapRest(seed).lateness, 0f)
}
}
/**
* A buried card has to end up near the rim of whatever is covering it. One
* left in the middle is one nobody can see, and then drawing the pile as a
* pile achieves nothing.
*/
@Test
fun `a buried card ends up out where a corner of it shows`() {
deck.forEach { card ->
val (x, y) = buriedOffset(cardSeed(card), halfWidth = 100f, halfHeight = 40f)
val howFarOut = kotlin.math.hypot(x / 100f, y / 40f)
assertTrue("$card sat at $howFarOut of the way out", howFarOut >= 0.719f)
assertTrue(howFarOut <= 1.001f)
}
}
/** Two cards buried under the same set must not end up in the same place. */
@Test
fun `buried cards do not stack on each other`() {
val places = (0 until 6).map { buriedOffset(cardSeed(deck[it], it), 100f, 40f) }
assertEquals(places.size, places.toSet().size)
}
/** Every seat's card arrives from that seat, and from a card-width or two out. */
@Test
fun `a card comes in from its own player's side`() {
assertEquals(0f to THROW_DISTANCE, seatOrigin(0))
assertEquals(-THROW_DISTANCE to 0f, seatOrigin(1))
assertEquals(0f to -THROW_DISTANCE, seatOrigin(2))
assertEquals(THROW_DISTANCE to 0f, seatOrigin(3))
}
@Test
fun `a pile card comes in from somewhere off the pile`() {
deck.forEach { card ->
val (x, y) = pileOrigin(cardSeed(card))
val distance = kotlin.math.hypot(x, y)
assertEquals(THROW_DISTANCE, distance, 0.001f)
}
}
@Test
fun `nothing has left the table before the sweep starts`() {
deck.take(8).forEach { card ->
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card))), 0f)
}
}
@Test
fun `everything has left the table by the end of the sweep`() {
deck.take(8).forEach { card ->
assertEquals(1f, sweepProgress(1f, cardRest(cardSeed(card))), 0.0001f)
}
}
@Test
fun `a card only ever gets further from the table`() {
val rest = cardRest(cardSeed(Card(Rank.ACE, Suit.CLUBS)))
var previous = 0f
for (step in 0..100) {
val now = sweepProgress(step / 100f, rest)
assertTrue("went backwards at $step", now >= previous - 0.0001f)
assertTrue(now in 0f..1f)
previous = now
}
}
/**
* The stagger: a trick gathers up as one motion, so the cards must not all
* be at the same point in it, and none of them may still be sitting there
* when the sweep is nearly over.
*/
@Test
fun `cards leave at different moments but none is left behind`() {
val hand = deck.take(4).map { cardRest(cardSeed(it)) }
val halfway = hand.map { sweepProgress(0.5f, it) }
assertEquals(halfway.size, halfway.toSet().size)
hand.forEach { assertTrue(sweepProgress(0.95f, it) > 0.5f) }
}
/**
* A gathered card has to still be whole when it reaches the winner's pile,
* because it vanishes at the same moment the pile grows by one and that
* swap is only invisible if there was something there to swap.
*/
@Test
fun `a gathered card is whole most of the way and gone at the end`() {
assertEquals(1f, gatherAlpha(0f), 0f)
assertEquals(1f, gatherAlpha(0.5f), 0f)
assertEquals(0f, gatherAlpha(1f), 0f)
var previous = 1f
for (step in 0..100) {
val now = gatherAlpha(step / 100f)
assertTrue("went back up at $step", now <= previous + 0.0001f)
assertTrue(now in 0f..1f)
previous = now
}
}
/** And is squared down into the bundle rather than arriving full size. */
@Test
fun `a gathered card is squared down on the way`() {
assertEquals(1f, gatherScale(0f), 0f)
assertTrue(gatherScale(1f) < 1f)
assertTrue(gatherScale(1f) > 0.5f)
assertTrue(gatherScale(0.5f) < gatherScale(0.25f))
}
/** A trick that never completes must not leave a card part way off the table. */
@Test
fun `a sweep that never starts moves nothing`() {
deck.take(4).forEach { card ->
assertEquals(0f, sweepProgress(0f, cardRest(cardSeed(card)), spread = 0f), 0f)
}
}
}