diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f00587b..978bf67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,11 @@ jobs: src="app/build/outputs/apk/debug/app-debug.apk" dest="ProLibertateGames-${version}.apk" cp "$src" "$dest" + # The same build again under a name that never changes. GitHub's + # /releases/latest/download/ redirect needs a constant file + # name, so this second copy is what makes a permanent download link + # possible — the versioned copy stays as the traceable one. + cp "$src" "ProLibertateGames.apk" echo "version=$version" >> "$GITHUB_OUTPUT" echo "apk=$dest" >> "$GITHUB_OUTPUT" @@ -70,9 +75,14 @@ jobs: set -euo pipefail gh release create "${{ steps.stage.outputs.version }}" \ "${{ steps.stage.outputs.apk }}" \ + "ProLibertateGames.apk" \ --repo "${{ github.repository }}" \ --target "${{ github.sha }}" \ --title "ProLibertateGames ${{ steps.stage.outputs.version }}" \ --notes "Automated release built from \`${{ github.sha }}\` on push to \`main\`. - Download **${{ steps.stage.outputs.apk }}** below and install it on an Android device (you may need to allow *Install unknown apps*). This is a debug-signed build." + Download **${{ steps.stage.outputs.apk }}** below and install it on an Android device (you may need to allow *Install unknown apps*). This is a debug-signed build. + + Both files below are the same build. \`ProLibertateGames.apk\` is a copy under a fixed name, so this link always fetches the newest release and can be bookmarked or shared: + + https://github.com/${{ github.repository }}/releases/latest/download/ProLibertateGames.apk" diff --git a/README.md b/README.md index db4866a..537870c 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,22 @@ Requires JDK 17 and the Android SDK (compileSdk 34). publishes it as a GitHub Release tagged `v1.0.`. The in-app updater reads that tag, which is why the APK's `versionCode` is the same run number. +### Installing without the app + +The newest production build is always at this link, which never changes: + +**https://github.com/adrianoftyriel/ProLibertateGames/releases/latest/download/ProLibertateGames.apk** + +Every release carries the same APK twice — once named for its version, and once +as plain `ProLibertateGames.apk`, because GitHub's `/releases/latest/download/` +redirect needs a file name that stays put. The in-app updater deliberately picks +the versioned copy, so an installed build can always be traced back to its tag. + +There is no equivalent link for the dev channel: `/releases/latest` skips +prereleases by design, and that is exactly what keeps a production install from +being handed a dev build. Dev builds are reached through the in-app updater with +the channel set to dev, or from the releases page. + ### Update channels Both pipelines publish, to two separate channels: 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 7c4c045..6bbbf1d 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -71,8 +71,22 @@ class MatchController( val finished: StateFlow = _finished.asStateFlow() private val _notice = MutableStateFlow(null) + + /** A passing remark about the last thing that happened, such as a refused move. */ val notice: StateFlow = _notice.asStateFlow() + private val _abandoned = MutableStateFlow(null) + + /** + * Why this table can no longer be played, or null while it still can. + * + * Kept apart from [notice] because the two want opposite treatment: a + * refused move is worth a word in passing, whereas a game with nobody left + * to play it has to be brought to the player's attention along with the way + * out of it. + */ + val abandoned: StateFlow = _abandoned.asStateFlow() + private val _awaitingConfirmation = MutableStateFlow(false) /** True while a scoreboard is up and the table is waiting to be released. */ @@ -91,6 +105,21 @@ class MatchController( private val seatPeers: Map = config.seats.associate { it.seat to it.peerId } + private fun nameOfPeer(peerId: String): String = + config.seats.firstOrNull { it.peerId == peerId }?.name ?: "The other player" + + /** + * Records that this table cannot be played any further. + * + * Set on both channels on purpose: screens that show [notice] as a line of + * text carry on doing so, and one that wants to stop the player and offer + * the way out reads [abandoned] instead. + */ + private fun abandon(reason: String) { + _notice.value = reason + _abandoned.value = reason + } + // ----------------------------------------------------------------------- // Start-up // ----------------------------------------------------------------------- @@ -105,8 +134,15 @@ class MatchController( } } scope.launch { + val initial = runCatching { rules.initialState(config) }.getOrElse { error -> + // A table the rules will not accept is a dead end, not a reason + // to take the app down with it: this runs in the screen's own + // scope, where nothing would catch it. + abandon("This table could not be set up: ${error.message}") + return@launch + } lock.withLock { - authoritative = rules.initialState(config) + authoritative = initial publish() } driveIdleSeats() @@ -121,13 +157,30 @@ class MatchController( link.incoming.collect { message -> when (message) { is StateSync -> { - _state.value = rules.decodeState(message.stateJson) - _legalMoves.value = message.legalMoves.map { rules.decodeMove(it) } - _finished.value = _state.value?.let { rules.isFinished(it) } ?: false + // 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) } is Rejected -> _notice.value = message.reason - is Bye -> _notice.value = "The host left the game." + 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 } } @@ -156,6 +209,13 @@ class MatchController( } private suspend fun onHostMessage(connection: Connection, message: NetMessage) { + if (message is Bye) { + // One of the players has left. The host keeps the position — there is + // nothing wrong with it — but a table that cannot go on should say so + // rather than leave somebody waiting on a move that is not coming. + abandon("${nameOfPeer(connection.peerId)} left the game.") + return + } if (message !is MoveIntent) return val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key if (seat == null) { @@ -244,13 +304,21 @@ class MatchController( val applied = lock.withLock { val live = authoritative ?: return // Nothing else can move while an AI seat is on the clock, but - // the state is re-read rather than assumed. + // the state is re-read rather than assumed. The move was chosen + // off this thread and a chess search takes seconds, so it is + // checked against the position as it stands now: applyMove + // throws on a move that is no longer legal, and there is nothing + // above this to catch it. if (rules.currentSeat(live) != seat) return@withLock false + if (move !in rules.legalMoves(live, seat)) return@withLock false authoritative = rules.applyMove(live, seat, move) publish() true } - if (!applied) return + // The position moved on under the search. Go round again and pick a + // move for the position as it now stands; the loop returns on its own + // as soon as a person is on the clock. + if (!applied) continue } } @@ -299,6 +367,15 @@ class MatchController( scope.launch { driveIdleSeats() } } + /** Lets a player go on looking at a table they have been told is over. */ + fun dismissAbandoned() { + _abandoned.value = null + } + + /** + * Lets go of every link. Safe on the thread drawing the screen — see + * [StreamConnection.close], which this leans on to not block. + */ fun close() { peers.values.forEach { runCatching { it.close() } } peers.clear() diff --git a/app/src/main/java/org/prolibertate/games/net/Transport.kt b/app/src/main/java/org/prolibertate/games/net/Transport.kt index 6ba84d4..13d8a59 100644 --- a/app/src/main/java/org/prolibertate/games/net/Transport.kt +++ b/app/src/main/java/org/prolibertate/games/net/Transport.kt @@ -2,6 +2,7 @@ package org.prolibertate.games.net import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -73,9 +74,16 @@ class StreamConnection( init { scope.launch(Dispatchers.IO) { + // Told apart from a link this side closed on purpose, because only + // one of the two is worth telling anybody about. + var droppedByPeer = false try { while (isActive && !closed.get()) { - val line = reader.readLine() ?: break + val line = reader.readLine() + if (line == null) { + droppedByPeer = true + break + } if (line.isBlank()) continue val message = runCatching { protocolJson.decodeFromString(line) @@ -85,8 +93,23 @@ class StreamConnection( } catch (_: Exception) { // A dropped link is normal: someone walked out of range or // closed the app. Fall through to close(). + droppedByPeer = true } finally { + // Safe here and nowhere else: this thread owns the reader's + // lock, so it is the only one that can close it without + // waiting. See close(). + runCatching { reader.close() } + val weClosedIt = closed.get() close() + // A link that died on its own reaches the screens on the same + // channel as everything else, so leaving a game is something the + // other end finds out about the same way it finds out about a + // move. Withdrawing deliberately needs no announcement, and the + // emit is not cancellable — the whole point of it is to arrive + // while everything around it is being torn down. + if (droppedByPeer && !weClosedIt) { + withContext(NonCancellable) { runCatching { _incoming.emit(Bye) } } + } } } } @@ -104,10 +127,24 @@ class StreamConnection( } } + /** + * Safe to call from any thread, including the one drawing the screen. + * + * Only the raw streams are closed here, and that is deliberate. A reader + * parked in [BufferedReader.readLine] holds the reader's own lock for the + * whole of the blocking read, and [BufferedReader.close] wants that same + * lock — so closing the wrapper from another thread waits for a read that + * only this close can end. Since the read is waiting on the socket, and the + * caller is usually the main thread leaving a game, that wait is the app + * hanging for good. + * + * Closing the underlying stream is what actually ends the read. The reader + * coroutine then unblocks and closes its own wrapper on its own thread. The + * writer needs no closing: every [send] flushes, so nothing is ever left + * buffered in it. + */ override fun close() { if (!closed.compareAndSet(false, true)) return - runCatching { reader.close() } - runCatching { writer.close() } runCatching { input.close() } runCatching { output.close() } onClosed(this) diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index b706ec3..cf8d4c0 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -86,12 +86,23 @@ fun AppRoot(env: AppEnv) { val pop: () -> Unit = { if (stack.size > 1) { + val leaving = stack.last() // Leaving a lobby or a table tears the networking down with it. - when (stack.last()) { + when (leaving) { is Route.Lobby, is Route.Play -> env.lobby.stop() else -> Unit } - stack = stack.dropLast(1) + stack = stack.dropLast(1).let { remaining -> + // Leaving a table must not land back in the lobby that started + // it. A lobby starts advertising the moment it is drawn, so + // ending a game would have put the player straight into another + // one — which is no way to leave a game. + if (leaving is Route.Play) { + remaining.dropLastWhile { it is Route.Lobby } + } else { + remaining + } + } } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt index 72a302a..16d8a80 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt @@ -72,16 +72,32 @@ fun ChessScreen( ) { val state by controller.state.collectAsState() val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() var selected by remember { mutableStateOf(null) } // A pawn reaching the last rank is one tap that stands for four moves, so // the destination is held while the piece is chosen. var promotionTo by remember { mutableStateOf(null) } + // A game with nobody left to play it, or one being walked away from, both end + // through a dialog rather than by silently dropping off the screen. + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } val current = state if (current == null) { - ScreenScaffold(title = "Chess", onBack = onExit) { modifier -> + ScreenScaffold(title = "Chess", onBack = onExit, actions = endGameAction) { modifier -> Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + // A table that never arrives — a host that went away mid-handshake — + // still has to be leavable. + abandoned?.let { + LeftTheTableDialog(it, onExit = onExit, onStay = controller::dismissAbandoned) + } + if (confirmingEnd) { + ConfirmEndDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false }) + } } return } @@ -104,7 +120,7 @@ fun ChessScreen( } } - ScreenScaffold(title = "Chess", onBack = onExit) { modifier -> + ScreenScaffold(title = "Chess", onBack = onExit, actions = endGameAction) { modifier -> BoxWithConstraints(modifier = modifier) { val wide = maxWidth > maxHeight val boardSide = if (wide) { @@ -152,10 +168,21 @@ fun ChessScreen( } } - promotionTo?.let { destination -> - PromotionDialog( + // One dialog at a time, most pressing first. A game nobody can finish + // comes before the result of one that finished, and before a + // half-made promotion: there is no point choosing a piece for a move + // that has nowhere left to go. + val over = abandoned + when { + over != null -> + LeftTheTableDialog(over, onExit = onExit, onStay = controller::dismissAbandoned) + + confirmingEnd -> + ConfirmEndDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false }) + + promotionTo != null -> PromotionDialog( white = current.whiteToMove, - choices = fromSelected.filter { it.to == destination }, + choices = fromSelected.filter { it.to == promotionTo }, onPick = { controller.submit(it) promotionTo = null @@ -163,15 +190,48 @@ fun ChessScreen( }, onDismiss = { promotionTo = null }, ) - } - if (current.phase == ChessPhase.GAME_OVER) { - ResultDialog(current, onExit) + current.phase == ChessPhase.GAME_OVER -> ResultDialog(current, onExit) } } } } +/** + * Leaving a game on purpose. + * + * Chess is the one game here that a player wants out of before the position is + * settled: a card hand is over in minutes, whereas a game of chess between two + * people who are no longer enjoying it has no natural end at all. + */ +@Composable +private fun ConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { + Text( + "This game ends here and the position is not saved. Anyone else " + + "at the table is told you have left." + ) + }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +/** The other end has gone, so no further move is ever going to arrive. */ +@Composable +private fun LeftTheTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text("$notice There are no more moves to play.") }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} + @Composable private fun Board( state: ChessState, @@ -372,7 +432,7 @@ private fun ResultDialog(state: ChessState, onExit: () -> Unit) { ) } }, - confirmButton = { TextButton(onClick = onExit) { Text("Back to the menu") } }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index 3f353b9..fbfa3f4 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -120,20 +120,25 @@ class Updater(private val activity: Activity) { } val assets = json.optJSONArray("assets") ?: return null - for (i in 0 until assets.length()) { - val asset = assets.getJSONObject(i) - val name = asset.optString("name") - if (name.endsWith(".apk")) { - return Release( - tag = tag, - versionCode = versionCode, - apkUrl = asset.getString("browser_download_url"), - apkName = name, - channel = channel, - ) - } - } - return null + val apks = (0 until assets.length()) + .map { assets.getJSONObject(it) } + .filter { it.optString("name").endsWith(".apk") } + + // A production release carries the same build twice: once named for its + // version, and once under a fixed name so a permanent download link + // exists. Pick the versioned one explicitly rather than whichever was + // uploaded first, so what gets installed is traceable to its tag. + val chosen = apks.firstOrNull { it.optString("name").contains(tag) } + ?: apks.firstOrNull() + ?: return null + + return Release( + tag = tag, + versionCode = versionCode, + apkUrl = chosen.getString("browser_download_url"), + apkName = chosen.optString("name"), + channel = channel, + ) } private fun openConnection(url: String): HttpURLConnection = diff --git a/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt b/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt new file mode 100644 index 0000000..a8c4cdb --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt @@ -0,0 +1,262 @@ +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 kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.chess.ChessAi +import org.prolibertate.games.game.chess.ChessMove +import org.prolibertate.games.game.chess.ChessOptions +import org.prolibertate.games.game.chess.ChessOutcome +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.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket + +/** + * Two devices playing one networked game, wired up the way PlayScreen wires it, + * over a real socket pair on loopback. + * + * 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. + */ +class MatchControllerNetworkTest { + + private val tables = mutableListOf() + + @After + fun tearDown() { + tables.forEach { it.dispose() } + } + + private fun table(): Table = Table().also { tables += it } + + // ----------------------------------------------------------------------- + + @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) + + table.white("f2", "f3") + table.black("e7", "e5") + table.white("g2", "g4") + table.black("d8", "h4") + + assertEquals(ChessOutcome.BLACK_WINS, table.host.state.value?.outcome) + assertEquals(ChessOutcome.BLACK_WINS, table.client.state.value?.outcome) + assertTrue(table.host.finished.value) + assertTrue(table.client.finished.value) + } + + /** + * 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 + * whole of a blocking read, so the main thread waited on a read that only its + * own close could ever end. The app hung for good, and a chess game is the + * one long enough that a player wants out of it mid-game. + */ + @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() + table.white("e2", "e4") + + // Left on its own thread, and a daemon one: should this ever hang + // again, the test has to report it rather than take the build down + // with it — a thread stuck on a lock cannot be interrupted. + val left = Thread { table.leave(side) }.apply { isDaemon = true } + left.start() + left.join(BLOCKED_MILLIS) + + assertTrue( + "Leaving as $side did not return: the thread that ends a game is stuck in close()", + !left.isAlive, + ) + } + } + + @Test + fun `a player leaving is reported to the device still at the table`() = runBlocking { + val guestLeft = table() + guestLeft.settle() + guestLeft.leave(Side.GUEST) + guestLeft.settle() + assertEquals("Guest left the game.", guestLeft.host.abandoned.value) + + val hostLeft = table() + hostLeft.settle() + 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) + } + + @Test + fun `closing a link on purpose is not reported as the other player leaving`() = runBlocking { + val table = table() + table.settle() + assertNull(table.host.abandoned.value) + + // The host closes its own end, as leaving does. Its own screen has no + // business being told that somebody else left. + table.hostLink.close() + table.settle() + assertNull(table.host.abandoned.value) + // The guest, on the other hand, has lost the game it was playing. + assertNotNull(table.client.abandoned.value) + } + + // ----------------------------------------------------------------------- + + private enum class Side { HOST, GUEST } + + /** One two-seat chess table with a real socket between the two devices. */ + private class Table { + private val server = ServerSocket(0) + private val clientSocket = Socket() + private val hostSocket: Socket + private val hostScope = CoroutineScope(Job() + Dispatchers.Default) + private val clientScope = CoroutineScope(Job() + Dispatchers.Default) + + val hostLink: StreamConnection + private val clientLink: StreamConnection + val host: MatchController + val client: MatchController + + init { + clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS) + hostSocket = server.accept() + val guestPeerId = hostSocket.inetAddress?.hostAddress ?: "guest" + + hostLink = StreamConnection( + peerId = guestPeerId, + kind = TransportKind.LAN, + input = hostSocket.getInputStream(), + output = hostSocket.getOutputStream(), + scope = hostScope, + onClosed = { runCatching { hostSocket.close() } }, + ) + clientLink = StreamConnection( + peerId = "lan:host", + kind = TransportKind.LAN, + input = clientSocket.getInputStream(), + output = clientSocket.getOutputStream(), + scope = clientScope, + onClosed = { runCatching { clientSocket.close() } }, + ) + + 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()), + seed = 1L, + ) + + fun controller(role: MatchController.Role, seat: Int, scope: CoroutineScope) = + MatchController( + rules = ChessRules, + ai = ChessAi(), + config = config, + scope = scope, + role = role, + localSeats = setOf(seat), + primarySeat = seat, + aiThinkingMillis = { 0L }, + ) + + host = controller(MatchController.Role.HOST, 0, hostScope) + client = controller(MatchController.Role.CLIENT, 1, clientScope) + client.startAsClient(clientLink) + host.startAsHost(listOf(hostLink)) + } + + /** 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)) + settle() + } + + suspend fun black(from: String, to: String) { + client.submit(move(from, to)) + settle() + } + + /** Exactly what AppRoot.pop() and PlayScreen's onDispose do, in that order. */ + fun leave(side: Side) { + if (side == Side.HOST) { + runCatching { hostLink.close() } + host.close() + hostScope.cancel() + } else { + runCatching { clientLink.close() } + client.close() + clientScope.cancel() + } + } + + /** + * Sockets first, on purpose: that is what frees any reader still parked + * on one, so clearing up after a failed test cannot itself hang. + */ + fun dispose() { + runCatching { hostSocket.close() } + runCatching { clientSocket.close() } + runCatching { server.close() } + runCatching { hostLink.close() } + runCatching { clientLink.close() } + runCatching { host.close() } + runCatching { client.close() } + hostScope.cancel() + clientScope.cancel() + } + + private fun move(from: String, to: String) = + ChessMove(squareFromName(from), squareFromName(to)) + } + + private companion object { + const val CONNECT_MILLIS = 2_000 + const val SETTLE_MILLIS = 300L + + /** + * 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 + } +}