Merge dev: Ta Yü, and the fix that left guests staring at an empty table

Two things reach production together.

Ta Yü is a new board game: 1x3 river tiles laid out from the centre of an
18x18 board, two or four players, scored by multiplying the exits you reach on
one edge by the exits on the opposite one. The game is out of print and its
rulebook could not be obtained, so docs/RULES-tayu.md separates what is
attested from what was reconstructed, and names the three things to check
against a real copy.

The other is the networking fix already on dev: a guest saw nothing until the
next state push, and with more than one guest every device decided it was
sitting in the same seat. Both are worth having in a production build more
than Ta Yü is, since they affect every networked game rather than one new one.

Verified as the merged tree rather than as two separate branches: CI run 26
built, tested and linted e17272b clean, and the published dev APK was opened
to confirm it carries both.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
This commit is contained in:
Claude
2026-08-06 20:13:11 +00:00
20 changed files with 2962 additions and 97 deletions
+20 -8
View File
@@ -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: |
+27 -5
View File
@@ -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.<n>` | release |
| Dev | `ci.yml`, push to `dev` | `v1.0.<n>-dev` | prerelease |
| Dev | `ci.yml`, any build of `dev` | `v1.0.<n>-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/<n>/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
@@ -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<GameDescriptor> = 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 }
@@ -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
@@ -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<TayuState, TayuMove> {
override fun chooseMove(state: TayuState, seat: Int, legal: List<TayuMove>): 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
}
}
@@ -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<Int> = (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<Int> =
(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<Int> = 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<Int> = 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<Int> = 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<Int> = (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<Int> = (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<Int>,
/** Index into [placed] for the tile covering each cell, or [NO_TILE]. */
val tileAt: List<Int>,
val placed: List<PlacedTile>,
/**
* 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<Int>,
/**
* 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<Int>,
val turn: Int,
val phase: TayuPhase,
val log: List<String>,
) {
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)
}
@@ -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<TayuState, TayuMove> {
override val gameId: String = GameCatalog.TAYU
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun initialState(config: TableConfig): TayuState {
val options = json.decodeFromString<TayuOptions>(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<TayuMove> {
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<TayuMove> {
val mouths = mouthMap(state)
val opening = state.placed.isEmpty()
val moves = mutableListOf<TayuMove>()
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<Pair<Int, Facing>> {
val open = mutableListOf<Pair<Int, Facing>>()
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}"
}
@@ -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() }
@@ -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<S : Any, M : Any>(
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<S : Any, M : Any>(
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<S : Any, M : Any>(
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)
}
}
@@ -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
@@ -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<WizardOptions>(optionsJson).playerCount
GameCatalog.CHESS -> 2
GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(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 <T> ChipRow(
@@ -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)
@@ -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<TayuState, TayuMove>(
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<ChessState, ChessMove>(
@@ -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<TayuState, TayuMove>,
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<Int?>(null) }
var lined by remember { mutableStateOf<TayuMove?>(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<TayuMove> = { 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<TayuMove>,
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<Int>,
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") } },
)
}
@@ -59,6 +59,7 @@ class CatalogTest {
GameCatalog.CRAZY_EIGHTS,
GameCatalog.WIZARD,
GameCatalog.CHESS,
GameCatalog.TAYU,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -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<Int, Int> {
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)
}
}
@@ -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>): 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(),
)
}
}
@@ -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<Table>()
private val tables = mutableListOf<Table<*, *>>()
@After
fun tearDown() {
tables.forEach { it.dispose() }
}
private fun table(): Table = Table().also { tables += it }
private fun chess(hostSeat: Int = HOST_SEAT): Table<ChessState, ChessMove> = Table(
rules = ChessRules,
ai = ChessAi(),
gameId = GameCatalog.CHESS,
optionsJson = testJson.encodeToString(ChessOptions()),
seatCount = 2,
hostSeat = hostSeat,
).also { tables += it }
private fun crazyEights(): Table<CrazyEightsState, CrazyEightsMove> = 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<Any>(), 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<CrazyEightsMove>(), 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<ChessMove>(), table.host.legalMoves.value)
}
@Test
fun `a guest is given its moves when its turn arrives`() = runBlocking {
val table = chess()
table.start()
assertEquals(emptyList<ChessMove>(), 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<ChessMove>(), 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<S : Any, M : Any>(
private val rules: GameRules<S, M>,
ai: GameAi<S, M>,
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<ChessState, ChessMove>
val client: MatchController<ChessState, ChessMove>
val host: MatchController<S, M>
val client: MatchController<S, M>
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<ChessState, ChessMove>(
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 }
}
}
@@ -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)
}
+255
View File
@@ -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.