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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user