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,
|
val team: Int,
|
||||||
/** Identifies the remote device for [PlayerKind.HUMAN_REMOTE] seats. */
|
/** Identifies the remote device for [PlayerKind.HUMAN_REMOTE] seats. */
|
||||||
val peerId: String? = null,
|
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
|
@Serializable
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ class LobbyController(
|
|||||||
name = hello.displayName,
|
name = hello.displayName,
|
||||||
kind = PlayerKind.HUMAN_REMOTE,
|
kind = PlayerKind.HUMAN_REMOTE,
|
||||||
peerId = connection.peerId,
|
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
|
connections[connection.peerId] = connection
|
||||||
_state.value = current.copy(
|
_state.value = current.copy(
|
||||||
@@ -178,6 +182,9 @@ class LobbyController(
|
|||||||
kind = kind,
|
kind = kind,
|
||||||
name = name ?: seats[seat].name,
|
name = name ?: seats[seat].name,
|
||||||
peerId = if (kind == PlayerKind.AI) null else seats[seat].peerId,
|
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)
|
_state.value = current.copy(seats = seats)
|
||||||
scope.launch { broadcastLobby() }
|
scope.launch { broadcastLobby() }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.onSubscription
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -154,18 +155,26 @@ class MatchController<S : Any, M : Any>(
|
|||||||
check(role == Role.CLIENT) { "Not a client" }
|
check(role == Role.CLIENT) { "Not a client" }
|
||||||
hostLink = link
|
hostLink = link
|
||||||
scope.launch {
|
scope.launch {
|
||||||
link.incoming.collect { message ->
|
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) {
|
when (message) {
|
||||||
is StateSync -> {
|
is StateSync -> {
|
||||||
// Anything off the wire is decoded defensively. This
|
// Anything off the wire is decoded defensively. This
|
||||||
// collector runs in the screen's own scope, so throwing
|
// collector runs in the screen's own scope, so
|
||||||
// here would take the app down rather than the message.
|
// throwing here would take the app down rather than
|
||||||
|
// the message.
|
||||||
val decoded = runCatching {
|
val decoded = runCatching {
|
||||||
val state = rules.decodeState(message.stateJson)
|
val state = rules.decodeState(message.stateJson)
|
||||||
state to message.legalMoves.map { rules.decodeMove(it) }
|
state to message.legalMoves.map { rules.decodeMove(it) }
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
if (decoded == null) {
|
if (decoded == null) {
|
||||||
_notice.value = "The host sent something this version cannot read."
|
_notice.value =
|
||||||
|
"The host sent something this version cannot read."
|
||||||
return@collect
|
return@collect
|
||||||
}
|
}
|
||||||
_state.value = decoded.first
|
_state.value = decoded.first
|
||||||
@@ -175,8 +184,9 @@ class MatchController<S : Any, M : Any>(
|
|||||||
|
|
||||||
is Rejected -> _notice.value = message.reason
|
is Rejected -> _notice.value = message.reason
|
||||||
is Bye -> {
|
is Bye -> {
|
||||||
// The host has gone: the game cannot go on, and saying so
|
// The host has gone: the game cannot go on, and
|
||||||
// is what gives the screen something to offer a way out of.
|
// saying so is what gives the screen something to
|
||||||
|
// offer a way out of.
|
||||||
_legalMoves.value = emptyList()
|
_legalMoves.value = emptyList()
|
||||||
abandon("The host left the game.")
|
abandon("The host left the game.")
|
||||||
}
|
}
|
||||||
@@ -216,6 +226,10 @@ class MatchController<S : Any, M : Any>(
|
|||||||
abandon("${nameOfPeer(connection.peerId)} left the game.")
|
abandon("${nameOfPeer(connection.peerId)} left the game.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (message is Resync) {
|
||||||
|
sendStateTo(connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (message !is MoveIntent) return
|
if (message !is MoveIntent) return
|
||||||
val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key
|
val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key
|
||||||
if (seat == null) {
|
if (seat == null) {
|
||||||
@@ -338,21 +352,41 @@ class MatchController<S : Any, M : Any>(
|
|||||||
for (slot in config.seats) {
|
for (slot in config.seats) {
|
||||||
val peerId = slot.peerId ?: continue
|
val peerId = slot.peerId ?: continue
|
||||||
val connection = peers[peerId] ?: continue
|
val connection = peers[peerId] ?: continue
|
||||||
val seatView = rules.viewFor(current, slot.seat)
|
sendStateSync(connection, slot.seat, current)
|
||||||
val moves = if (rules.currentSeat(current) == slot.seat) {
|
}
|
||||||
rules.legalMoves(current, slot.seat).map { rules.encodeMove(it) }
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
connection.send(
|
connection.send(
|
||||||
StateSync(
|
StateSync(
|
||||||
seq = sequence,
|
seq = sequence,
|
||||||
yourSeat = slot.seat,
|
yourSeat = seat,
|
||||||
stateJson = rules.encodeState(seatView),
|
stateJson = rules.encodeState(rules.viewFor(current, seat)),
|
||||||
legalMoves = moves,
|
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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
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.PlayerSlot
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
@@ -30,6 +31,24 @@ val protocolJson: Json = Json {
|
|||||||
classDiscriminator = "t"
|
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
|
@Serializable
|
||||||
sealed interface NetMessage
|
sealed interface NetMessage
|
||||||
|
|
||||||
@@ -83,6 +102,19 @@ data class StateSync(
|
|||||||
@SerialName("intent")
|
@SerialName("intent")
|
||||||
data class MoveIntent(val moveJson: String) : NetMessage
|
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
|
@Serializable
|
||||||
@SerialName("rejected")
|
@SerialName("rejected")
|
||||||
data class Rejected(val reason: String) : NetMessage
|
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.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.DiscoveredHost
|
import org.prolibertate.games.net.DiscoveredHost
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
|
import org.prolibertate.games.net.seatForDevice
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hosting or joining a table.
|
* Hosting or joining a table.
|
||||||
@@ -66,9 +67,7 @@ fun LobbyScreen(
|
|||||||
val seat = if (route.hosting) {
|
val seat = if (route.hosting) {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
config.seats.firstOrNull { it.peerId == env.peerId }?.seat
|
seatForDevice(config, env.peerId)
|
||||||
?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat
|
|
||||||
?: 0
|
|
||||||
}
|
}
|
||||||
env.lobby.clearStarted()
|
env.lobby.clearStarted()
|
||||||
onStart(config, route.hosting, seat)
|
onStart(config, route.hosting, seat)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -23,6 +24,13 @@ import org.prolibertate.games.game.chess.ChessPhase
|
|||||||
import org.prolibertate.games.game.chess.ChessRules
|
import org.prolibertate.games.game.chess.ChessRules
|
||||||
import org.prolibertate.games.game.chess.ChessState
|
import org.prolibertate.games.game.chess.ChessState
|
||||||
import org.prolibertate.games.game.chess.squareFromName
|
import org.prolibertate.games.game.chess.squareFromName
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsRules
|
||||||
|
import org.prolibertate.games.game.crazyeights.CrazyEightsState
|
||||||
|
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.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.PlayerSlot
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
@@ -36,30 +44,139 @@ import java.net.Socket
|
|||||||
*
|
*
|
||||||
* The engines are covered exhaustively elsewhere; what is worth testing here is
|
* The engines are covered exhaustively elsewhere; what is worth testing here is
|
||||||
* the part no rules test can reach — a game with a live connection in it being
|
* the part no rules test can reach — a game with a live connection in it being
|
||||||
* played, finished, and above all *left*, which is where a networked table has
|
* started, played, and left.
|
||||||
* to let go of a socket while the screen that owns it is being torn down.
|
*
|
||||||
|
* The order matters and is reproduced faithfully. A guest's link is opened in the
|
||||||
|
* lobby and is already being read there; the match screen only subscribes to it
|
||||||
|
* once the host's "start" message has been through a recomposition. So the host
|
||||||
|
* is always publishing into a link whose match screen has not attached yet, which
|
||||||
|
* is the gap [Table.start] leaves open on purpose.
|
||||||
*/
|
*/
|
||||||
class MatchControllerNetworkTest {
|
class MatchControllerNetworkTest {
|
||||||
|
|
||||||
private val tables = mutableListOf<Table>()
|
private val tables = mutableListOf<Table<*, *>>()
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
tables.forEach { it.dispose() }
|
tables.forEach { it.dispose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun table(): Table = Table().also { tables += it }
|
private fun chess(hostSeat: Int = HOST_SEAT): Table<ChessState, ChessMove> = Table(
|
||||||
|
rules = ChessRules,
|
||||||
|
ai = ChessAi(),
|
||||||
|
gameId = GameCatalog.CHESS,
|
||||||
|
optionsJson = testJson.encodeToString(ChessOptions()),
|
||||||
|
seatCount = 2,
|
||||||
|
hostSeat = hostSeat,
|
||||||
|
).also { tables += it }
|
||||||
|
|
||||||
|
private fun crazyEights(): Table<CrazyEightsState, CrazyEightsMove> = Table(
|
||||||
|
rules = CrazyEightsRules,
|
||||||
|
ai = CrazyEightsAi(),
|
||||||
|
gameId = GameCatalog.CRAZY_EIGHTS,
|
||||||
|
optionsJson = testJson.encodeToString(CrazyEightsOptions(playerCount = 2)),
|
||||||
|
seatCount = 2,
|
||||||
|
).also { tables += it }
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Everyone can see the table from the moment it starts
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guest's screen has to have something to draw as soon as the game
|
||||||
|
* starts. It used to have nothing until the next state push, which on most
|
||||||
|
* tables means until the guest's own turn came round — a board that does not
|
||||||
|
* appear, and a hand of cards that does not either.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a guest can see the board as soon as the game starts`() = runBlocking {
|
||||||
|
val table = chess()
|
||||||
|
table.start()
|
||||||
|
|
||||||
|
assertNotNull(
|
||||||
|
"The guest has no state to draw: the opening position never reached it",
|
||||||
|
table.client.state.value,
|
||||||
|
)
|
||||||
|
assertEquals(ChessPhase.PLAYING, table.client.state.value?.phase)
|
||||||
|
// 32 men on the board, before anybody has moved.
|
||||||
|
assertEquals(32, table.client.state.value?.board?.count { it != null })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a guest can see its own hand as soon as the game starts`() = runBlocking {
|
||||||
|
val table = crazyEights()
|
||||||
|
table.start()
|
||||||
|
|
||||||
|
val view = table.client.state.value
|
||||||
|
assertNotNull("The guest was never dealt a visible hand", view)
|
||||||
|
assertTrue(
|
||||||
|
"The guest cannot see its own cards: hand was ${view?.hands?.get(GUEST_SEAT)}",
|
||||||
|
(view?.hands?.get(GUEST_SEAT)?.size ?: 0) > 0,
|
||||||
|
)
|
||||||
|
// The table itself, not just the hand: something has been turned up to
|
||||||
|
// play on to.
|
||||||
|
assertNotNull("No discard pile to play on to", view?.topCard)
|
||||||
|
// And the opponent's hand is still none of the guest's business.
|
||||||
|
assertEquals(emptyList<Any>(), view?.hands?.get(HOST_SEAT))
|
||||||
|
assertTrue(
|
||||||
|
"Opponent's card count should still be known",
|
||||||
|
(view?.handCounts?.get(HOST_SEAT) ?: 0) > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the player on the clock is given its moves as soon as the game starts`() = runBlocking {
|
||||||
|
// Crazy 8s deals to seat 0 first, so on this table it is the host that is
|
||||||
|
// on the clock; the guest correctly has nothing to play yet.
|
||||||
|
val table = crazyEights()
|
||||||
|
table.start()
|
||||||
|
|
||||||
|
assertEquals(HOST_SEAT, table.host.state.value?.turn)
|
||||||
|
assertTrue("The host has no moves to make on its own turn", table.host.legalMoves.value.isNotEmpty())
|
||||||
|
assertEquals(emptyList<CrazyEightsMove>(), table.client.legalMoves.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guest can be the one to move first — a card game's turn order follows the
|
||||||
|
* deal, not the seating — and a guest that can see the table but has nothing
|
||||||
|
* it is allowed to play is no better off than one that can see nothing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a guest on the clock at the deal is given its moves`() = runBlocking {
|
||||||
|
// The host takes Black, which leaves the guest playing White and first to
|
||||||
|
// move before either device has done anything.
|
||||||
|
val table = chess(hostSeat = 1)
|
||||||
|
table.start()
|
||||||
|
|
||||||
|
assertNotNull(table.client.state.value)
|
||||||
|
assertEquals(
|
||||||
|
"The guest has to move but was given nothing to play",
|
||||||
|
20,
|
||||||
|
table.client.legalMoves.value.size,
|
||||||
|
)
|
||||||
|
assertEquals(emptyList<ChessMove>(), table.host.legalMoves.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a guest is given its moves when its turn arrives`() = runBlocking {
|
||||||
|
val table = chess()
|
||||||
|
table.start()
|
||||||
|
|
||||||
|
assertEquals(emptyList<ChessMove>(), table.client.legalMoves.value)
|
||||||
|
table.host.submit(ChessMove(squareFromName("e2"), squareFromName("e4")))
|
||||||
|
table.settle()
|
||||||
|
// Twenty legal replies to 1. e4.
|
||||||
|
assertEquals(20, table.client.legalMoves.value.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Playing and finishing
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a networked game plays to a result on both devices`() = runBlocking {
|
fun `a networked game plays to a result on both devices`() = runBlocking {
|
||||||
val table = table()
|
val table = chess()
|
||||||
table.settle()
|
table.start()
|
||||||
|
|
||||||
assertEquals(ChessPhase.PLAYING, table.client.state.value?.phase)
|
|
||||||
// Black is not on the clock, so has nothing to play.
|
|
||||||
assertEquals(0, table.client.legalMoves.value.size)
|
|
||||||
|
|
||||||
table.white("f2", "f3")
|
table.white("f2", "f3")
|
||||||
table.black("e7", "e5")
|
table.black("e7", "e5")
|
||||||
@@ -72,9 +189,11 @@ class MatchControllerNetworkTest {
|
|||||||
assertTrue(table.client.finished.value)
|
assertTrue(table.client.finished.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Leaving
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regression this file exists for.
|
|
||||||
*
|
|
||||||
* Leaving a game runs on the thread drawing the screen — Android's main
|
* Leaving a game runs on the thread drawing the screen — Android's main
|
||||||
* thread — by way of AppRoot.pop() and PlayScreen's onDispose. It closed the
|
* thread — by way of AppRoot.pop() and PlayScreen's onDispose. It closed the
|
||||||
* buffered reader first, which wants the same lock readLine() holds for the
|
* buffered reader first, which wants the same lock readLine() holds for the
|
||||||
@@ -85,8 +204,8 @@ class MatchControllerNetworkTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `leaving a game in progress does not block the thread that leaves`() = runBlocking {
|
fun `leaving a game in progress does not block the thread that leaves`() = runBlocking {
|
||||||
for (side in listOf(Side.HOST, Side.GUEST)) {
|
for (side in listOf(Side.HOST, Side.GUEST)) {
|
||||||
val table = table()
|
val table = chess()
|
||||||
table.settle()
|
table.start()
|
||||||
table.white("e2", "e4")
|
table.white("e2", "e4")
|
||||||
|
|
||||||
// Left on its own thread, and a daemon one: should this ever hang
|
// Left on its own thread, and a daemon one: should this ever hang
|
||||||
@@ -105,25 +224,25 @@ class MatchControllerNetworkTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a player leaving is reported to the device still at the table`() = runBlocking {
|
fun `a player leaving is reported to the device still at the table`() = runBlocking {
|
||||||
val guestLeft = table()
|
val guestLeft = chess()
|
||||||
guestLeft.settle()
|
guestLeft.start()
|
||||||
guestLeft.leave(Side.GUEST)
|
guestLeft.leave(Side.GUEST)
|
||||||
guestLeft.settle()
|
guestLeft.settle()
|
||||||
assertEquals("Guest left the game.", guestLeft.host.abandoned.value)
|
assertEquals("Guest left the game.", guestLeft.host.abandoned.value)
|
||||||
|
|
||||||
val hostLeft = table()
|
val hostLeft = chess()
|
||||||
hostLeft.settle()
|
hostLeft.start()
|
||||||
hostLeft.leave(Side.HOST)
|
hostLeft.leave(Side.HOST)
|
||||||
hostLeft.settle()
|
hostLeft.settle()
|
||||||
assertEquals("The host left the game.", hostLeft.client.abandoned.value)
|
assertEquals("The host left the game.", hostLeft.client.abandoned.value)
|
||||||
// Nothing more can be played on a table with no host.
|
// Nothing more can be played on a table with no host.
|
||||||
assertEquals(0, hostLeft.client.legalMoves.value.size)
|
assertEquals(emptyList<ChessMove>(), hostLeft.client.legalMoves.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `closing a link on purpose is not reported as the other player leaving`() = runBlocking {
|
fun `closing a link on purpose is not reported as the other player leaving`() = runBlocking {
|
||||||
val table = table()
|
val table = chess()
|
||||||
table.settle()
|
table.start()
|
||||||
assertNull(table.host.abandoned.value)
|
assertNull(table.host.abandoned.value)
|
||||||
|
|
||||||
// The host closes its own end, as leaving does. Its own screen has no
|
// The host closes its own end, as leaving does. Its own screen has no
|
||||||
@@ -139,8 +258,15 @@ class MatchControllerNetworkTest {
|
|||||||
|
|
||||||
private enum class Side { HOST, GUEST }
|
private enum class Side { HOST, GUEST }
|
||||||
|
|
||||||
/** One two-seat chess table with a real socket between the two devices. */
|
/** One two-seat table with a real socket between the two devices. */
|
||||||
private class Table {
|
private class Table<S : Any, M : Any>(
|
||||||
|
private val rules: GameRules<S, M>,
|
||||||
|
ai: GameAi<S, M>,
|
||||||
|
gameId: String,
|
||||||
|
optionsJson: String,
|
||||||
|
seatCount: Int,
|
||||||
|
private val hostSeat: Int = HOST_SEAT,
|
||||||
|
) {
|
||||||
private val server = ServerSocket(0)
|
private val server = ServerSocket(0)
|
||||||
private val clientSocket = Socket()
|
private val clientSocket = Socket()
|
||||||
private val hostSocket: Socket
|
private val hostSocket: Socket
|
||||||
@@ -149,8 +275,8 @@ class MatchControllerNetworkTest {
|
|||||||
|
|
||||||
val hostLink: StreamConnection
|
val hostLink: StreamConnection
|
||||||
private val clientLink: StreamConnection
|
private val clientLink: StreamConnection
|
||||||
val host: MatchController<ChessState, ChessMove>
|
val host: MatchController<S, M>
|
||||||
val client: MatchController<ChessState, ChessMove>
|
val client: MatchController<S, M>
|
||||||
|
|
||||||
init {
|
init {
|
||||||
clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS)
|
clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS)
|
||||||
@@ -175,19 +301,28 @@ class MatchControllerNetworkTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val config = TableConfig(
|
val config = TableConfig(
|
||||||
gameId = GameCatalog.CHESS,
|
gameId = gameId,
|
||||||
seats = listOf(
|
seats = (0 until seatCount).map { seat ->
|
||||||
PlayerSlot(0, "Host", PlayerKind.HUMAN_LOCAL, team = 0),
|
PlayerSlot(
|
||||||
PlayerSlot(1, "Guest", PlayerKind.HUMAN_REMOTE, team = 1, peerId = guestPeerId),
|
seat = seat,
|
||||||
),
|
name = if (seat == hostSeat) "Host" else "Guest",
|
||||||
optionsJson = Json { encodeDefaults = true }.encodeToString(ChessOptions()),
|
kind = if (seat == hostSeat) {
|
||||||
|
PlayerKind.HUMAN_LOCAL
|
||||||
|
} else {
|
||||||
|
PlayerKind.HUMAN_REMOTE
|
||||||
|
},
|
||||||
|
team = seat,
|
||||||
|
peerId = if (seat == hostSeat) null else guestPeerId,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
optionsJson = optionsJson,
|
||||||
seed = 1L,
|
seed = 1L,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun controller(role: MatchController.Role, seat: Int, scope: CoroutineScope) =
|
fun controller(role: MatchController.Role, seat: Int, scope: CoroutineScope) =
|
||||||
MatchController<ChessState, ChessMove>(
|
MatchController(
|
||||||
rules = ChessRules,
|
rules = rules,
|
||||||
ai = ChessAi(),
|
ai = ai,
|
||||||
config = config,
|
config = config,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
role = role,
|
role = role,
|
||||||
@@ -196,22 +331,44 @@ class MatchControllerNetworkTest {
|
|||||||
aiThinkingMillis = { 0L },
|
aiThinkingMillis = { 0L },
|
||||||
)
|
)
|
||||||
|
|
||||||
host = controller(MatchController.Role.HOST, 0, hostScope)
|
host = controller(MatchController.Role.HOST, hostSeat, hostScope)
|
||||||
client = controller(MatchController.Role.CLIENT, 1, clientScope)
|
client = controller(
|
||||||
client.startAsClient(clientLink)
|
MatchController.Role.CLIENT,
|
||||||
|
(0 until seatCount).first { it != hostSeat },
|
||||||
|
clientScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The lobby is already reading the guest's link when the game starts,
|
||||||
|
// and goes on reading it for the whole match. Without this the test
|
||||||
|
// would be the only reader, which is not the arrangement the app has.
|
||||||
|
clientScope.launch { clientLink.incoming.collect { /* the lobby's collector */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the match the way the two devices do: the host first, then the
|
||||||
|
* guest once its screen has been through a frame. Anything the host
|
||||||
|
* publishes in between is published into a link the guest's match screen
|
||||||
|
* has not subscribed to yet.
|
||||||
|
*/
|
||||||
|
suspend fun start() {
|
||||||
host.startAsHost(listOf(hostLink))
|
host.startAsHost(listOf(hostLink))
|
||||||
|
delay(NAVIGATION_MILLIS)
|
||||||
|
client.startAsClient(clientLink)
|
||||||
|
settle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lets whatever is in flight cross the socket and be applied. */
|
/** Lets whatever is in flight cross the socket and be applied. */
|
||||||
suspend fun settle() = delay(SETTLE_MILLIS)
|
suspend fun settle() = delay(SETTLE_MILLIS)
|
||||||
|
|
||||||
suspend fun white(from: String, to: String) {
|
suspend fun white(from: String, to: String) {
|
||||||
host.submit(move(from, to))
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
host.submit(chessMove(from, to) as M)
|
||||||
settle()
|
settle()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun black(from: String, to: String) {
|
suspend fun black(from: String, to: String) {
|
||||||
client.submit(move(from, to))
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
client.submit(chessMove(from, to) as M)
|
||||||
settle()
|
settle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,19 +401,30 @@ class MatchControllerNetworkTest {
|
|||||||
clientScope.cancel()
|
clientScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun move(from: String, to: String) =
|
private fun chessMove(from: String, to: String) =
|
||||||
ChessMove(squareFromName(from), squareFromName(to))
|
ChessMove(squareFromName(from), squareFromName(to))
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val HOST_SEAT = 0
|
||||||
|
const val GUEST_SEAT = 1
|
||||||
const val CONNECT_MILLIS = 2_000
|
const val CONNECT_MILLIS = 2_000
|
||||||
const val SETTLE_MILLIS = 300L
|
const val SETTLE_MILLIS = 300L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for the guest's trip from the lobby to the table: a state
|
||||||
|
* change, a recomposition and a LaunchedEffect, none of which the host
|
||||||
|
* waits for.
|
||||||
|
*/
|
||||||
|
const val NAVIGATION_MILLIS = 250L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generous on purpose: leaving takes single-digit milliseconds, and the
|
* Generous on purpose: leaving takes single-digit milliseconds, and the
|
||||||
* failure this guards against is not a slow close but one that never
|
* failure this guards against is not a slow close but one that never
|
||||||
* returns at all.
|
* returns at all.
|
||||||
*/
|
*/
|
||||||
const val BLOCKED_MILLIS = 5_000L
|
const val BLOCKED_MILLIS = 5_000L
|
||||||
|
|
||||||
|
val testJson = Json { encodeDefaults = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package org.prolibertate.games.net
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.prolibertate.games.game.GameCatalog
|
||||||
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which seat a guest sits in, worked out from the seat list the host sends.
|
||||||
|
*
|
||||||
|
* A guest that gets this wrong draws the wrong seat's hand — and since the host
|
||||||
|
* redacts every hand but the one it addressed the view to, the wrong seat's hand
|
||||||
|
* is an empty one. So this is what stands between a guest and a table it cannot
|
||||||
|
* see itself at.
|
||||||
|
*/
|
||||||
|
class SeatForDeviceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a guest finds itself by its own device id`() {
|
||||||
|
val config = table(
|
||||||
|
host(0),
|
||||||
|
guest(1, deviceId = "device-a"),
|
||||||
|
guest(2, deviceId = "device-b"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, seatForDevice(config, "device-a"))
|
||||||
|
assertEquals(2, seatForDevice(config, "device-b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regression this exists for.
|
||||||
|
*
|
||||||
|
* The seat used to be looked up by [PlayerSlot.peerId], which is the address
|
||||||
|
* the host's own transport made up for the incoming link — an IP on Wi-Fi, a
|
||||||
|
* MAC over Bluetooth. A guest has never seen that value and so never matched
|
||||||
|
* it, and every guest fell through to "the first seat held by somebody
|
||||||
|
* remote". With one guest that is right by luck. With two, both of them
|
||||||
|
* claimed seat 1, and the one really sitting in seat 2 was left looking at a
|
||||||
|
* table with no cards in front of it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `two guests do not both claim the first remote seat`() {
|
||||||
|
val config = table(
|
||||||
|
host(0),
|
||||||
|
// Filed under a transport address, as the host files them.
|
||||||
|
guest(1, deviceId = "device-a", peerId = "192.168.1.20"),
|
||||||
|
guest(2, deviceId = "device-b", peerId = "192.168.1.21"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val seats = listOf(seatForDevice(config, "device-a"), seatForDevice(config, "device-b"))
|
||||||
|
assertEquals(listOf(1, 2), seats)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `seats held by the computer are never claimed by a guest`() {
|
||||||
|
val config = table(host(0), guest(1, deviceId = "device-a"), ai(2))
|
||||||
|
|
||||||
|
assertEquals(1, seatForDevice(config, "device-a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a guest dropped for the computer no longer claims its old seat`() {
|
||||||
|
// What setSeatKind leaves behind when the host drops somebody.
|
||||||
|
val config = table(host(0), ai(1), guest(2, deviceId = "device-b"))
|
||||||
|
|
||||||
|
assertEquals(2, seatForDevice(config, "device-b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A host on an older version sends no device ids at all. One guest still
|
||||||
|
* lands in the right seat, which is what keeps a mixed pair of devices
|
||||||
|
* playable rather than silently wrong.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a single guest still finds its seat without device ids`() {
|
||||||
|
val config = table(host(0), guest(1, deviceId = null), ai(2), ai(3))
|
||||||
|
|
||||||
|
assertEquals(1, seatForDevice(config, "device-a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown device falls back to a seat rather than failing`() {
|
||||||
|
assertEquals(0, seatForDevice(table(host(0), ai(1)), "who-is-this"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun table(vararg seats: PlayerSlot) = TableConfig(
|
||||||
|
gameId = GameCatalog.CRAZY_EIGHTS,
|
||||||
|
seats = seats.toList(),
|
||||||
|
optionsJson = "{}",
|
||||||
|
seed = 1L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun host(seat: Int) =
|
||||||
|
PlayerSlot(seat, "Host", PlayerKind.HUMAN_LOCAL, team = seat)
|
||||||
|
|
||||||
|
private fun guest(seat: Int, deviceId: String?, peerId: String = "10.0.0.$seat") =
|
||||||
|
PlayerSlot(seat, "Guest $seat", PlayerKind.HUMAN_REMOTE, team = seat, peerId, deviceId)
|
||||||
|
|
||||||
|
private fun ai(seat: Int) =
|
||||||
|
PlayerSlot(seat, "Computer $seat", PlayerKind.AI, team = seat)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user