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 537870c..a8d08af 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ 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 | +| **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 @@ -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 @@ -134,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 @@ -170,6 +188,9 @@ game: - [Wizard](docs/RULES-wizard.md) - [Crazy 8s](docs/RULES-crazy8s.md) - [Chess](docs/RULES-chess.md) +- [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 @@ -185,6 +206,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 5a09f32..c543f10 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 = true, + ), ) fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } 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/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/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/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/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/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/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) +} diff --git a/docs/RULES-tayu.md b/docs/RULES-tayu.md new file mode 100644 index 0000000..bf18fc2 --- /dev/null +++ b/docs/RULES-tayu.md @@ -0,0 +1,255 @@ +# 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 has been out of print for years, which is why it is rebuilt here. + +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. + +**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.** Seat 0 takes north–south, seat 1 east–west. + - **Four.** Two partnerships: seats 0 and 2 play north–south, seats 1 and 3 + 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 count from the north edge, columns from the +west. + +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 + +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 +with 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 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 — so those tiles are drawn with a ring here too. + +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 + 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. + +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 + +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 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; + - *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 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 down. + +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 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 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; 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 +served than another. 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 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 +mouth-accounting placement rule and the ban on a river ending against a blank; +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 +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 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 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 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. + +## 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 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.