From 89b19a894ac4169b5e123e2f50fc174962b888db Mon Sep 17 00:00:00 2001 From: James Wallace Date: Sat, 10 Oct 2026 01:27:40 +0000 Subject: [PATCH] Scorekeeper: share a sheet between devices A host can share its score sheet and others nearby can join it, found the same way games are, or by typed address over a hotspot. - A joining guest is not let in on its own say-so. The host is prompted with the guest's name and answers Allow or Deny. A device already let in that drops out (locked phone) is let straight back; the host can remove anyone. - The host's sheet is authoritative. What travels is what was done (SheetOp: add round, rename, move column, ...), not whole sheets, so changes made at the same moment all land. A guest's own changes show at once and are laid over the host's sheet until confirmed (SheetReplica). - Sync asks the host for the sheet as it stands, and runs on its own when the app returns to the front, as it does for a game table. - Hello carries a purpose and hosts advertise one, so a game join screen does not list score sheets and a game guest typing a sheet's address is told so. - The Scorekeeper screen now expresses every edit as a SheetOp, so one grid serves a local sheet, a hosted one and a joined one. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G --- README.md | 20 + .../org/prolibertate/games/MainActivity.kt | 20 +- .../prolibertate/games/net/LanTransport.kt | 25 +- .../prolibertate/games/net/LobbyController.kt | 16 +- .../org/prolibertate/games/net/Protocol.kt | 36 ++ .../games/net/ScorekeeperSession.kt | 564 ++++++++++++++++++ .../org/prolibertate/games/net/Transport.kt | 10 +- .../org/prolibertate/games/score/SheetOp.kt | 89 +++ .../prolibertate/games/score/SheetReplica.kt | 71 +++ .../java/org/prolibertate/games/ui/AppRoot.kt | 5 + .../games/ui/ScorekeeperScreen.kt | 216 ++++++- .../games/ui/ScorekeeperShareUi.kt | 239 ++++++++ .../games/net/ScorekeeperSessionTest.kt | 358 +++++++++++ .../prolibertate/games/score/SheetSyncTest.kt | 176 ++++++ 14 files changed, 1807 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt create mode 100644 app/src/main/java/org/prolibertate/games/score/SheetOp.kt create mode 100644 app/src/main/java/org/prolibertate/games/score/SheetReplica.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt create mode 100644 app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/score/SheetSyncTest.kt diff --git a/README.md b/README.md index f452991..c7d19c9 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,26 @@ Points are taken away by typing a minus, or with the **±** button, which flips the last number typed. Not every keyboard offers a minus on its number pad, and subtracting has to work on all of them. +### Sharing a sheet between devices + +Tap **Share** and this phone becomes the host of its sheet; others choose +**Scorekeeper → Join a shared sheet nearby**. Discovery and joining by address +work exactly as they do for a game, hotspot included. Nobody is let in on their +own say-so: the host gets a prompt with the guest's name and has to **Allow** or +**Deny** it. A guest who was let in and drops out — a locked phone, say — is let +straight back in rather than asked again, and the host can **Remove** anybody +from the Sharing panel. + +Everyone on the sheet can add rounds, correct them, rename and move columns. The +host's copy is the truth, and what travels is what was *done* ("add a round +with these points"), not whole sheets, so two people entering a round at once +get two rounds rather than one overwriting the other. A guest's own change shows +immediately and is laid over whatever the host sends until the host confirms it. +**Sync** asks the host for the sheet as it stands, and happens by itself when the +app comes back to the front. The host keeps the sheet as its own saved one; +a guest's view is not saved, and leaving puts them back on their own sheet. Only +the host can start a new game. + The sheet is saved as it is edited, so leaving the app or taking a phone call does not lose the game. It is kept in its own DataStore rather than with the settings, because it is a game in progress rather than a preference. diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt index b4a7059..84f272d 100644 --- a/app/src/main/java/org/prolibertate/games/MainActivity.kt +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -5,7 +5,9 @@ import android.provider.Settings as AndroidSettings import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.lifecycle.lifecycleScope +import org.prolibertate.games.net.LanTransport import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.net.ScorekeeperSession import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.settings.SettingsRepository import org.prolibertate.games.ui.AppEnv @@ -21,12 +23,23 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val peerId = stablePeerId() + // Its own transport rather than the lobby's: both listen on the same + // port, and a screen at a time is only ever using one of them. + val scoreLan = LanTransport(applicationContext) + env = AppEnv( settingsRepository = SettingsRepository(applicationContext), updater = Updater(this), lobby = LobbyController(applicationContext, lifecycleScope), scorekeeper = ScorekeeperRepository(applicationContext), - peerId = stablePeerId(), + scoreSession = ScorekeeperSession( + transport = scoreLan, + endpoint = scoreLan.endpoint, + scope = lifecycleScope, + deviceId = peerId, + ), + peerId = peerId, ) setContent { @@ -50,6 +63,9 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() - if (::env.isInitialized) env.lobby.stop() + if (::env.isInitialized) { + env.lobby.stop() + env.scoreSession.stop() + } } } diff --git a/app/src/main/java/org/prolibertate/games/net/LanTransport.kt b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt index 30633d7..0d1c6e4 100644 --- a/app/src/main/java/org/prolibertate/games/net/LanTransport.kt +++ b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt @@ -83,7 +83,11 @@ class LanTransport(private val context: Context) : Transport { // Hosting // ----------------------------------------------------------------------- - override fun host(displayName: String, scope: CoroutineScope): Flow = callbackFlow { + override fun host( + displayName: String, + scope: CoroutineScope, + purpose: HostPurpose, + ): Flow = callbackFlow { holdRadio() // The known port when it can be had, so that joining by hand needs an @@ -97,7 +101,7 @@ class LanTransport(private val context: Context) : Transport { multicast.acquire() val serviceInfo = NsdServiceInfo().apply { - serviceName = "$SERVICE_NAME @ $displayName" + serviceName = "${serviceLabel(purpose)} @ $displayName" serviceType = SERVICE_TYPE port = server.localPort } @@ -175,6 +179,7 @@ class LanTransport(private val context: Context) : Transport { kind = TransportKind.LAN, address = address, port = info.port, + purpose = purposeOf(info.serviceName), ) synchronized(found) { found[host.id] = host } publish() @@ -278,6 +283,22 @@ class LanTransport(private val context: Context) : Transport { } private companion object { + /** + * The service name is the only thing a host says about itself before + * anyone has connected, so what it is hosting goes in it. + */ + const val SCOREKEEPER_LABEL = "$SERVICE_NAME Scorekeeper" + + fun serviceLabel(purpose: HostPurpose): String = + if (purpose == HostPurpose.SCOREKEEPER) SCOREKEEPER_LABEL else SERVICE_NAME + + fun purposeOf(serviceName: String?): HostPurpose = + if (serviceName?.startsWith("$SCOREKEEPER_LABEL @") == true) { + HostPurpose.SCOREKEEPER + } else { + HostPurpose.GAME + } + const val CONNECT_TIMEOUT_MS = 8_000 const val WIFI_LOCK_TAG = "ProLibertateGames:lan" } 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 a07554a..c1e1f2c 100644 --- a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt +++ b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt @@ -129,6 +129,18 @@ class LobbyController( jobs += scope.launch { connection.incoming.collect { message -> if (message !is Hello) return@collect + if (message.purpose != PURPOSE_GAME) { + connection.send( + Welcome( + hostName = _state.value.hostName, + gameId = _state.value.gameId.orEmpty(), + accepted = false, + reason = "That is a game, not a score sheet.", + ) + ) + connection.close() + return@collect + } if (message.protocol != PROTOCOL_VERSION) { connection.send( Welcome( @@ -257,7 +269,9 @@ class LobbyController( _state.value = State(joining = true, message = "Looking for games…") for (transport in availableTransports()) { jobs += scope.launch { - transport.discover(scope).collect { hosts -> + transport.discover(scope).collect { found -> + // Score sheets are joined from the Scorekeeper, not from here. + val hosts = found.filter { it.purpose == HostPurpose.GAME } val others = _state.value.discovered.filter { it.kind != transport.kind } _state.value = _state.value.copy(discovered = others + hosts) } 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 109b314..08a919f 100644 --- a/app/src/main/java/org/prolibertate/games/net/Protocol.kt +++ b/app/src/main/java/org/prolibertate/games/net/Protocol.kt @@ -6,6 +6,8 @@ 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 +import org.prolibertate.games.score.ScoreSheet +import org.prolibertate.games.score.SheetOp /** * The wire protocol, shared by every transport. @@ -25,6 +27,10 @@ import org.prolibertate.games.game.engine.TableConfig */ const val PROTOCOL_VERSION = 2 +/** What a [Hello] is asking to join. Absent means a game, which is all there was before. */ +const val PURPOSE_GAME = "game" +const val PURPOSE_SCOREKEEPER = "scorekeeper" + /** Service type and name advertised over mDNS. */ const val SERVICE_TYPE = "_plgames._tcp" const val SERVICE_NAME = "ProLibertateGames" @@ -63,6 +69,13 @@ data class Hello( val peerId: String, val displayName: String, val protocol: Int = PROTOCOL_VERSION, + /** + * Whether this is somebody joining a game or a shared score sheet. Both + * listen the same way, so a phone pointed at the wrong sort of host by a typed + * address needs a way to be told so rather than waiting for a table that will + * never start. + */ + val purpose: String = PURPOSE_GAME, ) : NetMessage /** Host's reply, or a refusal if the lobby is full or the versions differ. */ @@ -150,3 +163,26 @@ data object Pong : NetMessage @Serializable @SerialName("bye") data object Bye : NetMessage + +/** + * The host's score sheet, sent to a guest on joining, whenever it changes, and + * whenever the guest asks with [Resync]. + * + * The whole sheet rather than the change: a sheet is a few hundred bytes, and a + * guest that has missed a message is put right by the next one instead of + * needing to be told what it missed. [acknowledged] is the last [SheetEdit] from + * *this* guest that the sheet already includes, which is how the guest knows + * which of its own changes it may stop holding on top of it. + */ +@Serializable +@SerialName("sheet") +data class SheetState( + val revision: Int, + val sheet: ScoreSheet, + val acknowledged: Int = 0, +) : NetMessage + +/** A guest's change to the shared sheet. The host applies it, then answers with [SheetState]. */ +@Serializable +@SerialName("sheet-edit") +data class SheetEdit(val seq: Int, val op: SheetOp) : NetMessage diff --git a/app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt b/app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt new file mode 100644 index 0000000..850485e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/ScorekeeperSession.kt @@ -0,0 +1,564 @@ +package org.prolibertate.games.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.prolibertate.games.score.ScoreSheet +import org.prolibertate.games.score.SheetOp +import org.prolibertate.games.score.SheetReplica +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean + +/** + * One score sheet shared between several devices, in either of two roles. + * + * As HOST this device owns the sheet. Everybody's changes — its own and its + * guests' — are applied to it here, in the order they arrive, and the result is + * sent back out. As GUEST it holds what the host last sent, with its own + * unacknowledged changes laid on top (see [SheetReplica]), and sends changes + * rather than sheets. + * + * A guest is not let in on its own say-so. Its [Hello] lands in [State.requests] + * and waits there until somebody on the host answers it with [allow] or [deny]; + * nothing the guest sends in the meantime is looked at. A device that has already + * been let in is let back in without asking again, because a phone that was + * locked for a minute is not a new person. + * + * All the state is changed synchronously, under one lock, and the messages that + * follow from it are queued and sent in order by one coroutine. The first half + * is what lets a keystroke show on screen the moment it is typed; the second is + * what stops a guest being sent a sheet before it has been sent its welcome. + */ +class ScorekeeperSession( + private val transport: Transport, + private val endpoint: StateFlow, + private val scope: CoroutineScope, + /** Identifies this device across reconnects, so a returning guest is recognised. */ + private val deviceId: String, +) { + + enum class Role { NONE, HOST, GUEST } + + /** A guest the host has let in. [online] is false while its link is down. */ + data class Guest(val id: String, val name: String, val online: Boolean) + + /** Somebody asking to be let in. */ + data class Request(val id: String, val name: String) + + data class State( + val role: Role = Role.NONE, + val hostName: String = "", + /** The sheet being shared: the host's own, or a guest's view of it. */ + val sheet: ScoreSheet = ScoreSheet(), + /** Guest: let in, and the link to the host is up. */ + val connected: Boolean = false, + /** Guest: asked to join and not yet answered. */ + val awaiting: Boolean = false, + /** Guest: turned away, or removed. Nothing will be retried. */ + val refused: Boolean = false, + val guests: List = emptyList(), + val requests: List = emptyList(), + val searching: Boolean = false, + val discovered: List = emptyList(), + /** Host: where this device is listening, for reading out. */ + val endpoint: HostEndpoint? = null, + val message: String? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private val lock = Any() + private val jobs = CopyOnWriteArrayList() + private var discoveryJob: Job? = null + + /** Everything that goes out, in order. See the class comment. */ + private val outbox = Channel Unit>(Channel.UNLIMITED) + + init { + scope.launch { + for (task in outbox) runCatching { task() } + } + } + + private fun enqueue(connection: Connection, message: NetMessage) { + outbox.trySend { connection.send(message) } + } + + private fun enqueueClose(connection: Connection) { + outbox.trySend { connection.close() } + } + + // ----------------------------------------------------------------------- + // Host + // ----------------------------------------------------------------------- + + /** A guest that has been let in. Its [connection] is null while the link is down. */ + private class HostLink( + val deviceId: String, + var name: String, + var connection: Connection?, + /** The last change from this guest, on this link, that has been applied. */ + var lastSeq: Int = 0, + ) + + private val admitted = linkedMapOf() + private val waiting = linkedMapOf>() + private var hostSheet = ScoreSheet() + private var revision = 0 + private var hostName = "" + + /** Starts sharing [sheet] with whoever is allowed to join. */ + fun startHosting(name: String, sheet: ScoreSheet) { + stop() + synchronized(lock) { + hostName = name + hostSheet = sheet + revision = 0 + _state.value = State( + role = Role.HOST, + hostName = name, + sheet = sheet, + endpoint = endpoint.value, + message = "Waiting for others to join…", + ) + } + jobs += scope.launch { + transport.host(name, scope, HostPurpose.SCOREKEEPER).collect { connection -> + listenToGuest(connection) + } + } + jobs += scope.launch { + endpoint.collect { where -> _state.update { it.copy(endpoint = where) } } + } + } + + private fun listenToGuest(connection: Connection) { + jobs += scope.launch { + connection.incoming.collect { message -> + synchronized(lock) { onHostMessage(connection, message) } + } + } + } + + private fun linkFor(connection: Connection): HostLink? = + admitted.values.firstOrNull { it.connection === connection } + + private fun onHostMessage(connection: Connection, message: NetMessage) { + if (_state.value.role != Role.HOST) return + when (message) { + is Hello -> onHello(connection, message) + + is SheetEdit -> { + val link = linkFor(connection) ?: return + // A change already applied is answered rather than applied twice. + if (message.seq > link.lastSeq) { + link.lastSeq = message.seq + val next = message.op.applyTo(hostSheet) + if (next != hostSheet) { + hostSheet = next + revision++ + } + } + broadcast() + } + + is Resync -> linkFor(connection)?.let { sendSheet(it) } + + is Bye -> { + val link = linkFor(connection) + if (link != null) { + link.connection = null + publishHost("${link.name} lost their connection.") + } else { + val gone = waiting.filterValues { it.second === connection }.keys + if (gone.isNotEmpty()) { + gone.toList().forEach { waiting.remove(it) } + publishHost(null) + } + } + } + + else -> Unit + } + } + + private fun onHello(connection: Connection, hello: Hello) { + val refusal = when { + hello.purpose != PURPOSE_SCOREKEEPER -> "That is a score sheet, not a game." + hello.protocol != PROTOCOL_VERSION -> "Different app version — update both devices." + else -> null + } + if (refusal != null) { + enqueue(connection, Welcome(hostName, "", accepted = false, reason = refusal)) + enqueueClose(connection) + return + } + + val known = admitted[hello.peerId] + if (known != null) { + // Somebody already let in, back on a new link — or saying hello a + // second time on the one it has, which is what a guest does until it + // hears something. Either way it is put straight back. + val stale = known.connection + if (stale != null && stale !== connection) enqueueClose(stale) + if (known.connection !== connection) known.lastSeq = 0 + known.connection = connection + known.name = hello.displayName + enqueue(connection, Welcome(hostName, "", accepted = true)) + sendSheet(known) + publishHost("${known.name} is back.") + return + } + + if (admitted.size >= MAX_GUESTS) { + enqueue(connection, Welcome(hostName, "", accepted = false, reason = "The sheet is full.")) + enqueueClose(connection) + return + } + + val earlier = waiting[hello.peerId]?.second + if (earlier != null && earlier !== connection) enqueueClose(earlier) + waiting[hello.peerId] = hello.displayName to connection + publishHost("${hello.displayName} wants to join.") + } + + /** Host: lets a waiting guest in. */ + fun allow(requestId: String) = synchronized(lock) { + val (name, connection) = waiting.remove(requestId) ?: return@synchronized + val link = HostLink(requestId, name, connection) + admitted[requestId] = link + enqueue(connection, Welcome(hostName, "", accepted = true)) + sendSheet(link) + publishHost("$name joined.") + } + + /** Host: turns a waiting guest away. */ + fun deny(requestId: String) = synchronized(lock) { + val (name, connection) = waiting.remove(requestId) ?: return@synchronized + enqueue(connection, Welcome(hostName, "", accepted = false, reason = "The host declined.")) + enqueueClose(connection) + publishHost("Turned $name away.") + } + + /** Host: takes a guest who was let in back out. They are asked again if they return. */ + fun remove(guestId: String) = synchronized(lock) { + val link = admitted.remove(guestId) ?: return@synchronized + link.connection?.let { + enqueue(it, Welcome(hostName, "", accepted = false, reason = "The host removed you.")) + enqueueClose(it) + } + publishHost("${link.name} was removed.") + } + + private fun sendSheet(link: HostLink) { + link.connection?.let { enqueue(it, SheetState(revision, hostSheet, link.lastSeq)) } + } + + private fun broadcast() { + admitted.values.forEach { sendSheet(it) } + publishHost(_state.value.message) + } + + private fun publishHost(message: String?) { + _state.update { + it.copy( + sheet = hostSheet, + guests = admitted.values.map { g -> Guest(g.deviceId, g.name, g.connection != null) }, + requests = waiting.map { (id, entry) -> Request(id, entry.first) }, + message = message, + ) + } + } + + // ----------------------------------------------------------------------- + // Guest + // ----------------------------------------------------------------------- + + private val replica = SheetReplica() + private var link: Connection? = null + private var linkJob: Job? = null + private var joined: DiscoveredHost? = null + private var joinedName = "" + @Volatile + private var heardBack = false + private var refused = false + private val reconnecting = AtomicBoolean(false) + + /** Starts looking for sheets being shared nearby. */ + fun startDiscovery() { + stop() + _state.value = State(searching = true, message = "Looking for shared sheets…") + discoveryJob = scope.launch { + transport.discover(scope).collect { found -> + val sheets = found.filter { it.purpose == HostPurpose.SCOREKEEPER } + _state.update { it.copy(discovered = sheets) } + } + } + } + + /** Joins a host at an address somebody typed in, as [LobbyController.joinAt] does. */ + fun joinAt(typed: String, name: String) { + val parsed = parseManualAddress(typed) + if (parsed == null) { + _state.update { it.copy(message = "That doesn't look like an address. Try 192.168.43.1") } + return + } + join( + DiscoveredHost( + id = "lan:${parsed.address}:${parsed.port}", + name = parsed.address, + kind = TransportKind.LAN, + address = parsed.address, + port = parsed.port, + purpose = HostPurpose.SCOREKEEPER, + ), + name, + ) + } + + fun join(host: DiscoveredHost, name: String) { + discoveryJob?.cancel() + discoveryJob = null + synchronized(lock) { + joined = host + joinedName = name + heardBack = false + refused = false + replica.reset() + _state.value = State( + role = Role.GUEST, + hostName = host.name, + awaiting = true, + message = "Connecting to ${host.name}…", + ) + } + jobs += scope.launch { + val connection = dial(host) + if (connection == null) { + // Back to the list, with the reason on it. + val why = _state.value.message + startDiscovery() + _state.update { it.copy(message = why) } + return@launch + } + attach(connection) + } + } + + private suspend fun dial(host: DiscoveredHost): Connection? = + runCatching { transport.join(host, scope) }.getOrElse { error -> + _state.update { it.copy(message = "Couldn't connect: ${error.message}") } + null + } + + /** + * Starts reading [connection] as the way to the host, and introduces this + * device on it. + * + * The hello is sent from inside the subscription, so it cannot leave before + * anything is listening for the answer. It is then repeated until the host + * has said something, because a host that has only just accepted the link + * may not be listening yet either, and a hello nobody heard is a guest + * waiting for an answer to a question that was never asked. + */ + private fun attach(connection: Connection) { + synchronized(lock) { + linkJob?.cancel() + link?.let { old -> if (old !== connection) enqueueClose(old) } + link = connection + heardBack = false + replica.reset() + } + val hello = Hello(peerId = deviceId, displayName = joinedName, purpose = PURPOSE_SCOREKEEPER) + linkJob = scope.launch { + launch { + var tries = 0 + while (isActive && !heardBack && connection.isOpen && tries++ < HELLO_TRIES) { + delay(HELLO_RETRY_MILLIS) + if (!heardBack && connection.isOpen) connection.send(hello) + } + } + connection.incoming + .onSubscription { connection.send(hello) } + .collect { message -> synchronized(lock) { onGuestMessage(connection, message) } } + } + } + + private fun onGuestMessage(connection: Connection, message: NetMessage) { + if (connection !== link || _state.value.role != Role.GUEST) return + when (message) { + is Welcome -> { + heardBack = true + if (message.accepted) { + _state.update { + it.copy( + hostName = message.hostName.ifBlank { it.hostName }, + awaiting = false, + connected = true, + message = "Joined ${message.hostName.ifBlank { it.hostName }}.", + ) + } + } else { + refused = true + enqueueClose(connection) + _state.update { + it.copy( + awaiting = false, + connected = false, + refused = true, + message = message.reason ?: "The host declined.", + ) + } + } + } + + is SheetState -> { + if (refused) return + heardBack = true + replica.receive(message.sheet, message.revision, message.acknowledged) + _state.update { it.copy(sheet = replica.view) } + } + + is Bye -> { + // A refusal closes the link too, and the Bye that follows it + // must not turn into an attempt to get back in. + if (refused) return + _state.update { + it.copy(connected = false, message = "Lost the connection to the host.") + } + reconnectLater() + } + + else -> Unit + } + } + + /** + * Guest: asks the host for the sheet as it stands, or gets back to the host + * if the link has gone. + * + * Always safe, and cheap when nothing is wrong — a live link is sent one + * [Resync] and answered with the sheet it already has. + */ + fun sync() { + val live = synchronized(lock) { + if (_state.value.role != Role.GUEST || refused) return + link + } + if (live != null && live.isOpen) { + enqueue(live, Resync) + } else { + reconnectLater() + } + } + + private fun reconnectLater() { + jobs += scope.launch { reconnect() } + } + + private suspend fun reconnect() { + val host = synchronized(lock) { if (refused) null else joined } ?: return + if (!reconnecting.compareAndSet(false, true)) return + try { + repeat(RECONNECT_ATTEMPTS) { attempt -> + if (attempt > 0) delay(RECONNECT_BACKOFF_MILLIS * attempt) + if (synchronized(lock) { refused || _state.value.role != Role.GUEST }) return + _state.update { it.copy(message = "Reconnecting to ${host.name}…") } + val connection = dial(host) + if (connection != null) { + attach(connection) + return + } + } + _state.update { it.copy(message = "Couldn't get back to ${host.name}.") } + } finally { + reconnecting.set(false) + } + } + + // ----------------------------------------------------------------------- + // Either role + // ----------------------------------------------------------------------- + + /** + * Makes a change to the sheet being shared. + * + * Applied here before it returns, so the screen draws it at once. As host it + * is then sent to everyone; as guest it is sent to the host, which may + * disagree — the sheet it sends back is the answer. + */ + fun edit(op: SheetOp) { + synchronized(lock) { + when (_state.value.role) { + Role.HOST -> { + val next = op.applyTo(hostSheet) + if (next != hostSheet) { + hostSheet = next + revision++ + broadcast() + } + } + + Role.GUEST -> { + val live = link + if (!_state.value.connected || live == null || !live.isOpen) { + _state.update { + it.copy(message = "Not connected to the host. Tap Sync to reconnect.") + } + return + } + val seq = replica.local(op) + _state.update { it.copy(sheet = replica.view) } + enqueue(live, SheetEdit(seq, op)) + } + + Role.NONE -> Unit + } + } + } + + /** Stops hosting, joining or looking, and lets go of every link. */ + fun stop() { + discoveryJob?.cancel() + discoveryJob = null + jobs.forEach { it.cancel() } + jobs.clear() + linkJob?.cancel() + linkJob = null + val open = synchronized(lock) { + val all = admitted.values.mapNotNull { it.connection } + + waiting.values.map { it.second } + listOfNotNull(link) + admitted.clear() + waiting.clear() + link = null + joined = null + refused = false + heardBack = false + replica.reset() + hostSheet = ScoreSheet() + revision = 0 + all + } + open.forEach { runCatching { it.close() } } + runCatching { transport.stop() } + _state.value = State() + } + + private companion object { + /** A table of friends, not a room. */ + const val MAX_GUESTS = 8 + const val HELLO_RETRY_MILLIS = 2_000L + const val HELLO_TRIES = 15 + const val RECONNECT_ATTEMPTS = 4 + const val RECONNECT_BACKOFF_MILLIS = 750L + } +} 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 bca97ab..1210fce 100644 --- a/app/src/main/java/org/prolibertate/games/net/Transport.kt +++ b/app/src/main/java/org/prolibertate/games/net/Transport.kt @@ -24,6 +24,9 @@ enum class TransportKind(val label: String) { LAN("Wi-Fi"), } +/** What a host is hosting, so a phone looking for one sort is not shown the other. */ +enum class HostPurpose { GAME, SCOREKEEPER } + /** * A host someone could join, found by one of the transports. [address] is * transport-specific: an IP for LAN. @@ -34,6 +37,7 @@ data class DiscoveredHost( val kind: TransportKind, val address: String, val port: Int = 0, + val purpose: HostPurpose = HostPurpose.GAME, ) /** @@ -292,7 +296,11 @@ interface Transport { fun isAvailable(): Boolean /** Starts advertising and emits each client that connects. */ - fun host(displayName: String, scope: CoroutineScope): Flow + fun host( + displayName: String, + scope: CoroutineScope, + purpose: HostPurpose = HostPurpose.GAME, + ): Flow /** Emits the current list of visible hosts as it changes. */ fun discover(scope: CoroutineScope): Flow> diff --git a/app/src/main/java/org/prolibertate/games/score/SheetOp.kt b/app/src/main/java/org/prolibertate/games/score/SheetOp.kt new file mode 100644 index 0000000..d746417 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/SheetOp.kt @@ -0,0 +1,89 @@ +package org.prolibertate.games.score + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One change to a score sheet, as something that can be sent. + * + * A shared sheet cannot work by guests posting whole sheets at the host: two + * people entering a round at the same moment would each send a sheet missing the + * other's, and one of the rounds would quietly vanish. Sending what was *done* + * instead means the host can apply everybody's changes in turn, and two rounds + * entered together are simply two rounds. + * + * Players are named by id wherever they are named at all, for the same reason + * [ScorePlayer.id] exists: a position is only right until somebody else moves + * a column. Rounds have no id, so [EditRound] and [DeleteRound] go by index; if + * two people delete rounds in the same instant the second can land on the wrong + * one. Both of them are in the room looking at the same table, and the sheet + * shows what happened. + * + * Applying an op to a sheet it no longer makes sense for — editing a round that + * has since been deleted, renaming a player who has been removed — does nothing, + * as the sheet's own methods already do. + */ +@Serializable +sealed interface SheetOp { + fun applyTo(sheet: ScoreSheet): ScoreSheet +} + +/** Sets a fresh sheet up for [count] players. */ +@Serializable +@SerialName("start") +data class StartSheet(val count: Int) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet.of(count) +} + +/** Rubs everything out. */ +@Serializable +@SerialName("clear") +data object ClearSheet : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = ScoreSheet() +} + +@Serializable +@SerialName("add-player") +data object AddPlayer : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withPlayerAdded() +} + +@Serializable +@SerialName("remove-player") +data class RemovePlayer(val playerId: Int) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withPlayerRemoved(playerId) +} + +@Serializable +@SerialName("rename") +data class RenamePlayer(val playerId: Int, val name: String) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.renamed(playerId, name) +} + +/** Moves a player's column so that it sits at position [to]. */ +@Serializable +@SerialName("move-player") +data class MovePlayer(val playerId: Int, val to: Int) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet { + val from = sheet.players.indexOfFirst { it.id == playerId } + return if (from < 0) sheet else sheet.moved(from, to) + } +} + +@Serializable +@SerialName("add-round") +data class AddRound(val deltas: Map) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withRound(deltas) +} + +@Serializable +@SerialName("edit-round") +data class EditRound(val index: Int, val deltas: Map) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withRoundAt(index, deltas) +} + +@Serializable +@SerialName("delete-round") +data class DeleteRound(val index: Int) : SheetOp { + override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withoutRound(index) +} diff --git a/app/src/main/java/org/prolibertate/games/score/SheetReplica.kt b/app/src/main/java/org/prolibertate/games/score/SheetReplica.kt new file mode 100644 index 0000000..e9a1e9c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/score/SheetReplica.kt @@ -0,0 +1,71 @@ +package org.prolibertate.games.score + +/** + * A guest's copy of somebody else's score sheet. + * + * The host's sheet is the truth. What a guest *shows* is that sheet with the + * guest's own not-yet-acknowledged changes laid on top, so a number typed into a + * cell appears at once instead of after a round trip — and, more to the point, + * so that a sheet pushed by the host a moment before the guest's change has + * landed does not wipe out what the guest has just done. Each change is tagged + * with a sequence number; the host reports the last one it has applied, and + * everything up to it is dropped from the pile because it is by then part of the + * sheet the host sent. + * + * Not thread-safe. The session that owns one serialises access to it. + */ +class SheetReplica { + + private var confirmed = ScoreSheet() + private var nextSeq = 1 + private val pending = ArrayDeque>() + + /** The revision of the host's sheet this was last brought up to. */ + var revision: Int = 0 + private set + + /** What to draw: the host's sheet plus this guest's own unacknowledged changes. */ + var view: ScoreSheet = confirmed + private set + + /** How many of this guest's changes the host has not yet confirmed. */ + val unconfirmed: Int get() = pending.size + + /** Records a change made here, and returns the sequence number to send it under. */ + fun local(op: SheetOp): Int { + val seq = nextSeq++ + pending.addLast(seq to op) + view = op.applyTo(view) + return seq + } + + /** + * Takes the host's sheet at [revision], which includes every change of this + * guest's up to and including [acknowledged]. + * + * A sheet older than the one already held is ignored; links deliver in + * order, but a resync request and a broadcast can cross. + */ + fun receive(sheet: ScoreSheet, revision: Int, acknowledged: Int) { + if (revision < this.revision) return + this.revision = revision + confirmed = sheet + while (pending.isNotEmpty() && pending.first().first <= acknowledged) pending.removeFirst() + view = pending.fold(confirmed) { acc, (_, op) -> op.applyTo(acc) } + } + + /** + * Forgets everything, for a fresh link to the host. + * + * Changes still waiting on the old link are gone with it: nothing says + * whether the host got them, and replaying them could apply them twice. The + * sheet the host sends next is the answer either way. + */ + fun reset() { + confirmed = ScoreSheet() + view = confirmed + pending.clear() + revision = 0 + nextSeq = 1 + } +} 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 98bf6af..fd5c69b 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -43,6 +43,7 @@ import org.prolibertate.games.game.GameMenu import org.prolibertate.games.game.GameDescriptor import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.net.ScorekeeperSession import org.prolibertate.games.score.ScorekeeperRepository import org.prolibertate.games.settings.Settings import org.prolibertate.games.settings.SettingsRepository @@ -56,6 +57,8 @@ class AppEnv( val updater: Updater, val lobby: LobbyController, val scorekeeper: ScorekeeperRepository, + /** The shared score sheet, for the Scorekeeper to host or join. */ + val scoreSession: ScorekeeperSession, val peerId: String, ) @@ -186,6 +189,8 @@ private fun AppContent( is Route.Scorekeeper -> ScorekeeperScreen( repository = env.scorekeeper, + session = env.scoreSession, + playerName = settings.displayName, onBack = { pop() }, ) diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt index e9e276f..e98873f 100644 --- a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperScreen.kt @@ -1,5 +1,6 @@ package org.prolibertate.games.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -30,7 +31,9 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -47,6 +50,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -54,11 +58,25 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.prolibertate.games.net.ScorekeeperSession +import org.prolibertate.games.net.ScorekeeperSession.Role +import org.prolibertate.games.score.AddPlayer +import org.prolibertate.games.score.AddRound +import org.prolibertate.games.score.ClearSheet +import org.prolibertate.games.score.DeleteRound +import org.prolibertate.games.score.EditRound +import org.prolibertate.games.score.MovePlayer +import org.prolibertate.games.score.RemovePlayer +import org.prolibertate.games.score.RenamePlayer import org.prolibertate.games.score.ScorePlayer import org.prolibertate.games.score.ScoreSheet import org.prolibertate.games.score.ScorekeeperRepository +import org.prolibertate.games.score.SheetOp +import org.prolibertate.games.score.StartSheet import kotlin.math.roundToInt /** @@ -68,10 +86,18 @@ import kotlin.math.roundToInt * * The bottom row is always the one being filled in. Finish it and it is written * down, and a fresh empty row opens above the totals for the next round. + * + * The sheet can be shared. Sharing makes this device the host of a + * [ScorekeeperSession]; joining makes it a guest of somebody else's. Either way + * every change is a [SheetOp] — applied here when the sheet is only this + * device's, and handed to the session when it is not — so the grid below does + * not need to know which of the three it is drawing. */ @Composable fun ScorekeeperScreen( repository: ScorekeeperRepository, + session: ScorekeeperSession, + playerName: String, onBack: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -80,36 +106,121 @@ fun ScorekeeperScreen( // of storage on every change would put a DataStore round trip between a // keystroke and the digit appearing, which is how a field loses characters. var loaded by remember { mutableStateOf(false) } - var sheet by remember { mutableStateOf(ScoreSheet()) } + var local by remember { mutableStateOf(ScoreSheet()) } LaunchedEffect(Unit) { - sheet = repository.sheet.first() + local = repository.sheet.first() loaded = true } - val update: (ScoreSheet) -> Unit = { next -> - sheet = next - scope.launch { repository.save(next) } - } - + val shared by session.state.collectAsState() + val role = shared.role + var joining by remember { mutableStateOf(false) } + var showShare by remember { mutableStateOf(false) } var confirmNewGame by remember { mutableStateOf(false) } + // A session lives exactly as long as this screen does: leaving it ends the + // sharing, and with it the links. + DisposableEffect(Unit) { onDispose { session.stop() } } + + // What the host shares is the host's own sheet, so it is the one kept. A + // phone killed mid-game comes back to the sheet as the guests last saw it. + LaunchedEffect(shared.sheet, role) { + if (role == Role.HOST) repository.save(shared.sheet) + } + + // Coming back to the screen after the phone was put down. A guest has missed + // whatever the host did in the meantime, and may have lost its link while the + // radio slept, so it asks again — the same thing a game table does. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) session.sync() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val sheet = if (role == Role.NONE) local else shared.sheet + + val change: (SheetOp) -> Unit = { op -> + if (role == Role.NONE) { + val next = op.applyTo(local) + local = next + scope.launch { repository.save(next) } + } else { + session.edit(op) + } + } + + // Stops whatever is going on with the network and goes back to this + // device's own sheet — which, for a host, is the one it has been keeping. + val leaveSharing: () -> Unit = { + if (role == Role.HOST) local = shared.sheet + session.stop() + joining = false + showShare = false + } + + BackHandler(enabled = role == Role.GUEST || joining) { leaveSharing() } + ScreenScaffold( - title = "Scorekeeper", - onBack = onBack, + title = when (role) { + Role.GUEST -> "Scorekeeper · ${shared.hostName}" + Role.HOST -> "Scorekeeper · sharing" + Role.NONE -> "Scorekeeper" + }, + onBack = { if (role == Role.GUEST || joining) leaveSharing() else onBack() }, actions = { - if (sheet.started) { - TextButton(onClick = { confirmNewGame = true }) { Text("New game") } + when (role) { + Role.NONE -> if (sheet.started && !joining) { + TextButton(onClick = { + session.startHosting(playerName, local) + showShare = true + }) { Text("Share") } + TextButton(onClick = { confirmNewGame = true }) { Text("New game") } + } + + Role.HOST -> { + TextButton(onClick = { showShare = true }) { + Text(if (shared.guests.isEmpty()) "Sharing" else "Sharing (${shared.guests.size})") + } + if (sheet.started) { + TextButton(onClick = { confirmNewGame = true }) { Text("New game") } + } + } + + // Rubbing out the sheet is the host's to do; a guest can leave + // and nothing more drastic. + Role.GUEST -> { + TextButton(onClick = { session.sync() }) { Text("Sync") } + TextButton(onClick = leaveSharing) { Text("Leave") } + } } }, ) { modifier -> // Nothing is drawn until the stored sheet has been read, so a game in // progress does not flash the "how many are playing" pane first. - if (!loaded) { - Box(modifier = modifier) - } else if (!sheet.started) { - SetupPane(modifier = modifier) { count -> update(ScoreSheet.of(count)) } - } else { - Sheet(modifier = modifier, sheet = sheet, onChange = update) + when { + !loaded -> Box(modifier = modifier) + + role == Role.GUEST && (shared.awaiting || shared.refused || !sheet.started) -> + WaitingPane(modifier = modifier, shared = shared, onLeave = leaveSharing) + + role == Role.GUEST -> Column(modifier = modifier) { + ConnectionBanner(shared = shared, onSync = { session.sync() }) + Sheet(modifier = Modifier.weight(1f), sheet = sheet, onOp = change) + } + + joining && role == Role.NONE -> + JoinPane(modifier = modifier, session = session, shared = shared, playerName = playerName) + + !sheet.started -> SetupPane( + modifier = modifier, + onStart = { count -> change(StartSheet(count)) }, + onJoin = { joining = true }, + ) + + else -> Sheet(modifier = modifier, sheet = sheet, onOp = change) } } @@ -117,11 +228,20 @@ fun ScorekeeperScreen( AlertDialog( onDismissRequest = { confirmNewGame = false }, title = { Text("Start a new game?") }, - text = { Text("The players and every round scored so far are rubbed out.") }, + text = { + Text( + if (role == Role.HOST) { + "The players and every round scored so far are rubbed out, " + + "for everyone on the sheet." + } else { + "The players and every round scored so far are rubbed out." + } + ) + }, confirmButton = { TextButton(onClick = { confirmNewGame = false - update(ScoreSheet()) + change(ClearSheet) }) { Text("Rub it out") } }, dismissButton = { @@ -129,6 +249,25 @@ fun ScorekeeperScreen( }, ) } + + // Asked over whatever else is on screen, one at a time. + if (role == Role.HOST) { + shared.requests.firstOrNull()?.let { request -> + JoinRequestDialog( + request = request, + onAllow = { session.allow(request.id) }, + onDeny = { session.deny(request.id) }, + ) + } + if (showShare) { + ShareDialog( + shared = shared, + onRemove = { session.remove(it) }, + onStop = leaveSharing, + onClose = { showShare = false }, + ) + } + } } // --------------------------------------------------------------------------- @@ -137,7 +276,7 @@ fun ScorekeeperScreen( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { +private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit, onJoin: () -> Unit) { var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) } Column( @@ -170,6 +309,9 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { style = MaterialTheme.typography.bodySmall, ) PrimaryAction(text = "Start scoring") { onStart(count) } + OutlinedButton(onClick = onJoin, modifier = Modifier.fillMaxWidth()) { + Text("Join a shared sheet nearby") + } } } @@ -178,7 +320,7 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) { // --------------------------------------------------------------------------- @Composable -private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit) { +private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onOp: (SheetOp) -> Unit) { // One scroll state shared by the head, the rows and the totals, so all three // move together: a column and the name over it must never come apart. Every // row is built the same way and is therefore exactly the same width, which @@ -213,7 +355,16 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) // Keep the row being filled in in view when a round is written down. LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) } - val drag = rememberColumnDrag(sheet = sheet, onChange = onChange) + // On a shared sheet somebody else can delete the round being corrected from + // under it. There is then nothing to correct, and the row it was in is gone. + LaunchedEffect(sheet.rounds.size) { + if (correcting >= sheet.rounds.size) { + correcting = -1 + correction.clear() + } + } + + val drag = rememberColumnDrag(sheet = sheet, onOp = onOp) Column(modifier = modifier) { Text( @@ -234,7 +385,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) trailing = { AddColumnCell( enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS, - onClick = { onChange(sheet.withPlayerAdded()) }, + onClick = { onOp(AddPlayer) }, ) }, ) { index, player -> @@ -360,18 +511,18 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) onFlip = { editing[lastTyped] = flipSign(editing[lastTyped].orEmpty()) }, onFinish = { if (correcting >= 0) { - onChange(sheet.withRoundAt(correcting, amountsAsPoints(correction))) + onOp(EditRound(correcting, amountsAsPoints(correction))) correcting = -1 correction.clear() } else { - onChange(sheet.withRound(amountsAsPoints(pending))) + onOp(AddRound(amountsAsPoints(pending))) pending.clear() } lastTyped = -1 focusPlayer = -1 }, onDelete = { - onChange(sheet.withoutRound(correcting)) + onOp(DeleteRound(correcting)) correcting = -1 correction.clear() lastTyped = -1 @@ -391,9 +542,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) RenameDialog( sheet = sheet, player = renamed, - onName = { onChange(sheet.renamed(renamed.id, it)) }, + onName = { onOp(RenamePlayer(renamed.id, it)) }, onRemove = { - onChange(sheet.withPlayerRemoved(renamed.id)) + onOp(RemovePlayer(renamed.id)) renaming = -1 }, onDismiss = { renaming = -1 }, @@ -417,17 +568,17 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet) * since. */ @Composable -private fun rememberColumnDrag(sheet: ScoreSheet, onChange: (ScoreSheet) -> Unit): ColumnDrag { +private fun rememberColumnDrag(sheet: ScoreSheet, onOp: (SheetOp) -> Unit): ColumnDrag { val widthPx = with(LocalDensity.current) { PLAYER_COLUMN.toPx() } val currentSheet by rememberUpdatedState(sheet) - val commit by rememberUpdatedState(onChange) + val commit by rememberUpdatedState(onOp) return remember { ColumnDrag(widthPx, { currentSheet }, { commit(it) }) } } private class ColumnDrag( private val widthPx: Float, private val sheet: () -> ScoreSheet, - private val commit: (ScoreSheet) -> Unit, + private val commit: (SheetOp) -> Unit, ) { /** Which column is under the finger, or -1 when nothing is being dragged. */ var index by mutableStateOf(-1) @@ -454,7 +605,8 @@ private class ColumnDrag( val to = landing() index = -1 offset = 0f - if (from >= 0 && to >= 0) commit(sheet().moved(from, to)) + val moved = sheet().players.getOrNull(from) + if (moved != null && to >= 0 && to != from) commit(MovePlayer(moved.id, to)) } fun cancel() { diff --git a/app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt new file mode 100644 index 0000000..85acf21 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/ScorekeeperShareUi.kt @@ -0,0 +1,239 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.prolibertate.games.net.ScorekeeperSession + +/* + * The parts of the Scorekeeper that only exist while a sheet is being shared: + * finding one to join, waiting to be let in, and — on the host — letting people + * in and seeing who is there. + */ + +/** Guest: the sheets nearby, and a way in by address when they cannot be found. */ +@Composable +internal fun JoinPane( + modifier: Modifier, + session: ScorekeeperSession, + shared: ScorekeeperSession.State, + playerName: String, +) { + LaunchedEffect(Unit) { session.startDiscovery() } + + var typed by remember { mutableStateOf("") } + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Join a sheet somebody else is keeping. The host is asked to let " + + "you in, and once you are, you see the same sheet they do.", + style = MaterialTheme.typography.bodyMedium, + ) + shared.message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + + if (shared.discovered.isEmpty()) { + Text("No shared sheets found yet. Make sure the host has tapped Share.") + } else { + shared.discovered.forEach { host -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { session.join(host, playerName) }, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(host.name, fontWeight = FontWeight.Bold) + Text( + text = "over ${host.kind.label}", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + Divider() + + // Always offered, for the same reason the game lobby always offers it: + // over a phone's hotspot nothing is found, and a list that only says + // "no sheets" leaves nowhere to go. + Text("Join by address", fontWeight = FontWeight.Bold) + Text( + text = "The host's screen shows where it is, under Sharing.", + style = MaterialTheme.typography.bodySmall, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = typed, + onValueChange = { typed = it }, + singleLine = true, + label = { Text("192.168.43.1") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.weight(1f), + ) + Button( + onClick = { session.joinAt(typed, playerName) }, + enabled = typed.isNotBlank(), + ) { Text("Connect") } + } + } +} + +/** Guest: asked to join, and nobody has answered yet — or somebody has said no. */ +@Composable +internal fun WaitingPane( + modifier: Modifier, + shared: ScorekeeperSession.State, + onLeave: () -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = when { + shared.refused -> shared.message ?: "The host declined." + shared.awaiting -> "Waiting for ${shared.hostName} to let you in…" + else -> "Waiting for ${shared.hostName} to set up the sheet…" + }, + style = MaterialTheme.typography.titleMedium, + ) + if (shared.awaiting) { + Text( + text = "They will see a request with your name on it.", + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedButton(onClick = onLeave) { Text(if (shared.refused) "Back" else "Cancel") } + } +} + +/** Guest: the link is down, or was never up. Shown over a sheet that can still be read. */ +@Composable +internal fun ConnectionBanner(shared: ScorekeeperSession.State, onSync: () -> Unit) { + if (shared.connected) return + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = shared.message ?: "Not connected to the host.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onSync) { Text("Reconnect") } + } +} + +/** + * Host: somebody is asking to join. + * + * Not dismissible by tapping outside it. A request that goes away on a stray + * touch is a guest told nothing, and a request answered by accident is a stranger + * with a pencil; either way it should take a button. + */ +@Composable +internal fun JoinRequestDialog( + request: ScorekeeperSession.Request, + onAllow: () -> Unit, + onDeny: () -> Unit, +) { + AlertDialog( + onDismissRequest = {}, + title = { Text("${request.name} wants to join") }, + text = { Text("Let them see this sheet and add scores to it?") }, + confirmButton = { TextButton(onClick = onAllow) { Text("Allow") } }, + dismissButton = { TextButton(onClick = onDeny) { Text("Deny") } }, + ) +} + +/** Host: where this phone is, who is on the sheet, and the way to stop sharing it. */ +@Composable +internal fun ShareDialog( + shared: ScorekeeperSession.State, + onRemove: (String) -> Unit, + onStop: () -> Unit, + onClose: () -> Unit, +) { + AlertDialog( + onDismissRequest = onClose, + title = { Text("Sharing this sheet") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Others nearby can find it under Scorekeeper → Join a shared sheet.", + style = MaterialTheme.typography.bodySmall, + ) + shared.endpoint?.primary?.let { where -> + Text("This phone is at", style = MaterialTheme.typography.labelSmall) + Text(where, fontWeight = FontWeight.Bold) + Text( + text = "If the other phone doesn't find it on its own — which happens " + + "over a phone's hotspot — type that into its Join screen.", + style = MaterialTheme.typography.bodySmall, + ) + } + Divider() + Text("On the sheet", fontWeight = FontWeight.Bold) + if (shared.guests.isEmpty()) { + Text("Nobody else yet.", style = MaterialTheme.typography.bodySmall) + } + shared.guests.forEach { guest -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(guest.name) + Text( + text = if (guest.online) "Connected" else "Connection lost", + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton(onClick = { onRemove(guest.id) }) { Text("Remove") } + } + } + } + }, + confirmButton = { TextButton(onClick = onClose) { Text("Done") } }, + dismissButton = { TextButton(onClick = onStop) { Text("Stop sharing") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt b/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt new file mode 100644 index 0000000..0e8c65e --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/ScorekeeperSessionTest.kt @@ -0,0 +1,358 @@ +package org.prolibertate.games.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.prolibertate.games.net.ScorekeeperSession.Role +import org.prolibertate.games.score.AddRound +import org.prolibertate.games.score.RenamePlayer +import org.prolibertate.games.score.ScoreSheet +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.atomic.AtomicInteger + +/** + * A shared score sheet between real sessions, over real sockets on loopback. + * + * The sheet logic is covered on its own; what is worth testing here is the part + * that only exists with a link in it — being asked before being let in, being + * let back in without asking, and everybody ending up looking at one sheet. + */ +class ScorekeeperSessionTest { + + private val scopes = mutableListOf() + private val sessions = mutableListOf() + private val transports = mutableListOf() + + @After + fun tearDown() { + sessions.forEach { runCatching { it.stop() } } + transports.forEach { runCatching { it.stop() } } + scopes.forEach { it.cancel() } + } + + /** TCP on loopback, with the discovery a test does not need left out. */ + private class LoopbackTransport : Transport { + override val kind = TransportKind.LAN + + @Volatile + var server: ServerSocket? = null + val port: Int get() = server!!.localPort + + override fun isAvailable() = true + + override fun host( + displayName: String, + scope: CoroutineScope, + purpose: HostPurpose, + ): Flow = callbackFlow { + val listening = ServerSocket(0) + server = listening + val accepting = scope.launch(Dispatchers.IO) { + while (isActive && !listening.isClosed) { + val socket = runCatching { listening.accept() }.getOrNull() ?: break + trySend( + StreamConnection( + peerId = "guest:${socket.port}", + kind = TransportKind.LAN, + input = socket.getInputStream(), + output = socket.getOutputStream(), + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + ) + } + } + awaitClose { + accepting.cancel() + runCatching { listening.close() } + } + } + + override fun discover(scope: CoroutineScope): Flow> = flowOf(emptyList()) + + override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection { + val socket = Socket("127.0.0.1", host.port) + return StreamConnection( + peerId = host.id, + kind = TransportKind.LAN, + input = socket.getInputStream(), + output = socket.getOutputStream(), + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + } + + override fun stop() { + runCatching { server?.close() } + } + } + + private fun newSession(deviceId: String): Pair { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes += it } + val transport = LoopbackTransport().also { transports += it } + val session = ScorekeeperSession(transport, MutableStateFlow(null), scope, deviceId) + .also { sessions += it } + return session to transport + } + + private class Host(val session: ScorekeeperSession, val transport: LoopbackTransport) { + val state get() = session.state.value + } + + private fun startHost(sheet: ScoreSheet = ScoreSheet.of(2)): Host { + val (session, transport) = newSession("host-device") + session.startHosting("Hosty", sheet) + await("the host to be listening") { transport.server != null } + return Host(session, transport) + } + + private fun joinOf(host: Host) = DiscoveredHost( + id = "lan:127.0.0.1:${host.transport.port}", + name = "Hosty", + kind = TransportKind.LAN, + address = "127.0.0.1", + port = host.transport.port, + purpose = HostPurpose.SCOREKEEPER, + ) + + private fun guestOf(host: Host, deviceId: String, name: String): ScorekeeperSession { + val (session, _) = newSession(deviceId) + session.join(joinOf(host), name) + return session + } + + /** A guest that has asked, been let in, and is looking at the host's sheet. */ + private fun admittedGuest(host: Host, deviceId: String, name: String): ScorekeeperSession { + val guest = guestOf(host, deviceId, name) + await("$name to be asking") { host.state.requests.any { it.id == deviceId } } + host.session.allow(deviceId) + await("$name to be let in") { guest.state.value.connected } + return guest + } + + @Test + fun `a guest is asked about, and sees nothing, until the host allows it`() { + val host = startHost() + val guest = guestOf(host, "g1", "Gwen") + + await("the request to reach the host") { host.state.requests.isNotEmpty() } + assertEquals("Gwen", host.state.requests.single().name) + assertTrue(guest.state.value.awaiting) + assertFalse(guest.state.value.connected) + assertFalse(guest.state.value.sheet.started) + assertTrue(host.state.guests.isEmpty()) + + host.session.allow("g1") + + await("the guest to be let in") { guest.state.value.connected } + assertFalse(guest.state.value.awaiting) + assertEquals(host.state.sheet, guest.state.value.sheet) + assertTrue(guest.state.value.sheet.started) + assertTrue(host.state.requests.isEmpty()) + assertEquals(listOf("Gwen"), host.state.guests.map { it.name }) + } + + @Test + fun `a guest that is turned away stays turned away`() { + val host = startHost() + val guest = guestOf(host, "g1", "Gwen") + await("the request to reach the host") { host.state.requests.isNotEmpty() } + + host.session.deny("g1") + + await("the refusal to arrive") { guest.state.value.refused } + assertEquals("The host declined.", guest.state.value.message) + assertFalse(guest.state.value.connected) + assertFalse(guest.state.value.sheet.started) + assertTrue(host.state.guests.isEmpty()) + assertTrue(host.state.requests.isEmpty()) + } + + @Test + fun `a guest's change reaches the host and the other guests`() { + val host = startHost() + val first = admittedGuest(host, "g1", "Gwen") + val second = admittedGuest(host, "g2", "Gus") + val player = host.state.sheet.players[0].id + + first.edit(AddRound(mapOf(player to 7))) + + // Shown at once on the guest that made it, without waiting for the host. + assertEquals(7, first.state.value.sheet.total(player)) + await("the host to apply it") { host.state.sheet.total(player) == 7 } + await("the other guest to see it") { second.state.value.sheet.total(player) == 7 } + assertEquals(1, host.state.sheet.rounds.size) + } + + @Test + fun `the host's own change reaches the guests`() { + val host = startHost() + val guest = admittedGuest(host, "g1", "Gwen") + val player = host.state.sheet.players[1].id + + host.session.edit(RenamePlayer(player, "Hosty")) + + await("the guest to see the new name") { + guest.state.value.sheet.players[1].name == "Hosty" + } + } + + @Test + fun `changes made at the same moment all land`() { + val host = startHost() + val first = admittedGuest(host, "g1", "Gwen") + val second = admittedGuest(host, "g2", "Gus") + val player = host.state.sheet.players[0].id + + first.edit(AddRound(mapOf(player to 1))) + second.edit(AddRound(mapOf(player to 10))) + host.session.edit(AddRound(mapOf(player to 100))) + + await("every round to land everywhere") { + listOf(host.state.sheet, first.state.value.sheet, second.state.value.sheet) + .all { it.rounds.size == 3 && it.total(player) == 111 } + } + } + + @Test + fun `a guest asking to sync is sent the sheet as it stands`() { + val host = startHost() + val guest = admittedGuest(host, "g1", "Gwen") + val player = host.state.sheet.players[0].id + host.session.edit(AddRound(mapOf(player to 4))) + + guest.sync() + + await("the guest to be in step") { guest.state.value.sheet.total(player) == 4 } + assertEquals(host.state.sheet, guest.state.value.sheet) + } + + @Test + fun `a guest that was let in is let back in without being asked again`() { + val host = startHost() + val first = admittedGuest(host, "g1", "Gwen") + // The phone is put down and its link goes with it. + first.stop() + await("the host to notice") { host.state.guests.singleOrNull()?.online == false } + + val back = guestOf(host, "g1", "Gwen") + + await("the guest to be back on the sheet") { back.state.value.connected } + assertTrue(host.state.requests.isEmpty()) + assertEquals(1, host.state.guests.size) + assertTrue(host.state.guests.single().online) + assertEquals(host.state.sheet, back.state.value.sheet) + } + + @Test + fun `a guest the host removes has to ask again`() { + val host = startHost() + val guest = admittedGuest(host, "g1", "Gwen") + + host.session.remove("g1") + + await("the removal to arrive") { guest.state.value.refused } + assertEquals("The host removed you.", guest.state.value.message) + assertTrue(host.state.guests.isEmpty()) + + val again = guestOf(host, "g1", "Gwen") + await("the new request") { host.state.requests.any { it.id == "g1" } } + assertFalse(again.state.value.connected) + } + + @Test + fun `somebody who is not let in cannot change the sheet`() { + val host = startHost() + val (_, transport) = newSession("stranger") + val before = host.state.sheet + val player = before.players[0].id + + runBlocking { + val raw = transport.join(joinOf(host), CoroutineScope(Dispatchers.IO).also { scopes += it }) + val hello = Hello("stranger", "Stan", purpose = PURPOSE_SCOREKEEPER) + // Said more than once, as a real guest does: the host may not be + // listening yet the first time. Saying it twice is harmless. + repeat(3) { + raw.send(hello) + delay(150) + } + raw.send(SheetEdit(1, AddRound(mapOf(player to 99)))) + } + + await("the request to reach the host") { host.state.requests.any { it.id == "stranger" } } + Thread.sleep(300) + assertEquals(before, host.state.sheet) + } + + @Test + fun `a phone looking for a game is told this is a score sheet`() { + val host = startHost() + val (_, transport) = newSession("gamer") + val answer = AtomicInteger(0) + + runBlocking { + val scope = CoroutineScope(Dispatchers.IO).also { scopes += it } + val raw = transport.join(joinOf(host), scope) + val reply = scope.launch { + raw.incoming + .onSubscription { raw.send(Hello("gamer", "Gail", purpose = PURPOSE_GAME)) } + .collect { message -> + if (message is Welcome && !message.accepted) { + assertEquals("That is a score sheet, not a game.", message.reason) + answer.set(1) + } + } + } + val hello = Hello("gamer", "Gail", purpose = PURPOSE_GAME) + val end = System.currentTimeMillis() + 5_000 + while (answer.get() == 0 && System.currentTimeMillis() < end) { + delay(300) + if (answer.get() == 0) raw.send(hello) + } + reply.cancel() + } + + assertEquals(1, answer.get()) + assertTrue(host.state.requests.isEmpty()) + assertEquals(Role.HOST, host.state.role) + } + + @Test + fun `stopping hosting lets go of everybody`() { + val host = startHost() + val guest = admittedGuest(host, "g1", "Gwen") + + host.session.stop() + + assertEquals(Role.NONE, host.state.role) + assertTrue(host.state.guests.isEmpty()) + await("the guest to notice") { !guest.state.value.connected } + } + + private fun await(what: String, timeoutMillis: Long = 5_000, condition: () -> Boolean) { + val end = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < end) { + if (condition()) return + Thread.sleep(25) + } + fail("Timed out waiting for $what") + } +} diff --git a/app/src/test/java/org/prolibertate/games/score/SheetSyncTest.kt b/app/src/test/java/org/prolibertate/games/score/SheetSyncTest.kt new file mode 100644 index 0000000..c552883 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/score/SheetSyncTest.kt @@ -0,0 +1,176 @@ +package org.prolibertate.games.score + +import kotlinx.serialization.encodeToString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.net.Hello +import org.prolibertate.games.net.NetMessage +import org.prolibertate.games.net.PURPOSE_GAME +import org.prolibertate.games.net.SheetEdit +import org.prolibertate.games.net.SheetState +import org.prolibertate.games.net.protocolJson + +class SheetOpTest { + + private val table = ScoreSheet.of(3) + private val ids = table.players.map { it.id } + + @Test + fun `every op does what the sheet method of the same name does`() { + assertEquals(table.withPlayerAdded(), AddPlayer.applyTo(table)) + assertEquals(table.renamed(ids[1], "Bob"), RenamePlayer(ids[1], "Bob").applyTo(table)) + assertEquals(table.withPlayerRemoved(ids[2]), RemovePlayer(ids[2]).applyTo(table)) + assertEquals(table.moved(0, 2), MovePlayer(ids[0], 2).applyTo(table)) + assertEquals(ScoreSheet.of(5), StartSheet(5).applyTo(table)) + assertEquals(ScoreSheet(), ClearSheet.applyTo(table)) + + val scored = AddRound(mapOf(ids[0] to 4)).applyTo(table) + assertEquals(table.withRound(mapOf(ids[0] to 4)), scored) + assertEquals(4, EditRound(0, mapOf(ids[0] to 4)).applyTo(AddRound(mapOf(ids[0] to 1)).applyTo(table)).total(ids[0])) + assertTrue(DeleteRound(0).applyTo(scored).rounds.isEmpty()) + } + + @Test + fun `an op that no longer makes sense is a no-op`() { + val scored = AddRound(mapOf(ids[0] to 4)).applyTo(table) + + assertEquals(scored, RenamePlayer(999, "Nobody").applyTo(scored)) + assertEquals(scored, MovePlayer(999, 0).applyTo(scored)) + assertEquals(scored, RemovePlayer(999).applyTo(scored)) + assertEquals(scored, EditRound(7, mapOf(ids[0] to 1)).applyTo(scored)) + assertEquals(scored, DeleteRound(7).applyTo(scored)) + } + + @Test + fun `moving a column goes by who it is, not where it was`() { + // Somebody else has already moved the column this guest was looking at. + val shuffled = table.moved(0, 2) + val moved = MovePlayer(ids[0], 0).applyTo(shuffled) + assertEquals(ids, moved.players.map { it.id }) + } + + @Test + fun `two rounds entered at once are two rounds`() { + val both = listOf( + AddRound(mapOf(ids[0] to 3)), + AddRound(mapOf(ids[1] to 5)), + ).fold(table) { sheet, op -> op.applyTo(sheet) } + + assertEquals(2, both.rounds.size) + assertEquals(3, both.total(ids[0])) + assertEquals(5, both.total(ids[1])) + } + + @Test + fun `every op survives the wire`() { + val ops = listOf( + StartSheet(4), ClearSheet, AddPlayer, RemovePlayer(2), RenamePlayer(1, "Ann"), + MovePlayer(1, 3), AddRound(mapOf(1 to 5, 2 to -3, 3 to 0)), + EditRound(0, mapOf(1 to 9)), DeleteRound(2), + ) + ops.forEach { op -> + val message: NetMessage = SheetEdit(seq = 7, op = op) + val decoded = protocolJson.decodeFromString(protocolJson.encodeToString(message)) + assertEquals(message, decoded) + } + } + + @Test + fun `a sheet survives the wire with its rounds`() { + val sheet = AddRound(mapOf(ids[0] to 3, ids[2] to -2)).applyTo(table) + val message: NetMessage = SheetState(revision = 4, sheet = sheet, acknowledged = 2) + assertEquals(message, protocolJson.decodeFromString(protocolJson.encodeToString(message))) + } + + @Test + fun `a hello from before there were score sheets is a hello for a game`() { + val old = """{"t":"hello","peerId":"p","displayName":"Ann","protocol":2}""" + val hello = protocolJson.decodeFromString(old) as Hello + assertEquals(PURPOSE_GAME, hello.purpose) + } +} + +class SheetReplicaTest { + + private val base = ScoreSheet.of(2) + private val a = base.players[0].id + private val b = base.players[1].id + + @Test + fun `a change shows at once`() { + val replica = SheetReplica() + replica.receive(base, revision = 1, acknowledged = 0) + + replica.local(AddRound(mapOf(a to 5))) + + assertEquals(5, replica.view.total(a)) + assertEquals(1, replica.unconfirmed) + } + + @Test + fun `a sheet that has not seen my change does not wipe it out`() { + val replica = SheetReplica() + replica.receive(base, 1, 0) + replica.local(AddRound(mapOf(a to 5))) + + // The host pushes a rename it made before it had heard about the round. + replica.receive(base.renamed(b, "Bob"), 2, 0) + + assertEquals("Bob", replica.view.players[1].name) + assertEquals(5, replica.view.total(a)) + } + + @Test + fun `a change the host has applied is not applied again`() { + val replica = SheetReplica() + replica.receive(base, 1, 0) + val seq = replica.local(AddRound(mapOf(a to 5))) + + // The host's sheet now includes the round, and says so. + replica.receive(base.withRound(mapOf(a to 5)), 2, seq) + + assertEquals(5, replica.view.total(a)) + assertEquals(1, replica.view.rounds.size) + assertEquals(0, replica.unconfirmed) + } + + @Test + fun `only the acknowledged changes are dropped`() { + val replica = SheetReplica() + replica.receive(base, 1, 0) + val first = replica.local(AddRound(mapOf(a to 1))) + replica.local(AddRound(mapOf(a to 10))) + + replica.receive(base.withRound(mapOf(a to 1)), 2, first) + + assertEquals(1, replica.unconfirmed) + assertEquals(11, replica.view.total(a)) + assertEquals(2, replica.view.rounds.size) + } + + @Test + fun `an older sheet that arrives late is ignored`() { + val replica = SheetReplica() + replica.receive(base.withRound(mapOf(a to 3)), 5, 0) + replica.receive(base, 4, 0) + + assertEquals(3, replica.view.total(a)) + assertEquals(5, replica.revision) + } + + @Test + fun `a fresh link starts from nothing`() { + val replica = SheetReplica() + replica.receive(base, 3, 0) + replica.local(AddRound(mapOf(a to 5))) + + replica.reset() + + assertEquals(ScoreSheet(), replica.view) + assertEquals(0, replica.unconfirmed) + // And a sheet at an early revision is taken rather than thought stale. + replica.receive(base, 1, 0) + assertEquals(base, replica.view) + } +}