diff --git a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt index d44b825..52a3665 100644 --- a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt +++ b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt @@ -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 diff --git a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt index 6728a2f..f2da41f 100644 --- a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt +++ b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt @@ -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() } 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 6bbbf1d..f43fd8f 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -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( 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( 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( 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) } } diff --git a/app/src/main/java/org/prolibertate/games/net/Protocol.kt b/app/src/main/java/org/prolibertate/games/net/Protocol.kt index 5fef50f..8432247 100644 --- a/app/src/main/java/org/prolibertate/games/net/Protocol.kt +++ b/app/src/main/java/org/prolibertate/games/net/Protocol.kt @@ -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 diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt index 6c8e016..a598769 100644 --- a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -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) diff --git a/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt b/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt index a8c4cdb..8b37c9b 100644 --- a/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt +++ b/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString 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.ChessState 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.PlayerSlot 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 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 - * to let go of a socket while the screen that owns it is being torn down. + * started, played, and left. + * + * 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 { - private val tables = mutableListOf() + private val tables = mutableListOf>() @After fun tearDown() { tables.forEach { it.dispose() } } - private fun table(): Table = Table().also { tables += it } + private fun chess(hostSeat: Int = HOST_SEAT): Table = Table( + rules = ChessRules, + ai = ChessAi(), + gameId = GameCatalog.CHESS, + optionsJson = testJson.encodeToString(ChessOptions()), + seatCount = 2, + hostSeat = hostSeat, + ).also { tables += it } + private fun crazyEights(): Table = 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(), 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(), 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(), table.host.legalMoves.value) + } + + @Test + fun `a guest is given its moves when its turn arrives`() = runBlocking { + val table = chess() + table.start() + + assertEquals(emptyList(), 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 fun `a networked game plays to a result on both devices`() = runBlocking { - val table = table() - table.settle() - - 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) + val table = chess() + table.start() table.white("f2", "f3") table.black("e7", "e5") @@ -72,9 +189,11 @@ class MatchControllerNetworkTest { 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 * 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 @@ -85,8 +204,8 @@ class MatchControllerNetworkTest { @Test fun `leaving a game in progress does not block the thread that leaves`() = runBlocking { for (side in listOf(Side.HOST, Side.GUEST)) { - val table = table() - table.settle() + val table = chess() + table.start() table.white("e2", "e4") // Left on its own thread, and a daemon one: should this ever hang @@ -105,25 +224,25 @@ class MatchControllerNetworkTest { @Test fun `a player leaving is reported to the device still at the table`() = runBlocking { - val guestLeft = table() - guestLeft.settle() + val guestLeft = chess() + guestLeft.start() guestLeft.leave(Side.GUEST) guestLeft.settle() assertEquals("Guest left the game.", guestLeft.host.abandoned.value) - val hostLeft = table() - hostLeft.settle() + val hostLeft = chess() + hostLeft.start() hostLeft.leave(Side.HOST) hostLeft.settle() assertEquals("The host left the game.", hostLeft.client.abandoned.value) // Nothing more can be played on a table with no host. - assertEquals(0, hostLeft.client.legalMoves.value.size) + assertEquals(emptyList(), hostLeft.client.legalMoves.value) } @Test fun `closing a link on purpose is not reported as the other player leaving`() = runBlocking { - val table = table() - table.settle() + val table = chess() + table.start() assertNull(table.host.abandoned.value) // 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 } - /** One two-seat chess table with a real socket between the two devices. */ - private class Table { + /** One two-seat table with a real socket between the two devices. */ + private class Table( + private val rules: GameRules, + ai: GameAi, + gameId: String, + optionsJson: String, + seatCount: Int, + private val hostSeat: Int = HOST_SEAT, + ) { private val server = ServerSocket(0) private val clientSocket = Socket() private val hostSocket: Socket @@ -149,8 +275,8 @@ class MatchControllerNetworkTest { val hostLink: StreamConnection private val clientLink: StreamConnection - val host: MatchController - val client: MatchController + val host: MatchController + val client: MatchController init { clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS) @@ -175,19 +301,28 @@ class MatchControllerNetworkTest { ) val config = TableConfig( - gameId = GameCatalog.CHESS, - seats = listOf( - PlayerSlot(0, "Host", PlayerKind.HUMAN_LOCAL, team = 0), - PlayerSlot(1, "Guest", PlayerKind.HUMAN_REMOTE, team = 1, peerId = guestPeerId), - ), - optionsJson = Json { encodeDefaults = true }.encodeToString(ChessOptions()), + gameId = gameId, + seats = (0 until seatCount).map { seat -> + PlayerSlot( + seat = seat, + name = if (seat == hostSeat) "Host" else "Guest", + kind = if (seat == hostSeat) { + PlayerKind.HUMAN_LOCAL + } else { + PlayerKind.HUMAN_REMOTE + }, + team = seat, + peerId = if (seat == hostSeat) null else guestPeerId, + ) + }, + optionsJson = optionsJson, seed = 1L, ) fun controller(role: MatchController.Role, seat: Int, scope: CoroutineScope) = - MatchController( - rules = ChessRules, - ai = ChessAi(), + MatchController( + rules = rules, + ai = ai, config = config, scope = scope, role = role, @@ -196,22 +331,44 @@ class MatchControllerNetworkTest { aiThinkingMillis = { 0L }, ) - host = controller(MatchController.Role.HOST, 0, hostScope) - client = controller(MatchController.Role.CLIENT, 1, clientScope) - client.startAsClient(clientLink) + host = controller(MatchController.Role.HOST, hostSeat, hostScope) + client = controller( + 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)) + delay(NAVIGATION_MILLIS) + client.startAsClient(clientLink) + settle() } /** Lets whatever is in flight cross the socket and be applied. */ suspend fun settle() = delay(SETTLE_MILLIS) suspend fun white(from: String, to: String) { - host.submit(move(from, to)) + @Suppress("UNCHECKED_CAST") + host.submit(chessMove(from, to) as M) settle() } suspend fun black(from: String, to: String) { - client.submit(move(from, to)) + @Suppress("UNCHECKED_CAST") + client.submit(chessMove(from, to) as M) settle() } @@ -244,19 +401,30 @@ class MatchControllerNetworkTest { clientScope.cancel() } - private fun move(from: String, to: String) = + private fun chessMove(from: String, to: String) = ChessMove(squareFromName(from), squareFromName(to)) } private companion object { + const val HOST_SEAT = 0 + const val GUEST_SEAT = 1 const val CONNECT_MILLIS = 2_000 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 * failure this guards against is not a slow close but one that never * returns at all. */ const val BLOCKED_MILLIS = 5_000L + + val testJson = Json { encodeDefaults = true } } } diff --git a/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt b/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt new file mode 100644 index 0000000..210ecc6 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt @@ -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) +}