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
+11 -2
View File
@@ -5,11 +5,11 @@ or against other people over Wi-Fi (LAN) or Bluetooth.
## What works today ## 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 | | **Listed, not yet implemented** | Mastermind, Backgammon, Checkers |
Unimplemented games appear in the menu marked *Coming soon* **on dev builds 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 are a fraction of the smaller screen dimension. Rotation is handled inside the
activity, so a hand is not lost when the device turns. 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 ## Building
```sh ```sh
@@ -170,6 +175,9 @@ game:
- [Wizard](docs/RULES-wizard.md) - [Wizard](docs/RULES-wizard.md)
- [Crazy 8s](docs/RULES-crazy8s.md) - [Crazy 8s](docs/RULES-crazy8s.md)
- [Chess](docs/RULES-chess.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 ## Layout
@@ -185,6 +193,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
wizard/ Wizard model, rules, AI wizard/ Wizard model, rules, AI
crazyeights/ Crazy 8s model, rules, AI crazyeights/ Crazy 8s model, rules, AI
chess/ Chess model, FEN, move generation, search 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 net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
settings/ DataStore-backed preferences settings/ DataStore-backed preferences
update/ GitHub Releases OTA updater update/ GitHub Releases OTA updater
@@ -38,6 +38,7 @@ object GameCatalog {
const val BACKGAMMON = "backgammon" const val BACKGAMMON = "backgammon"
const val CHESS = "chess" const val CHESS = "chess"
const val CHECKERS = "checkers" const val CHECKERS = "checkers"
const val TAYU = "tayu"
val all: List<GameDescriptor> = listOf( val all: List<GameDescriptor> = listOf(
GameDescriptor( GameDescriptor(
@@ -150,6 +151,18 @@ object GameCatalog {
blurb = "Forced captures, kings, and the long diagonal.", blurb = "Forced captures, kings, and the long diagonal.",
available = false, 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 } 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.kaiser.KaiserOptions
import org.prolibertate.games.game.president.PresidentOptions import org.prolibertate.games.game.president.PresidentOptions
import org.prolibertate.games.game.sequence.SequenceOptions 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 import org.prolibertate.games.game.wizard.WizardOptions
private val setupJson = Json { private val setupJson = Json {
@@ -67,6 +70,7 @@ fun GameSetupScreen(
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) } var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
var wizard by remember { mutableStateOf(WizardOptions()) } var wizard by remember { mutableStateOf(WizardOptions()) }
var chess by remember { mutableStateOf(ChessOptions()) } 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 // Not an engine option: which seat the local player takes. Seat 0 is
// always White, so choosing Black means sitting in seat 1. // always White, so choosing Black means sitting in seat 1.
var playWhite by remember { mutableStateOf(true) } var playWhite by remember { mutableStateOf(true) }
@@ -80,6 +84,7 @@ fun GameSetupScreen(
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights) GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
GameCatalog.WIZARD -> setupJson.encodeToString(wizard) GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
GameCatalog.CHESS -> setupJson.encodeToString(chess) GameCatalog.CHESS -> setupJson.encodeToString(chess)
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
else -> "{}" else -> "{}"
} }
val seatCount = seatCountFor(descriptor.id, optionsJson) val seatCount = seatCountFor(descriptor.id, optionsJson)
@@ -112,6 +117,8 @@ fun GameSetupScreen(
onColour = { playWhite = it }, onColour = { playWhite = it },
) )
GameCatalog.TAYU -> TayuOptionsEditor(tayu) { tayu = it }
else -> Text("No options yet for this game.") else -> Text("No options yet for this game.")
} }
@@ -190,14 +197,20 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
GameCatalog.CHESS -> 2 GameCatalog.CHESS -> 2
GameCatalog.TAYU ->
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
else -> fallback else -> fallback
} }
}.getOrDefault(fallback) }.getOrDefault(fallback)
} }
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) { fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
// Partners sit opposite each other. // Partners sit opposite each other. In Ta Yü that is also which pair of
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2 // 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. // Everyone else plays for themselves.
else -> seat 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun <T> ChipRow( 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.SequenceMove
import org.prolibertate.games.game.sequence.SequenceRules import org.prolibertate.games.game.sequence.SequenceRules
import org.prolibertate.games.game.sequence.SequenceState 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.WizardAi
import org.prolibertate.games.game.wizard.WizardMove import org.prolibertate.games.game.wizard.WizardMove
import org.prolibertate.games.game.wizard.WizardPhase 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.PresidentScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
import org.prolibertate.games.ui.game.SequenceScreen import org.prolibertate.games.ui.game.SequenceScreen
import org.prolibertate.games.ui.game.TayuScreen
import org.prolibertate.games.ui.game.WizardScreen import org.prolibertate.games.ui.game.WizardScreen
/** /**
@@ -278,6 +283,25 @@ fun PlayScreen(
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) 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 -> { GameCatalog.CHESS -> {
val controller = remember(route) { val controller = remember(route) {
MatchController<ChessState, ChessMove>( 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.CRAZY_EIGHTS,
GameCatalog.WIZARD, GameCatalog.WIZARD,
GameCatalog.CHESS, GameCatalog.CHESS,
GameCatalog.TAYU,
), ),
GameCatalog.playable.map { it.id }.toSet(), 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(),
)
}
}
+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.