Show every player the table from the moment the game starts

A guest saw nothing until the next state push, which on most tables means
until its own turn came round: no board, no cards, no scoreboard. The
cause is a gap in who is listening. A guest's link is opened in the lobby
and read there; the table only subscribes to it once the host's start
message has been through a state change, a recomposition and a
LaunchedEffect. The host publishes the opening position immediately, into
a link whose table has not attached yet, and incoming is a SharedFlow with
replay = 0 — so that first push went to the lobby's collector and nowhere
else. Not a rare race but the normal order of events, which is why it
looked like a rule rather than a bug.

The guest now asks. A new Resync message is sent from inside
onSubscription, so the request cannot leave before the collector is
attached and the reply cannot arrive before there is anything to receive
it. The host answers one peer with that seat's redacted view. A request
that arrives before the deal needs no answer, since a peer that has asked
is listening and the opening publish will reach it.

Second reason a guest could not see its hand, on any table with more than
one guest: guests worked out their own seat by matching PlayerSlot.peerId,
which is the address the host's transport invented for the incoming link —
an IP on Wi-Fi, a MAC over Bluetooth. A guest has never seen that value,
so no guest ever matched it and every one fell through to "the first seat
held by somebody remote". With one guest that is right by luck; with two,
both claimed the same seat and the other drew a hand belonging to a seat
it was not sitting in, which redacted is no hand at all.

The host now records the id a guest introduced itself by, as
PlayerSlot.deviceId, kept separate from peerId because the two answer
different questions: peerId is how the host recognises a link, deviceId is
how a guest recognises itself. The lookup moved out of LobbyScreen into
seatForDevice, where it can be tested, and a dropped player stops claiming
its old seat.

Neither change needs a protocol bump. Resync is ignored by a host that
does not know it, deviceId is an unknown key to a guest that does not read
it, and the old fallback is kept for exactly that case.

Tests reproduce the real start-up order — host publishes, then the guest
subscribes a frame later — rather than the order that happens to work, and
cover a board, a dealt hand, and a guest that is on the clock at the deal.
Both regressions were confirmed to fail before the fix: the guest had no
state at all, and two guests both came back as seat 1.

