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:
James Wallace
2026-10-10 01:27:40 +00:00
co-authored by Claude Sonnet 5.5
parent 16f9321ca3
commit 89b19a894a
14 changed files with 1807 additions and 38 deletions
@@ -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)
}
}