Let a scoreboard actually be dismissed

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCuRESmSZeJoJond43itw2
This commit is contained in:
Claude
2026-08-07 19:11:59 +00:00
parent 27af034095
commit 136f5bc4f1
2 changed files with 176 additions and 3 deletions
@@ -98,6 +98,18 @@ class MatchController<S : Any, M : Any>(
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<String, Connection>()
private var hostLink: Connection? = null
@@ -288,8 +300,15 @@ class MatchController<S : Any, M : Any>(
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<S : Any, M : Any>(
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. */
@@ -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<CoroutineScope>()
@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<Hands, String> {
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<String> =
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<Hands, String> {
val scope = CoroutineScope(Job() + Dispatchers.Default).also { scopes += it }
return MatchController(
rules = Rules,
ai = object : GameAi<Hands, String> {
override fun chooseMove(state: Hands, seat: Int, legal: List<String>) = 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
}
}