216 JVM tests pass.
This commit is contained in:
Claude
2026-08-06 17:14:33 +00:00
parent 469c900ed4
commit d165e5efff
7 changed files with 437 additions and 82 deletions
@@ -23,6 +23,16 @@ data class PlayerSlot(
val team: Int,
/** Identifies the remote device for [PlayerKind.HUMAN_REMOTE] seats. */
val peerId: String? = null,
/**
* The joining device's own id, as it introduced itself.
*
* Kept alongside [peerId] because the two answer different questions.
* [peerId] is how the host recognises which link a message came in on, and is
* an address the transport made up — a socket's IP, a Bluetooth MAC. This is
* how a guest recognises *itself* in a seat list it did not write, and only
* the guest's own id can do that.
*/
val deviceId: String? = null,
)
@Serializable
@@ -133,6 +133,10 @@ class LobbyController(
name = hello.displayName,
kind = PlayerKind.HUMAN_REMOTE,
peerId = connection.peerId,
// Recorded so the guest can find itself in this list once it is sent
// back: connection.peerId is an address this end invented and the
// guest has never seen.
deviceId = hello.peerId,
)
connections[connection.peerId] = connection
_state.value = current.copy(
@@ -178,6 +182,9 @@ class LobbyController(
kind = kind,
name = name ?: seats[seat].name,
peerId = if (kind == PlayerKind.AI) null else seats[seat].peerId,
// A dropped player must stop claiming the seat, or it would still
// recognise itself in it when the table starts.
deviceId = if (kind == PlayerKind.AI) null else seats[seat].deviceId,
)
_state.value = current.copy(seats = seats)
scope.launch { broadcastLobby() }
@@ -7,6 +7,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -154,36 +155,45 @@ class MatchController<S : Any, M : Any>(
check(role == Role.CLIENT) { "Not a client" }
hostLink = link
scope.launch {
link.incoming.collect { message ->
when (message) {
is StateSync -> {
// Anything off the wire is decoded defensively. This
// collector runs in the screen's own scope, so throwing
// here would take the app down rather than the message.
val decoded = runCatching {
val state = rules.decodeState(message.stateJson)
state to message.legalMoves.map { rules.decodeMove(it) }
}.getOrNull()
if (decoded == null) {
_notice.value = "The host sent something this version cannot read."
return@collect
link.incoming
// Asked for from inside onSubscription, which is what makes it
// reliable: the request cannot leave before this collector is
// attached, so the reply cannot arrive before there is anything
// here to receive it.
.onSubscription { link.send(Resync) }
.collect { message ->
when (message) {
is StateSync -> {
// Anything off the wire is decoded defensively. This
// collector runs in the screen's own scope, so
// throwing here would take the app down rather than
// the message.
val decoded = runCatching {
val state = rules.decodeState(message.stateJson)
state to message.legalMoves.map { rules.decodeMove(it) }
}.getOrNull()
if (decoded == null) {
_notice.value =
"The host sent something this version cannot read."
return@collect
}
_state.value = decoded.first
_legalMoves.value = decoded.second
_finished.value = rules.isFinished(decoded.first)
}
_state.value = decoded.first
_legalMoves.value = decoded.second
_finished.value = rules.isFinished(decoded.first)
}
is Rejected -> _notice.value = message.reason
is Bye -> {
// The host has gone: the game cannot go on, and saying so
// is what gives the screen something to offer a way out of.
_legalMoves.value = emptyList()
abandon("The host left the game.")
}
is Rejected -> _notice.value = message.reason
is Bye -> {
// The host has gone: the game cannot go on, and
// saying so is what gives the screen something to
// offer a way out of.
_legalMoves.value = emptyList()
abandon("The host left the game.")
}
else -> Unit
else -> Unit
}
}
}
}
}
@@ -216,6 +226,10 @@ class MatchController<S : Any, M : Any>(
abandon("${nameOfPeer(connection.peerId)} left the game.")
return
}
if (message is Resync) {
sendStateTo(connection)
return
}
if (message !is MoveIntent) return
val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key
if (seat == null) {
@@ -338,20 +352,40 @@ class MatchController<S : Any, M : Any>(
for (slot in config.seats) {
val peerId = slot.peerId ?: continue
val connection = peers[peerId] ?: continue
val seatView = rules.viewFor(current, slot.seat)
val moves = if (rules.currentSeat(current) == slot.seat) {
rules.legalMoves(current, slot.seat).map { rules.encodeMove(it) }
} else {
emptyList()
}
connection.send(
StateSync(
seq = sequence,
yourSeat = slot.seat,
stateJson = rules.encodeState(seatView),
legalMoves = moves,
)
sendStateSync(connection, slot.seat, current)
}
}
/** One seat's view of [current], redacted and addressed to it. Call under [lock]. */
private suspend fun sendStateSync(connection: Connection, seat: Int, current: S) {
val moves = if (rules.currentSeat(current) == seat) {
rules.legalMoves(current, seat).map { rules.encodeMove(it) }
} else {
emptyList()
}
connection.send(
StateSync(
seq = sequence,
yourSeat = seat,
stateJson = rules.encodeState(rules.viewFor(current, seat)),
legalMoves = moves,
)
)
}
/**
* Answers one peer's [Resync] with the state as it stands.
*
* Nothing to send yet means the table has not been dealt, and that is not a
* problem: a peer that has asked is by definition listening, so the opening
* publish moments later will reach it.
*/
private suspend fun sendStateTo(connection: Connection) {
lock.withLock {
val current = authoritative ?: return
val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key
?: return
sendStateSync(connection, seat, current)
}
}
@@ -3,6 +3,7 @@ package org.prolibertate.games.net
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
@@ -30,6 +31,24 @@ val protocolJson: Json = Json {
classDiscriminator = "t"
}
/**
* Which seat [deviceId] is sitting in, given the table the host has settled on.
*
* A guest has to answer this for itself: the seat list arrives as a whole and
* says nothing about who is reading it. Matching on [PlayerSlot.deviceId] is the
* only reliable way round, because that is the one field a guest can recognise —
* [PlayerSlot.peerId] is an address the host's transport invented for the link
* and bears no relation to anything the guest knows about itself.
*
* The fall back to the first remote seat is for a host running a version that
* sends no device ids. It is right whenever there is only one guest, and with two
* it hands them both the same seat, which is why it is only ever reached last.
*/
fun seatForDevice(config: TableConfig, deviceId: String): Int =
config.seats.firstOrNull { it.deviceId == deviceId }?.seat
?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat
?: 0
@Serializable
sealed interface NetMessage
@@ -83,6 +102,19 @@ data class StateSync(
@SerialName("intent")
data class MoveIntent(val moveJson: String) : NetMessage
/**
* A client asking for the current state.
*
* A guest's link is opened in the lobby and is only handed to the table once the
* host's [StartGame] has been through a recomposition, so the host publishes the
* opening position before the guest's table is listening for it. Rather than
* leave that to timing, the guest asks as soon as it is listening — which is also
* what gets a guest back in step after any missed push.
*/
@Serializable
@SerialName("resync")
data object Resync : NetMessage
@Serializable
@SerialName("rejected")
data class Rejected(val reason: String) : NetMessage
@@ -25,6 +25,7 @@ import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.DiscoveredHost
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.net.seatForDevice
/**
* Hosting or joining a table.
@@ -66,9 +67,7 @@ fun LobbyScreen(
val seat = if (route.hosting) {
0
} else {
config.seats.firstOrNull { it.peerId == env.peerId }?.seat
?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat
?: 0
seatForDevice(config, env.peerId)
}
env.lobby.clearStarted()
onStart(config, route.hosting, seat)