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 <[email protected]> Claude-Session: https://claude.ai/code/session_01CBG7URddsUZjQ6KE1Kqf6G
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
16f9321ca3
commit
89b19a894a
@@ -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
|
the last number typed. Not every keyboard offers a minus on its number pad, and
|
||||||
subtracting has to work on all of them.
|
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
|
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
|
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.
|
settings, because it is a game in progress rather than a preference.
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import android.provider.Settings as AndroidSettings
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import org.prolibertate.games.net.LanTransport
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
|
import org.prolibertate.games.net.ScorekeeperSession
|
||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
import org.prolibertate.games.ui.AppEnv
|
import org.prolibertate.games.ui.AppEnv
|
||||||
@@ -21,12 +23,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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(
|
env = AppEnv(
|
||||||
settingsRepository = SettingsRepository(applicationContext),
|
settingsRepository = SettingsRepository(applicationContext),
|
||||||
updater = Updater(this),
|
updater = Updater(this),
|
||||||
lobby = LobbyController(applicationContext, lifecycleScope),
|
lobby = LobbyController(applicationContext, lifecycleScope),
|
||||||
scorekeeper = ScorekeeperRepository(applicationContext),
|
scorekeeper = ScorekeeperRepository(applicationContext),
|
||||||
peerId = stablePeerId(),
|
scoreSession = ScorekeeperSession(
|
||||||
|
transport = scoreLan,
|
||||||
|
endpoint = scoreLan.endpoint,
|
||||||
|
scope = lifecycleScope,
|
||||||
|
deviceId = peerId,
|
||||||
|
),
|
||||||
|
peerId = peerId,
|
||||||
)
|
)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
@@ -50,6 +63,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
if (::env.isInitialized) env.lobby.stop()
|
if (::env.isInitialized) {
|
||||||
|
env.lobby.stop()
|
||||||
|
env.scoreSession.stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
// Hosting
|
// Hosting
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
override fun host(displayName: String, scope: CoroutineScope): Flow<Connection> = callbackFlow {
|
override fun host(
|
||||||
|
displayName: String,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
purpose: HostPurpose,
|
||||||
|
): Flow<Connection> = callbackFlow {
|
||||||
holdRadio()
|
holdRadio()
|
||||||
|
|
||||||
// The known port when it can be had, so that joining by hand needs an
|
// 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()
|
multicast.acquire()
|
||||||
|
|
||||||
val serviceInfo = NsdServiceInfo().apply {
|
val serviceInfo = NsdServiceInfo().apply {
|
||||||
serviceName = "$SERVICE_NAME @ $displayName"
|
serviceName = "${serviceLabel(purpose)} @ $displayName"
|
||||||
serviceType = SERVICE_TYPE
|
serviceType = SERVICE_TYPE
|
||||||
port = server.localPort
|
port = server.localPort
|
||||||
}
|
}
|
||||||
@@ -175,6 +179,7 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
kind = TransportKind.LAN,
|
kind = TransportKind.LAN,
|
||||||
address = address,
|
address = address,
|
||||||
port = info.port,
|
port = info.port,
|
||||||
|
purpose = purposeOf(info.serviceName),
|
||||||
)
|
)
|
||||||
synchronized(found) { found[host.id] = host }
|
synchronized(found) { found[host.id] = host }
|
||||||
publish()
|
publish()
|
||||||
@@ -278,6 +283,22 @@ class LanTransport(private val context: Context) : Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
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 CONNECT_TIMEOUT_MS = 8_000
|
||||||
const val WIFI_LOCK_TAG = "ProLibertateGames:lan"
|
const val WIFI_LOCK_TAG = "ProLibertateGames:lan"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ class LobbyController(
|
|||||||
jobs += scope.launch {
|
jobs += scope.launch {
|
||||||
connection.incoming.collect { message ->
|
connection.incoming.collect { message ->
|
||||||
if (message !is Hello) return@collect
|
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) {
|
if (message.protocol != PROTOCOL_VERSION) {
|
||||||
connection.send(
|
connection.send(
|
||||||
Welcome(
|
Welcome(
|
||||||
@@ -257,7 +269,9 @@ class LobbyController(
|
|||||||
_state.value = State(joining = true, message = "Looking for games…")
|
_state.value = State(joining = true, message = "Looking for games…")
|
||||||
for (transport in availableTransports()) {
|
for (transport in availableTransports()) {
|
||||||
jobs += scope.launch {
|
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 }
|
val others = _state.value.discovered.filter { it.kind != transport.kind }
|
||||||
_state.value = _state.value.copy(discovered = others + hosts)
|
_state.value = _state.value.copy(discovered = others + hosts)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import kotlinx.serialization.json.Json
|
|||||||
import org.prolibertate.games.game.engine.PlayerKind
|
import org.prolibertate.games.game.engine.PlayerKind
|
||||||
import org.prolibertate.games.game.engine.PlayerSlot
|
import org.prolibertate.games.game.engine.PlayerSlot
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
|
import org.prolibertate.games.score.ScoreSheet
|
||||||
|
import org.prolibertate.games.score.SheetOp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The wire protocol, shared by every transport.
|
* The wire protocol, shared by every transport.
|
||||||
@@ -25,6 +27,10 @@ import org.prolibertate.games.game.engine.TableConfig
|
|||||||
*/
|
*/
|
||||||
const val PROTOCOL_VERSION = 2
|
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. */
|
/** Service type and name advertised over mDNS. */
|
||||||
const val SERVICE_TYPE = "_plgames._tcp"
|
const val SERVICE_TYPE = "_plgames._tcp"
|
||||||
const val SERVICE_NAME = "ProLibertateGames"
|
const val SERVICE_NAME = "ProLibertateGames"
|
||||||
@@ -63,6 +69,13 @@ data class Hello(
|
|||||||
val peerId: String,
|
val peerId: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val protocol: Int = PROTOCOL_VERSION,
|
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
|
) : NetMessage
|
||||||
|
|
||||||
/** Host's reply, or a refusal if the lobby is full or the versions differ. */
|
/** Host's reply, or a refusal if the lobby is full or the versions differ. */
|
||||||
@@ -150,3 +163,26 @@ data object Pong : NetMessage
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("bye")
|
@SerialName("bye")
|
||||||
data object Bye : NetMessage
|
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
|
||||||
|
|||||||
@@ -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<HostEndpoint?>,
|
||||||
|
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<Guest> = emptyList(),
|
||||||
|
val requests: List<Request> = emptyList(),
|
||||||
|
val searching: Boolean = false,
|
||||||
|
val discovered: List<DiscoveredHost> = 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> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val jobs = CopyOnWriteArrayList<Job>()
|
||||||
|
private var discoveryJob: Job? = null
|
||||||
|
|
||||||
|
/** Everything that goes out, in order. See the class comment. */
|
||||||
|
private val outbox = Channel<suspend () -> 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<String, HostLink>()
|
||||||
|
private val waiting = linkedMapOf<String, Pair<String, Connection>>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ enum class TransportKind(val label: String) {
|
|||||||
LAN("Wi-Fi"),
|
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
|
* A host someone could join, found by one of the transports. [address] is
|
||||||
* transport-specific: an IP for LAN.
|
* transport-specific: an IP for LAN.
|
||||||
@@ -34,6 +37,7 @@ data class DiscoveredHost(
|
|||||||
val kind: TransportKind,
|
val kind: TransportKind,
|
||||||
val address: String,
|
val address: String,
|
||||||
val port: Int = 0,
|
val port: Int = 0,
|
||||||
|
val purpose: HostPurpose = HostPurpose.GAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,7 +296,11 @@ interface Transport {
|
|||||||
fun isAvailable(): Boolean
|
fun isAvailable(): Boolean
|
||||||
|
|
||||||
/** Starts advertising and emits each client that connects. */
|
/** Starts advertising and emits each client that connects. */
|
||||||
fun host(displayName: String, scope: CoroutineScope): Flow<Connection>
|
fun host(
|
||||||
|
displayName: String,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
purpose: HostPurpose = HostPurpose.GAME,
|
||||||
|
): Flow<Connection>
|
||||||
|
|
||||||
/** Emits the current list of visible hosts as it changes. */
|
/** Emits the current list of visible hosts as it changes. */
|
||||||
fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>>
|
fun discover(scope: CoroutineScope): Flow<List<DiscoveredHost>>
|
||||||
|
|||||||
@@ -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<Int, Int>) : SheetOp {
|
||||||
|
override fun applyTo(sheet: ScoreSheet): ScoreSheet = sheet.withRound(deltas)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("edit-round")
|
||||||
|
data class EditRound(val index: Int, val deltas: Map<Int, Int>) : 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)
|
||||||
|
}
|
||||||
@@ -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<Pair<Int, SheetOp>>()
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import org.prolibertate.games.game.GameMenu
|
|||||||
import org.prolibertate.games.game.GameDescriptor
|
import org.prolibertate.games.game.GameDescriptor
|
||||||
import org.prolibertate.games.game.engine.TableConfig
|
import org.prolibertate.games.game.engine.TableConfig
|
||||||
import org.prolibertate.games.net.LobbyController
|
import org.prolibertate.games.net.LobbyController
|
||||||
|
import org.prolibertate.games.net.ScorekeeperSession
|
||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
import org.prolibertate.games.settings.Settings
|
import org.prolibertate.games.settings.Settings
|
||||||
import org.prolibertate.games.settings.SettingsRepository
|
import org.prolibertate.games.settings.SettingsRepository
|
||||||
@@ -56,6 +57,8 @@ class AppEnv(
|
|||||||
val updater: Updater,
|
val updater: Updater,
|
||||||
val lobby: LobbyController,
|
val lobby: LobbyController,
|
||||||
val scorekeeper: ScorekeeperRepository,
|
val scorekeeper: ScorekeeperRepository,
|
||||||
|
/** The shared score sheet, for the Scorekeeper to host or join. */
|
||||||
|
val scoreSession: ScorekeeperSession,
|
||||||
val peerId: String,
|
val peerId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,6 +189,8 @@ private fun AppContent(
|
|||||||
|
|
||||||
is Route.Scorekeeper -> ScorekeeperScreen(
|
is Route.Scorekeeper -> ScorekeeperScreen(
|
||||||
repository = env.scorekeeper,
|
repository = env.scorekeeper,
|
||||||
|
session = env.scoreSession,
|
||||||
|
playerName = settings.displayName,
|
||||||
onBack = { pop() },
|
onBack = { pop() },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.prolibertate.games.ui
|
package org.prolibertate.games.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.ScrollState
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -30,7 +31,9 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
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.ScorePlayer
|
||||||
import org.prolibertate.games.score.ScoreSheet
|
import org.prolibertate.games.score.ScoreSheet
|
||||||
import org.prolibertate.games.score.ScorekeeperRepository
|
import org.prolibertate.games.score.ScorekeeperRepository
|
||||||
|
import org.prolibertate.games.score.SheetOp
|
||||||
|
import org.prolibertate.games.score.StartSheet
|
||||||
import kotlin.math.roundToInt
|
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
|
* 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.
|
* 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
|
@Composable
|
||||||
fun ScorekeeperScreen(
|
fun ScorekeeperScreen(
|
||||||
repository: ScorekeeperRepository,
|
repository: ScorekeeperRepository,
|
||||||
|
session: ScorekeeperSession,
|
||||||
|
playerName: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -80,36 +106,121 @@ fun ScorekeeperScreen(
|
|||||||
// of storage on every change would put a DataStore round trip between a
|
// 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.
|
// keystroke and the digit appearing, which is how a field loses characters.
|
||||||
var loaded by remember { mutableStateOf(false) }
|
var loaded by remember { mutableStateOf(false) }
|
||||||
var sheet by remember { mutableStateOf(ScoreSheet()) }
|
var local by remember { mutableStateOf(ScoreSheet()) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
sheet = repository.sheet.first()
|
local = repository.sheet.first()
|
||||||
loaded = true
|
loaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
val update: (ScoreSheet) -> Unit = { next ->
|
val shared by session.state.collectAsState()
|
||||||
sheet = next
|
val role = shared.role
|
||||||
scope.launch { repository.save(next) }
|
var joining by remember { mutableStateOf(false) }
|
||||||
}
|
var showShare by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var confirmNewGame 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(
|
ScreenScaffold(
|
||||||
title = "Scorekeeper",
|
title = when (role) {
|
||||||
onBack = onBack,
|
Role.GUEST -> "Scorekeeper · ${shared.hostName}"
|
||||||
|
Role.HOST -> "Scorekeeper · sharing"
|
||||||
|
Role.NONE -> "Scorekeeper"
|
||||||
|
},
|
||||||
|
onBack = { if (role == Role.GUEST || joining) leaveSharing() else onBack() },
|
||||||
actions = {
|
actions = {
|
||||||
|
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) {
|
if (sheet.started) {
|
||||||
TextButton(onClick = { confirmNewGame = true }) { Text("New game") }
|
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 ->
|
) { modifier ->
|
||||||
// Nothing is drawn until the stored sheet has been read, so a game in
|
// 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.
|
// progress does not flash the "how many are playing" pane first.
|
||||||
if (!loaded) {
|
when {
|
||||||
Box(modifier = modifier)
|
!loaded -> Box(modifier = modifier)
|
||||||
} else if (!sheet.started) {
|
|
||||||
SetupPane(modifier = modifier) { count -> update(ScoreSheet.of(count)) }
|
role == Role.GUEST && (shared.awaiting || shared.refused || !sheet.started) ->
|
||||||
} else {
|
WaitingPane(modifier = modifier, shared = shared, onLeave = leaveSharing)
|
||||||
Sheet(modifier = modifier, sheet = sheet, onChange = update)
|
|
||||||
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmNewGame = false },
|
onDismissRequest = { confirmNewGame = false },
|
||||||
title = { Text("Start a new game?") },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
confirmNewGame = false
|
confirmNewGame = false
|
||||||
update(ScoreSheet())
|
change(ClearSheet)
|
||||||
}) { Text("Rub it out") }
|
}) { Text("Rub it out") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
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)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@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) }
|
var count by remember { mutableStateOf(ScoreSheet.DEFAULT_PLAYERS) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -170,6 +309,9 @@ private fun SetupPane(modifier: Modifier, onStart: (Int) -> Unit) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
PrimaryAction(text = "Start scoring") { onStart(count) }
|
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
|
@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
|
// 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
|
// 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
|
// 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.
|
// Keep the row being filled in in view when a round is written down.
|
||||||
LaunchedEffect(sheet.rounds.size) { rows.animateScrollTo(rows.maxValue) }
|
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) {
|
Column(modifier = modifier) {
|
||||||
Text(
|
Text(
|
||||||
@@ -234,7 +385,7 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
trailing = {
|
trailing = {
|
||||||
AddColumnCell(
|
AddColumnCell(
|
||||||
enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS,
|
enabled = sheet.players.size < ScoreSheet.MAX_PLAYERS,
|
||||||
onClick = { onChange(sheet.withPlayerAdded()) },
|
onClick = { onOp(AddPlayer) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { index, player ->
|
) { index, player ->
|
||||||
@@ -360,18 +511,18 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
onFlip = { editing[lastTyped] = flipSign(editing[lastTyped].orEmpty()) },
|
onFlip = { editing[lastTyped] = flipSign(editing[lastTyped].orEmpty()) },
|
||||||
onFinish = {
|
onFinish = {
|
||||||
if (correcting >= 0) {
|
if (correcting >= 0) {
|
||||||
onChange(sheet.withRoundAt(correcting, amountsAsPoints(correction)))
|
onOp(EditRound(correcting, amountsAsPoints(correction)))
|
||||||
correcting = -1
|
correcting = -1
|
||||||
correction.clear()
|
correction.clear()
|
||||||
} else {
|
} else {
|
||||||
onChange(sheet.withRound(amountsAsPoints(pending)))
|
onOp(AddRound(amountsAsPoints(pending)))
|
||||||
pending.clear()
|
pending.clear()
|
||||||
}
|
}
|
||||||
lastTyped = -1
|
lastTyped = -1
|
||||||
focusPlayer = -1
|
focusPlayer = -1
|
||||||
},
|
},
|
||||||
onDelete = {
|
onDelete = {
|
||||||
onChange(sheet.withoutRound(correcting))
|
onOp(DeleteRound(correcting))
|
||||||
correcting = -1
|
correcting = -1
|
||||||
correction.clear()
|
correction.clear()
|
||||||
lastTyped = -1
|
lastTyped = -1
|
||||||
@@ -391,9 +542,9 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
RenameDialog(
|
RenameDialog(
|
||||||
sheet = sheet,
|
sheet = sheet,
|
||||||
player = renamed,
|
player = renamed,
|
||||||
onName = { onChange(sheet.renamed(renamed.id, it)) },
|
onName = { onOp(RenamePlayer(renamed.id, it)) },
|
||||||
onRemove = {
|
onRemove = {
|
||||||
onChange(sheet.withPlayerRemoved(renamed.id))
|
onOp(RemovePlayer(renamed.id))
|
||||||
renaming = -1
|
renaming = -1
|
||||||
},
|
},
|
||||||
onDismiss = { renaming = -1 },
|
onDismiss = { renaming = -1 },
|
||||||
@@ -417,17 +568,17 @@ private fun Sheet(modifier: Modifier, sheet: ScoreSheet, onChange: (ScoreSheet)
|
|||||||
* since.
|
* since.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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 widthPx = with(LocalDensity.current) { PLAYER_COLUMN.toPx() }
|
||||||
val currentSheet by rememberUpdatedState(sheet)
|
val currentSheet by rememberUpdatedState(sheet)
|
||||||
val commit by rememberUpdatedState(onChange)
|
val commit by rememberUpdatedState(onOp)
|
||||||
return remember { ColumnDrag(widthPx, { currentSheet }, { commit(it) }) }
|
return remember { ColumnDrag(widthPx, { currentSheet }, { commit(it) }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ColumnDrag(
|
private class ColumnDrag(
|
||||||
private val widthPx: Float,
|
private val widthPx: Float,
|
||||||
private val sheet: () -> ScoreSheet,
|
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. */
|
/** Which column is under the finger, or -1 when nothing is being dragged. */
|
||||||
var index by mutableStateOf(-1)
|
var index by mutableStateOf(-1)
|
||||||
@@ -454,7 +605,8 @@ private class ColumnDrag(
|
|||||||
val to = landing()
|
val to = landing()
|
||||||
index = -1
|
index = -1
|
||||||
offset = 0f
|
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() {
|
fun cancel() {
|
||||||
|
|||||||
@@ -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") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<CoroutineScope>()
|
||||||
|
private val sessions = mutableListOf<ScorekeeperSession>()
|
||||||
|
private val transports = mutableListOf<LoopbackTransport>()
|
||||||
|
|
||||||
|
@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<Connection> = 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<List<DiscoveredHost>> = 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<ScorekeeperSession, LoopbackTransport> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NetMessage>(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<NetMessage>(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<NetMessage>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user