Merge dev: Ta Yü

Rivers laid out from the centre of an 18x18 board on 1x3 tiles, two or four
players, against a computer opponent held to measured numbers.

Clean merge: the Ta Yü work touches only its own package, its screen, and the
three wiring points every game goes through, so it does not overlap the guest
resync work already on dev. PlayerSlot.deviceId arrived there in the meantime
but the GameRules contract itself is unchanged, so the new engine needed no
adjustment for it.

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 18:16:10 +00:00
12 changed files with 2489 additions and 4 deletions
@@ -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 }
@@ -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}"
}
@@ -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(
@@ -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") } },
)
}