Merge dev: fix the hang that made a networked game impossible to leave
Leaving a multiplayer game deadlocked the main thread in BufferedReader.close(), which wants the lock readLine() holds for the whole of a blocking read — so a game of chess could not be ended at all. Chess also gains an End game action, the other device is told when a player leaves, and leaving no longer lands back in a live lobby. Also carries the fixed-name dev APK change from dev.
This commit is contained in:
@@ -53,6 +53,11 @@ jobs:
|
|||||||
src="app/build/outputs/apk/debug/app-debug.apk"
|
src="app/build/outputs/apk/debug/app-debug.apk"
|
||||||
dest="ProLibertateGames-${version}.apk"
|
dest="ProLibertateGames-${version}.apk"
|
||||||
cp "$src" "$dest"
|
cp "$src" "$dest"
|
||||||
|
# The same build again under a name that never changes. GitHub's
|
||||||
|
# /releases/latest/download/<name> 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 "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "apk=$dest" >> "$GITHUB_OUTPUT"
|
echo "apk=$dest" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -70,9 +75,14 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
gh release create "${{ steps.stage.outputs.version }}" \
|
gh release create "${{ steps.stage.outputs.version }}" \
|
||||||
"${{ steps.stage.outputs.apk }}" \
|
"${{ steps.stage.outputs.apk }}" \
|
||||||
|
"ProLibertateGames.apk" \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--target "${{ github.sha }}" \
|
--target "${{ github.sha }}" \
|
||||||
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \
|
--title "ProLibertateGames ${{ steps.stage.outputs.version }}" \
|
||||||
--notes "Automated release built from \`${{ github.sha }}\` on push to \`main\`.
|
--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"
|
||||||
|
|||||||
@@ -111,6 +111,22 @@ Requires JDK 17 and the Android SDK (compileSdk 34).
|
|||||||
publishes it as a GitHub Release tagged `v1.0.<run-number>`. The in-app updater
|
publishes it as a GitHub Release tagged `v1.0.<run-number>`. The in-app updater
|
||||||
reads that tag, which is why the APK's `versionCode` is the same run number.
|
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
|
### Update channels
|
||||||
|
|
||||||
Both pipelines publish, to two separate channels:
|
Both pipelines publish, to two separate channels:
|
||||||
|
|||||||
@@ -71,8 +71,22 @@ class MatchController<S : Any, M : Any>(
|
|||||||
val finished: StateFlow<Boolean> = _finished.asStateFlow()
|
val finished: StateFlow<Boolean> = _finished.asStateFlow()
|
||||||
|
|
||||||
private val _notice = MutableStateFlow<String?>(null)
|
private val _notice = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
/** A passing remark about the last thing that happened, such as a refused move. */
|
||||||
val notice: StateFlow<String?> = _notice.asStateFlow()
|
val notice: StateFlow<String?> = _notice.asStateFlow()
|
||||||
|
|
||||||
|
private val _abandoned = MutableStateFlow<String?>(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<String?> = _abandoned.asStateFlow()
|
||||||
|
|
||||||
private val _awaitingConfirmation = MutableStateFlow(false)
|
private val _awaitingConfirmation = MutableStateFlow(false)
|
||||||
|
|
||||||
/** True while a scoreboard is up and the table is waiting to be released. */
|
/** True while a scoreboard is up and the table is waiting to be released. */
|
||||||
@@ -91,6 +105,21 @@ class MatchController<S : Any, M : Any>(
|
|||||||
private val seatPeers: Map<Int, String?> =
|
private val seatPeers: Map<Int, String?> =
|
||||||
config.seats.associate { it.seat to it.peerId }
|
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
|
// Start-up
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -105,8 +134,15 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
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 {
|
lock.withLock {
|
||||||
authoritative = rules.initialState(config)
|
authoritative = initial
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
driveIdleSeats()
|
driveIdleSeats()
|
||||||
@@ -121,13 +157,30 @@ class MatchController<S : Any, M : Any>(
|
|||||||
link.incoming.collect { message ->
|
link.incoming.collect { message ->
|
||||||
when (message) {
|
when (message) {
|
||||||
is StateSync -> {
|
is StateSync -> {
|
||||||
_state.value = rules.decodeState(message.stateJson)
|
// Anything off the wire is decoded defensively. This
|
||||||
_legalMoves.value = message.legalMoves.map { rules.decodeMove(it) }
|
// collector runs in the screen's own scope, so throwing
|
||||||
_finished.value = _state.value?.let { rules.isFinished(it) } ?: false
|
// 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 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
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +209,13 @@ class MatchController<S : Any, M : Any>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onHostMessage(connection: Connection, message: NetMessage) {
|
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
|
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) {
|
||||||
@@ -244,13 +304,21 @@ class MatchController<S : Any, M : Any>(
|
|||||||
val applied = lock.withLock {
|
val applied = lock.withLock {
|
||||||
val live = authoritative ?: return
|
val live = authoritative ?: return
|
||||||
// Nothing else can move while an AI seat is on the clock, but
|
// 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 (rules.currentSeat(live) != seat) return@withLock false
|
||||||
|
if (move !in rules.legalMoves(live, seat)) return@withLock false
|
||||||
authoritative = rules.applyMove(live, seat, move)
|
authoritative = rules.applyMove(live, seat, move)
|
||||||
publish()
|
publish()
|
||||||
true
|
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<S : Any, M : Any>(
|
|||||||
scope.launch { driveIdleSeats() }
|
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() {
|
fun close() {
|
||||||
peers.values.forEach { runCatching { it.close() } }
|
peers.values.forEach { runCatching { it.close() } }
|
||||||
peers.clear()
|
peers.clear()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.prolibertate.games.net
|
|||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -73,9 +74,16 @@ class StreamConnection(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch(Dispatchers.IO) {
|
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 {
|
try {
|
||||||
while (isActive && !closed.get()) {
|
while (isActive && !closed.get()) {
|
||||||
val line = reader.readLine() ?: break
|
val line = reader.readLine()
|
||||||
|
if (line == null) {
|
||||||
|
droppedByPeer = true
|
||||||
|
break
|
||||||
|
}
|
||||||
if (line.isBlank()) continue
|
if (line.isBlank()) continue
|
||||||
val message = runCatching {
|
val message = runCatching {
|
||||||
protocolJson.decodeFromString<NetMessage>(line)
|
protocolJson.decodeFromString<NetMessage>(line)
|
||||||
@@ -85,8 +93,23 @@ class StreamConnection(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// A dropped link is normal: someone walked out of range or
|
// A dropped link is normal: someone walked out of range or
|
||||||
// closed the app. Fall through to close().
|
// closed the app. Fall through to close().
|
||||||
|
droppedByPeer = true
|
||||||
} finally {
|
} 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()
|
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() {
|
override fun close() {
|
||||||
if (!closed.compareAndSet(false, true)) return
|
if (!closed.compareAndSet(false, true)) return
|
||||||
runCatching { reader.close() }
|
|
||||||
runCatching { writer.close() }
|
|
||||||
runCatching { input.close() }
|
runCatching { input.close() }
|
||||||
runCatching { output.close() }
|
runCatching { output.close() }
|
||||||
onClosed(this)
|
onClosed(this)
|
||||||
|
|||||||
@@ -86,12 +86,23 @@ fun AppRoot(env: AppEnv) {
|
|||||||
|
|
||||||
val pop: () -> Unit = {
|
val pop: () -> Unit = {
|
||||||
if (stack.size > 1) {
|
if (stack.size > 1) {
|
||||||
|
val leaving = stack.last()
|
||||||
// Leaving a lobby or a table tears the networking down with it.
|
// 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()
|
is Route.Lobby, is Route.Play -> env.lobby.stop()
|
||||||
else -> Unit
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,16 +72,32 @@ fun ChessScreen(
|
|||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsState()
|
val state by controller.state.collectAsState()
|
||||||
val legal by controller.legalMoves.collectAsState()
|
val legal by controller.legalMoves.collectAsState()
|
||||||
|
val abandoned by controller.abandoned.collectAsState()
|
||||||
|
|
||||||
var selected by remember { mutableStateOf<Int?>(null) }
|
var selected by remember { mutableStateOf<Int?>(null) }
|
||||||
// A pawn reaching the last rank is one tap that stands for four moves, so
|
// A pawn reaching the last rank is one tap that stands for four moves, so
|
||||||
// the destination is held while the piece is chosen.
|
// the destination is held while the piece is chosen.
|
||||||
var promotionTo by remember { mutableStateOf<Int?>(null) }
|
var promotionTo by remember { mutableStateOf<Int?>(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
|
val current = state
|
||||||
if (current == null) {
|
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…") }
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -104,7 +120,7 @@ fun ChessScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScreenScaffold(title = "Chess", onBack = onExit) { modifier ->
|
ScreenScaffold(title = "Chess", onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
BoxWithConstraints(modifier = modifier) {
|
BoxWithConstraints(modifier = modifier) {
|
||||||
val wide = maxWidth > maxHeight
|
val wide = maxWidth > maxHeight
|
||||||
val boardSide = if (wide) {
|
val boardSide = if (wide) {
|
||||||
@@ -152,10 +168,21 @@ fun ChessScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promotionTo?.let { destination ->
|
// One dialog at a time, most pressing first. A game nobody can finish
|
||||||
PromotionDialog(
|
// 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,
|
white = current.whiteToMove,
|
||||||
choices = fromSelected.filter { it.to == destination },
|
choices = fromSelected.filter { it.to == promotionTo },
|
||||||
onPick = {
|
onPick = {
|
||||||
controller.submit(it)
|
controller.submit(it)
|
||||||
promotionTo = null
|
promotionTo = null
|
||||||
@@ -163,15 +190,48 @@ fun ChessScreen(
|
|||||||
},
|
},
|
||||||
onDismiss = { promotionTo = null },
|
onDismiss = { promotionTo = null },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (current.phase == ChessPhase.GAME_OVER) {
|
current.phase == ChessPhase.GAME_OVER -> ResultDialog(current, onExit)
|
||||||
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
|
@Composable
|
||||||
private fun Board(
|
private fun Board(
|
||||||
state: ChessState,
|
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") } },
|
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,20 +120,25 @@ class Updater(private val activity: Activity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val assets = json.optJSONArray("assets") ?: return null
|
val assets = json.optJSONArray("assets") ?: return null
|
||||||
for (i in 0 until assets.length()) {
|
val apks = (0 until assets.length())
|
||||||
val asset = assets.getJSONObject(i)
|
.map { assets.getJSONObject(it) }
|
||||||
val name = asset.optString("name")
|
.filter { it.optString("name").endsWith(".apk") }
|
||||||
if (name.endsWith(".apk")) {
|
|
||||||
return Release(
|
// A production release carries the same build twice: once named for its
|
||||||
tag = tag,
|
// version, and once under a fixed name so a permanent download link
|
||||||
versionCode = versionCode,
|
// exists. Pick the versioned one explicitly rather than whichever was
|
||||||
apkUrl = asset.getString("browser_download_url"),
|
// uploaded first, so what gets installed is traceable to its tag.
|
||||||
apkName = name,
|
val chosen = apks.firstOrNull { it.optString("name").contains(tag) }
|
||||||
channel = channel,
|
?: apks.firstOrNull()
|
||||||
)
|
?: return null
|
||||||
}
|
|
||||||
}
|
return Release(
|
||||||
return null
|
tag = tag,
|
||||||
|
versionCode = versionCode,
|
||||||
|
apkUrl = chosen.getString("browser_download_url"),
|
||||||
|
apkName = chosen.optString("name"),
|
||||||
|
channel = channel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String): HttpURLConnection =
|
private fun openConnection(url: String): HttpURLConnection =
|
||||||
|
|||||||
@@ -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<Table>()
|
||||||
|
|
||||||
|
@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<ChessState, ChessMove>
|
||||||
|
val client: MatchController<ChessState, ChessMove>
|
||||||
|
|
||||||
|
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<ChessState, ChessMove>(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user