From 136f5bc4f1341a34537a5e412d95e11392472794 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 19:11:59 +0000 Subject: [PATCH] Let a scoreboard actually be dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pause between hands never ended. driveIdleSeats asks awaitsConfirmation about the state, and the state does not change while the players are reading it — so confirmAdvance restarted the loop, the loop stopped on the same scoreboard, and the next hand was never dealt. The screens hid their own dialog on the way out, so the table looked released and then simply sat there. The release is now something spendable rather than a question re-asked about an unchanged state: confirmAdvance grants one, and the loop consumes it to walk past the hold exactly once. This is not new with cribbage — Golf's card, Wizard's bids against tricks and the Crazy 8s round score all stop on the same hold and all end there. Cribbage's show is what made it impossible to miss, because a game of cribbage is nothing but hands one after another. Covered by MatchControllerScoreboardTest, which plays a whole game of one-move hands and insists every one of them stops to be read and then goes on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2 --- .../prolibertate/games/net/MatchController.kt | 28 +++- .../net/MatchControllerScoreboardTest.kt | 151 ++++++++++++++++++ 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index f43fd8f..89dde9c 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -98,6 +98,18 @@ class MatchController( private val lock = Mutex() private var sequence = 0 + /** + * A scoreboard a player has read and released, waiting to be let past. + * + * The hold belongs to a particular scoreboard rather than to the phase, and + * releasing it has to be spent: [awaitsConfirmation] is asked about the + * state, which has not changed while the players were reading it, so + * without something consumable here the same state would stop the table + * again the instant [confirmAdvance] restarted the loop — and the next hand + * would never be dealt. + */ + private var released = false + private val peers = mutableMapOf() private var hostLink: Connection? = null @@ -288,8 +300,15 @@ class MatchController( if (role == Role.HOST && awaitsConfirmation(current)) { // Hold here until a player has read the scoreboard. Only the // host waits; clients simply render what they were sent. - _awaitingConfirmation.value = true - return + val letThrough = lock.withLock { + val granted = released + released = false + granted + } + if (!letThrough) { + _awaitingConfirmation.value = true + return + } } delay(aiThinkingMillis() * 2) val advanced = advanceIdle(current) @@ -398,7 +417,10 @@ class MatchController( if (!_awaitingConfirmation.value) return _awaitingConfirmation.value = false if (role != Role.HOST) return - scope.launch { driveIdleSeats() } + scope.launch { + lock.withLock { released = true } + driveIdleSeats() + } } /** Lets a player go on looking at a table they have been told is over. */ diff --git a/app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt b/app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt new file mode 100644 index 0000000..6cc1cb9 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/MatchControllerScoreboardTest.kt @@ -0,0 +1,151 @@ +package org.prolibertate.games.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.GameAi +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * The pause between hands, on a table with nobody but the local player at it. + * + * Cribbage's show, Golf's card and Wizard's bids-against-tricks all stop the + * table on a scoreboard and wait to be released. The release has to be spent: + * the state does not change while it is being read, so a controller that only + * re-asks `awaitsConfirmation` about it stops on the same scoreboard again the + * moment it is restarted, and the next hand is never dealt. + */ +class MatchControllerScoreboardTest { + + private val scopes = mutableListOf() + + @After + fun tearDown() { + scopes.forEach { it.cancel() } + } + + /** A hand is one move long, and every hand ends on a scoreboard. */ + private data class Hands(val handNumber: Int, val scoring: Boolean) + + private object Rules : GameRules { + override val gameId: String = "scoreboard" + override fun initialState(config: TableConfig) = Hands(0, scoring = false) + override fun currentSeat(state: Hands): Int? = if (state.scoring) null else 0 + override fun legalMoves(state: Hands, seat: Int): List = + if (state.scoring) emptyList() else listOf("play") + + override fun applyMove(state: Hands, seat: Int, move: String) = + state.copy(scoring = true) + + override fun isFinished(state: Hands) = state.handNumber >= HANDS + override fun summary(state: Hands) = state.toString() + override fun viewFor(state: Hands, seat: Int) = state + override fun encodeState(state: Hands) = "${state.handNumber}:${state.scoring}" + override fun decodeState(json: String) = Hands( + handNumber = json.substringBefore(':').toInt(), + scoring = json.substringAfter(':').toBooleanStrict(), + ) + + override fun encodeMove(move: String) = move + override fun decodeMove(json: String) = json + } + + private fun controller(): MatchController { + val scope = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it } + return MatchController( + rules = Rules, + ai = object : GameAi { + override fun chooseMove(state: Hands, seat: Int, legal: List) = legal.first() + }, + config = TableConfig( + gameId = "scoreboard", + seats = listOf(PlayerSlot(0, "Me", PlayerKind.HUMAN_LOCAL, team = 0)), + optionsJson = "{}", + seed = 1L, + ), + scope = scope, + role = MatchController.Role.HOST, + localSeats = setOf(0), + primarySeat = 0, + advanceIdle = { state -> + if (state.scoring) { + Hands(state.handNumber + 1, scoring = false) + } else { + null + } + }, + aiThinkingMillis = { 0L }, + awaitsConfirmation = { state -> state.scoring }, + ) + } + + @Test + fun `the table stops on a scoreboard`() = runBlocking { + val table = controller() + table.startAsHost(emptyList()) + settle() + + assertFalse("nothing to read yet", table.awaitingConfirmation.value) + table.submit("play") + settle() + + assertTrue("the scoreboard was skipped past", table.awaitingConfirmation.value) + assertEquals(Hands(0, scoring = true), table.state.value) + } + + @Test + fun `confirming it deals the next hand`() = runBlocking { + val table = controller() + table.startAsHost(emptyList()) + settle() + table.submit("play") + settle() + + table.confirmAdvance() + settle() + + assertEquals( + "the table is still sitting on the scoreboard it was told to leave", + Hands(1, scoring = false), + table.state.value, + ) + assertFalse(table.awaitingConfirmation.value) + assertTrue("and the next hand is playable", table.legalMoves.value.isNotEmpty()) + } + + @Test + fun `every hand in the game stops and is released in turn`() = runBlocking { + val table = controller() + table.startAsHost(emptyList()) + settle() + + repeat(HANDS) { hand -> + table.submit("play") + settle() + assertTrue("hand $hand did not stop to be read", table.awaitingConfirmation.value) + table.confirmAdvance() + settle() + } + + assertTrue(table.finished.value) + assertEquals(HANDS, table.state.value?.handNumber) + } + + private suspend fun settle() = delay(SETTLE_MILLIS) + + private companion object { + const val HANDS = 3 + const val SETTLE_MILLIS = 250L + } +}