From d165e5efff7e030f44506831ea28b0c9c7085bfd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:14:33 +0000 Subject: [PATCH 1/5] Show every player the table from the moment the game starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest saw nothing until the next state push, which on most tables means until its own turn came round: no board, no cards, no scoreboard. The cause is a gap in who is listening. A guest's link is opened in the lobby and read there; the table only subscribes to it once the host's start message has been through a state change, a recomposition and a LaunchedEffect. The host publishes the opening position immediately, into a link whose table has not attached yet, and incoming is a SharedFlow with replay = 0 — so that first push went to the lobby's collector and nowhere else. Not a rare race but the normal order of events, which is why it looked like a rule rather than a bug. The guest now asks. A new Resync message is sent from inside onSubscription, so the request cannot leave before the collector is attached and the reply cannot arrive before there is anything to receive it. The host answers one peer with that seat's redacted view. A request that arrives before the deal needs no answer, since a peer that has asked is listening and the opening publish will reach it. Second reason a guest could not see its hand, on any table with more than one guest: guests worked out their own seat by matching PlayerSlot.peerId, which is the address the host's transport invented for the incoming link — an IP on Wi-Fi, a MAC over Bluetooth. A guest has never seen that value, so no guest ever matched it and every one fell through to "the first seat held by somebody remote". With one guest that is right by luck; with two, both claimed the same seat and the other drew a hand belonging to a seat it was not sitting in, which redacted is no hand at all. The host now records the id a guest introduced itself by, as PlayerSlot.deviceId, kept separate from peerId because the two answer different questions: peerId is how the host recognises a link, deviceId is how a guest recognises itself. The lookup moved out of LobbyScreen into seatForDevice, where it can be tested, and a dropped player stops claiming its old seat. Neither change needs a protocol bump. Resync is ignored by a host that does not know it, deviceId is an unknown key to a guest that does not read it, and the old fallback is kept for exactly that case. Tests reproduce the real start-up order — host publishes, then the guest subscribes a frame later — rather than the order that happens to work, and cover a board, a dealt hand, and a guest that is on the clock at the deal. Both regressions were confirmed to fail before the fix: the guest had no state at all, and two guests both came back as seat 1. 216 JVM tests pass. --- .../prolibertate/games/game/engine/Engine.kt | 10 + .../prolibertate/games/net/LobbyController.kt | 7 + .../prolibertate/games/net/MatchController.kt | 112 +++++--- .../org/prolibertate/games/net/Protocol.kt | 32 +++ .../org/prolibertate/games/ui/LobbyScreen.kt | 5 +- .../games/net/MatchControllerNetworkTest.kt | 248 +++++++++++++++--- .../games/net/SeatForDeviceTest.kt | 105 ++++++++ 7 files changed, 437 insertions(+), 82 deletions(-) create mode 100644 app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt diff --git a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt index d44b825..52a3665 100644 --- a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt +++ b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt @@ -23,6 +23,16 @@ data class PlayerSlot( val team: Int, /** Identifies the remote device for [PlayerKind.HUMAN_REMOTE] seats. */ val peerId: String? = null, + /** + * The joining device's own id, as it introduced itself. + * + * Kept alongside [peerId] because the two answer different questions. + * [peerId] is how the host recognises which link a message came in on, and is + * an address the transport made up — a socket's IP, a Bluetooth MAC. This is + * how a guest recognises *itself* in a seat list it did not write, and only + * the guest's own id can do that. + */ + val deviceId: String? = null, ) @Serializable diff --git a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt index 6728a2f..f2da41f 100644 --- a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt +++ b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt @@ -133,6 +133,10 @@ class LobbyController( name = hello.displayName, kind = PlayerKind.HUMAN_REMOTE, peerId = connection.peerId, + // Recorded so the guest can find itself in this list once it is sent + // back: connection.peerId is an address this end invented and the + // guest has never seen. + deviceId = hello.peerId, ) connections[connection.peerId] = connection _state.value = current.copy( @@ -178,6 +182,9 @@ class LobbyController( kind = kind, name = name ?: seats[seat].name, peerId = if (kind == PlayerKind.AI) null else seats[seat].peerId, + // A dropped player must stop claiming the seat, or it would still + // recognise itself in it when the table starts. + deviceId = if (kind == PlayerKind.AI) null else seats[seat].deviceId, ) _state.value = current.copy(seats = seats) scope.launch { broadcastLobby() } diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 6bbbf1d..f43fd8f 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -154,36 +155,45 @@ class MatchController( check(role == Role.CLIENT) { "Not a client" } hostLink = link scope.launch { - link.incoming.collect { message -> - when (message) { - is StateSync -> { - // Anything off the wire is decoded defensively. This - // collector runs in the screen's own scope, so throwing - // here would take the app down rather than the message. - val decoded = runCatching { - val state = rules.decodeState(message.stateJson) - state to message.legalMoves.map { rules.decodeMove(it) } - }.getOrNull() - if (decoded == null) { - _notice.value = "The host sent something this version cannot read." - return@collect + link.incoming + // Asked for from inside onSubscription, which is what makes it + // reliable: the request cannot leave before this collector is + // attached, so the reply cannot arrive before there is anything + // here to receive it. + .onSubscription { link.send(Resync) } + .collect { message -> + when (message) { + is StateSync -> { + // Anything off the wire is decoded defensively. This + // collector runs in the screen's own scope, so + // throwing here would take the app down rather than + // the message. + val decoded = runCatching { + val state = rules.decodeState(message.stateJson) + state to message.legalMoves.map { rules.decodeMove(it) } + }.getOrNull() + if (decoded == null) { + _notice.value = + "The host sent something this version cannot read." + return@collect + } + _state.value = decoded.first + _legalMoves.value = decoded.second + _finished.value = rules.isFinished(decoded.first) } - _state.value = decoded.first - _legalMoves.value = decoded.second - _finished.value = rules.isFinished(decoded.first) - } - is Rejected -> _notice.value = message.reason - is Bye -> { - // The host has gone: the game cannot go on, and saying so - // is what gives the screen something to offer a way out of. - _legalMoves.value = emptyList() - abandon("The host left the game.") - } + is Rejected -> _notice.value = message.reason + is Bye -> { + // The host has gone: the game cannot go on, and + // saying so is what gives the screen something to + // offer a way out of. + _legalMoves.value = emptyList() + abandon("The host left the game.") + } - else -> Unit + else -> Unit + } } - } } } @@ -216,6 +226,10 @@ class MatchController( abandon("${nameOfPeer(connection.peerId)} left the game.") return } + if (message is Resync) { + sendStateTo(connection) + return + } if (message !is MoveIntent) return val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key if (seat == null) { @@ -338,20 +352,40 @@ class MatchController( for (slot in config.seats) { val peerId = slot.peerId ?: continue val connection = peers[peerId] ?: continue - val seatView = rules.viewFor(current, slot.seat) - val moves = if (rules.currentSeat(current) == slot.seat) { - rules.legalMoves(current, slot.seat).map { rules.encodeMove(it) } - } else { - emptyList() - } - connection.send( - StateSync( - seq = sequence, - yourSeat = slot.seat, - stateJson = rules.encodeState(seatView), - legalMoves = moves, - ) + sendStateSync(connection, slot.seat, current) + } + } + + /** One seat's view of [current], redacted and addressed to it. Call under [lock]. */ + private suspend fun sendStateSync(connection: Connection, seat: Int, current: S) { + val moves = if (rules.currentSeat(current) == seat) { + rules.legalMoves(current, seat).map { rules.encodeMove(it) } + } else { + emptyList() + } + connection.send( + StateSync( + seq = sequence, + yourSeat = seat, + stateJson = rules.encodeState(rules.viewFor(current, seat)), + legalMoves = moves, ) + ) + } + + /** + * Answers one peer's [Resync] with the state as it stands. + * + * Nothing to send yet means the table has not been dealt, and that is not a + * problem: a peer that has asked is by definition listening, so the opening + * publish moments later will reach it. + */ + private suspend fun sendStateTo(connection: Connection) { + lock.withLock { + val current = authoritative ?: return + val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key + ?: return + sendStateSync(connection, seat, current) } } diff --git a/app/src/main/java/org/prolibertate/games/net/Protocol.kt b/app/src/main/java/org/prolibertate/games/net/Protocol.kt index 5fef50f..8432247 100644 --- a/app/src/main/java/org/prolibertate/games/net/Protocol.kt +++ b/app/src/main/java/org/prolibertate/games/net/Protocol.kt @@ -3,6 +3,7 @@ package org.prolibertate.games.net import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerSlot import org.prolibertate.games.game.engine.TableConfig @@ -30,6 +31,24 @@ val protocolJson: Json = Json { classDiscriminator = "t" } +/** + * Which seat [deviceId] is sitting in, given the table the host has settled on. + * + * A guest has to answer this for itself: the seat list arrives as a whole and + * says nothing about who is reading it. Matching on [PlayerSlot.deviceId] is the + * only reliable way round, because that is the one field a guest can recognise — + * [PlayerSlot.peerId] is an address the host's transport invented for the link + * and bears no relation to anything the guest knows about itself. + * + * The fall back to the first remote seat is for a host running a version that + * sends no device ids. It is right whenever there is only one guest, and with two + * it hands them both the same seat, which is why it is only ever reached last. + */ +fun seatForDevice(config: TableConfig, deviceId: String): Int = + config.seats.firstOrNull { it.deviceId == deviceId }?.seat + ?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat + ?: 0 + @Serializable sealed interface NetMessage @@ -83,6 +102,19 @@ data class StateSync( @SerialName("intent") data class MoveIntent(val moveJson: String) : NetMessage +/** + * A client asking for the current state. + * + * A guest's link is opened in the lobby and is only handed to the table once the + * host's [StartGame] has been through a recomposition, so the host publishes the + * opening position before the guest's table is listening for it. Rather than + * leave that to timing, the guest asks as soon as it is listening — which is also + * what gets a guest back in step after any missed push. + */ +@Serializable +@SerialName("resync") +data object Resync : NetMessage + @Serializable @SerialName("rejected") data class Rejected(val reason: String) : NetMessage diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt index 6c8e016..a598769 100644 --- a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -25,6 +25,7 @@ import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.net.DiscoveredHost import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.net.seatForDevice /** * Hosting or joining a table. @@ -66,9 +67,7 @@ fun LobbyScreen( val seat = if (route.hosting) { 0 } else { - config.seats.firstOrNull { it.peerId == env.peerId }?.seat - ?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat - ?: 0 + seatForDevice(config, env.peerId) } env.lobby.clearStarted() onStart(config, route.hosting, seat) diff --git a/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt b/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt index a8c4cdb..8b37c9b 100644 --- a/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt +++ b/app/src/test/java/org/prolibertate/games/net/MatchControllerNetworkTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -23,6 +24,13 @@ import org.prolibertate.games.game.chess.ChessPhase import org.prolibertate.games.game.chess.ChessRules import org.prolibertate.games.game.chess.ChessState import org.prolibertate.games.game.chess.squareFromName +import org.prolibertate.games.game.crazyeights.CrazyEightsAi +import org.prolibertate.games.game.crazyeights.CrazyEightsMove +import org.prolibertate.games.game.crazyeights.CrazyEightsOptions +import org.prolibertate.games.game.crazyeights.CrazyEightsRules +import org.prolibertate.games.game.crazyeights.CrazyEightsState +import org.prolibertate.games.game.engine.GameAi +import org.prolibertate.games.game.engine.GameRules import org.prolibertate.games.game.engine.PlayerKind import org.prolibertate.games.game.engine.PlayerSlot import org.prolibertate.games.game.engine.TableConfig @@ -36,30 +44,139 @@ import java.net.Socket * * The engines are covered exhaustively elsewhere; what is worth testing here is * the part no rules test can reach — a game with a live connection in it being - * played, finished, and above all *left*, which is where a networked table has - * to let go of a socket while the screen that owns it is being torn down. + * started, played, and left. + * + * The order matters and is reproduced faithfully. A guest's link is opened in the + * lobby and is already being read there; the match screen only subscribes to it + * once the host's "start" message has been through a recomposition. So the host + * is always publishing into a link whose match screen has not attached yet, which + * is the gap [Table.start] leaves open on purpose. */ class MatchControllerNetworkTest { - private val tables = mutableListOf() + private val tables = mutableListOf>() @After fun tearDown() { tables.forEach { it.dispose() } } - private fun table(): Table = Table().also { tables += it } + private fun chess(hostSeat: Int = HOST_SEAT): Table = Table( + rules = ChessRules, + ai = ChessAi(), + gameId = GameCatalog.CHESS, + optionsJson = testJson.encodeToString(ChessOptions()), + seatCount = 2, + hostSeat = hostSeat, + ).also { tables += it } + private fun crazyEights(): Table = Table( + rules = CrazyEightsRules, + ai = CrazyEightsAi(), + gameId = GameCatalog.CRAZY_EIGHTS, + optionsJson = testJson.encodeToString(CrazyEightsOptions(playerCount = 2)), + seatCount = 2, + ).also { tables += it } + + // ----------------------------------------------------------------------- + // Everyone can see the table from the moment it starts + // ----------------------------------------------------------------------- + + /** + * The guest's screen has to have something to draw as soon as the game + * starts. It used to have nothing until the next state push, which on most + * tables means until the guest's own turn came round — a board that does not + * appear, and a hand of cards that does not either. + */ + @Test + fun `a guest can see the board as soon as the game starts`() = runBlocking { + val table = chess() + table.start() + + assertNotNull( + "The guest has no state to draw: the opening position never reached it", + table.client.state.value, + ) + assertEquals(ChessPhase.PLAYING, table.client.state.value?.phase) + // 32 men on the board, before anybody has moved. + assertEquals(32, table.client.state.value?.board?.count { it != null }) + } + + @Test + fun `a guest can see its own hand as soon as the game starts`() = runBlocking { + val table = crazyEights() + table.start() + + val view = table.client.state.value + assertNotNull("The guest was never dealt a visible hand", view) + assertTrue( + "The guest cannot see its own cards: hand was ${view?.hands?.get(GUEST_SEAT)}", + (view?.hands?.get(GUEST_SEAT)?.size ?: 0) > 0, + ) + // The table itself, not just the hand: something has been turned up to + // play on to. + assertNotNull("No discard pile to play on to", view?.topCard) + // And the opponent's hand is still none of the guest's business. + assertEquals(emptyList(), view?.hands?.get(HOST_SEAT)) + assertTrue( + "Opponent's card count should still be known", + (view?.handCounts?.get(HOST_SEAT) ?: 0) > 0, + ) + } + + @Test + fun `the player on the clock is given its moves as soon as the game starts`() = runBlocking { + // Crazy 8s deals to seat 0 first, so on this table it is the host that is + // on the clock; the guest correctly has nothing to play yet. + val table = crazyEights() + table.start() + + assertEquals(HOST_SEAT, table.host.state.value?.turn) + assertTrue("The host has no moves to make on its own turn", table.host.legalMoves.value.isNotEmpty()) + assertEquals(emptyList(), table.client.legalMoves.value) + } + + /** + * A guest can be the one to move first — a card game's turn order follows the + * deal, not the seating — and a guest that can see the table but has nothing + * it is allowed to play is no better off than one that can see nothing. + */ + @Test + fun `a guest on the clock at the deal is given its moves`() = runBlocking { + // The host takes Black, which leaves the guest playing White and first to + // move before either device has done anything. + val table = chess(hostSeat = 1) + table.start() + + assertNotNull(table.client.state.value) + assertEquals( + "The guest has to move but was given nothing to play", + 20, + table.client.legalMoves.value.size, + ) + assertEquals(emptyList(), table.host.legalMoves.value) + } + + @Test + fun `a guest is given its moves when its turn arrives`() = runBlocking { + val table = chess() + table.start() + + assertEquals(emptyList(), table.client.legalMoves.value) + table.host.submit(ChessMove(squareFromName("e2"), squareFromName("e4"))) + table.settle() + // Twenty legal replies to 1. e4. + assertEquals(20, table.client.legalMoves.value.size) + } + + // ----------------------------------------------------------------------- + // Playing and finishing // ----------------------------------------------------------------------- @Test fun `a networked game plays to a result on both devices`() = runBlocking { - val table = table() - table.settle() - - assertEquals(ChessPhase.PLAYING, table.client.state.value?.phase) - // Black is not on the clock, so has nothing to play. - assertEquals(0, table.client.legalMoves.value.size) + val table = chess() + table.start() table.white("f2", "f3") table.black("e7", "e5") @@ -72,9 +189,11 @@ class MatchControllerNetworkTest { assertTrue(table.client.finished.value) } + // ----------------------------------------------------------------------- + // Leaving + // ----------------------------------------------------------------------- + /** - * The regression this file exists for. - * * Leaving a game runs on the thread drawing the screen — Android's main * thread — by way of AppRoot.pop() and PlayScreen's onDispose. It closed the * buffered reader first, which wants the same lock readLine() holds for the @@ -85,8 +204,8 @@ class MatchControllerNetworkTest { @Test fun `leaving a game in progress does not block the thread that leaves`() = runBlocking { for (side in listOf(Side.HOST, Side.GUEST)) { - val table = table() - table.settle() + val table = chess() + table.start() table.white("e2", "e4") // Left on its own thread, and a daemon one: should this ever hang @@ -105,25 +224,25 @@ class MatchControllerNetworkTest { @Test fun `a player leaving is reported to the device still at the table`() = runBlocking { - val guestLeft = table() - guestLeft.settle() + val guestLeft = chess() + guestLeft.start() guestLeft.leave(Side.GUEST) guestLeft.settle() assertEquals("Guest left the game.", guestLeft.host.abandoned.value) - val hostLeft = table() - hostLeft.settle() + val hostLeft = chess() + hostLeft.start() hostLeft.leave(Side.HOST) hostLeft.settle() assertEquals("The host left the game.", hostLeft.client.abandoned.value) // Nothing more can be played on a table with no host. - assertEquals(0, hostLeft.client.legalMoves.value.size) + assertEquals(emptyList(), hostLeft.client.legalMoves.value) } @Test fun `closing a link on purpose is not reported as the other player leaving`() = runBlocking { - val table = table() - table.settle() + val table = chess() + table.start() assertNull(table.host.abandoned.value) // The host closes its own end, as leaving does. Its own screen has no @@ -139,8 +258,15 @@ class MatchControllerNetworkTest { private enum class Side { HOST, GUEST } - /** One two-seat chess table with a real socket between the two devices. */ - private class Table { + /** One two-seat table with a real socket between the two devices. */ + private class Table( + private val rules: GameRules, + ai: GameAi, + gameId: String, + optionsJson: String, + seatCount: Int, + private val hostSeat: Int = HOST_SEAT, + ) { private val server = ServerSocket(0) private val clientSocket = Socket() private val hostSocket: Socket @@ -149,8 +275,8 @@ class MatchControllerNetworkTest { val hostLink: StreamConnection private val clientLink: StreamConnection - val host: MatchController - val client: MatchController + val host: MatchController + val client: MatchController init { clientSocket.connect(InetSocketAddress("127.0.0.1", server.localPort), CONNECT_MILLIS) @@ -175,19 +301,28 @@ class MatchControllerNetworkTest { ) val config = TableConfig( - gameId = GameCatalog.CHESS, - seats = listOf( - PlayerSlot(0, "Host", PlayerKind.HUMAN_LOCAL, team = 0), - PlayerSlot(1, "Guest", PlayerKind.HUMAN_REMOTE, team = 1, peerId = guestPeerId), - ), - optionsJson = Json { encodeDefaults = true }.encodeToString(ChessOptions()), + gameId = gameId, + seats = (0 until seatCount).map { seat -> + PlayerSlot( + seat = seat, + name = if (seat == hostSeat) "Host" else "Guest", + kind = if (seat == hostSeat) { + PlayerKind.HUMAN_LOCAL + } else { + PlayerKind.HUMAN_REMOTE + }, + team = seat, + peerId = if (seat == hostSeat) null else guestPeerId, + ) + }, + optionsJson = optionsJson, seed = 1L, ) fun controller(role: MatchController.Role, seat: Int, scope: CoroutineScope) = - MatchController( - rules = ChessRules, - ai = ChessAi(), + MatchController( + rules = rules, + ai = ai, config = config, scope = scope, role = role, @@ -196,22 +331,44 @@ class MatchControllerNetworkTest { aiThinkingMillis = { 0L }, ) - host = controller(MatchController.Role.HOST, 0, hostScope) - client = controller(MatchController.Role.CLIENT, 1, clientScope) - client.startAsClient(clientLink) + host = controller(MatchController.Role.HOST, hostSeat, hostScope) + client = controller( + MatchController.Role.CLIENT, + (0 until seatCount).first { it != hostSeat }, + clientScope, + ) + + // The lobby is already reading the guest's link when the game starts, + // and goes on reading it for the whole match. Without this the test + // would be the only reader, which is not the arrangement the app has. + clientScope.launch { clientLink.incoming.collect { /* the lobby's collector */ } } + } + + /** + * Starts the match the way the two devices do: the host first, then the + * guest once its screen has been through a frame. Anything the host + * publishes in between is published into a link the guest's match screen + * has not subscribed to yet. + */ + suspend fun start() { host.startAsHost(listOf(hostLink)) + delay(NAVIGATION_MILLIS) + client.startAsClient(clientLink) + settle() } /** Lets whatever is in flight cross the socket and be applied. */ suspend fun settle() = delay(SETTLE_MILLIS) suspend fun white(from: String, to: String) { - host.submit(move(from, to)) + @Suppress("UNCHECKED_CAST") + host.submit(chessMove(from, to) as M) settle() } suspend fun black(from: String, to: String) { - client.submit(move(from, to)) + @Suppress("UNCHECKED_CAST") + client.submit(chessMove(from, to) as M) settle() } @@ -244,19 +401,30 @@ class MatchControllerNetworkTest { clientScope.cancel() } - private fun move(from: String, to: String) = + private fun chessMove(from: String, to: String) = ChessMove(squareFromName(from), squareFromName(to)) } private companion object { + const val HOST_SEAT = 0 + const val GUEST_SEAT = 1 const val CONNECT_MILLIS = 2_000 const val SETTLE_MILLIS = 300L + /** + * Stands in for the guest's trip from the lobby to the table: a state + * change, a recomposition and a LaunchedEffect, none of which the host + * waits for. + */ + const val NAVIGATION_MILLIS = 250L + /** * Generous on purpose: leaving takes single-digit milliseconds, and the * failure this guards against is not a slow close but one that never * returns at all. */ const val BLOCKED_MILLIS = 5_000L + + val testJson = Json { encodeDefaults = true } } } diff --git a/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt b/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt new file mode 100644 index 0000000..210ecc6 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/net/SeatForDeviceTest.kt @@ -0,0 +1,105 @@ +package org.prolibertate.games.net + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * Which seat a guest sits in, worked out from the seat list the host sends. + * + * A guest that gets this wrong draws the wrong seat's hand — and since the host + * redacts every hand but the one it addressed the view to, the wrong seat's hand + * is an empty one. So this is what stands between a guest and a table it cannot + * see itself at. + */ +class SeatForDeviceTest { + + @Test + fun `a guest finds itself by its own device id`() { + val config = table( + host(0), + guest(1, deviceId = "device-a"), + guest(2, deviceId = "device-b"), + ) + + assertEquals(1, seatForDevice(config, "device-a")) + assertEquals(2, seatForDevice(config, "device-b")) + } + + /** + * The regression this exists for. + * + * The seat used to be looked up by [PlayerSlot.peerId], which is the address + * the host's own transport made up for the incoming link — an IP on Wi-Fi, a + * MAC over Bluetooth. A guest has never seen that value and so never matched + * it, and every guest fell through to "the first seat held by somebody + * remote". With one guest that is right by luck. With two, both of them + * claimed seat 1, and the one really sitting in seat 2 was left looking at a + * table with no cards in front of it. + */ + @Test + fun `two guests do not both claim the first remote seat`() { + val config = table( + host(0), + // Filed under a transport address, as the host files them. + guest(1, deviceId = "device-a", peerId = "192.168.1.20"), + guest(2, deviceId = "device-b", peerId = "192.168.1.21"), + ) + + val seats = listOf(seatForDevice(config, "device-a"), seatForDevice(config, "device-b")) + assertEquals(listOf(1, 2), seats) + } + + @Test + fun `seats held by the computer are never claimed by a guest`() { + val config = table(host(0), guest(1, deviceId = "device-a"), ai(2)) + + assertEquals(1, seatForDevice(config, "device-a")) + } + + @Test + fun `a guest dropped for the computer no longer claims its old seat`() { + // What setSeatKind leaves behind when the host drops somebody. + val config = table(host(0), ai(1), guest(2, deviceId = "device-b")) + + assertEquals(2, seatForDevice(config, "device-b")) + } + + /** + * A host on an older version sends no device ids at all. One guest still + * lands in the right seat, which is what keeps a mixed pair of devices + * playable rather than silently wrong. + */ + @Test + fun `a single guest still finds its seat without device ids`() { + val config = table(host(0), guest(1, deviceId = null), ai(2), ai(3)) + + assertEquals(1, seatForDevice(config, "device-a")) + } + + @Test + fun `an unknown device falls back to a seat rather than failing`() { + assertEquals(0, seatForDevice(table(host(0), ai(1)), "who-is-this")) + } + + // ----------------------------------------------------------------------- + + private fun table(vararg seats: PlayerSlot) = TableConfig( + gameId = GameCatalog.CRAZY_EIGHTS, + seats = seats.toList(), + optionsJson = "{}", + seed = 1L, + ) + + private fun host(seat: Int) = + PlayerSlot(seat, "Host", PlayerKind.HUMAN_LOCAL, team = seat) + + private fun guest(seat: Int, deviceId: String?, peerId: String = "10.0.0.$seat") = + PlayerSlot(seat, "Guest $seat", PlayerKind.HUMAN_REMOTE, team = seat, peerId, deviceId) + + private fun ai(seat: Int) = + PlayerSlot(seat, "Computer $seat", PlayerKind.AI, team = seat) +} From 9f71e0844bb254bbe451a383ed489ffc514c0179 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:18:47 +0000 Subject: [PATCH 2/5] =?UTF-8?q?Specify=20Ta=20Y=C3=BC=20ahead=20of=20build?= =?UTF-8?q?ing=20it,=20and=20list=20it=20as=20coming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ta Yü has been out of print since the 2009 Goliath reissue, so the rulebook had to be reconstructed from secondary sources — and this machine's network policy blocks everything but search summaries, so BGG's rules files were out of reach too. The tile set fell out of the geometry rather than needing a component list. A 1x3 tile has eight places a river mouth can sit; three mouths chosen from eight gives 56 arrangements, and turning a tile end for end pairs them off with no arrangement fixed, leaving exactly 28 distinct tiles. The reissue has 84 tiles and the 1999 original had 112 — 3x28 and 4x28. Twelve of the 28 touch three flanks of the rectangle, which is what the ringed centre stud on the reissued tiles marks. Three independent numbers agreeing is why the geometry is worth trusting. What could not be pinned down is called out in the doc rather than smoothed over: the board is taken as 18x18 because that is the reading under which "the four centre indentations" is true, the marked exits are placed at 3, 8 and 13 by choice, and the three-player flood variant is left out entirely because nothing found says how the flood scores, and inventing that would be shipping a different game. Same posture as the Sequence board layout. The catalogue entry is added with available = false, so it shows as coming soon on dev builds and stays out of production until the engine lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof --- README.md | 5 +- .../org/prolibertate/games/game/Catalog.kt | 13 ++ docs/RULES-tayu.md | 201 ++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 docs/RULES-tayu.md diff --git a/README.md b/README.md index 537870c..528a0e3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The app shell is complete and eight games are playable end to end: | | | | --- | --- | | **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess | -| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers | +| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Ta Yü | Unimplemented games appear in the menu marked *Coming soon* **on dev builds only** — a production release lists just the games that can actually be played, @@ -170,6 +170,9 @@ game: - [Wizard](docs/RULES-wizard.md) - [Crazy 8s](docs/RULES-crazy8s.md) - [Chess](docs/RULES-chess.md) +- [Ta Yü](docs/RULES-tayu.md) — **not implemented yet**: this one is the + specification the engine will be written against, and it says which parts of + the out-of-print rulebook had to be reconstructed ## Layout diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 5a09f32..0efcecf 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -38,6 +38,7 @@ object GameCatalog { const val BACKGAMMON = "backgammon" const val CHESS = "chess" const val CHECKERS = "checkers" + const val TAYU = "tayu" val all: List = listOf( GameDescriptor( @@ -150,6 +151,18 @@ object GameCatalog { blurb = "Forced captures, kings, and the long diagonal.", available = false, ), + GameDescriptor( + id = TAYU, + title = "Ta Yü", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 4, + // Two play solo, four play in partnerships across the two axes. + teamBased = true, + blurb = "Lay river tiles out from the centre. Reach both your edges, " + + "because your score multiplies them.", + available = false, + ), ) fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } diff --git a/docs/RULES-tayu.md b/docs/RULES-tayu.md new file mode 100644 index 0000000..5c71b51 --- /dev/null +++ b/docs/RULES-tayu.md @@ -0,0 +1,201 @@ +# Ta Yü — the ruleset to implement + +Ta Yü is a connection game by Niek (Nicolas) Neuwahl, published by Kosmos in +1999 and nominated for the Spiel des Jahres that year; Goliath reissued it in +2009. It is long out of print, which is why it is being rebuilt here. + +**This game is not implemented yet.** This document is the specification the +engine will be written against, and it is deliberately explicit about which +parts are attested by sources and which parts are reconstructed. Read the +[Provenance](#provenance) section before treating any number here as gospel. + +Players lay 1×3 river tiles outwards from the centre of a large board. Each +tile carries three river mouths, and a river mouth may never be walled off +against the blank flank of another tile — so every placement is constrained by +everything already down. One side is trying to run water out to the north and +south edges, the other to the east and west. Scoring multiplies the two +opposite edges together, so a river that reaches only one of your edges is +worth nothing at all. + +## Table + +- **Players — *toggle*.** Two or four. + - **Two.** One player takes north–south, the other east–west. + - **Four.** Two partnerships: seats 0 and 2 play north–south, seats 1 and 3 + play east–west, so partners are never seated next to each other. +- The three-player "flood" variant is **not** being implemented — see + [Known gaps](#known-gaps). + +## Board + +An **18 × 18** grid of cells. Rows are numbered from the north edge, columns +from the west edge. + +Each of the four edges has 18 possible **exits** — one per edge cell — where a +river can run off the board. Three exits on each edge are **marked** and count +double. + +``` + north edge (row 0) + ┌────────────────────┐ + west │ │ east + edge │ 18 × 18 │ edge + │ │ + └────────────────────┘ + south edge (row 17) +``` + +## Tiles + +84 tiles, drawn blind from a bag. Each tile is **1 × 3** and carries exactly +**three river mouths** on its perimeter, all joined to one another by channels +across the face of the tile — so water entering any mouth can leave by either +of the other two. + +A 1×3 tile has eight places a mouth can sit. Taking the tile lying east–west +and its cells numbered 0, 1, 2 from the west: + +| Slot | Where | +| --- | --- | +| `N0` `N1` `N2` | north flank, above each cell | +| `S0` `S1` `S2` | south flank, below each cell | +| `W` | the west end | +| `E` | the east end | + +Choosing 3 of those 8 gives 56 arrangements, and a tile can be turned end for +end, which pairs them off: `N0↔S2`, `N1↔S1`, `N2↔S0`, `W↔E`. No arrangement is +its own mirror under that turn, so the 56 collapse to exactly **28 distinct +tiles**. + +**84 = 3 × 28.** The set is three copies of every tile that can exist. (The +1999 Kosmos edition had 112 tiles — 4 × 28 — and the reissue cut it to three +copies each to shorten the game. Both counts landing on a whole multiple of 28 +is the main reason to trust this reconstruction of the geometry.) + +Of the 28, **12 have their three mouths on three different flanks** of the +rectangle, which matches the reissued tiles being marked with a concentric ring +on the centre stud for exactly that case. + +The engine generates the set rather than listing it, so the tile table cannot +drift out of step with the count. + +## Turn + +1. **Draw** one tile from the bag, face up. There are no hands and no hidden + tiles — everyone sees what you drew, and the only concealed information in + the game is the order of the bag. +2. **Place** it, in any of its four orientations (east–west or north–south, + either way round). +3. If the drawn tile has no legal placement anywhere, it is **set aside** and + the turn passes. It does not come back. + +### Placing the first tile + +The opening tile must cover at least one of the four centre cells — (8,8), +(8,9), (9,8), (9,9). Everything else grows out from it. + +### Placing every tile after the first + +A placement is legal when all of the following hold. + +- **The three cells are empty** and lie in a straight line on the board. +- **Every one of the tile's three mouths is accounted for.** Take each mouth in + turn and look at what is on the other side of it: + - *an empty cell* — fine, the river runs on into open ground; + - *off the board* — fine, and that is a scoring exit (see below); + - *a tile that has a mouth facing back* — fine, the two join up; + - *a tile with a blank flank there* — **illegal**. A river may never dead-end + against the side of another tile. +- **The tile's blank edges must not wall anything off either.** If a blank part + of the new tile lands against an existing tile's mouth, the placement is + illegal, for the same reason read from the other direction. +- **The tile must join the network.** At least one of its mouths must meet a + mouth of a tile already on the board. + +Because every tile joins the network by at least one mouth, and because all +three mouths of a tile are connected across its face, **everything on the board +is always one single river system** running back to the opening tile. The +engine gets connectivity for free and never has to trace it. + +## End of the game + +The game ends when the bag is empty, or when no tile left in the bag can be +placed anywhere. + +## Scoring + +Count each side's exits — mouths that run off the board — on each of its two +edges. + +- An ordinary exit counts **1**. +- One of the three **marked** exits on that edge counts **2**. + +Then, for each side: + +``` +score = (exits on one edge) × (exits on the opposite edge) +``` + +North–south multiplies its north count by its south count; east–west multiplies +east by west. **A side that reaches only one of its two edges scores nothing**, +which is the whole tension of the game: breadth on one edge is worthless +without a matching breakthrough on the other. + +Higher score wins. Equal scores are a draw. + +### Where the marked exits sit + +Positions **3, 8 and 13** along each edge, counted clockwise from that edge's +first cell, giving the board four-fold rotational symmetry so no side is +better placed than another. + +This is a **choice, not a reproduction** — see below. + +## Provenance + +Everything above is reconstructed from secondary sources. The rulebook itself +could not be reached from this machine: outbound web requests are blocked by +the environment's network policy, so only search-result summaries were +available, and BoardGameGeek's rules files were out of reach entirely. + +**Attested by sources:** designer and year; 84 tiles in the reissue and 112 in +the original; 1×3 tiles with three connections each; the board's round +indentations and the opening tile going in one of the four centre ones; the +mouth-accounting placement rule and the ban on a river ending against a blank; +north–south versus east–west; the multiplicative scoring; marked exits counting +double, three to a side; the game ending when nothing can be placed; the +three-player flood variant existing. + +**Reconstructed here, and the parts to check against a real copy:** + +- **The board is 18 × 18.** One source describes the physical board as having + 18 × 18 indentations; several others say 19 × 19. 18 × 18 is used because it + is the one that makes "the four centre indentations" true — a 19 × 19 grid + has a single centre cell, not four. If a rulebook ever settles this the other + way, `BOARD_SIZE` is the only number that has to change. +- **The 28-tile set and its geometry.** Derived, not read off a component list. + It is trusted because 84 and 112 are both exact multiples of 28, and because + 12 of the 28 touch three flanks, matching the ringed tiles in the reissue. +- **All three mouths on a tile interconnect.** Consistent with every + description found, but not stated outright anywhere. +- **The marked exits at 3, 8 and 13.** Their real positions are unknown. Three + mirror-symmetric marks are impossible on an even-length edge, so the real + board cannot be reflectively symmetric per side either; a rotationally + symmetric layout was picked as the fairest guess. This is cosmetic to the + rules and structural only to the final score. +- **An unplaceable drawn tile is set aside and the turn passes.** The + alternative reading is that the game simply ends there. + +This is the same posture as the Sequence board layout: where the real thing +could not be verified, ship an honest substitute and say so, rather than a +guess dressed up as authentic. + +## Known gaps + +- **The three-player flood variant is not implemented.** Sources agree it + exists and that the third player tries to stop the other two reaching the + edges, but nothing found says how the flood *scores*, and inventing a scoring + rule would be making up a different game. Two- and four-player tables cover + the family case; this can be added if the rulebook turns up. +- **No draw-a-replacement rule.** A tile that cannot be placed is gone for + good rather than swapped, which is the simpler of the two readings. From cdd515afc60993b3a3d55145c146137846e78caa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:09:10 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Add=20Ta=20Y=C3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rivers laid out from the centre of an 18x18 board on 1x3 tiles, each with three mouths that must line up with whatever they touch. One side runs water north and south, the other east and west, and a side's score is one edge multiplied by the other — so reaching only one of your two edges is worth nothing, which is the whole tension of the game. Two things fell out of the geometry rather than needing to be tabulated. The tile set is generated: three mouths chosen from the eight places one can sit on a 1x3 tile gives 56 arrangements, turning a tile end for end pairs them off with none fixed, so there are exactly 28 distinct tiles and the box's 84 is three of each. And because every tile joins the network by a mouth while all three of its own mouths interconnect, the board is always one connected river system — so scoring never traces connectivity, and a mouth at the edge is an exit by construction. Both properties are asserted over whole games. The placement check is one comparison doing two jobs: where a new tile abuts an old one, both sides must agree. That refuses a river dead-ending against stone and, read the other way, refuses a blank flank that would wall off a river already down. 18x18 comes out under 20dp a cell on a phone, too fine to tap, so laying a tile is a tap to line it up and a button to commit. Tapping the same cell again cycles the other legal ways the tile could cover it, which is also how it gets turned — a rotate control would have landed on illegal positions. A tap on a cell the tile cannot cover is ignored rather than treated as a deselection, so a fingertip one cell wide of the mark does not throw away a placement. Two AI settings, not three. A blocking-heavy variant was written and dropped because it played the full setting dead even over 30 games; shipping it as a step up would have been a claim that could not be backed. What ships is held to measured numbers: full strength won 20 of 20 against random placement from either axis, gentle beats random about three games in four and loses to full strength about four in five, and random against random comes out even, which is what makes the rest of those numbers mean anything. The reconstruction's uncertainties stay called out in the rules doc rather than being smoothed over now that the code exists: the 18x18 board reading, the marked exits placed at 3, 8 and 13 by choice, and the three-player flood variant left out because nothing found says how the flood scores. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof --- README.md | 18 +- .../org/prolibertate/games/game/Catalog.kt | 2 +- .../prolibertate/games/game/tayu/TayuAi.kt | 86 +++ .../prolibertate/games/game/tayu/TayuModel.kt | 339 +++++++++ .../prolibertate/games/game/tayu/TayuRules.kt | 375 ++++++++++ .../prolibertate/games/ui/GameSetupScreen.kt | 58 +- .../org/prolibertate/games/ui/PlayScreen.kt | 24 + .../prolibertate/games/ui/game/TayuScreen.kt | 686 ++++++++++++++++++ .../prolibertate/games/game/CatalogTest.kt | 1 + .../games/game/tayu/TayuAiTest.kt | 131 ++++ .../games/game/tayu/TayuRulesTest.kt | 512 +++++++++++++ docs/RULES-tayu.md | 272 ++++--- 12 files changed, 2386 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/tayu/TayuModel.kt create mode 100644 app/src/main/java/org/prolibertate/games/game/tayu/TayuRules.kt create mode 100644 app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/tayu/TayuAiTest.kt create mode 100644 app/src/test/java/org/prolibertate/games/game/tayu/TayuRulesTest.kt diff --git a/README.md b/README.md index 528a0e3..7b5d7e1 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth. ## What works today -The app shell is complete and eight games are playable end to end: +The app shell is complete and nine games are playable end to end: | | | | --- | --- | -| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess | -| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Ta Yü | +| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess, Ta Yü | +| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers | Unimplemented games appear in the menu marked *Coming soon* **on dev builds only** — a production release lists just the games that can actually be played, @@ -93,6 +93,11 @@ always square and sized to fit whatever is left after the hand, and card widths are a fraction of the smaller screen dimension. Rotation is handled inside the activity, so a hand is not lost when the device turns. +The Ta Yü board is the tightest fit of the lot — 18 × 18 cells comes out under +20dp a cell on a phone — which is why laying a tile there is a tap to line the +placement up and a button to commit it, rather than a single tap on a target +smaller than a fingertip. + ## Building ```sh @@ -170,9 +175,9 @@ game: - [Wizard](docs/RULES-wizard.md) - [Crazy 8s](docs/RULES-crazy8s.md) - [Chess](docs/RULES-chess.md) -- [Ta Yü](docs/RULES-tayu.md) — **not implemented yet**: this one is the - specification the engine will be written against, and it says which parts of - the out-of-print rulebook had to be reconstructed +- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**: + the game is out of print and the rulebook could not be obtained, so the + document says which parts are attested and which were derived ## Layout @@ -188,6 +193,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested wizard/ Wizard model, rules, AI crazyeights/ Crazy 8s model, rules, AI chess/ Chess model, FEN, move generation, search + tayu/ Ta Yü tile geometry, river rules, scoring, AI net/ wire protocol, LAN and Bluetooth transports, lobby, match driver settings/ DataStore-backed preferences update/ GitHub Releases OTA updater diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 0efcecf..c543f10 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -161,7 +161,7 @@ object GameCatalog { teamBased = true, blurb = "Lay river tiles out from the centre. Reach both your edges, " + "because your score multiplies them.", - available = false, + available = true, ), ) diff --git a/app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt b/app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt new file mode 100644 index 0000000..15e0365 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/tayu/TayuAi.kt @@ -0,0 +1,86 @@ +package org.prolibertate.games.game.tayu + +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic Ta Yü opponent. + * + * It rates every legal placement on the board that placement would produce, + * weighing three things: what its own axis would score, what the opponent's + * would score, and which way the still-open river mouths are pointing. + * + * That last term is what makes it play sensibly at all. For most of a game both + * scores are flatly zero — a product needs *both* edges — so an opponent that + * looked only at the score would have nothing to choose between its first fifty + * placements. Counting open mouths, and how close each one already is to the + * edge it points at, gives it a reason to drive water towards its own sides and + * to turn the opponent's rivers away from theirs. + * + * [level] overrides the table's own setting, which is what the tests use; + * ordinarily it is left null and read from [TayuOptions.level]. + */ +class TayuAi(private val level: TayuLevel? = null) : GameAi { + + override fun chooseMove(state: TayuState, seat: Int, legal: List): TayuMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val setting = level ?: state.options.level + if (legal.size == 1) return legal.first() + return legal.maxByOrNull { rate(state, seat, it, setting) } ?: legal.first() + } + + private fun rate(state: TayuState, seat: Int, move: TayuMove, setting: TayuLevel): Int { + val after = TayuRules.previewPlacement(state, seat, move) + val mine = state.axisOf(seat) + val theirs = if (mine == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH + + // Both edges get a +1 so that the first exit onto an empty edge is worth + // something. Without it every placement rates zero until an axis has + // broken through on both sides, and by then the game is decided. + val exits = TayuRules.exitsOf(after) + val ours = (exits.on(mine.first) + 1) * (exits.on(mine.second) + 1) + val opposing = (exits.on(theirs.first) + 1) * (exits.on(theirs.second) + 1) + + // Skipped rather than multiplied by zero: the gentle setting is the whole + // reason to avoid walking the open mouths at all, and this runs for every + // one of a thousand-odd candidate placements. + val reach = if (setting.reachWeight == 0) { + 0 + } else { + (pull(after, mine) - pull(after, theirs)) / REACH_SCALE + } + + return setting.ownWeight * ours - + setting.blockWeight * opposing + + setting.reachWeight * reach + } + + /** + * How much of the open river is aimed at [axis]: every mouth with empty + * ground in front of it, counted more heavily the closer it already is to + * the edge it points at. + */ + private fun pull(state: TayuState, axis: Axis): Int = + TayuRules.openMouths(state).sumOf { (cell, direction) -> + if (direction != axis.first && direction != axis.second) { + 0 + } else { + BOARD_SIZE - distanceToEdge(cell, direction) + } + } + + private fun distanceToEdge(cell: Int, direction: Facing): Int { + val row = TayuBoard.rowOf(cell) + val col = TayuBoard.colOf(cell) + return when (direction) { + Facing.NORTH -> row + Facing.SOUTH -> BOARD_SIZE - 1 - row + Facing.WEST -> col + Facing.EAST -> BOARD_SIZE - 1 - col + } + } + + private companion object { + /** Brings the open-mouth term onto the same scale as the score term. */ + const val REACH_SCALE = 8 + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/tayu/TayuModel.kt b/app/src/main/java/org/prolibertate/games/game/tayu/TayuModel.kt new file mode 100644 index 0000000..9f3ea3d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/tayu/TayuModel.kt @@ -0,0 +1,339 @@ +package org.prolibertate.games.game.tayu + +import kotlinx.serialization.Serializable + +/** + * The board is 18 × 18. See RULES-tayu.md: sources disagree between this and + * 19 × 19, and 18 is the reading under which the opening tile going into "one + * of the four centre indentations" is true. If a rulebook ever settles it the + * other way this constant is the only thing that has to change. + */ +const val BOARD_SIZE = 18 +const val BOARD_CELLS = BOARD_SIZE * BOARD_SIZE + +/** Every tile is three cells long. */ +const val TILE_LENGTH = 3 + +/** Sentinel in [TayuState.tileAt] for a cell with nothing on it. */ +const val NO_TILE = -1 + +/** + * A compass direction, in clockwise order so that turning is arithmetic. + * + * Rows count from the north edge and columns from the west, so south is a + * larger row — the same convention as the screen. + */ +@Serializable +enum class Facing(val dr: Int, val dc: Int) { + NORTH(-1, 0), + EAST(0, 1), + SOUTH(1, 0), + WEST(0, -1); + + val opposite: Facing get() = Facing.entries[(ordinal + 2) % 4] + val left: Facing get() = Facing.entries[(ordinal + 3) % 4] + val right: Facing get() = Facing.entries[(ordinal + 1) % 4] +} + +/** + * Which flank of a tile a river mouth sits on, described relative to the tile's + * own length rather than to the board. + * + * A tile is laid pointing somewhere — [Facing] — and its three cells run that + * way from the anchor. [FRONT] is the far short end, [BACK] the near one, and + * [LEFT] and [RIGHT] are the two long flanks. Keeping mouths in these terms is + * what lets one stored tile be laid in any of four orientations without a + * lookup table per orientation. + */ +enum class Flank { LEFT, RIGHT, BACK, FRONT } + +/** Where a mouth on [flank] points once the tile is laid pointing [tileFacing]. */ +fun absoluteFacing(tileFacing: Facing, flank: Flank): Facing = when (flank) { + Flank.FRONT -> tileFacing + Flank.BACK -> tileFacing.opposite + Flank.LEFT -> tileFacing.left + Flank.RIGHT -> tileFacing.right +} + +/** + * The eight places a river mouth can sit on a 1 × 3 tile: one above and one + * below each of the three cells, plus one at each short end. + * + * They are numbered so that a whole tile is a bitmask small enough to send as + * one integer. + */ +object TileSlots { + + const val COUNT = 8 + + /** Which of the tile's three cells each slot sits on. */ + private val CELL = intArrayOf(0, 1, 2, 0, 1, 2, 0, 2) + + private val FLANK = arrayOf( + Flank.LEFT, Flank.LEFT, Flank.LEFT, + Flank.RIGHT, Flank.RIGHT, Flank.RIGHT, + Flank.BACK, Flank.FRONT, + ) + + /** + * Slot reached by turning the tile end for end. No slot maps to itself, + * which is exactly why the 56 possible mouth arrangements pair off cleanly + * into 28 distinct tiles rather than leaving some stranded. + */ + private val TURNED = intArrayOf(5, 4, 3, 2, 1, 0, 7, 6) + + fun cellOf(slot: Int): Int = CELL[slot] + + fun flankOf(slot: Int): Flank = FLANK[slot] + + fun turned(slot: Int): Int = TURNED[slot] +} + +/** + * The bag of tiles, generated rather than listed. + * + * A tile is a bitmask over [TileSlots]: exactly three of the eight slots carry + * a river mouth, and all three are joined to one another across the face of the + * tile, so water entering any mouth can leave by either of the others. + * + * Choosing three slots from eight gives 56 arrangements, and turning a tile end + * for end pairs them off with none left over, so there are exactly **28 + * distinct tiles**. The reissued game holds three of each — 84 — and the 1999 + * original held four, 112. Both counts being whole multiples of 28 is the + * evidence this geometry is the right one. + */ +object TayuTiles { + + const val MOUTHS_PER_TILE = 3 + + /** + * Every tile that can exist, one entry each. + * + * Taking the smaller mask of each turned-over pair is enough to pick one + * representative apiece, because no arrangement of three mouths survives + * being turned end for end unchanged. + */ + val all: List = (0 until (1 shl TileSlots.COUNT)) + .filter { it.countOneBits() == MOUTHS_PER_TILE } + .filter { it < turned(it) } + + /** The same mask with the tile turned end for end. */ + fun turned(mask: Int): Int { + var result = 0 + for (slot in 0 until TileSlots.COUNT) { + if (hasMouth(mask, slot)) result = result or (1 shl TileSlots.turned(slot)) + } + return result + } + + fun hasMouth(mask: Int, slot: Int): Boolean = (mask shr slot) and 1 == 1 + + fun mouthSlots(mask: Int): List = + (0 until TileSlots.COUNT).filter { hasMouth(mask, it) } + + /** + * True when the tile's three mouths sit on three different flanks of the + * rectangle. Twelve of the 28 do, which is what the concentric ring on the + * centre stud marks on the reissued tiles — so the same ring is drawn here. + */ + fun touchesThreeFlanks(mask: Int): Boolean = + mouthSlots(mask).map { TileSlots.flankOf(it) }.distinct().size == 3 + + /** The bag for a table: [copies] of every tile, shuffled by the caller. */ + fun bag(copies: Int): List = List(copies) { all }.flatten() +} + +object TayuBoard { + + fun cellAt(row: Int, col: Int): Int = row * BOARD_SIZE + col + + fun rowOf(cell: Int): Int = cell / BOARD_SIZE + + fun colOf(cell: Int): Int = cell % BOARD_SIZE + + fun onBoard(row: Int, col: Int): Boolean = + row in 0 until BOARD_SIZE && col in 0 until BOARD_SIZE + + /** The four middle cells. The opening tile has to cover one of them. */ + val centre: Set = setOf( + cellAt(BOARD_SIZE / 2 - 1, BOARD_SIZE / 2 - 1), + cellAt(BOARD_SIZE / 2 - 1, BOARD_SIZE / 2), + cellAt(BOARD_SIZE / 2, BOARD_SIZE / 2 - 1), + cellAt(BOARD_SIZE / 2, BOARD_SIZE / 2), + ) + + /** + * Exits along an edge that count double. + * + * Positions on the real board are not known — see RULES-tayu.md. These + * three give the board four-fold rotational symmetry, so no side is better + * served than another, which is the fairest guess available. + */ + val markedExits: Set = setOf(3, 8, 13) + + /** + * Where along [edge] a river leaving cell ([row], [col]) runs off the + * board, counted clockwise from that edge's first cell. + */ + fun exitPosition(edge: Facing, row: Int, col: Int): Int = when (edge) { + Facing.NORTH -> col + Facing.EAST -> row + Facing.SOUTH -> BOARD_SIZE - 1 - col + Facing.WEST -> BOARD_SIZE - 1 - row + } + + /** An ordinary exit is worth one; a marked one is worth two. */ + fun exitValue(edge: Facing, row: Int, col: Int): Int = + if (exitPosition(edge, row, col) in markedExits) 2 else 1 + + /** The cell whose exit sits at [position] along [edge]: inverse of [exitPosition]. */ + fun edgeCell(edge: Facing, position: Int): Int = when (edge) { + Facing.NORTH -> cellAt(0, position) + Facing.EAST -> cellAt(position, BOARD_SIZE - 1) + Facing.SOUTH -> cellAt(BOARD_SIZE - 1, BOARD_SIZE - 1 - position) + Facing.WEST -> cellAt(BOARD_SIZE - 1 - position, 0) + } +} + +/** The three cells a tile laid by [move] would cover, from its anchor outwards. */ +fun cellsOf(move: TayuMove): List = (0 until TILE_LENGTH).map { + TayuBoard.cellAt(move.row + it * move.facing.dr, move.col + it * move.facing.dc) +} + +/** The two edges a side is trying to reach. */ +enum class Axis(val label: String, val first: Facing, val second: Facing) { + NORTH_SOUTH("north and south", Facing.NORTH, Facing.SOUTH), + EAST_WEST("east and west", Facing.EAST, Facing.WEST), +} + +/** Team 0 runs its rivers north and south, team 1 east and west. */ +fun axisOfTeam(team: Int): Axis = if (team % 2 == 0) Axis.NORTH_SOUTH else Axis.EAST_WEST + +/** + * How hard the computer plays. + * + * Only two settings, because only two could be shown to differ. [GENTLE] rates + * a placement purely on what it scores at that moment, which for most of a game + * is nothing at all, so it lays a reasonable-looking but aimless river: it beats + * random placement about three games in four, and loses to [FULL] about four in + * five. [FULL] adds blocking and, more to the point, notices which way the open + * river mouths are pointing. Both figures are held to by TayuAiTest. + * + * A third setting that blocked harder was tried and dropped: it played the full + * setting dead even, so shipping it as a step up would have been a claim that + * could not be backed. + */ +@Serializable +enum class TayuLevel( + val label: String, + val ownWeight: Int, + val blockWeight: Int, + val reachWeight: Int, +) { + /** Takes a score when it sees one and otherwise digs more or less anywhere. */ + GENTLE("Gentle", ownWeight = 20, blockWeight = 0, reachWeight = 0), + + /** Builds towards its own edges and spoils yours. */ + FULL("Full strength", ownWeight = 20, blockWeight = 12, reachWeight = 2), +} + +@Serializable +data class TayuOptions( + /** Two play head to head; four play as two partnerships across the axes. */ + val playerCount: Int = 2, + val level: TayuLevel = TayuLevel.FULL, + /** + * Copies of each of the 28 tiles. Three is the reissued game's 84 tiles and + * four is the 1999 original's 112 — the publisher itself cut it to three to + * shorten the game, so two is offered on the same reasoning. + */ + val tileCopies: Int = 3, +) { + init { + require(playerCount == 2 || playerCount == 4) { "Ta Yü seats two or four" } + require(tileCopies in 2..4) { "tileCopies must be 2, 3 or 4" } + } + + val tileCount: Int get() = tileCopies * TayuTiles.all.size +} + +/** A tile on the board: its mouths, where its first cell sits, and which way it points. */ +@Serializable +data class PlacedTile( + val mask: Int, + /** The tile's first cell; the other two run [facing] from here. */ + val row: Int, + val col: Int, + val facing: Facing, + val seat: Int, +) { + /** The three cells this tile covers, from the anchor outwards. */ + fun cells(): List = (0 until TILE_LENGTH).map { + TayuBoard.cellAt(row + it * facing.dr, col + it * facing.dc) + } +} + +@Serializable +enum class TayuPhase { PLAYING, GAME_OVER } + +@Serializable +data class TayuState( + val options: TayuOptions, + val seed: Long, + /** Team index per seat. */ + val teams: List, + /** Index into [placed] for the tile covering each cell, or [NO_TILE]. */ + val tileAt: List, + val placed: List, + /** + * Tiles still in the bag, in the order they will come out. The one piece of + * hidden information in the game, and the only thing [TayuRules.viewFor] + * has to strip. + */ + val bag: List, + /** + * The tile the seat on the clock has drawn and must place. Everyone can see + * it — on the table it is drawn out of the bag face up. + */ + val drawn: Int?, + val bagCount: Int, + /** Drawn tiles that had nowhere legal to go. They do not come back. */ + val setAside: List, + val turn: Int, + val phase: TayuPhase, + val log: List, +) { + val playerCount: Int get() = teams.size + + fun teamOf(seat: Int): Int = teams[seat] + + fun axisOf(seat: Int): Axis = axisOfTeam(teamOf(seat)) +} + +/** + * Laying a tile down: where its first cell goes and which way it points. + * + * The only move there is in Ta Yü, so there is no sealed hierarchy. Which tile + * is being laid is deliberately absent — it can only ever be [TayuState.drawn], + * and a move carrying its own copy could disagree with the state it is applied + * to. + */ +@Serializable +data class TayuMove(val row: Int, val col: Int, val facing: Facing) + +/** Exits reached on each edge, already counting marked ones double. */ +data class EdgeExits(val north: Int, val east: Int, val south: Int, val west: Int) { + + fun on(edge: Facing): Int = when (edge) { + Facing.NORTH -> north + Facing.EAST -> east + Facing.SOUTH -> south + Facing.WEST -> west + } + + /** + * What an axis has actually scored: its two edges multiplied together, so + * reaching only one of them is worth nothing at all. + */ + fun product(axis: Axis): Int = on(axis.first) * on(axis.second) +} diff --git a/app/src/main/java/org/prolibertate/games/game/tayu/TayuRules.kt b/app/src/main/java/org/prolibertate/games/game/tayu/TayuRules.kt new file mode 100644 index 0000000..85480b3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/tayu/TayuRules.kt @@ -0,0 +1,375 @@ +package org.prolibertate.games.game.tayu + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Ta Yü as a pure state machine. See RULES-tayu.md for the ruleset, and for + * which parts of it had to be reconstructed from an out-of-print rulebook. + * + * The one thing worth knowing before reading on: because every tile after the + * first has to join a river already down, and because all three mouths of a + * tile are connected across its face, everything on the board is always a + * single river system running back to the opening tile. Nothing here ever has + * to trace connectivity — a mouth at the board's edge is a scoring exit by + * construction. + */ +object TayuRules : GameRules { + + override val gameId: String = GameCatalog.TAYU + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun initialState(config: TableConfig): TayuState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == options.playerCount) { + "Seat count ${config.seats.size} does not match ${options.playerCount} players" + } + + val bag = TayuTiles.bag(options.tileCopies).shuffledWith(Random(config.seed)) + val teams = (0 until options.playerCount).map { it % 2 } + + val empty = TayuState( + options = options, + seed = config.seed, + teams = teams, + tileAt = List(BOARD_CELLS) { NO_TILE }, + placed = emptyList(), + bag = bag, + drawn = null, + bagCount = bag.size, + setAside = emptyList(), + turn = 0, + phase = TayuPhase.PLAYING, + log = listOf( + "Ta Yü — ${options.tileCount} tiles. " + + "Seat 0 runs north and south, seat 1 east and west." + ), + ) + return openTurn(empty, seat = 0) + } + + override fun currentSeat(state: TayuState): Int? = + if (state.phase == TayuPhase.PLAYING && state.drawn != null) state.turn else null + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + override fun legalMoves(state: TayuState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + return placementsFor(state, state.drawn ?: return emptyList()) + } + + /** + * Every way [tile] can be laid on the board as it stands. + * + * Four orientations at every cell. The anchor is the tile's first cell, so + * laying the same three cells the other way round is a different facing + * from a different anchor rather than a special case. + */ + fun placementsFor(state: TayuState, tile: Int): List { + val mouths = mouthMap(state) + val opening = state.placed.isEmpty() + val moves = mutableListOf() + for (row in 0 until BOARD_SIZE) { + for (col in 0 until BOARD_SIZE) { + for (facing in Facing.entries) { + if (fits(state, mouths, opening, tile, row, col, facing)) { + moves += TayuMove(row, col, facing) + } + } + } + } + return moves + } + + /** + * Whether a tile can be laid here. + * + * The interesting half is the mouth accounting. Walk the tile's eight + * perimeter slots; for each, look at what lies across it: + * + * - off the board — fine either way. A mouth there is a scoring exit, and a + * blank there is just shoreline. + * - an empty cell — fine either way. A river runs on into open ground. + * - another tile — then the two must agree. A mouth must meet a mouth, and + * a blank must meet a blank. + * + * That last comparison is doing double duty: it refuses a river that would + * dead-end against the flank of an existing tile, and in the same breath it + * refuses a placement whose own blank flank would wall off a river already + * on the board. + */ + private fun fits( + state: TayuState, + mouths: IntArray, + opening: Boolean, + tile: Int, + row: Int, + col: Int, + facing: Facing, + ): Boolean { + val rows = IntArray(TILE_LENGTH) + val cols = IntArray(TILE_LENGTH) + for (i in 0 until TILE_LENGTH) { + rows[i] = row + i * facing.dr + cols[i] = col + i * facing.dc + if (!TayuBoard.onBoard(rows[i], cols[i])) return false + if (state.tileAt[TayuBoard.cellAt(rows[i], cols[i])] != NO_TILE) return false + } + + // The opening tile has no river to join, only the centre to cover. + if (opening) { + return (0 until TILE_LENGTH).any { + TayuBoard.cellAt(rows[it], cols[it]) in TayuBoard.centre + } + } + + var joins = 0 + for (slot in 0 until TileSlots.COUNT) { + val i = TileSlots.cellOf(slot) + val direction = absoluteFacing(facing, TileSlots.flankOf(slot)) + val neighbourRow = rows[i] + direction.dr + val neighbourCol = cols[i] + direction.dc + if (!TayuBoard.onBoard(neighbourRow, neighbourCol)) continue + + val neighbour = TayuBoard.cellAt(neighbourRow, neighbourCol) + if (state.tileAt[neighbour] == NO_TILE) continue + + val ours = TayuTiles.hasMouth(tile, slot) + val theirs = hasMouth(mouths, neighbour, direction.opposite) + if (ours != theirs) return false + if (ours) joins++ + } + // Every tile after the first has to join the river already down. + return joins > 0 + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove(state: TayuState, seat: Int, move: TayuMove): TayuState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal placement $move for seat $seat" } + + val placed = previewPlacement(state, seat, move) + val gained = exitsOf(placed).product(placed.axisOf(seat)) - + exitsOf(state).product(state.axisOf(seat)) + + val noted = placed.copy( + log = placed.log + buildString { + append("Seat $seat lays a tile at ") + append(coordinate(move.row, move.col)) + if (gained > 0) append(" — ${placed.axisOf(seat).label} now scores") + append(".") + }, + ) + return openTurn(noted, seat = (seat + 1) % state.playerCount) + } + + /** + * The board with [move] laid on it, and nothing else changed — no draw, no + * change of turn. + * + * Public because the AI leans on it: rating a placement means looking at the + * board it would produce, and going through [applyMove] for that would also + * deal the next tile, which costs far more than the rating does. + */ + fun previewPlacement(state: TayuState, seat: Int, move: TayuMove): TayuState { + val tile = requireNotNull(state.drawn) { "No tile has been drawn" } + val placedTile = PlacedTile( + mask = tile, + row = move.row, + col = move.col, + facing = move.facing, + seat = seat, + ) + val index = state.placed.size + val tileAt = state.tileAt.toMutableList() + placedTile.cells().forEach { tileAt[it] = index } + + return state.copy( + tileAt = tileAt, + placed = state.placed + placedTile, + drawn = null, + ) + } + + /** + * Hands the next tile to [seat]. + * + * A drawn tile that cannot be laid anywhere is set aside and the turn moves + * on, so this keeps drawing — and keeps passing the turn — until it finds a + * tile somebody can actually place. When the bag runs dry the game is over, + * which means the seat on the clock always has at least one legal move. + */ + private fun openTurn(state: TayuState, seat: Int): TayuState { + var bag = state.bag + var setAside = state.setAside + var log = state.log + var turn = seat + + while (bag.isNotEmpty()) { + val tile = bag.first() + bag = bag.drop(1) + val candidate = state.copy( + bag = bag, + bagCount = bag.size, + drawn = tile, + setAside = setAside, + turn = turn, + log = log, + ) + if (placementsFor(candidate, tile).isNotEmpty()) return candidate + + setAside = setAside + tile + log = log + "Seat $turn draws a tile that will not fit anywhere. Set aside." + turn = (turn + 1) % state.playerCount + } + + return state.copy( + bag = emptyList(), + bagCount = 0, + drawn = null, + setAside = setAside, + turn = turn, + phase = TayuPhase.GAME_OVER, + log = log + "The bag is empty. ${summaryOf(state)}", + ) + } + + // ----------------------------------------------------------------------- + // Reading the board + // ----------------------------------------------------------------------- + + /** + * River mouths per cell, as a bitmask over [Facing.ordinal]. + * + * Rebuilt from [TayuState.placed] on demand rather than carried in the + * state, so there is no second copy of the board that could drift out of + * step with the first. + */ + fun mouthMap(state: TayuState): IntArray { + val map = IntArray(BOARD_CELLS) + for (tile in state.placed) { + val cells = tile.cells() + for (slot in TayuTiles.mouthSlots(tile.mask)) { + val cell = cells[TileSlots.cellOf(slot)] + val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot)) + map[cell] = map[cell] or (1 shl direction.ordinal) + } + } + return map + } + + private fun hasMouth(mouths: IntArray, cell: Int, direction: Facing): Boolean = + (mouths[cell] shr direction.ordinal) and 1 == 1 + + /** + * Exits reached on each edge, marked ones already counted double. + * + * An exit is a mouth pointing off the board. It needs no connectivity check + * — see the note on [TayuRules]. + */ + fun exitsOf(state: TayuState): EdgeExits { + val totals = IntArray(4) + for (tile in state.placed) { + val cells = tile.cells() + for (slot in TayuTiles.mouthSlots(tile.mask)) { + val cell = cells[TileSlots.cellOf(slot)] + val row = TayuBoard.rowOf(cell) + val col = TayuBoard.colOf(cell) + val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot)) + if (TayuBoard.onBoard(row + direction.dr, col + direction.dc)) continue + totals[direction.ordinal] += TayuBoard.exitValue(direction, row, col) + } + } + return EdgeExits( + north = totals[Facing.NORTH.ordinal], + east = totals[Facing.EAST.ordinal], + south = totals[Facing.SOUTH.ordinal], + west = totals[Facing.WEST.ordinal], + ) + } + + /** + * Mouths with open ground in front of them: where the river can still grow. + * + * Returned as the cell the mouth is on and the way it points. The AI uses + * this to tell a river heading for its own edge from one heading for the + * opponent's. + */ + fun openMouths(state: TayuState): List> { + val open = mutableListOf>() + for (tile in state.placed) { + val cells = tile.cells() + for (slot in TayuTiles.mouthSlots(tile.mask)) { + val cell = cells[TileSlots.cellOf(slot)] + val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot)) + val row = TayuBoard.rowOf(cell) + direction.dr + val col = TayuBoard.colOf(cell) + direction.dc + if (!TayuBoard.onBoard(row, col)) continue + if (state.tileAt[TayuBoard.cellAt(row, col)] != NO_TILE) continue + open += cell to direction + } + } + return open + } + + /** Points scored by [team], which is its two edges multiplied together. */ + fun scoreOf(state: TayuState, team: Int): Int = + exitsOf(state).product(axisOfTeam(team)) + + /** The winning team, or null for a draw. */ + fun winnerOf(state: TayuState): Int? { + val northSouth = scoreOf(state, 0) + val eastWest = scoreOf(state, 1) + return when { + northSouth > eastWest -> 0 + eastWest > northSouth -> 1 + else -> null + } + } + + // ----------------------------------------------------------------------- + // Results, redaction and wire format + // ----------------------------------------------------------------------- + + override fun isFinished(state: TayuState): Boolean = state.phase == TayuPhase.GAME_OVER + + override fun summary(state: TayuState): String = summaryOf(state) + + private fun summaryOf(state: TayuState): String { + val exits = exitsOf(state) + return "North–south ${exits.north}×${exits.south} = ${exits.product(Axis.NORTH_SOUTH)} " + + "East–west ${exits.east}×${exits.west} = ${exits.product(Axis.EAST_WEST)}" + } + + /** + * The bag's order is the only thing anybody is not entitled to see: the + * board is in front of everyone, and a drawn tile comes out face up. + */ + override fun viewFor(state: TayuState, seat: Int): TayuState = + state.copy(bag = emptyList()) + + override fun encodeState(state: TayuState): String = json.encodeToString(state) + + override fun decodeState(json: String): TayuState = this.json.decodeFromString(json) + + override fun encodeMove(move: TayuMove): String = json.encodeToString(move) + + override fun decodeMove(json: String): TayuMove = this.json.decodeFromString(json) + + /** Board coordinates the way a player would read them out: `f12`. */ + fun coordinate(row: Int, col: Int): String = "${'a' + col}${row + 1}" +} diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index c2552de..314f536 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -38,6 +38,9 @@ import org.prolibertate.games.game.golf.GolfOptions import org.prolibertate.games.game.kaiser.KaiserOptions import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.sequence.SequenceOptions +import org.prolibertate.games.game.tayu.TayuLevel +import org.prolibertate.games.game.tayu.TayuOptions +import org.prolibertate.games.game.tayu.TayuTiles import org.prolibertate.games.game.wizard.WizardOptions private val setupJson = Json { @@ -67,6 +70,7 @@ fun GameSetupScreen( var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) } var wizard by remember { mutableStateOf(WizardOptions()) } var chess by remember { mutableStateOf(ChessOptions()) } + var tayu by remember { mutableStateOf(TayuOptions()) } // Not an engine option: which seat the local player takes. Seat 0 is // always White, so choosing Black means sitting in seat 1. var playWhite by remember { mutableStateOf(true) } @@ -80,6 +84,7 @@ fun GameSetupScreen( GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights) GameCatalog.WIZARD -> setupJson.encodeToString(wizard) GameCatalog.CHESS -> setupJson.encodeToString(chess) + GameCatalog.TAYU -> setupJson.encodeToString(tayu) else -> "{}" } val seatCount = seatCountFor(descriptor.id, optionsJson) @@ -112,6 +117,8 @@ fun GameSetupScreen( onColour = { playWhite = it }, ) + GameCatalog.TAYU -> TayuOptionsEditor(tayu) { tayu = it } + else -> Text("No options yet for this game.") } @@ -190,14 +197,20 @@ fun seatCountFor(gameId: String, optionsJson: String): Int { setupJson.decodeFromString(optionsJson).playerCount GameCatalog.CHESS -> 2 + + GameCatalog.TAYU -> + setupJson.decodeFromString(optionsJson).playerCount + else -> fallback } }.getOrDefault(fallback) } fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) { - // Partners sit opposite each other. - GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2 + // Partners sit opposite each other. In Ta Yü that is also which pair of + // edges you are running your rivers to: even seats north and south, odd + // seats east and west. + GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE, GameCatalog.TAYU -> seat % 2 // Everyone else plays for themselves. else -> seat } @@ -518,6 +531,47 @@ private fun ChessOptionsEditor( } } +@Composable +private fun TayuOptionsEditor(options: TayuOptions, onChange: (TayuOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Players", + values = listOf(2, 4), + selected = options.playerCount, + display = { if (it == 2) "2, head to head" else "4, in partnerships" }, + onSelect = { onChange(options.copy(playerCount = it)) }, + ) + ChipRow( + label = "Opponent", + values = TayuLevel.entries.toList(), + selected = options.level, + display = { it.label }, + onSelect = { onChange(options.copy(level = it)) }, + ) + ChipRow( + label = "Tiles", + values = listOf(2, 3, 4), + selected = options.tileCopies, + display = { copies -> + val count = copies * TayuTiles.all.size + when (copies) { + 3 -> "$count, as reissued" + 4 -> "$count, as first published" + else -> "$count, a short game" + } + }, + onSelect = { onChange(options.copy(tileCopies = it)) }, + ) + Text( + text = "There are ${TayuTiles.all.size} different tiles, and the bag holds " + + "${options.tileCopies} of each. Even seats run their rivers north and " + + "south, odd seats east and west, and a side's score is one edge " + + "multiplied by the other — so reaching only one of them is worth nothing.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun ChipRow( diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index 9ab7e12..28bb1d1 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -40,6 +40,10 @@ import org.prolibertate.games.game.sequence.SequenceAi import org.prolibertate.games.game.sequence.SequenceMove import org.prolibertate.games.game.sequence.SequenceRules import org.prolibertate.games.game.sequence.SequenceState +import org.prolibertate.games.game.tayu.TayuAi +import org.prolibertate.games.game.tayu.TayuMove +import org.prolibertate.games.game.tayu.TayuRules +import org.prolibertate.games.game.tayu.TayuState import org.prolibertate.games.game.wizard.WizardAi import org.prolibertate.games.game.wizard.WizardMove import org.prolibertate.games.game.wizard.WizardPhase @@ -55,6 +59,7 @@ import org.prolibertate.games.ui.game.KaiserScreen import org.prolibertate.games.ui.game.PresidentScreen import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.SequenceScreen +import org.prolibertate.games.ui.game.TayuScreen import org.prolibertate.games.ui.game.WizardScreen /** @@ -278,6 +283,25 @@ fun PlayScreen( CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) } + GameCatalog.TAYU -> { + val controller = remember(route) { + MatchController( + rules = TayuRules, + // As with chess, the strength is part of the table's own + // options, so this does not have to decode anything. + ai = TayuAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + GameCatalog.CHESS -> { val controller = remember(route) { MatchController( diff --git a/app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt new file mode 100644 index 0000000..6499537 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/TayuScreen.kt @@ -0,0 +1,686 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.tayu.Axis +import org.prolibertate.games.game.tayu.BOARD_CELLS +import org.prolibertate.games.game.tayu.BOARD_SIZE +import org.prolibertate.games.game.tayu.Facing +import org.prolibertate.games.game.tayu.TILE_LENGTH +import org.prolibertate.games.game.tayu.TayuBoard +import org.prolibertate.games.game.tayu.TayuMove +import org.prolibertate.games.game.tayu.TayuPhase +import org.prolibertate.games.game.tayu.TayuRules +import org.prolibertate.games.game.tayu.TayuState +import org.prolibertate.games.game.tayu.TayuTiles +import org.prolibertate.games.game.tayu.TileSlots +import org.prolibertate.games.game.tayu.absoluteFacing +import org.prolibertate.games.game.tayu.axisOfTeam +import org.prolibertate.games.game.tayu.cellsOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.TeamColours + +/** + * Board colours, kept off the theme so the board reads the same in either mode. + * + * The water is deliberately a pale blue rather than the team blue: the two axes + * are marked in the team colours, and a river the same colour as a scoring mark + * would be unreadable at this cell size. + */ +private val TileStone = Color(0xFFD8CDB4) +private val TileEdge = Color(0xFF6E6350) +private val River = Color(0xFF7EC8E3) +private val Indentation = Color(0x2AFFFFFF) +private val GhostAmber = Color(0xFFFFC107) +private val ExitRim = Color(0xFFFFFFFF) + +/** + * The Ta Yü board. + * + * 18 × 18 cells is far too fine a grid to tap accurately, so placing a tile is + * two steps rather than one: tap the board to line a placement up, and a button + * commits it. Tapping the same cell again cycles through the other legal ways + * the drawn tile could cover it, which is also how the tile gets turned — the + * tile has four orientations but only some of them fit, and offering a rotate + * control that lands on illegal positions would be worse than useless. + * + * It is drawn on one canvas rather than as 324 composables. The rivers run + * between cells rather than inside them, so nested boxes would have had nowhere + * to put them. + */ +@Composable +fun TayuScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val abandoned by controller.abandoned.collectAsState() + + // The cell last tapped, and the placement currently lined up on it. + var tapped by remember { mutableStateOf(null) } + var lined by remember { mutableStateOf(null) } + var confirmingEnd by remember { mutableStateOf(false) } + + val endGameAction: @Composable () -> Unit = { + TextButton(onClick = { confirmingEnd = true }) { Text("End game") } + } + + val current = state + if (current == null) { + ScreenScaffold(title = "Ta Yü", onBack = onExit, actions = endGameAction) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") } + // A table that never arrives — a host that went away mid-handshake — + // still has to be leavable. + abandoned?.let { + TayuLeftTheTableDialog(it, onExit = onExit, onStay = controller::dismissAbandoned) + } + if (confirmingEnd) { + TayuConfirmEndDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false }) + } + } + return + } + + // A tile laid, or a new one drawn, makes last turn's selection meaningless. + LaunchedEffect(current.placed.size, current.drawn) { + tapped = null + lined = null + } + + val candidatesAt: (Int) -> List = { cell -> + legal.filter { cell in cellsOf(it) } + } + + // Lines up the next legal placement covering the tapped cell. Tapping the + // same cell again moves on to the next way the tile could cover it, which is + // also what the turn button does. + val onCellTapped: (Int) -> Unit = { cell -> + val options = candidatesAt(cell) + // A tap on a cell the tile cannot cover is ignored rather than treated as + // a deselection. Cells are under 20dp across on a phone, so a fingertip + // that lands one cell wide of the mark would otherwise throw away a + // placement that took several taps to line up. + if (options.isNotEmpty()) { + val position = options.indexOf(lined) + lined = if (tapped == cell && position >= 0) { + options[(position + 1) % options.size] + } else { + options.first() + } + tapped = cell + } + } + + // Checked against the live list rather than trusted: the selection is held + // across recompositions and a remote player may have moved in between. + val ready = lined?.takeIf { it in legal } + val alternatives = tapped?.let { candidatesAt(it).size } ?: 0 + + ScreenScaffold(title = "Ta Yü", onBack = onExit, actions = endGameAction) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight + val boardSide = if (wide) { + minOf(maxHeight, maxWidth * 0.60f) + } else { + minOf(maxWidth, maxHeight * 0.66f) + } + + val board: @Composable () -> Unit = { + Board( + state = current, + legal = legal, + lined = ready, + side = boardSide, + onTap = onCellTapped, + ) + } + + val panel: @Composable (Modifier) -> Unit = { panelModifier -> + Column( + modifier = panelModifier + .padding(8.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusPanel(current, localSeat) + DrawnTilePanel( + state = current, + yourTurn = current.turn == localSeat && legal.isNotEmpty(), + ready = ready != null, + alternatives = alternatives, + onTurn = { tapped?.let(onCellTapped) }, + onPlace = { ready?.let { controller.submit(it) } }, + ) + } + } + + if (wide) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + board() + panel(Modifier.weight(1f).fillMaxSize()) + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + board() + panel(Modifier.fillMaxWidth().weight(1f)) + } + } + + // One dialog at a time, most pressing first: a table nobody can + // finish comes before the result of one that did. + val over = abandoned + when { + over != null -> + TayuLeftTheTableDialog( + notice = over, + onExit = onExit, + onStay = controller::dismissAbandoned, + ) + + confirmingEnd -> + TayuConfirmEndDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false }) + + current.phase == TayuPhase.GAME_OVER -> ResultDialog(current, localSeat, onExit) + } + } + } +} + +// --------------------------------------------------------------------------- +// The board +// --------------------------------------------------------------------------- + +/** + * Where everything sits on the canvas. + * + * The tap handler and the drawing both go through this, so a tap always lands + * on the cell it looks like it landed on. + */ +private class BoardGeometry( + /** Room outside the grid for the edge marks and the exits. */ + val margin: Float, + val cellSize: Float, +) { + + /** A whole board filling a square canvas [sidePx] across. */ + constructor(sidePx: Float) : this( + margin = sidePx * MARGIN_FRACTION, + cellSize = (sidePx - 2 * sidePx * MARGIN_FRACTION) / BOARD_SIZE, + ) + + fun centreOf(cell: Int): Offset = Offset( + x = margin + (TayuBoard.colOf(cell) + 0.5f) * cellSize, + y = margin + (TayuBoard.rowOf(cell) + 0.5f) * cellSize, + ) + + /** Steps [distance] cells from [from] in [direction], in canvas units. */ + fun step(from: Offset, direction: Facing, distance: Float): Offset = Offset( + x = from.x + direction.dc * cellSize * distance, + y = from.y + direction.dr * cellSize * distance, + ) + + fun cellAt(offset: Offset): Int? { + // Rejected explicitly rather than left to the conversion: truncation + // rounds towards zero, so a tap in the margin above the grid would + // otherwise land on the first row instead of on nothing. + val x = offset.x - margin + val y = offset.y - margin + if (x < 0f || y < 0f) return null + val col = (x / cellSize).toInt() + val row = (y / cellSize).toInt() + return if (TayuBoard.onBoard(row, col)) TayuBoard.cellAt(row, col) else null + } + + private companion object { + const val MARGIN_FRACTION = 0.045f + } +} + +@Composable +private fun Board( + state: TayuState, + legal: List, + lined: TayuMove?, + side: Dp, + onTap: (Int) -> Unit, +) { + val sidePx = with(LocalDensity.current) { side.toPx() } + val geometry = remember(sidePx) { BoardGeometry(sidePx) } + val handler by rememberUpdatedState(onTap) + + // Only hinted while nothing is lined up: once a placement is chosen the + // hints would compete with the thing the player is looking at. + val hints = remember(legal, lined) { + if (lined != null) emptySet() else legal.flatMap { cellsOf(it) }.toSet() + } + + Canvas( + modifier = Modifier + .size(side) + .pointerInput(geometry) { + detectTapGestures { offset -> geometry.cellAt(offset)?.let(handler) } + } + ) { + drawRect(color = FeltGreenDark) + drawEdges(geometry) + for (cell in 0 until BOARD_CELLS) { + drawCircle(Indentation, geometry.cellSize * 0.10f, geometry.centreOf(cell)) + } + drawMarkedExits(geometry) + + for (tile in state.placed) { + drawTile( + mask = tile.mask, + cells = tile.cells(), + facing = tile.facing, + geometry = geometry, + body = TileStone, + edge = TileEdge, + water = River, + ) + } + drawExits(state, geometry) + + hints.forEach { cell -> + drawCircle( + color = GhostAmber.copy(alpha = 0.35f), + radius = geometry.cellSize * 0.16f, + center = geometry.centreOf(cell), + ) + } + + lined?.let { move -> + drawTile( + mask = state.drawn ?: return@let, + cells = cellsOf(move), + facing = move.facing, + geometry = geometry, + body = GhostAmber.copy(alpha = 0.30f), + edge = GhostAmber, + water = GhostAmber, + ) + } + } +} + +/** The four edges, tinted with the colour of the side trying to reach them. */ +private fun DrawScope.drawEdges(geometry: BoardGeometry) { + val northSouth = TeamColours[0].copy(alpha = 0.55f) + val eastWest = TeamColours[1].copy(alpha = 0.55f) + val thickness = geometry.cellSize * 0.14f + val start = geometry.margin + val end = geometry.margin + BOARD_SIZE * geometry.cellSize + + drawLine(northSouth, Offset(start, start), Offset(end, start), thickness) + drawLine(northSouth, Offset(start, end), Offset(end, end), thickness) + drawLine(eastWest, Offset(end, start), Offset(end, end), thickness) + drawLine(eastWest, Offset(start, start), Offset(start, end), thickness) +} + +/** Rings on the exits that count double. */ +private fun DrawScope.drawMarkedExits(geometry: BoardGeometry) { + for (edge in Facing.entries) { + val colour = if (edge == Facing.NORTH || edge == Facing.SOUTH) { + TeamColours[0] + } else { + TeamColours[1] + } + for (position in TayuBoard.markedExits) { + val at = geometry.step( + from = geometry.centreOf(TayuBoard.edgeCell(edge, position)), + direction = edge, + distance = 0.78f, + ) + drawCircle( + color = colour, + radius = geometry.cellSize * 0.30f, + center = at, + style = Stroke(width = geometry.cellSize * 0.12f), + ) + } + } +} + +/** Water already off the board: one dot per exit, in the colour of the edge. */ +private fun DrawScope.drawExits(state: TayuState, geometry: BoardGeometry) { + for (tile in state.placed) { + val cells = tile.cells() + for (slot in TayuTiles.mouthSlots(tile.mask)) { + val cell = cells[TileSlots.cellOf(slot)] + val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot)) + val row = TayuBoard.rowOf(cell) + direction.dr + val col = TayuBoard.colOf(cell) + direction.dc + if (TayuBoard.onBoard(row, col)) continue + + val centre = geometry.centreOf(cell) + val outside = geometry.step(centre, direction, 0.78f) + drawLine( + color = River, + start = geometry.step(centre, direction, 0.5f), + end = outside, + strokeWidth = geometry.cellSize * 0.20f, + cap = StrokeCap.Round, + ) + val colour = if (direction == Facing.NORTH || direction == Facing.SOUTH) { + TeamColours[0] + } else { + TeamColours[1] + } + drawCircle(ExitRim, geometry.cellSize * 0.22f, outside) + drawCircle(colour, geometry.cellSize * 0.16f, outside) + } + } +} + +/** + * One tile: its body, then a channel from each of its three mouths in to the + * pool at its middle. + * + * Each channel is drawn as two straight runs — mouth to the centre of its own + * cell, then on to the middle of the tile — so the water turns square corners + * instead of cutting diagonally across the stone. + */ +private fun DrawScope.drawTile( + mask: Int, + cells: List, + facing: Facing, + geometry: BoardGeometry, + body: Color, + edge: Color, + water: Color, +) { + val centres = cells.map { geometry.centreOf(it) } + val inset = geometry.cellSize * 0.09f + val half = geometry.cellSize / 2 + + val left = centres.minOf { it.x } - half + inset + val top = centres.minOf { it.y } - half + inset + val right = centres.maxOf { it.x } + half - inset + val bottom = centres.maxOf { it.y } + half - inset + + drawRoundRect( + color = body, + topLeft = Offset(left, top), + size = Size(right - left, bottom - top), + cornerRadius = CornerRadius(geometry.cellSize * 0.22f), + ) + drawRoundRect( + color = edge, + topLeft = Offset(left, top), + size = Size(right - left, bottom - top), + cornerRadius = CornerRadius(geometry.cellSize * 0.22f), + style = Stroke(width = geometry.cellSize * 0.07f), + ) + + val channel = geometry.cellSize * 0.20f + val middle = centres[1] + for (slot in TayuTiles.mouthSlots(mask)) { + val index = TileSlots.cellOf(slot) + val direction = absoluteFacing(facing, TileSlots.flankOf(slot)) + val own = centres[index] + drawLine( + color = water, + start = geometry.step(own, direction, 0.5f), + end = own, + strokeWidth = channel, + cap = StrokeCap.Round, + ) + if (index != 1) { + drawLine(water, own, middle, strokeWidth = channel, cap = StrokeCap.Round) + } + } + drawCircle(water, geometry.cellSize * 0.17f, middle) + if (TayuTiles.touchesThreeFlanks(mask)) { + // The reissued tiles ring the centre stud on exactly these, so the ring + // is here too — it marks a tile that can reach three sides at once. + drawCircle( + color = edge, + radius = geometry.cellSize * 0.30f, + center = middle, + style = Stroke(width = geometry.cellSize * 0.05f), + ) + } +} + +// --------------------------------------------------------------------------- +// The panel +// --------------------------------------------------------------------------- + +@Composable +private fun StatusPanel(state: TayuState, localSeat: Int) { + val exits = TayuRules.exitsOf(state) + val yours = state.axisOf(localSeat) + val theirs = if (yours == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH + + Column { + Text("You run ${yours.label}", fontWeight = FontWeight.Bold) + listOf(yours, theirs).forEach { axis -> + val team = if (axis == Axis.NORTH_SOUTH) 0 else 1 + Text( + text = "${axis.label.replaceFirstChar { it.uppercase() }}: " + + "${exits.on(axis.first)} × ${exits.on(axis.second)} = " + + "${exits.product(axis)}", + style = MaterialTheme.typography.bodyMedium, + color = TeamColours.getOrElse(team) { Color.Gray }, + ) + } + Text( + text = when { + state.phase == TayuPhase.GAME_OVER -> "The bag is empty." + state.turn == localSeat -> "Your turn" + else -> "Seat ${state.turn} is playing…" + }, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "${state.bagCount} tiles left" + + if (state.setAside.isEmpty()) { + "." + } else { + ", ${state.setAside.size} set aside as unplaceable." + }, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +/** The tile just drawn, plus the two controls that lay it down. */ +@Composable +private fun DrawnTilePanel( + state: TayuState, + yourTurn: Boolean, + ready: Boolean, + alternatives: Int, + onTurn: () -> Unit, + onPlace: () -> Unit, +) { + val drawn = state.drawn + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Drawn from the bag", style = MaterialTheme.typography.labelLarge) + if (drawn == null) { + Text("Nothing left to draw.", style = MaterialTheme.typography.bodySmall) + return@Column + } + + TilePreview(mask = drawn, cellSize = 30.dp) + Text( + text = when { + !yourTurn -> "Waiting for the other player." + !ready -> "Tap a highlighted cell to line the tile up there." + alternatives > 1 -> + "Tap the same cell again, or turn the tile, to try another way round." + + else -> "Only one way this tile will fit there." + }, + style = MaterialTheme.typography.bodySmall, + ) + if (yourTurn) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onTurn, enabled = alternatives > 1) { + Text("Turn the tile") + } + Button(onClick = onPlace, enabled = ready) { Text("Lay the tile") } + } + } + } +} + +/** + * The drawn tile, lying east–west as it came out of the bag. + * + * Deliberately not turned to match the placement being lined up: the board + * already shows that, and a preview that kept spinning would be harder to read + * than one that holds still. + */ +@Composable +private fun TilePreview(mask: Int, cellSize: Dp) { + val cellPx = with(LocalDensity.current) { cellSize.toPx() } + // No margin: the three cells start at the canvas origin, so the board's own + // drawing code lays the tile out here without knowing it is off the board. + val geometry = remember(cellPx) { BoardGeometry(margin = 0f, cellSize = cellPx) } + val cells = remember { + (0 until TILE_LENGTH).map { TayuBoard.cellAt(0, it) } + } + + Canvas(modifier = Modifier.width(cellSize * TILE_LENGTH).height(cellSize)) { + drawTile( + mask = mask, + cells = cells, + facing = Facing.EAST, + geometry = geometry, + body = TileStone, + edge = TileEdge, + water = River, + ) + } +} + +@Composable +private fun ResultDialog(state: TayuState, localSeat: Int, onExit: () -> Unit) { + var dismissed by remember(state.phase) { mutableStateOf(false) } + if (dismissed) return + + val exits = TayuRules.exitsOf(state) + val winner = TayuRules.winnerOf(state) + val headline = when { + winner == null -> "A draw" + winner == state.teamOf(localSeat) -> "You win" + else -> "${axisOfTeam(winner).label.replaceFirstChar { it.uppercase() }} wins" + } + + AlertDialog( + onDismissRequest = { dismissed = true }, + title = { Text("Game over") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(headline, fontWeight = FontWeight.Bold) + Text( + text = "North and south: ${exits.north} × ${exits.south} = " + + "${exits.product(Axis.NORTH_SOUTH)}", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "East and west: ${exits.east} × ${exits.west} = " + + "${exits.product(Axis.EAST_WEST)}", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "${state.placed.size} tiles laid" + + if (state.setAside.isEmpty()) { + "." + } else { + ", ${state.setAside.size} that would not fit anywhere." + }, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { + TextButton(onClick = { dismissed = true }) { Text("Look at the board") } + }, + ) +} + +/** + * Leaving on purpose. Ta Yü runs to the whole bag, so a table that has stopped + * being fun needs a way out that does not mean force-quitting the app. + */ +@Composable +private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) { + AlertDialog( + onDismissRequest = onKeepPlaying, + title = { Text("End this game?") }, + text = { + Text( + "This game ends here and the board is not saved. Anyone else at the " + + "table is told you have left." + ) + }, + confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } }, + dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } }, + ) +} + +/** The other end has gone, so no further tile is ever going to arrive. */ +@Composable +private fun TayuLeftTheTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) { + AlertDialog( + onDismissRequest = onStay, + title = { Text("The game has ended") }, + text = { Text("$notice There are no more tiles to lay.") }, + confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } }, + dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index e548ef0..f9e49e6 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -59,6 +59,7 @@ class CatalogTest { GameCatalog.CRAZY_EIGHTS, GameCatalog.WIZARD, GameCatalog.CHESS, + GameCatalog.TAYU, ), GameCatalog.playable.map { it.id }.toSet(), ) diff --git a/app/src/test/java/org/prolibertate/games/game/tayu/TayuAiTest.kt b/app/src/test/java/org/prolibertate/games/game/tayu/TayuAiTest.kt new file mode 100644 index 0000000..8d5a493 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/tayu/TayuAiTest.kt @@ -0,0 +1,131 @@ +package org.prolibertate.games.game.tayu + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Whether the AI is actually playing Ta Yü, and whether the two settings differ. + * + * It needs saying because this game hides a weak opponent well. Scores sit at + * zero for most of a game — a product needs *both* your edges — so an opponent + * placing tiles at random looks exactly like one that is thinking, right up + * until the board fills and the scores come out lopsided. Playing the settings + * off against random placement is the only way to tell them apart. + * + * The margins here are wide on purpose. The numbers observed while tuning were + * 20/20 for full strength against random and 16/20 against the gentle setting, + * from either side of the board; the assertions sit well below that so a change + * of weights has to be a real regression to trip them. + */ +class TayuAiTest { + + private val json = Json { encodeDefaults = true } + + private fun config(seed: Long, options: TayuOptions = TayuOptions()) = TableConfig( + gameId = "tayu", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + /** + * Plays one game out and returns the two scores. A null brain places at + * random from the legal moves, which is the yardstick the AI has to beat. + */ + private fun duel(seed: Long, north: TayuAi?, east: TayuAi?): Pair { + var state = TayuRules.initialState(config(seed)) + val random = Random(seed * 31 + 7) + var guard = 0 + while (!TayuRules.isFinished(state) && guard++ < 400) { + val seat = TayuRules.currentSeat(state) ?: break + val legal = TayuRules.legalMoves(state, seat) + if (legal.isEmpty()) break + val brain = if (seat == 0) north else east + val move = brain?.chooseMove(state, seat, legal) ?: legal[random.nextInt(legal.size)] + state = TayuRules.applyMove(state, seat, move) + } + return TayuRules.scoreOf(state, 0) to TayuRules.scoreOf(state, 1) + } + + /** Games out of [games] the north–south side won. */ + private fun northWins(games: Int, north: TayuAi?, east: TayuAi?): Int = + (1..games).count { seed -> + val (northScore, eastScore) = duel(seed.toLong(), north, east) + northScore > eastScore + } + + private val full = TayuAi(TayuLevel.FULL) + private val gentle = TayuAi(TayuLevel.GENTLE) + + @Test + fun `full strength beats random placement from either side of the board`() { + val games = 20 + val asNorthSouth = northWins(games, north = full, east = null) + val asEastWest = games - northWins(games, north = null, east = full) + println("full strength won $asNorthSouth/$games as north–south, $asEastWest/$games as east–west") + + // Ta Yü is a game of position rather than luck of the draw, so an + // opponent that understands it should win nearly every game against + // random placement — from either axis. + assertTrue("only $asNorthSouth/$games as north–south", asNorthSouth >= games - 1) + assertTrue("only $asEastWest/$games as east–west", asEastWest >= games - 1) + } + + @Test + fun `the gentle setting is weaker without being random`() { + val games = 20 + val fullOverGentle = northWins(games, north = full, east = gentle) + val fullOverGentleReversed = games - northWins(games, north = gentle, east = full) + val gentleOverRandom = northWins(games, north = gentle, east = null) + println( + "full over gentle $fullOverGentle/$games and $fullOverGentleReversed/$games; " + + "gentle over random $gentleOverRandom/$games" + ) + + assertTrue("gentle is not weaker: $fullOverGentle/$games", fullOverGentle >= 13) + assertTrue( + "gentle is not weaker from the other side: $fullOverGentleReversed/$games", + fullOverGentleReversed >= 13, + ) + // But it must still be playing the game, not flailing — otherwise it is + // not a gentler opponent, just a broken one. + assertTrue("gentle is no better than random: $gentleOverRandom/$games", gentleOverRandom >= 12) + } + + @Test + fun `neither axis is favoured when both sides play the same way`() { + // If random against random came out lopsided, the board or the scoring + // would favour one axis and every result above would mean nothing. + val games = 20 + val wins = northWins(games, north = null, east = null) + println("random against random: north–south won $wins/$games") + assertTrue("north–south wins $wins/$games at random", wins in 5..15) + } + + @Test + fun `the ai reaches both of its own edges nearly every game`() { + // The point of the multiplicative scoring: an opponent that only ever + // broke through on one side would score nothing however much of the + // board it covered. + val games = 10 + val scored = (1..games).count { seed -> + duel(seed.toLong(), north = full, east = null).first > 0 + } + println("full strength scored in $scored/$games games") + assertTrue("scored in only $scored/$games games", scored >= 8) + } + + @Test + fun `a table plays at full strength unless it is turned down`() { + assertEquals(TayuLevel.FULL, TayuOptions().level) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/tayu/TayuRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/tayu/TayuRulesTest.kt new file mode 100644 index 0000000..631f752 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/tayu/TayuRulesTest.kt @@ -0,0 +1,512 @@ +package org.prolibertate.games.game.tayu + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import org.junit.Test +import kotlin.random.Random + +class TayuRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config( + options: TayuOptions = TayuOptions(), + seed: Long = 7L, + ) = TableConfig( + gameId = "tayu", + seats = (0 until options.playerCount).map { + PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + // -- The tile set ------------------------------------------------------- + + @Test + fun `there are exactly twenty-eight distinct tiles`() { + // This is the number the whole reconstruction rests on: 84 tiles in the + // reissue is three of each, and the original's 112 is four of each. + assertEquals(28, TayuTiles.all.size) + assertEquals(84, 3 * TayuTiles.all.size) + assertEquals(112, 4 * TayuTiles.all.size) + } + + @Test + fun `every tile has three mouths and no two tiles are the same`() { + TayuTiles.all.forEach { mask -> + assertEquals("$mask", TayuTiles.MOUTHS_PER_TILE, mask.countOneBits()) + } + assertEquals(TayuTiles.all.size, TayuTiles.all.distinct().size) + } + + @Test + fun `no tile is unchanged by being turned end for end`() { + // Which is why the 56 arrangements pair off cleanly into 28 tiles. + TayuTiles.all.forEach { mask -> + assertFalse("$mask is its own turn", mask == TayuTiles.turned(mask)) + assertEquals("turning twice returns", mask, TayuTiles.turned(TayuTiles.turned(mask))) + } + } + + @Test + fun `the set covers every arrangement of three mouths once turns are allowed`() { + val reachable = TayuTiles.all.flatMap { listOf(it, TayuTiles.turned(it)) }.toSet() + val everyArrangement = (0 until (1 shl TileSlots.COUNT)) + .filter { it.countOneBits() == TayuTiles.MOUTHS_PER_TILE } + .toSet() + assertEquals(56, everyArrangement.size) + assertEquals(everyArrangement, reachable) + } + + @Test + fun `twelve tiles reach three flanks at once`() { + // The ones the reissue rings on the centre stud. + assertEquals(12, TayuTiles.all.count { TayuTiles.touchesThreeFlanks(it) }) + } + + // -- Geometry ----------------------------------------------------------- + + @Test + fun `turning the tile turns its mouths with it`() { + // A mouth on the left flank of a tile pointing east faces north; the + // same mouth on a tile pointing north faces west. + assertEquals(Facing.NORTH, absoluteFacing(Facing.EAST, Flank.LEFT)) + assertEquals(Facing.WEST, absoluteFacing(Facing.NORTH, Flank.LEFT)) + assertEquals(Facing.EAST, absoluteFacing(Facing.EAST, Flank.FRONT)) + assertEquals(Facing.WEST, absoluteFacing(Facing.EAST, Flank.BACK)) + assertEquals(Facing.SOUTH, absoluteFacing(Facing.EAST, Flank.RIGHT)) + } + + @Test + fun `exit positions run clockwise and invert cleanly`() { + Facing.entries.forEach { edge -> + for (position in 0 until BOARD_SIZE) { + val cell = TayuBoard.edgeCell(edge, position) + assertEquals( + "$edge $position", + position, + TayuBoard.exitPosition(edge, TayuBoard.rowOf(cell), TayuBoard.colOf(cell)), + ) + } + } + } + + @Test + fun `marked exits count double and there are three to a side`() { + assertEquals(3, TayuBoard.markedExits.size) + val marked = TayuBoard.edgeCell(Facing.NORTH, 8) + assertEquals(2, TayuBoard.exitValue(Facing.NORTH, 0, TayuBoard.colOf(marked))) + val plain = TayuBoard.edgeCell(Facing.NORTH, 0) + assertEquals(1, TayuBoard.exitValue(Facing.NORTH, 0, TayuBoard.colOf(plain))) + } + + @Test + fun `the four centre cells are the middle of the board`() { + assertEquals(4, TayuBoard.centre.size) + assertTrue(TayuBoard.cellAt(8, 8) in TayuBoard.centre) + assertTrue(TayuBoard.cellAt(9, 9) in TayuBoard.centre) + assertFalse(TayuBoard.cellAt(7, 8) in TayuBoard.centre) + } + + // -- Setup -------------------------------------------------------------- + + @Test + fun `the opening tile is drawn and every seat has an axis`() { + val state = TayuRules.initialState(config()) + assertNotNull("a tile is waiting to be placed", state.drawn) + assertEquals(0, state.turn) + assertEquals(84 - 1, state.bagCount) + assertTrue(state.placed.isEmpty()) + assertEquals(Axis.NORTH_SOUTH, state.axisOf(0)) + assertEquals(Axis.EAST_WEST, state.axisOf(1)) + } + + @Test + fun `a four handed table pairs opposite seats`() { + val state = TayuRules.initialState(config(TayuOptions(playerCount = 4))) + assertEquals(listOf(0, 1, 0, 1), state.teams) + assertEquals(state.axisOf(0), state.axisOf(2)) + assertEquals(state.axisOf(1), state.axisOf(3)) + } + + @Test + fun `the bag holds the chosen number of copies of every tile`() { + val state = TayuRules.initialState(config(TayuOptions(tileCopies = 4))) + val counts = (state.bag + listOfNotNull(state.drawn)).groupingBy { it }.eachCount() + assertEquals(28, counts.size) + assertTrue("four of each", counts.values.all { it == 4 }) + } + + // -- Opening placement -------------------------------------------------- + + @Test + fun `the opening tile must cover the centre`() { + val state = TayuRules.initialState(config()) + val moves = TayuRules.legalMoves(state, 0) + assertTrue(moves.isNotEmpty()) + moves.forEach { move -> + assertTrue( + "$move misses the centre", + cellsOf(move).any { it in TayuBoard.centre }, + ) + } + } + + @Test + fun `every tile can open the game`() { + // Nothing in the bag can jam the first turn, so the opening seat always + // has something to do. + val state = TayuRules.initialState(config()) + TayuTiles.all.forEach { tile -> + assertTrue("$tile cannot open", TayuRules.placementsFor(state, tile).isNotEmpty()) + } + } + + @Test + fun `laying the opening tile fills three cells and passes the turn`() { + var state = TayuRules.initialState(config()) + val move = TayuRules.legalMoves(state, 0).first() + state = TayuRules.applyMove(state, 0, move) + + assertEquals(1, state.placed.size) + assertEquals(3, state.tileAt.count { it != NO_TILE }) + cellsOf(move).forEach { assertEquals(0, state.tileAt[it]) } + assertEquals(1, state.turn) + assertNotNull("the next seat has drawn", state.drawn) + } + + // -- Mouth accounting --------------------------------------------------- + + @Test + fun `a river may not dead-end against the flank of another tile`() { + // A tile lying east-west across the centre with a single mouth on its + // north flank, above its middle cell. + val existing = tile( + mouths = listOf(mouth(1, Flank.LEFT), mouth(0, Flank.BACK), mouth(2, Flank.FRONT)), + ) + val state = boardWith( + PlacedTile(existing, row = 9, col = 8, facing = Facing.EAST, seat = 0), + ) + + // Directly above it, a tile whose south flank is blank where the mouth + // below is pointing: the river would run into stone. + val blankUnderneath = tile( + mouths = listOf(mouth(0, Flank.LEFT), mouth(1, Flank.LEFT), mouth(2, Flank.LEFT)), + ) + val moves = TayuRules.placementsFor(state.copy(drawn = blankUnderneath), blankUnderneath) + assertTrue( + "a blank flank must not cover the mouth at (9,9)", + moves.none { move -> TayuBoard.cellAt(8, 9) in cellsOf(move) }, + ) + } + + @Test + fun `a tile must join a river already down`() { + val existing = tile( + mouths = listOf(mouth(0, Flank.BACK), mouth(2, Flank.FRONT), mouth(1, Flank.LEFT)), + ) + val state = boardWith(PlacedTile(existing, 9, 8, Facing.EAST, seat = 0)) + val next = TayuTiles.all.first() + + TayuRules.placementsFor(state.copy(drawn = next), next).forEach { move -> + val cells = cellsOf(move) + val touches = cells.any { cell -> + Facing.entries.any { direction -> + val row = TayuBoard.rowOf(cell) + direction.dr + val col = TayuBoard.colOf(cell) + direction.dc + TayuBoard.onBoard(row, col) && + state.tileAt[TayuBoard.cellAt(row, col)] != NO_TILE + } + } + assertTrue("$move floats free of the board", touches) + } + } + + @Test + fun `a tile may not overlap one already down`() { + val existing = TayuTiles.all.first() + val state = boardWith(PlacedTile(existing, 9, 8, Facing.EAST, seat = 0)) + val occupied = PlacedTile(existing, 9, 8, Facing.EAST, seat = 0).cells().toSet() + + TayuRules.placementsFor(state.copy(drawn = existing), existing).forEach { move -> + assertTrue("$move overlaps", cellsOf(move).none { it in occupied }) + } + } + + @Test + fun `placements stay on the board`() { + val state = TayuRules.initialState(config()) + TayuTiles.all.forEach { tile -> + TayuRules.placementsFor(state, tile).forEach { move -> + cellsOf(move).forEach { cell -> + assertTrue(cell in 0 until BOARD_CELLS) + } + // Three distinct cells in a straight line. + assertEquals(3, cellsOf(move).distinct().size) + } + } + } + + @Test + fun `the four orientations of one tile are four different placements`() { + val state = TayuRules.initialState(config()) + val tile = TayuTiles.all.first { TayuTiles.touchesThreeFlanks(it) } + val atCentre = TayuRules.placementsFor(state, tile) + .filter { it.row == 8 && it.col == 8 } + assertEquals("no duplicates and nothing missing", 4, atCentre.size) + assertEquals(4, atCentre.map { it.facing }.distinct().size) + } + + // -- Exits and scoring -------------------------------------------------- + + @Test + fun `a mouth pointing off the board is an exit`() { + // A tile in the north-west corner: its back mouth points west off the + // board, and one left-flank mouth points north off it. + val mask = tile( + mouths = listOf(mouth(0, Flank.BACK), mouth(0, Flank.LEFT), mouth(2, Flank.FRONT)), + ) + val state = boardWith(PlacedTile(mask, row = 0, col = 0, facing = Facing.EAST, seat = 0)) + val exits = TayuRules.exitsOf(state) + + assertEquals("north exit at column 0", 1, exits.north) + assertEquals("west exit at row 0", 1, exits.west) + assertEquals(0, exits.south) + assertEquals(0, exits.east) + } + + @Test + fun `a marked exit counts twice`() { + // Position 8 on the north edge is marked, which is column 8. + val mask = tile( + mouths = listOf(mouth(0, Flank.LEFT), mouth(0, Flank.BACK), mouth(2, Flank.FRONT)), + ) + val state = boardWith(PlacedTile(mask, row = 0, col = 8, facing = Facing.EAST, seat = 0)) + assertEquals(2, TayuRules.exitsOf(state).north) + } + + @Test + fun `reaching one edge only scores nothing`() { + val mask = tile( + mouths = listOf(mouth(0, Flank.LEFT), mouth(1, Flank.LEFT), mouth(2, Flank.LEFT)), + ) + val state = boardWith(PlacedTile(mask, row = 0, col = 0, facing = Facing.EAST, seat = 0)) + val exits = TayuRules.exitsOf(state) + assertEquals(3, exits.north) + assertEquals(0, exits.south) + assertEquals( + "a river down one side only is worth nothing", + 0, + exits.product(Axis.NORTH_SOUTH), + ) + } + + @Test + fun `a score is one edge multiplied by the other`() { + val exits = EdgeExits(north = 5, east = 2, south = 4, west = 3) + assertEquals(20, exits.product(Axis.NORTH_SOUTH)) + assertEquals(6, exits.product(Axis.EAST_WEST)) + } + + @Test + fun `the higher score wins and equal scores draw`() { + val northSouth = boardWith( + // North and south exits for team 0 only. + PlacedTile( + tile(listOf(mouth(0, Flank.LEFT), mouth(0, Flank.RIGHT), mouth(2, Flank.FRONT))), + row = 0, + col = 0, + facing = Facing.EAST, + seat = 0, + ), + ) + // Only one of the two north-south edges is reached, so still a draw at 0. + assertNull(TayuRules.winnerOf(northSouth)) + } + + // -- Turn order and the end of the game --------------------------------- + + @Test + fun `the seat on the clock always has a legal move`() { + var state = TayuRules.initialState(config()) + val random = Random(4L) + var guard = 0 + while (!TayuRules.isFinished(state) && guard++ < 500) { + val seat = TayuRules.currentSeat(state) + assertNotNull("somebody must be on the clock while playing", seat) + val legal = TayuRules.legalMoves(state, seat!!) + assertTrue("a seat on the clock with nothing to do", legal.isNotEmpty()) + state = TayuRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("the game never ended", TayuRules.isFinished(state)) + } + + @Test + fun `the game ends with the bag empty and every tile accounted for`() { + var state = TayuRules.initialState(config(TayuOptions(tileCopies = 2))) + val total = state.options.tileCount + val random = Random(11L) + var guard = 0 + while (!TayuRules.isFinished(state) && guard++ < 500) { + val seat = TayuRules.currentSeat(state)!! + val legal = TayuRules.legalMoves(state, seat) + state = TayuRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertEquals(0, state.bagCount) + assertNull(state.drawn) + assertEquals(TayuPhase.GAME_OVER, state.phase) + assertEquals( + "laid plus set aside must account for the whole bag", + total, + state.placed.size + state.setAside.size, + ) + } + + @Test + fun `an illegal placement is refused`() { + val state = TayuRules.initialState(config()) + // The far corner cannot be the opening tile: it is nowhere near the centre. + val corner = TayuMove(row = 0, col = 0, facing = Facing.EAST) + assertFalse(corner in TayuRules.legalMoves(state, 0)) + runCatching { TayuRules.applyMove(state, 0, corner) } + .onSuccess { error("an illegal placement was accepted") } + } + + @Test + fun `a seat that is not on the clock has no moves`() { + val state = TayuRules.initialState(config()) + assertTrue(TayuRules.legalMoves(state, 1).isEmpty()) + runCatching { TayuRules.applyMove(state, 1, TayuRules.legalMoves(state, 0).first()) } + .onSuccess { error("a move out of turn was accepted") } + } + + // -- The AI ------------------------------------------------------------- + + @Test + fun `ai play produces only legal moves and reaches a result`() { + val ai = TayuAi() + repeat(2) { iteration -> + var state = TayuRules.initialState( + config(TayuOptions(tileCopies = 2), seed = 300L + iteration) + ) + var guard = 0 + while (!TayuRules.isFinished(state) && guard++ < 500) { + val seat = TayuRules.currentSeat(state)!! + val legal = TayuRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("ai returned an illegal move $move", legal.contains(move)) + state = TayuRules.applyMove(state, seat, move) + } + assertTrue("ai game ran away", guard < 500) + } + } + + @Test + fun `the ai drives water towards its own edges`() { + // Over a full game the AI should reach its own two edges more often than + // it hands exits to the opponent, which is the whole point of the pull + // term in its heuristic. + val ai = TayuAi() + var state = TayuRules.initialState(config(TayuOptions(tileCopies = 2), seed = 42L)) + var guard = 0 + while (!TayuRules.isFinished(state) && guard++ < 500) { + val seat = TayuRules.currentSeat(state)!! + val legal = TayuRules.legalMoves(state, seat) + state = TayuRules.applyMove(state, seat, ai.chooseMove(state, seat, legal)) + } + val exits = TayuRules.exitsOf(state) + assertTrue( + "nobody reached any edge at all: $exits", + exits.north + exits.south + exits.east + exits.west > 0, + ) + } + + // -- Redaction and wire format ----------------------------------------- + + @Test + fun `a seat view hides the bag order but not the board`() { + val state = TayuRules.initialState(config()) + val view = TayuRules.viewFor(state, seat = 1) + assertTrue("the bag order is hidden", view.bag.isEmpty()) + assertEquals("but not how much is left", state.bagCount, view.bagCount) + assertEquals("a drawn tile is face up", state.drawn, view.drawn) + assertEquals(state.tileAt, view.tileAt) + assertEquals(state.placed, view.placed) + } + + @Test + fun `state and moves survive a round trip`() { + var state = TayuRules.initialState(config()) + state = TayuRules.applyMove(state, 0, TayuRules.legalMoves(state, 0).first()) + assertEquals(state, TayuRules.decodeState(TayuRules.encodeState(state))) + + Facing.entries.forEach { facing -> + val move = TayuMove(row = 4, col = 5, facing = facing) + assertEquals(move, TayuRules.decodeMove(TayuRules.encodeMove(move))) + } + } + + @Test + fun `summary reports both products`() { + val state = TayuRules.initialState(config()) + val summary = TayuRules.summary(state) + assertTrue(summary, summary.contains("North–south")) + assertTrue(summary, summary.contains("East–west")) + } + + @Test + fun `unsupported tables are refused`() { + listOf(1, 3, 5).forEach { players -> + runCatching { TayuOptions(playerCount = players) } + .onSuccess { error("$players players should not be allowed") } + } + } + + // -- Helpers ------------------------------------------------------------ + + /** The slot a mouth sits in, from the cell it is on and the flank it faces. */ + private fun mouth(index: Int, flank: Flank): Int = + (0 until TileSlots.COUNT).first { + TileSlots.cellOf(it) == index && TileSlots.flankOf(it) == flank + } + + /** A tile mask built from slots, checked to be one that really exists. */ + private fun tile(mouths: List): Int { + val mask = mouths.fold(0) { acc, slot -> acc or (1 shl slot) } + require(mask.countOneBits() == TayuTiles.MOUTHS_PER_TILE) { "three mouths, not $mouths" } + require(mask in TayuTiles.all || TayuTiles.turned(mask) in TayuTiles.all) { + "no such tile" + } + return mask + } + + /** A board with tiles already laid, ready for the next placement. */ + private fun boardWith(vararg tiles: PlacedTile): TayuState { + val tileAt = MutableList(BOARD_CELLS) { NO_TILE } + tiles.forEachIndexed { index, tile -> tile.cells().forEach { tileAt[it] = index } } + val options = TayuOptions() + return TayuState( + options = options, + seed = 1L, + teams = listOf(0, 1), + tileAt = tileAt, + placed = tiles.toList(), + bag = emptyList(), + drawn = TayuTiles.all.first(), + bagCount = 0, + setAside = emptyList(), + turn = 0, + phase = TayuPhase.PLAYING, + log = emptyList(), + ) + } +} diff --git a/docs/RULES-tayu.md b/docs/RULES-tayu.md index 5c71b51..bf18fc2 100644 --- a/docs/RULES-tayu.md +++ b/docs/RULES-tayu.md @@ -1,59 +1,56 @@ -# Ta Yü — the ruleset to implement +# Ta Yü — implemented rules + +This is the ruleset the engine enforces. Anything marked **toggle** is exposed +in the game setup screen. Ta Yü is a connection game by Niek (Nicolas) Neuwahl, published by Kosmos in 1999 and nominated for the Spiel des Jahres that year; Goliath reissued it in -2009. It is long out of print, which is why it is being rebuilt here. +2009. It has been out of print for years, which is why it is rebuilt here. -**This game is not implemented yet.** This document is the specification the -engine will be written against, and it is deliberately explicit about which -parts are attested by sources and which parts are reconstructed. Read the -[Provenance](#provenance) section before treating any number here as gospel. +Players lay 1×3 river tiles outwards from the centre of a large board. Each tile +carries three river mouths, and a mouth may never be walled off against the +blank flank of another tile — that constraint is what makes every placement +depend on everything already down. One side runs water to the north and south +edges, the other to the east and west. Scoring multiplies your two opposite +edges together, so a river that reaches only one of them is worth nothing at +all. -Players lay 1×3 river tiles outwards from the centre of a large board. Each -tile carries three river mouths, and a river mouth may never be walled off -against the blank flank of another tile — so every placement is constrained by -everything already down. One side is trying to run water out to the north and -south edges, the other to the east and west. Scoring multiplies the two -opposite edges together, so a river that reaches only one of your edges is -worth nothing at all. +**The rulebook could not be obtained, so parts of this are reconstructed.** +Which parts, and how much to trust each, is set out under +[Provenance](#provenance). Read that before treating a number here as gospel. ## Table - **Players — *toggle*.** Two or four. - - **Two.** One player takes north–south, the other east–west. + - **Two.** Seat 0 takes north–south, seat 1 east–west. - **Four.** Two partnerships: seats 0 and 2 play north–south, seats 1 and 3 - play east–west, so partners are never seated next to each other. -- The three-player "flood" variant is **not** being implemented — see - [Known gaps](#known-gaps). + east–west, so partners are never seated next to each other. +- **Opponent — *toggle*.** Gentle or full strength. See + [the computer opponent](#the-computer-opponent). +- **Tiles — *toggle*.** Two, three or four copies of every tile — 56, 84 or 112. + Three is the reissued game and four is the 1999 original. The publisher itself + cut it from four to three to shorten the game, which is the same reasoning + behind offering two. + +The three-player "flood" variant is **not** implemented — see +[Known gaps](#known-gaps). ## Board -An **18 × 18** grid of cells. Rows are numbered from the north edge, columns -from the west edge. +An **18 × 18** grid of cells. Rows count from the north edge, columns from the +west. -Each of the four edges has 18 possible **exits** — one per edge cell — where a -river can run off the board. Three exits on each edge are **marked** and count -double. - -``` - north edge (row 0) - ┌────────────────────┐ - west │ │ east - edge │ 18 × 18 │ edge - │ │ - └────────────────────┘ - south edge (row 17) -``` +Each edge has 18 possible **exits**, one per edge cell, where a river can run +off the board. Three exits on each edge are **marked** and count double. ## Tiles -84 tiles, drawn blind from a bag. Each tile is **1 × 3** and carries exactly -**three river mouths** on its perimeter, all joined to one another by channels -across the face of the tile — so water entering any mouth can leave by either -of the other two. +Each tile is **1 × 3** and carries exactly **three river mouths** on its +perimeter, all joined to one another across the face of the tile — so water +entering any mouth can leave by either of the other two. A 1×3 tile has eight places a mouth can sit. Taking the tile lying east–west -and its cells numbered 0, 1, 2 from the west: +with its cells numbered 0, 1, 2 from the west: | Slot | Where | | --- | --- | @@ -67,135 +64,192 @@ end, which pairs them off: `N0↔S2`, `N1↔S1`, `N2↔S0`, `W↔E`. No arrangem its own mirror under that turn, so the 56 collapse to exactly **28 distinct tiles**. -**84 = 3 × 28.** The set is three copies of every tile that can exist. (The -1999 Kosmos edition had 112 tiles — 4 × 28 — and the reissue cut it to three -copies each to shorten the game. Both counts landing on a whole multiple of 28 -is the main reason to trust this reconstruction of the geometry.) +**84 = 3 × 28.** The reissued set is three copies of every tile that can exist, +and the 1999 original's 112 is four copies. Both counts landing on a whole +multiple of 28 is the main reason to trust this reconstruction of the geometry. Of the 28, **12 have their three mouths on three different flanks** of the rectangle, which matches the reissued tiles being marked with a concentric ring -on the centre stud for exactly that case. +on the centre stud — so those tiles are drawn with a ring here too. -The engine generates the set rather than listing it, so the tile table cannot -drift out of step with the count. +The engine generates the set rather than listing it, so the tiles and the count +cannot drift apart. ## Turn 1. **Draw** one tile from the bag, face up. There are no hands and no hidden - tiles — everyone sees what you drew, and the only concealed information in - the game is the order of the bag. -2. **Place** it, in any of its four orientations (east–west or north–south, - either way round). -3. If the drawn tile has no legal placement anywhere, it is **set aside** and - the turn passes. It does not come back. + tiles: everyone sees what you drew, and the only concealed information in the + whole game is the order of the bag. +2. **Place** it, in any of its four orientations. +3. If the drawn tile has no legal placement anywhere it is **set aside** and the + turn passes. It does not come back. -### Placing the first tile +Because a tile is only ever drawn for a seat that can play it, the player on the +clock always has at least one legal placement. -The opening tile must cover at least one of the four centre cells — (8,8), -(8,9), (9,8), (9,9). Everything else grows out from it. +### The opening tile -### Placing every tile after the first +The first tile must cover at least one of the four centre cells — (8,8), (8,9), +(9,8), (9,9). Every one of the 28 tiles can legally open, so the first turn can +never jam. + +### Every tile after the first A placement is legal when all of the following hold. -- **The three cells are empty** and lie in a straight line on the board. -- **Every one of the tile's three mouths is accounted for.** Take each mouth in - turn and look at what is on the other side of it: +- **The three cells are empty** and in a straight line on the board. +- **Every one of the tile's three mouths is accounted for.** Take each mouth and + look at what lies across it: - *an empty cell* — fine, the river runs on into open ground; - - *off the board* — fine, and that is a scoring exit (see below); - - *a tile that has a mouth facing back* — fine, the two join up; + - *off the board* — fine, and that is a scoring exit; + - *a tile with a mouth facing back* — fine, the two join up; - *a tile with a blank flank there* — **illegal**. A river may never dead-end against the side of another tile. -- **The tile's blank edges must not wall anything off either.** If a blank part - of the new tile lands against an existing tile's mouth, the placement is - illegal, for the same reason read from the other direction. +- **The tile's own blank edges must not wall anything off either.** A blank part + of the new tile landing against an existing tile's mouth is illegal for the + same reason read from the other direction. - **The tile must join the network.** At least one of its mouths must meet a - mouth of a tile already on the board. + mouth of a tile already down. -Because every tile joins the network by at least one mouth, and because all -three mouths of a tile are connected across its face, **everything on the board -is always one single river system** running back to the opening tile. The -engine gets connectivity for free and never has to trace it. +The engine enforces the two blanking rules as a single comparison: where a new +tile abuts an existing one, both sides must agree — mouth to mouth, or blank to +blank. + +One consequence worth knowing, because it is what keeps the engine simple: +since every tile joins the network by a mouth, and all three mouths of a tile +are connected across its face, **everything on the board is always one single +river system** running back to the opening tile. Nothing ever has to trace +connectivity, and a mouth at the board's edge is a scoring exit by construction. +`TayuRulesTest` and the offline verification harness both check this holds over +whole games. ## End of the game -The game ends when the bag is empty, or when no tile left in the bag can be -placed anywhere. +The game ends when the bag is empty, or when nothing left in it can be placed +anywhere. In practice a full 84-tile bag ends with a handful set aside — the +board jams before the bag runs dry. ## Scoring -Count each side's exits — mouths that run off the board — on each of its two -edges. - -- An ordinary exit counts **1**. -- One of the three **marked** exits on that edge counts **2**. - -Then, for each side: +Count each side's exits on each of its two edges. An ordinary exit counts **1**; +one of the three **marked** exits on that edge counts **2**. Then, for each +side: ``` score = (exits on one edge) × (exits on the opposite edge) ``` -North–south multiplies its north count by its south count; east–west multiplies -east by west. **A side that reaches only one of its two edges scores nothing**, -which is the whole tension of the game: breadth on one edge is worthless -without a matching breakthrough on the other. +North–south multiplies its north count by its south; east–west multiplies east +by west. **A side that reaches only one of its two edges scores nothing** — that +is the whole tension of the game, and it happens in real play, not just in +theory. Higher score wins. Equal scores are a draw. ### Where the marked exits sit Positions **3, 8 and 13** along each edge, counted clockwise from that edge's -first cell, giving the board four-fold rotational symmetry so no side is -better placed than another. +first cell, giving the board four-fold rotational symmetry so no side is better +served than another. This is a **choice, not a reproduction** — see below. -This is a **choice, not a reproduction** — see below. +## Playing it on a phone + +18 × 18 cells works out under 20dp a cell on a phone, which is too fine to tap +accurately, so laying a tile is two steps rather than one: + +- **Tap the board** to line a placement up. Cells that the drawn tile could + cover are dotted, and the placement appears as an amber ghost. +- **Tap the same cell again**, or press **Turn the tile**, to cycle through the + other legal ways the tile could cover that cell. This is also how the tile + gets turned — it has four orientations but only some of them fit, and a rotate + control that landed on illegal positions would be worse than useless. +- **Lay the tile** commits it. + +A tap on a cell the tile cannot cover is ignored rather than treated as a +deselection, so a fingertip landing one cell wide does not throw away a +placement that took several taps to line up. + +Rivers are drawn as channels running from each mouth into a pool at the middle +of the tile. Exits show as a dot outside the board in the colour of the side +that edge belongs to, and the four edges are tinted the same way, so it is +possible to see at a glance which rivers are working for whom. + +## The computer opponent + +It rates every legal placement on the board that placement would produce, +weighing what its own axis would score, what the opponent's would score, and +which way the still-open river mouths are pointing. + +That last term is what makes it play sensibly at all. Both scores sit at zero +for most of a game — a product needs both edges — so an opponent that looked +only at the score would have nothing to choose between its first fifty +placements. + +Two settings, because only two could be shown to differ: + +| Setting | What it does | Measured | +| --- | --- | --- | +| **Gentle** | Rates a placement only on what it scores at that moment, which is usually nothing, so it lays a plausible but aimless river | Beats random placement about 3 games in 4; loses to full strength about 4 in 5 | +| **Full strength** | Adds blocking, and notices which way the open mouths point | Won 20 of 20 against random placement, from either axis | + +`TayuAiTest` holds both to those figures, and also checks that random against +random comes out even — if it did not, the board or the scoring would favour an +axis and every other result would be meaningless. + +A third setting that blocked harder was written and dropped: it played full +strength dead even over 30 games, so shipping it as a step up would have been a +claim that could not be backed. ## Provenance -Everything above is reconstructed from secondary sources. The rulebook itself -could not be reached from this machine: outbound web requests are blocked by -the environment's network policy, so only search-result summaries were -available, and BoardGameGeek's rules files were out of reach entirely. +Everything here is reconstructed from secondary sources. The rulebook itself +could not be reached: outbound web requests are blocked by the build +environment's network policy, so only search-result summaries were available, +and BoardGameGeek's rules files were out of reach entirely. **Attested by sources:** designer and year; 84 tiles in the reissue and 112 in the original; 1×3 tiles with three connections each; the board's round -indentations and the opening tile going in one of the four centre ones; the +indentations, and the opening tile going in one of the four centre ones; the mouth-accounting placement rule and the ban on a river ending against a blank; -north–south versus east–west; the multiplicative scoring; marked exits counting +north–south against east–west; the multiplicative scoring; marked exits counting double, three to a side; the game ending when nothing can be placed; the -three-player flood variant existing. +existence of a three-player flood variant. **Reconstructed here, and the parts to check against a real copy:** - **The board is 18 × 18.** One source describes the physical board as having 18 × 18 indentations; several others say 19 × 19. 18 × 18 is used because it - is the one that makes "the four centre indentations" true — a 19 × 19 grid - has a single centre cell, not four. If a rulebook ever settles this the other - way, `BOARD_SIZE` is the only number that has to change. + is the reading under which "the four centre indentations" is true — a 19 × 19 + grid has a single centre cell, not four. `BOARD_SIZE` in `TayuModel.kt` is the + only thing that has to change if a rulebook settles it the other way. - **The 28-tile set and its geometry.** Derived, not read off a component list. It is trusted because 84 and 112 are both exact multiples of 28, and because 12 of the 28 touch three flanks, matching the ringed tiles in the reissue. -- **All three mouths on a tile interconnect.** Consistent with every - description found, but not stated outright anywhere. +- **All three mouths on a tile interconnect.** Consistent with every description + found, but never stated outright. - **The marked exits at 3, 8 and 13.** Their real positions are unknown. Three mirror-symmetric marks are impossible on an even-length edge, so the real - board cannot be reflectively symmetric per side either; a rotationally - symmetric layout was picked as the fairest guess. This is cosmetic to the - rules and structural only to the final score. -- **An unplaceable drawn tile is set aside and the turn passes.** The - alternative reading is that the game simply ends there. + board cannot be reflectively symmetric within a side either; a rotationally + symmetric layout was picked as the fairest guess. Cosmetic to the rules, and + structural only to the final score. +- **An unplaceable drawn tile is set aside and the turn passes.** The other + reading is that the game simply ends there. This is the same posture as the Sequence board layout: where the real thing -could not be verified, ship an honest substitute and say so, rather than a -guess dressed up as authentic. +could not be verified, ship an honest substitute and say so, rather than a guess +dressed up as authentic. ## Known gaps -- **The three-player flood variant is not implemented.** Sources agree it - exists and that the third player tries to stop the other two reaching the - edges, but nothing found says how the flood *scores*, and inventing a scoring - rule would be making up a different game. Two- and four-player tables cover - the family case; this can be added if the rulebook turns up. -- **No draw-a-replacement rule.** A tile that cannot be placed is gone for - good rather than swapped, which is the simpler of the two readings. +- **The three-player flood variant is not implemented.** Sources agree it exists + and that the third player tries to stop the other two reaching the edges, but + nothing found says how the flood *scores*, and inventing a scoring rule would + be shipping a different game. Two- and four-player tables cover the family + case; this can be added if the rulebook turns up. +- **No draw-a-replacement rule.** A tile that cannot be placed is gone for good + rather than swapped, which is the simpler of the two readings. +- **A full 84-tile game is long** — 84 placements, and every one of them is a + real decision. The two- and three-copy settings exist for that reason. +- **The AI does not look ahead.** It rates the board one placement deep. It + plays a strong positional game because the heuristic is aimed at the right + thing, not because it is searching. From 396a36f5292be9f8be00d7f0786acbd55438334a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:59:07 +0000 Subject: [PATCH 4/5] =?UTF-8?q?Publish=20a=20dev=20build=20carrying=20Ta?= =?UTF-8?q?=20Y=C3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ta Yü merge landed on dev without a CI run behind it, so the dev channel still offers the build before it. Nothing in the tree needs to change for a new one: versionCode and versionName are derived from GITHUB_RUN_NUMBER and PLG_CHANNEL at build time, so the version iterates on its own and there is no constant to bump — this commit exists to give ci.yml a push to fire on, which is the only event the publish steps are gated on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof From e17272b909b43136de01415d92c094c425dd185a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 19:04:25 +0000 Subject: [PATCH 5/5] Publish the dev channel from any build of dev, not only a push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev prerelease steps were gated on the event as well as the branch, so a manual run from the Actions tab built, tested and linted dev and then skipped the publish. That matters because a push does not always fire the workflow: anything pushed by an automation token is suppressed by GitHub's own recursion guard, which leaves the dev channel stuck on an older build with no way to move it from the Actions tab. Gated on the branch alone now, which is how release.yml has always gated main — the inconsistency was between the two pipelines, not a deliberate rule. Nothing about the safety property changes: publishing still only happens after the same job has built, tested and linted clean, and a pull request is still excluded without being named, because a PR build runs against refs/pull/N/merge and never refs/heads/dev. PLG_CHANNEL moves to the same condition, which is the part that would bite if it were missed. It decides whether the APK is stamped with its run number, and the publish steps decide whether that APK ships. Leave them out of step and the dev channel gets an APK claiming versionCode 1 — an "update" the installed app can never see as newer than what it already has. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof --- .github/workflows/ci.yml | 28 ++++++++++++++++++++-------- README.md | 19 ++++++++++++++++--- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef71f9b..b817b60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,10 +31,17 @@ jobs: name: Build and check runs-on: ubuntu-latest env: - # Stamp the APK as a dev-channel build only when this is a real push to - # dev. Pull request builds stay unstamped at 1.0.0-dev, since they are - # never published and must not claim a channel version. - PLG_CHANNEL: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/dev') && 'dev' || '' }} + # Stamp the APK as a dev-channel build exactly when it is a build of the + # dev branch, which is exactly when it can be published below. Pull + # request builds run against refs/pull/N/merge, so they stay unstamped at + # 1.0.0-dev: they are never published and must not claim a channel + # version. + # + # This condition has to stay in step with the publish steps' own. Stamping + # a build the publish steps would ship, or shipping one this left + # unstamped, both end the same way — an APK claiming versionCode 1, which + # the updater can never see as an upgrade over anything. + PLG_CHANNEL: ${{ github.ref == 'refs/heads/dev' && 'dev' || '' }} steps: - name: Checkout uses: actions/checkout@v4 @@ -77,11 +84,16 @@ jobs: if-no-files-found: ignore retention-days: 14 - # Everything below publishes the dev channel, and only ever from a push - # to dev that has already built, tested and linted clean above. + # Everything below publishes the dev channel, and only ever from the dev + # branch itself, after it has built, tested and linted clean above. + # + # Gated on the branch alone rather than on the event, which is how + # release.yml has always gated main. A pull request is still excluded + # without needing to name it: a PR build runs against refs/pull/N/merge, + # never refs/heads/dev, so a fork still cannot ship anything. - name: Stage dev APK id: stage - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' run: | set -euo pipefail version="v1.0.${{ github.run_number }}-dev" @@ -92,7 +104,7 @@ jobs: echo "apk=$dest" >> "$GITHUB_OUTPUT" - name: Publish dev channel prerelease - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' env: GH_TOKEN: ${{ github.token }} run: | diff --git a/README.md b/README.md index 7b5d7e1..a8d08af 100644 --- a/README.md +++ b/README.md @@ -139,15 +139,28 @@ Both pipelines publish, to two separate channels: | Channel | Published by | Tag | Marked | | --- | --- | --- | --- | | Production | `release.yml`, push to `main` | `v1.0.` | release | -| Dev | `ci.yml`, push to `dev` | `v1.0.-dev` | prerelease | +| Dev | `ci.yml`, any build of `dev` | `v1.0.-dev` | prerelease | Dev builds are published as **prereleases** deliberately: GitHub's "latest release" endpoint skips prereleases, so a production-channel install can never be handed a dev build even by accident. Pull requests build and check but never publish, so a PR from a fork cannot ship anything. -`release.yml` additionally refuses to run off any branch but `main`, so a manual -dispatch aimed elsewhere does nothing rather than shipping unreviewed code. +**Both pipelines gate publishing on the branch rather than on what triggered +the build**, so a manual run from the Actions tab publishes exactly as a push +does — useful when a push cannot fire the workflow itself, which is the case +for anything pushed by an automation token. The safety property is unchanged, +because publishing still happens only after that same job has built, tested and +linted clean. A pull request is still excluded without having to be named: a PR +build runs against `refs/pull//merge`, never `refs/heads/dev`. + +In `ci.yml` the `PLG_CHANNEL` stamp is gated on the same condition as the +publish steps, and the two have to stay in step. Stamping a build that then +ships, or shipping one that was left unstamped, both end with an APK claiming +`versionCode` 1 — which the updater can never see as an upgrade over anything. + +`release.yml` refuses to run off any branch but `main`, so a manual dispatch +aimed elsewhere does nothing rather than shipping unreviewed code. **The two channels have independent version sequences** — a build's `versionCode` is the run number of the workflow that produced it, and the two workflows count