Add Ta Yü
Rivers laid out from the centre of an 18x18 board on 1x3 tiles, each with three mouths that must line up with whatever they touch. One side runs water north and south, the other east and west, and a side's score is one edge multiplied by the other — so reaching only one of your two edges is worth nothing, which is the whole tension of the game. Two things fell out of the geometry rather than needing to be tabulated. The tile set is generated: three mouths chosen from the eight places one can sit on a 1x3 tile gives 56 arrangements, turning a tile end for end pairs them off with none fixed, so there are exactly 28 distinct tiles and the box's 84 is three of each. And because every tile joins the network by a mouth while all three of its own mouths interconnect, the board is always one connected river system — so scoring never traces connectivity, and a mouth at the edge is an exit by construction. Both properties are asserted over whole games. The placement check is one comparison doing two jobs: where a new tile abuts an old one, both sides must agree. That refuses a river dead-ending against stone and, read the other way, refuses a blank flank that would wall off a river already down. 18x18 comes out under 20dp a cell on a phone, too fine to tap, so laying a tile is a tap to line it up and a button to commit. Tapping the same cell again cycles the other legal ways the tile could cover it, which is also how it gets turned — a rotate control would have landed on illegal positions. A tap on a cell the tile cannot cover is ignored rather than treated as a deselection, so a fingertip one cell wide of the mark does not throw away a placement. Two AI settings, not three. A blocking-heavy variant was written and dropped because it played the full setting dead even over 30 games; shipping it as a step up would have been a claim that could not be backed. What ships is held to measured numbers: full strength won 20 of 20 against random placement from either axis, gentle beats random about three games in four and loses to full strength about four in five, and random against random comes out even, which is what makes the rest of those numbers mean anything. The reconstruction's uncertainties stay called out in the rules doc rather than being smoothed over now that the code exists: the 18x18 board reading, the marked exits placed at 3, 8 and 13 by choice, and the three-player flood variant left out because nothing found says how the flood scores. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015pyhC1KyFkQk6hUnnX2Tof
This commit is contained in:
@@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth.
|
||||
|
||||
## What works today
|
||||
|
||||
The app shell is complete and eight games are playable end to end:
|
||||
The app shell is complete and nine games are playable end to end:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess |
|
||||
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers, Ta Yü |
|
||||
| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess, Ta Yü |
|
||||
| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers |
|
||||
|
||||
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
|
||||
only** — a production release lists just the games that can actually be played,
|
||||
@@ -93,6 +93,11 @@ always square and sized to fit whatever is left after the hand, and card widths
|
||||
are a fraction of the smaller screen dimension. Rotation is handled inside the
|
||||
activity, so a hand is not lost when the device turns.
|
||||
|
||||
The Ta Yü board is the tightest fit of the lot — 18 × 18 cells comes out under
|
||||
20dp a cell on a phone — which is why laying a tile there is a tap to line the
|
||||
placement up and a button to commit it, rather than a single tap on a target
|
||||
smaller than a fingertip.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
@@ -170,9 +175,9 @@ game:
|
||||
- [Wizard](docs/RULES-wizard.md)
|
||||
- [Crazy 8s](docs/RULES-crazy8s.md)
|
||||
- [Chess](docs/RULES-chess.md)
|
||||
- [Ta Yü](docs/RULES-tayu.md) — **not implemented yet**: this one is the
|
||||
specification the engine will be written against, and it says which parts of
|
||||
the out-of-print rulebook had to be reconstructed
|
||||
- [Ta Yü](docs/RULES-tayu.md) — **note the caveats about the reconstruction**:
|
||||
the game is out of print and the rulebook could not be obtained, so the
|
||||
document says which parts are attested and which were derived
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -188,6 +193,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested
|
||||
wizard/ Wizard model, rules, AI
|
||||
crazyeights/ Crazy 8s model, rules, AI
|
||||
chess/ Chess model, FEN, move generation, search
|
||||
tayu/ Ta Yü tile geometry, river rules, scoring, AI
|
||||
net/ wire protocol, LAN and Bluetooth transports, lobby, match driver
|
||||
settings/ DataStore-backed preferences
|
||||
update/ GitHub Releases OTA updater
|
||||
|
||||
@@ -161,7 +161,7 @@ object GameCatalog {
|
||||
teamBased = true,
|
||||
blurb = "Lay river tiles out from the centre. Reach both your edges, " +
|
||||
"because your score multiplies them.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.prolibertate.games.game.tayu
|
||||
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A heuristic Ta Yü opponent.
|
||||
*
|
||||
* It rates every legal placement on the board that placement would produce,
|
||||
* weighing three things: what its own axis would score, what the opponent's
|
||||
* would score, and which way the still-open river mouths are pointing.
|
||||
*
|
||||
* That last term is what makes it play sensibly at all. For most of a game both
|
||||
* scores are flatly zero — a product needs *both* edges — so an opponent that
|
||||
* looked only at the score would have nothing to choose between its first fifty
|
||||
* placements. Counting open mouths, and how close each one already is to the
|
||||
* edge it points at, gives it a reason to drive water towards its own sides and
|
||||
* to turn the opponent's rivers away from theirs.
|
||||
*
|
||||
* [level] overrides the table's own setting, which is what the tests use;
|
||||
* ordinarily it is left null and read from [TayuOptions.level].
|
||||
*/
|
||||
class TayuAi(private val level: TayuLevel? = null) : GameAi<TayuState, TayuMove> {
|
||||
|
||||
override fun chooseMove(state: TayuState, seat: Int, legal: List<TayuMove>): TayuMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
return legal.maxByOrNull { rate(state, seat, it, setting) } ?: legal.first()
|
||||
}
|
||||
|
||||
private fun rate(state: TayuState, seat: Int, move: TayuMove, setting: TayuLevel): Int {
|
||||
val after = TayuRules.previewPlacement(state, seat, move)
|
||||
val mine = state.axisOf(seat)
|
||||
val theirs = if (mine == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH
|
||||
|
||||
// Both edges get a +1 so that the first exit onto an empty edge is worth
|
||||
// something. Without it every placement rates zero until an axis has
|
||||
// broken through on both sides, and by then the game is decided.
|
||||
val exits = TayuRules.exitsOf(after)
|
||||
val ours = (exits.on(mine.first) + 1) * (exits.on(mine.second) + 1)
|
||||
val opposing = (exits.on(theirs.first) + 1) * (exits.on(theirs.second) + 1)
|
||||
|
||||
// Skipped rather than multiplied by zero: the gentle setting is the whole
|
||||
// reason to avoid walking the open mouths at all, and this runs for every
|
||||
// one of a thousand-odd candidate placements.
|
||||
val reach = if (setting.reachWeight == 0) {
|
||||
0
|
||||
} else {
|
||||
(pull(after, mine) - pull(after, theirs)) / REACH_SCALE
|
||||
}
|
||||
|
||||
return setting.ownWeight * ours -
|
||||
setting.blockWeight * opposing +
|
||||
setting.reachWeight * reach
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the open river is aimed at [axis]: every mouth with empty
|
||||
* ground in front of it, counted more heavily the closer it already is to
|
||||
* the edge it points at.
|
||||
*/
|
||||
private fun pull(state: TayuState, axis: Axis): Int =
|
||||
TayuRules.openMouths(state).sumOf { (cell, direction) ->
|
||||
if (direction != axis.first && direction != axis.second) {
|
||||
0
|
||||
} else {
|
||||
BOARD_SIZE - distanceToEdge(cell, direction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun distanceToEdge(cell: Int, direction: Facing): Int {
|
||||
val row = TayuBoard.rowOf(cell)
|
||||
val col = TayuBoard.colOf(cell)
|
||||
return when (direction) {
|
||||
Facing.NORTH -> row
|
||||
Facing.SOUTH -> BOARD_SIZE - 1 - row
|
||||
Facing.WEST -> col
|
||||
Facing.EAST -> BOARD_SIZE - 1 - col
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Brings the open-mouth term onto the same scale as the score term. */
|
||||
const val REACH_SCALE = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package org.prolibertate.games.game.tayu
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The board is 18 × 18. See RULES-tayu.md: sources disagree between this and
|
||||
* 19 × 19, and 18 is the reading under which the opening tile going into "one
|
||||
* of the four centre indentations" is true. If a rulebook ever settles it the
|
||||
* other way this constant is the only thing that has to change.
|
||||
*/
|
||||
const val BOARD_SIZE = 18
|
||||
const val BOARD_CELLS = BOARD_SIZE * BOARD_SIZE
|
||||
|
||||
/** Every tile is three cells long. */
|
||||
const val TILE_LENGTH = 3
|
||||
|
||||
/** Sentinel in [TayuState.tileAt] for a cell with nothing on it. */
|
||||
const val NO_TILE = -1
|
||||
|
||||
/**
|
||||
* A compass direction, in clockwise order so that turning is arithmetic.
|
||||
*
|
||||
* Rows count from the north edge and columns from the west, so south is a
|
||||
* larger row — the same convention as the screen.
|
||||
*/
|
||||
@Serializable
|
||||
enum class Facing(val dr: Int, val dc: Int) {
|
||||
NORTH(-1, 0),
|
||||
EAST(0, 1),
|
||||
SOUTH(1, 0),
|
||||
WEST(0, -1);
|
||||
|
||||
val opposite: Facing get() = Facing.entries[(ordinal + 2) % 4]
|
||||
val left: Facing get() = Facing.entries[(ordinal + 3) % 4]
|
||||
val right: Facing get() = Facing.entries[(ordinal + 1) % 4]
|
||||
}
|
||||
|
||||
/**
|
||||
* Which flank of a tile a river mouth sits on, described relative to the tile's
|
||||
* own length rather than to the board.
|
||||
*
|
||||
* A tile is laid pointing somewhere — [Facing] — and its three cells run that
|
||||
* way from the anchor. [FRONT] is the far short end, [BACK] the near one, and
|
||||
* [LEFT] and [RIGHT] are the two long flanks. Keeping mouths in these terms is
|
||||
* what lets one stored tile be laid in any of four orientations without a
|
||||
* lookup table per orientation.
|
||||
*/
|
||||
enum class Flank { LEFT, RIGHT, BACK, FRONT }
|
||||
|
||||
/** Where a mouth on [flank] points once the tile is laid pointing [tileFacing]. */
|
||||
fun absoluteFacing(tileFacing: Facing, flank: Flank): Facing = when (flank) {
|
||||
Flank.FRONT -> tileFacing
|
||||
Flank.BACK -> tileFacing.opposite
|
||||
Flank.LEFT -> tileFacing.left
|
||||
Flank.RIGHT -> tileFacing.right
|
||||
}
|
||||
|
||||
/**
|
||||
* The eight places a river mouth can sit on a 1 × 3 tile: one above and one
|
||||
* below each of the three cells, plus one at each short end.
|
||||
*
|
||||
* They are numbered so that a whole tile is a bitmask small enough to send as
|
||||
* one integer.
|
||||
*/
|
||||
object TileSlots {
|
||||
|
||||
const val COUNT = 8
|
||||
|
||||
/** Which of the tile's three cells each slot sits on. */
|
||||
private val CELL = intArrayOf(0, 1, 2, 0, 1, 2, 0, 2)
|
||||
|
||||
private val FLANK = arrayOf(
|
||||
Flank.LEFT, Flank.LEFT, Flank.LEFT,
|
||||
Flank.RIGHT, Flank.RIGHT, Flank.RIGHT,
|
||||
Flank.BACK, Flank.FRONT,
|
||||
)
|
||||
|
||||
/**
|
||||
* Slot reached by turning the tile end for end. No slot maps to itself,
|
||||
* which is exactly why the 56 possible mouth arrangements pair off cleanly
|
||||
* into 28 distinct tiles rather than leaving some stranded.
|
||||
*/
|
||||
private val TURNED = intArrayOf(5, 4, 3, 2, 1, 0, 7, 6)
|
||||
|
||||
fun cellOf(slot: Int): Int = CELL[slot]
|
||||
|
||||
fun flankOf(slot: Int): Flank = FLANK[slot]
|
||||
|
||||
fun turned(slot: Int): Int = TURNED[slot]
|
||||
}
|
||||
|
||||
/**
|
||||
* The bag of tiles, generated rather than listed.
|
||||
*
|
||||
* A tile is a bitmask over [TileSlots]: exactly three of the eight slots carry
|
||||
* a river mouth, and all three are joined to one another across the face of the
|
||||
* tile, so water entering any mouth can leave by either of the others.
|
||||
*
|
||||
* Choosing three slots from eight gives 56 arrangements, and turning a tile end
|
||||
* for end pairs them off with none left over, so there are exactly **28
|
||||
* distinct tiles**. The reissued game holds three of each — 84 — and the 1999
|
||||
* original held four, 112. Both counts being whole multiples of 28 is the
|
||||
* evidence this geometry is the right one.
|
||||
*/
|
||||
object TayuTiles {
|
||||
|
||||
const val MOUTHS_PER_TILE = 3
|
||||
|
||||
/**
|
||||
* Every tile that can exist, one entry each.
|
||||
*
|
||||
* Taking the smaller mask of each turned-over pair is enough to pick one
|
||||
* representative apiece, because no arrangement of three mouths survives
|
||||
* being turned end for end unchanged.
|
||||
*/
|
||||
val all: List<Int> = (0 until (1 shl TileSlots.COUNT))
|
||||
.filter { it.countOneBits() == MOUTHS_PER_TILE }
|
||||
.filter { it < turned(it) }
|
||||
|
||||
/** The same mask with the tile turned end for end. */
|
||||
fun turned(mask: Int): Int {
|
||||
var result = 0
|
||||
for (slot in 0 until TileSlots.COUNT) {
|
||||
if (hasMouth(mask, slot)) result = result or (1 shl TileSlots.turned(slot))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun hasMouth(mask: Int, slot: Int): Boolean = (mask shr slot) and 1 == 1
|
||||
|
||||
fun mouthSlots(mask: Int): List<Int> =
|
||||
(0 until TileSlots.COUNT).filter { hasMouth(mask, it) }
|
||||
|
||||
/**
|
||||
* True when the tile's three mouths sit on three different flanks of the
|
||||
* rectangle. Twelve of the 28 do, which is what the concentric ring on the
|
||||
* centre stud marks on the reissued tiles — so the same ring is drawn here.
|
||||
*/
|
||||
fun touchesThreeFlanks(mask: Int): Boolean =
|
||||
mouthSlots(mask).map { TileSlots.flankOf(it) }.distinct().size == 3
|
||||
|
||||
/** The bag for a table: [copies] of every tile, shuffled by the caller. */
|
||||
fun bag(copies: Int): List<Int> = List(copies) { all }.flatten()
|
||||
}
|
||||
|
||||
object TayuBoard {
|
||||
|
||||
fun cellAt(row: Int, col: Int): Int = row * BOARD_SIZE + col
|
||||
|
||||
fun rowOf(cell: Int): Int = cell / BOARD_SIZE
|
||||
|
||||
fun colOf(cell: Int): Int = cell % BOARD_SIZE
|
||||
|
||||
fun onBoard(row: Int, col: Int): Boolean =
|
||||
row in 0 until BOARD_SIZE && col in 0 until BOARD_SIZE
|
||||
|
||||
/** The four middle cells. The opening tile has to cover one of them. */
|
||||
val centre: Set<Int> = setOf(
|
||||
cellAt(BOARD_SIZE / 2 - 1, BOARD_SIZE / 2 - 1),
|
||||
cellAt(BOARD_SIZE / 2 - 1, BOARD_SIZE / 2),
|
||||
cellAt(BOARD_SIZE / 2, BOARD_SIZE / 2 - 1),
|
||||
cellAt(BOARD_SIZE / 2, BOARD_SIZE / 2),
|
||||
)
|
||||
|
||||
/**
|
||||
* Exits along an edge that count double.
|
||||
*
|
||||
* Positions on the real board are not known — see RULES-tayu.md. These
|
||||
* three give the board four-fold rotational symmetry, so no side is better
|
||||
* served than another, which is the fairest guess available.
|
||||
*/
|
||||
val markedExits: Set<Int> = setOf(3, 8, 13)
|
||||
|
||||
/**
|
||||
* Where along [edge] a river leaving cell ([row], [col]) runs off the
|
||||
* board, counted clockwise from that edge's first cell.
|
||||
*/
|
||||
fun exitPosition(edge: Facing, row: Int, col: Int): Int = when (edge) {
|
||||
Facing.NORTH -> col
|
||||
Facing.EAST -> row
|
||||
Facing.SOUTH -> BOARD_SIZE - 1 - col
|
||||
Facing.WEST -> BOARD_SIZE - 1 - row
|
||||
}
|
||||
|
||||
/** An ordinary exit is worth one; a marked one is worth two. */
|
||||
fun exitValue(edge: Facing, row: Int, col: Int): Int =
|
||||
if (exitPosition(edge, row, col) in markedExits) 2 else 1
|
||||
|
||||
/** The cell whose exit sits at [position] along [edge]: inverse of [exitPosition]. */
|
||||
fun edgeCell(edge: Facing, position: Int): Int = when (edge) {
|
||||
Facing.NORTH -> cellAt(0, position)
|
||||
Facing.EAST -> cellAt(position, BOARD_SIZE - 1)
|
||||
Facing.SOUTH -> cellAt(BOARD_SIZE - 1, BOARD_SIZE - 1 - position)
|
||||
Facing.WEST -> cellAt(BOARD_SIZE - 1 - position, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** The three cells a tile laid by [move] would cover, from its anchor outwards. */
|
||||
fun cellsOf(move: TayuMove): List<Int> = (0 until TILE_LENGTH).map {
|
||||
TayuBoard.cellAt(move.row + it * move.facing.dr, move.col + it * move.facing.dc)
|
||||
}
|
||||
|
||||
/** The two edges a side is trying to reach. */
|
||||
enum class Axis(val label: String, val first: Facing, val second: Facing) {
|
||||
NORTH_SOUTH("north and south", Facing.NORTH, Facing.SOUTH),
|
||||
EAST_WEST("east and west", Facing.EAST, Facing.WEST),
|
||||
}
|
||||
|
||||
/** Team 0 runs its rivers north and south, team 1 east and west. */
|
||||
fun axisOfTeam(team: Int): Axis = if (team % 2 == 0) Axis.NORTH_SOUTH else Axis.EAST_WEST
|
||||
|
||||
/**
|
||||
* How hard the computer plays.
|
||||
*
|
||||
* Only two settings, because only two could be shown to differ. [GENTLE] rates
|
||||
* a placement purely on what it scores at that moment, which for most of a game
|
||||
* is nothing at all, so it lays a reasonable-looking but aimless river: it beats
|
||||
* random placement about three games in four, and loses to [FULL] about four in
|
||||
* five. [FULL] adds blocking and, more to the point, notices which way the open
|
||||
* river mouths are pointing. Both figures are held to by TayuAiTest.
|
||||
*
|
||||
* A third setting that blocked harder was tried and dropped: it played the full
|
||||
* setting dead even, so shipping it as a step up would have been a claim that
|
||||
* could not be backed.
|
||||
*/
|
||||
@Serializable
|
||||
enum class TayuLevel(
|
||||
val label: String,
|
||||
val ownWeight: Int,
|
||||
val blockWeight: Int,
|
||||
val reachWeight: Int,
|
||||
) {
|
||||
/** Takes a score when it sees one and otherwise digs more or less anywhere. */
|
||||
GENTLE("Gentle", ownWeight = 20, blockWeight = 0, reachWeight = 0),
|
||||
|
||||
/** Builds towards its own edges and spoils yours. */
|
||||
FULL("Full strength", ownWeight = 20, blockWeight = 12, reachWeight = 2),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TayuOptions(
|
||||
/** Two play head to head; four play as two partnerships across the axes. */
|
||||
val playerCount: Int = 2,
|
||||
val level: TayuLevel = TayuLevel.FULL,
|
||||
/**
|
||||
* Copies of each of the 28 tiles. Three is the reissued game's 84 tiles and
|
||||
* four is the 1999 original's 112 — the publisher itself cut it to three to
|
||||
* shorten the game, so two is offered on the same reasoning.
|
||||
*/
|
||||
val tileCopies: Int = 3,
|
||||
) {
|
||||
init {
|
||||
require(playerCount == 2 || playerCount == 4) { "Ta Yü seats two or four" }
|
||||
require(tileCopies in 2..4) { "tileCopies must be 2, 3 or 4" }
|
||||
}
|
||||
|
||||
val tileCount: Int get() = tileCopies * TayuTiles.all.size
|
||||
}
|
||||
|
||||
/** A tile on the board: its mouths, where its first cell sits, and which way it points. */
|
||||
@Serializable
|
||||
data class PlacedTile(
|
||||
val mask: Int,
|
||||
/** The tile's first cell; the other two run [facing] from here. */
|
||||
val row: Int,
|
||||
val col: Int,
|
||||
val facing: Facing,
|
||||
val seat: Int,
|
||||
) {
|
||||
/** The three cells this tile covers, from the anchor outwards. */
|
||||
fun cells(): List<Int> = (0 until TILE_LENGTH).map {
|
||||
TayuBoard.cellAt(row + it * facing.dr, col + it * facing.dc)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class TayuPhase { PLAYING, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
data class TayuState(
|
||||
val options: TayuOptions,
|
||||
val seed: Long,
|
||||
/** Team index per seat. */
|
||||
val teams: List<Int>,
|
||||
/** Index into [placed] for the tile covering each cell, or [NO_TILE]. */
|
||||
val tileAt: List<Int>,
|
||||
val placed: List<PlacedTile>,
|
||||
/**
|
||||
* Tiles still in the bag, in the order they will come out. The one piece of
|
||||
* hidden information in the game, and the only thing [TayuRules.viewFor]
|
||||
* has to strip.
|
||||
*/
|
||||
val bag: List<Int>,
|
||||
/**
|
||||
* The tile the seat on the clock has drawn and must place. Everyone can see
|
||||
* it — on the table it is drawn out of the bag face up.
|
||||
*/
|
||||
val drawn: Int?,
|
||||
val bagCount: Int,
|
||||
/** Drawn tiles that had nowhere legal to go. They do not come back. */
|
||||
val setAside: List<Int>,
|
||||
val turn: Int,
|
||||
val phase: TayuPhase,
|
||||
val log: List<String>,
|
||||
) {
|
||||
val playerCount: Int get() = teams.size
|
||||
|
||||
fun teamOf(seat: Int): Int = teams[seat]
|
||||
|
||||
fun axisOf(seat: Int): Axis = axisOfTeam(teamOf(seat))
|
||||
}
|
||||
|
||||
/**
|
||||
* Laying a tile down: where its first cell goes and which way it points.
|
||||
*
|
||||
* The only move there is in Ta Yü, so there is no sealed hierarchy. Which tile
|
||||
* is being laid is deliberately absent — it can only ever be [TayuState.drawn],
|
||||
* and a move carrying its own copy could disagree with the state it is applied
|
||||
* to.
|
||||
*/
|
||||
@Serializable
|
||||
data class TayuMove(val row: Int, val col: Int, val facing: Facing)
|
||||
|
||||
/** Exits reached on each edge, already counting marked ones double. */
|
||||
data class EdgeExits(val north: Int, val east: Int, val south: Int, val west: Int) {
|
||||
|
||||
fun on(edge: Facing): Int = when (edge) {
|
||||
Facing.NORTH -> north
|
||||
Facing.EAST -> east
|
||||
Facing.SOUTH -> south
|
||||
Facing.WEST -> west
|
||||
}
|
||||
|
||||
/**
|
||||
* What an axis has actually scored: its two edges multiplied together, so
|
||||
* reaching only one of them is worth nothing at all.
|
||||
*/
|
||||
fun product(axis: Axis): Int = on(axis.first) * on(axis.second)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package org.prolibertate.games.game.tayu
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.cards.shuffledWith
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Ta Yü as a pure state machine. See RULES-tayu.md for the ruleset, and for
|
||||
* which parts of it had to be reconstructed from an out-of-print rulebook.
|
||||
*
|
||||
* The one thing worth knowing before reading on: because every tile after the
|
||||
* first has to join a river already down, and because all three mouths of a
|
||||
* tile are connected across its face, everything on the board is always a
|
||||
* single river system running back to the opening tile. Nothing here ever has
|
||||
* to trace connectivity — a mouth at the board's edge is a scoring exit by
|
||||
* construction.
|
||||
*/
|
||||
object TayuRules : GameRules<TayuState, TayuMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.TAYU
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): TayuState {
|
||||
val options = json.decodeFromString<TayuOptions>(config.optionsJson)
|
||||
require(config.seats.size == options.playerCount) {
|
||||
"Seat count ${config.seats.size} does not match ${options.playerCount} players"
|
||||
}
|
||||
|
||||
val bag = TayuTiles.bag(options.tileCopies).shuffledWith(Random(config.seed))
|
||||
val teams = (0 until options.playerCount).map { it % 2 }
|
||||
|
||||
val empty = TayuState(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
teams = teams,
|
||||
tileAt = List(BOARD_CELLS) { NO_TILE },
|
||||
placed = emptyList(),
|
||||
bag = bag,
|
||||
drawn = null,
|
||||
bagCount = bag.size,
|
||||
setAside = emptyList(),
|
||||
turn = 0,
|
||||
phase = TayuPhase.PLAYING,
|
||||
log = listOf(
|
||||
"Ta Yü — ${options.tileCount} tiles. " +
|
||||
"Seat 0 runs north and south, seat 1 east and west."
|
||||
),
|
||||
)
|
||||
return openTurn(empty, seat = 0)
|
||||
}
|
||||
|
||||
override fun currentSeat(state: TayuState): Int? =
|
||||
if (state.phase == TayuPhase.PLAYING && state.drawn != null) state.turn else null
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Legal moves
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun legalMoves(state: TayuState, seat: Int): List<TayuMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
return placementsFor(state, state.drawn ?: return emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way [tile] can be laid on the board as it stands.
|
||||
*
|
||||
* Four orientations at every cell. The anchor is the tile's first cell, so
|
||||
* laying the same three cells the other way round is a different facing
|
||||
* from a different anchor rather than a special case.
|
||||
*/
|
||||
fun placementsFor(state: TayuState, tile: Int): List<TayuMove> {
|
||||
val mouths = mouthMap(state)
|
||||
val opening = state.placed.isEmpty()
|
||||
val moves = mutableListOf<TayuMove>()
|
||||
for (row in 0 until BOARD_SIZE) {
|
||||
for (col in 0 until BOARD_SIZE) {
|
||||
for (facing in Facing.entries) {
|
||||
if (fits(state, mouths, opening, tile, row, col, facing)) {
|
||||
moves += TayuMove(row, col, facing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tile can be laid here.
|
||||
*
|
||||
* The interesting half is the mouth accounting. Walk the tile's eight
|
||||
* perimeter slots; for each, look at what lies across it:
|
||||
*
|
||||
* - off the board — fine either way. A mouth there is a scoring exit, and a
|
||||
* blank there is just shoreline.
|
||||
* - an empty cell — fine either way. A river runs on into open ground.
|
||||
* - another tile — then the two must agree. A mouth must meet a mouth, and
|
||||
* a blank must meet a blank.
|
||||
*
|
||||
* That last comparison is doing double duty: it refuses a river that would
|
||||
* dead-end against the flank of an existing tile, and in the same breath it
|
||||
* refuses a placement whose own blank flank would wall off a river already
|
||||
* on the board.
|
||||
*/
|
||||
private fun fits(
|
||||
state: TayuState,
|
||||
mouths: IntArray,
|
||||
opening: Boolean,
|
||||
tile: Int,
|
||||
row: Int,
|
||||
col: Int,
|
||||
facing: Facing,
|
||||
): Boolean {
|
||||
val rows = IntArray(TILE_LENGTH)
|
||||
val cols = IntArray(TILE_LENGTH)
|
||||
for (i in 0 until TILE_LENGTH) {
|
||||
rows[i] = row + i * facing.dr
|
||||
cols[i] = col + i * facing.dc
|
||||
if (!TayuBoard.onBoard(rows[i], cols[i])) return false
|
||||
if (state.tileAt[TayuBoard.cellAt(rows[i], cols[i])] != NO_TILE) return false
|
||||
}
|
||||
|
||||
// The opening tile has no river to join, only the centre to cover.
|
||||
if (opening) {
|
||||
return (0 until TILE_LENGTH).any {
|
||||
TayuBoard.cellAt(rows[it], cols[it]) in TayuBoard.centre
|
||||
}
|
||||
}
|
||||
|
||||
var joins = 0
|
||||
for (slot in 0 until TileSlots.COUNT) {
|
||||
val i = TileSlots.cellOf(slot)
|
||||
val direction = absoluteFacing(facing, TileSlots.flankOf(slot))
|
||||
val neighbourRow = rows[i] + direction.dr
|
||||
val neighbourCol = cols[i] + direction.dc
|
||||
if (!TayuBoard.onBoard(neighbourRow, neighbourCol)) continue
|
||||
|
||||
val neighbour = TayuBoard.cellAt(neighbourRow, neighbourCol)
|
||||
if (state.tileAt[neighbour] == NO_TILE) continue
|
||||
|
||||
val ours = TayuTiles.hasMouth(tile, slot)
|
||||
val theirs = hasMouth(mouths, neighbour, direction.opposite)
|
||||
if (ours != theirs) return false
|
||||
if (ours) joins++
|
||||
}
|
||||
// Every tile after the first has to join the river already down.
|
||||
return joins > 0
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move application
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun applyMove(state: TayuState, seat: Int, move: TayuMove): TayuState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal placement $move for seat $seat" }
|
||||
|
||||
val placed = previewPlacement(state, seat, move)
|
||||
val gained = exitsOf(placed).product(placed.axisOf(seat)) -
|
||||
exitsOf(state).product(state.axisOf(seat))
|
||||
|
||||
val noted = placed.copy(
|
||||
log = placed.log + buildString {
|
||||
append("Seat $seat lays a tile at ")
|
||||
append(coordinate(move.row, move.col))
|
||||
if (gained > 0) append(" — ${placed.axisOf(seat).label} now scores")
|
||||
append(".")
|
||||
},
|
||||
)
|
||||
return openTurn(noted, seat = (seat + 1) % state.playerCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* The board with [move] laid on it, and nothing else changed — no draw, no
|
||||
* change of turn.
|
||||
*
|
||||
* Public because the AI leans on it: rating a placement means looking at the
|
||||
* board it would produce, and going through [applyMove] for that would also
|
||||
* deal the next tile, which costs far more than the rating does.
|
||||
*/
|
||||
fun previewPlacement(state: TayuState, seat: Int, move: TayuMove): TayuState {
|
||||
val tile = requireNotNull(state.drawn) { "No tile has been drawn" }
|
||||
val placedTile = PlacedTile(
|
||||
mask = tile,
|
||||
row = move.row,
|
||||
col = move.col,
|
||||
facing = move.facing,
|
||||
seat = seat,
|
||||
)
|
||||
val index = state.placed.size
|
||||
val tileAt = state.tileAt.toMutableList()
|
||||
placedTile.cells().forEach { tileAt[it] = index }
|
||||
|
||||
return state.copy(
|
||||
tileAt = tileAt,
|
||||
placed = state.placed + placedTile,
|
||||
drawn = null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the next tile to [seat].
|
||||
*
|
||||
* A drawn tile that cannot be laid anywhere is set aside and the turn moves
|
||||
* on, so this keeps drawing — and keeps passing the turn — until it finds a
|
||||
* tile somebody can actually place. When the bag runs dry the game is over,
|
||||
* which means the seat on the clock always has at least one legal move.
|
||||
*/
|
||||
private fun openTurn(state: TayuState, seat: Int): TayuState {
|
||||
var bag = state.bag
|
||||
var setAside = state.setAside
|
||||
var log = state.log
|
||||
var turn = seat
|
||||
|
||||
while (bag.isNotEmpty()) {
|
||||
val tile = bag.first()
|
||||
bag = bag.drop(1)
|
||||
val candidate = state.copy(
|
||||
bag = bag,
|
||||
bagCount = bag.size,
|
||||
drawn = tile,
|
||||
setAside = setAside,
|
||||
turn = turn,
|
||||
log = log,
|
||||
)
|
||||
if (placementsFor(candidate, tile).isNotEmpty()) return candidate
|
||||
|
||||
setAside = setAside + tile
|
||||
log = log + "Seat $turn draws a tile that will not fit anywhere. Set aside."
|
||||
turn = (turn + 1) % state.playerCount
|
||||
}
|
||||
|
||||
return state.copy(
|
||||
bag = emptyList(),
|
||||
bagCount = 0,
|
||||
drawn = null,
|
||||
setAside = setAside,
|
||||
turn = turn,
|
||||
phase = TayuPhase.GAME_OVER,
|
||||
log = log + "The bag is empty. ${summaryOf(state)}",
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reading the board
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* River mouths per cell, as a bitmask over [Facing.ordinal].
|
||||
*
|
||||
* Rebuilt from [TayuState.placed] on demand rather than carried in the
|
||||
* state, so there is no second copy of the board that could drift out of
|
||||
* step with the first.
|
||||
*/
|
||||
fun mouthMap(state: TayuState): IntArray {
|
||||
val map = IntArray(BOARD_CELLS)
|
||||
for (tile in state.placed) {
|
||||
val cells = tile.cells()
|
||||
for (slot in TayuTiles.mouthSlots(tile.mask)) {
|
||||
val cell = cells[TileSlots.cellOf(slot)]
|
||||
val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot))
|
||||
map[cell] = map[cell] or (1 shl direction.ordinal)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun hasMouth(mouths: IntArray, cell: Int, direction: Facing): Boolean =
|
||||
(mouths[cell] shr direction.ordinal) and 1 == 1
|
||||
|
||||
/**
|
||||
* Exits reached on each edge, marked ones already counted double.
|
||||
*
|
||||
* An exit is a mouth pointing off the board. It needs no connectivity check
|
||||
* — see the note on [TayuRules].
|
||||
*/
|
||||
fun exitsOf(state: TayuState): EdgeExits {
|
||||
val totals = IntArray(4)
|
||||
for (tile in state.placed) {
|
||||
val cells = tile.cells()
|
||||
for (slot in TayuTiles.mouthSlots(tile.mask)) {
|
||||
val cell = cells[TileSlots.cellOf(slot)]
|
||||
val row = TayuBoard.rowOf(cell)
|
||||
val col = TayuBoard.colOf(cell)
|
||||
val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot))
|
||||
if (TayuBoard.onBoard(row + direction.dr, col + direction.dc)) continue
|
||||
totals[direction.ordinal] += TayuBoard.exitValue(direction, row, col)
|
||||
}
|
||||
}
|
||||
return EdgeExits(
|
||||
north = totals[Facing.NORTH.ordinal],
|
||||
east = totals[Facing.EAST.ordinal],
|
||||
south = totals[Facing.SOUTH.ordinal],
|
||||
west = totals[Facing.WEST.ordinal],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouths with open ground in front of them: where the river can still grow.
|
||||
*
|
||||
* Returned as the cell the mouth is on and the way it points. The AI uses
|
||||
* this to tell a river heading for its own edge from one heading for the
|
||||
* opponent's.
|
||||
*/
|
||||
fun openMouths(state: TayuState): List<Pair<Int, Facing>> {
|
||||
val open = mutableListOf<Pair<Int, Facing>>()
|
||||
for (tile in state.placed) {
|
||||
val cells = tile.cells()
|
||||
for (slot in TayuTiles.mouthSlots(tile.mask)) {
|
||||
val cell = cells[TileSlots.cellOf(slot)]
|
||||
val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot))
|
||||
val row = TayuBoard.rowOf(cell) + direction.dr
|
||||
val col = TayuBoard.colOf(cell) + direction.dc
|
||||
if (!TayuBoard.onBoard(row, col)) continue
|
||||
if (state.tileAt[TayuBoard.cellAt(row, col)] != NO_TILE) continue
|
||||
open += cell to direction
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
/** Points scored by [team], which is its two edges multiplied together. */
|
||||
fun scoreOf(state: TayuState, team: Int): Int =
|
||||
exitsOf(state).product(axisOfTeam(team))
|
||||
|
||||
/** The winning team, or null for a draw. */
|
||||
fun winnerOf(state: TayuState): Int? {
|
||||
val northSouth = scoreOf(state, 0)
|
||||
val eastWest = scoreOf(state, 1)
|
||||
return when {
|
||||
northSouth > eastWest -> 0
|
||||
eastWest > northSouth -> 1
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Results, redaction and wire format
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: TayuState): Boolean = state.phase == TayuPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: TayuState): String = summaryOf(state)
|
||||
|
||||
private fun summaryOf(state: TayuState): String {
|
||||
val exits = exitsOf(state)
|
||||
return "North–south ${exits.north}×${exits.south} = ${exits.product(Axis.NORTH_SOUTH)} " +
|
||||
"East–west ${exits.east}×${exits.west} = ${exits.product(Axis.EAST_WEST)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* The bag's order is the only thing anybody is not entitled to see: the
|
||||
* board is in front of everyone, and a drawn tile comes out face up.
|
||||
*/
|
||||
override fun viewFor(state: TayuState, seat: Int): TayuState =
|
||||
state.copy(bag = emptyList())
|
||||
|
||||
override fun encodeState(state: TayuState): String = json.encodeToString(state)
|
||||
|
||||
override fun decodeState(json: String): TayuState = this.json.decodeFromString(json)
|
||||
|
||||
override fun encodeMove(move: TayuMove): String = json.encodeToString(move)
|
||||
|
||||
override fun decodeMove(json: String): TayuMove = this.json.decodeFromString(json)
|
||||
|
||||
/** Board coordinates the way a player would read them out: `f12`. */
|
||||
fun coordinate(row: Int, col: Int): String = "${'a' + col}${row + 1}"
|
||||
}
|
||||
@@ -38,6 +38,9 @@ import org.prolibertate.games.game.golf.GolfOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||
import org.prolibertate.games.game.tayu.TayuLevel
|
||||
import org.prolibertate.games.game.tayu.TayuOptions
|
||||
import org.prolibertate.games.game.tayu.TayuTiles
|
||||
import org.prolibertate.games.game.wizard.WizardOptions
|
||||
|
||||
private val setupJson = Json {
|
||||
@@ -67,6 +70,7 @@ fun GameSetupScreen(
|
||||
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
|
||||
var wizard by remember { mutableStateOf(WizardOptions()) }
|
||||
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||
var tayu by remember { mutableStateOf(TayuOptions()) }
|
||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||
// always White, so choosing Black means sitting in seat 1.
|
||||
var playWhite by remember { mutableStateOf(true) }
|
||||
@@ -80,6 +84,7 @@ fun GameSetupScreen(
|
||||
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
|
||||
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
|
||||
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||
GameCatalog.TAYU -> setupJson.encodeToString(tayu)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
@@ -112,6 +117,8 @@ fun GameSetupScreen(
|
||||
onColour = { playWhite = it },
|
||||
)
|
||||
|
||||
GameCatalog.TAYU -> TayuOptionsEditor(tayu) { tayu = it }
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -190,14 +197,20 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CHESS -> 2
|
||||
|
||||
GameCatalog.TAYU ->
|
||||
setupJson.decodeFromString<TayuOptions>(optionsJson).playerCount
|
||||
|
||||
else -> fallback
|
||||
}
|
||||
}.getOrDefault(fallback)
|
||||
}
|
||||
|
||||
fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) {
|
||||
// Partners sit opposite each other.
|
||||
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE -> seat % 2
|
||||
// Partners sit opposite each other. In Ta Yü that is also which pair of
|
||||
// edges you are running your rivers to: even seats north and south, odd
|
||||
// seats east and west.
|
||||
GameCatalog.EUCHRE, GameCatalog.KAISER, GameCatalog.SEQUENCE, GameCatalog.TAYU -> seat % 2
|
||||
// Everyone else plays for themselves.
|
||||
else -> seat
|
||||
}
|
||||
@@ -518,6 +531,47 @@ private fun ChessOptionsEditor(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TayuOptionsEditor(options: TayuOptions, onChange: (TayuOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Players",
|
||||
values = listOf(2, 4),
|
||||
selected = options.playerCount,
|
||||
display = { if (it == 2) "2, head to head" else "4, in partnerships" },
|
||||
onSelect = { onChange(options.copy(playerCount = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = TayuLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Tiles",
|
||||
values = listOf(2, 3, 4),
|
||||
selected = options.tileCopies,
|
||||
display = { copies ->
|
||||
val count = copies * TayuTiles.all.size
|
||||
when (copies) {
|
||||
3 -> "$count, as reissued"
|
||||
4 -> "$count, as first published"
|
||||
else -> "$count, a short game"
|
||||
}
|
||||
},
|
||||
onSelect = { onChange(options.copy(tileCopies = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "There are ${TayuTiles.all.size} different tiles, and the bag holds " +
|
||||
"${options.tileCopies} of each. Even seats run their rivers north and " +
|
||||
"south, odd seats east and west, and a side's score is one edge " +
|
||||
"multiplied by the other — so reaching only one of them is worth nothing.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun <T> ChipRow(
|
||||
|
||||
@@ -40,6 +40,10 @@ import org.prolibertate.games.game.sequence.SequenceAi
|
||||
import org.prolibertate.games.game.sequence.SequenceMove
|
||||
import org.prolibertate.games.game.sequence.SequenceRules
|
||||
import org.prolibertate.games.game.sequence.SequenceState
|
||||
import org.prolibertate.games.game.tayu.TayuAi
|
||||
import org.prolibertate.games.game.tayu.TayuMove
|
||||
import org.prolibertate.games.game.tayu.TayuRules
|
||||
import org.prolibertate.games.game.tayu.TayuState
|
||||
import org.prolibertate.games.game.wizard.WizardAi
|
||||
import org.prolibertate.games.game.wizard.WizardMove
|
||||
import org.prolibertate.games.game.wizard.WizardPhase
|
||||
@@ -55,6 +59,7 @@ import org.prolibertate.games.ui.game.KaiserScreen
|
||||
import org.prolibertate.games.ui.game.PresidentScreen
|
||||
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
|
||||
import org.prolibertate.games.ui.game.SequenceScreen
|
||||
import org.prolibertate.games.ui.game.TayuScreen
|
||||
import org.prolibertate.games.ui.game.WizardScreen
|
||||
|
||||
/**
|
||||
@@ -278,6 +283,25 @@ fun PlayScreen(
|
||||
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.TAYU -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<TayuState, TayuMove>(
|
||||
rules = TayuRules,
|
||||
// As with chess, the strength is part of the table's own
|
||||
// options, so this does not have to decode anything.
|
||||
ai = TayuAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
aiThinkingMillis = { settings.scaled(700L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
TayuScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.CHESS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<ChessState, ChessMove>(
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.prolibertate.games.game.tayu.Axis
|
||||
import org.prolibertate.games.game.tayu.BOARD_CELLS
|
||||
import org.prolibertate.games.game.tayu.BOARD_SIZE
|
||||
import org.prolibertate.games.game.tayu.Facing
|
||||
import org.prolibertate.games.game.tayu.TILE_LENGTH
|
||||
import org.prolibertate.games.game.tayu.TayuBoard
|
||||
import org.prolibertate.games.game.tayu.TayuMove
|
||||
import org.prolibertate.games.game.tayu.TayuPhase
|
||||
import org.prolibertate.games.game.tayu.TayuRules
|
||||
import org.prolibertate.games.game.tayu.TayuState
|
||||
import org.prolibertate.games.game.tayu.TayuTiles
|
||||
import org.prolibertate.games.game.tayu.TileSlots
|
||||
import org.prolibertate.games.game.tayu.absoluteFacing
|
||||
import org.prolibertate.games.game.tayu.axisOfTeam
|
||||
import org.prolibertate.games.game.tayu.cellsOf
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
import org.prolibertate.games.ui.theme.FeltGreenDark
|
||||
import org.prolibertate.games.ui.theme.TeamColours
|
||||
|
||||
/**
|
||||
* Board colours, kept off the theme so the board reads the same in either mode.
|
||||
*
|
||||
* The water is deliberately a pale blue rather than the team blue: the two axes
|
||||
* are marked in the team colours, and a river the same colour as a scoring mark
|
||||
* would be unreadable at this cell size.
|
||||
*/
|
||||
private val TileStone = Color(0xFFD8CDB4)
|
||||
private val TileEdge = Color(0xFF6E6350)
|
||||
private val River = Color(0xFF7EC8E3)
|
||||
private val Indentation = Color(0x2AFFFFFF)
|
||||
private val GhostAmber = Color(0xFFFFC107)
|
||||
private val ExitRim = Color(0xFFFFFFFF)
|
||||
|
||||
/**
|
||||
* The Ta Yü board.
|
||||
*
|
||||
* 18 × 18 cells is far too fine a grid to tap accurately, so placing a tile is
|
||||
* two steps rather than one: tap the board to line a placement up, and a button
|
||||
* commits it. Tapping the same cell again cycles through the other legal ways
|
||||
* the drawn tile could cover it, which is also how the tile gets turned — the
|
||||
* tile has four orientations but only some of them fit, and offering a rotate
|
||||
* control that lands on illegal positions would be worse than useless.
|
||||
*
|
||||
* It is drawn on one canvas rather than as 324 composables. The rivers run
|
||||
* between cells rather than inside them, so nested boxes would have had nowhere
|
||||
* to put them.
|
||||
*/
|
||||
@Composable
|
||||
fun TayuScreen(
|
||||
controller: MatchController<TayuState, TayuMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
val abandoned by controller.abandoned.collectAsState()
|
||||
|
||||
// The cell last tapped, and the placement currently lined up on it.
|
||||
var tapped by remember { mutableStateOf<Int?>(null) }
|
||||
var lined by remember { mutableStateOf<TayuMove?>(null) }
|
||||
var confirmingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val endGameAction: @Composable () -> Unit = {
|
||||
TextButton(onClick = { confirmingEnd = true }) { Text("End game") }
|
||||
}
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Ta Yü", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
// A table that never arrives — a host that went away mid-handshake —
|
||||
// still has to be leavable.
|
||||
abandoned?.let {
|
||||
TayuLeftTheTableDialog(it, onExit = onExit, onStay = controller::dismissAbandoned)
|
||||
}
|
||||
if (confirmingEnd) {
|
||||
TayuConfirmEndDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A tile laid, or a new one drawn, makes last turn's selection meaningless.
|
||||
LaunchedEffect(current.placed.size, current.drawn) {
|
||||
tapped = null
|
||||
lined = null
|
||||
}
|
||||
|
||||
val candidatesAt: (Int) -> List<TayuMove> = { cell ->
|
||||
legal.filter { cell in cellsOf(it) }
|
||||
}
|
||||
|
||||
// Lines up the next legal placement covering the tapped cell. Tapping the
|
||||
// same cell again moves on to the next way the tile could cover it, which is
|
||||
// also what the turn button does.
|
||||
val onCellTapped: (Int) -> Unit = { cell ->
|
||||
val options = candidatesAt(cell)
|
||||
// A tap on a cell the tile cannot cover is ignored rather than treated as
|
||||
// a deselection. Cells are under 20dp across on a phone, so a fingertip
|
||||
// that lands one cell wide of the mark would otherwise throw away a
|
||||
// placement that took several taps to line up.
|
||||
if (options.isNotEmpty()) {
|
||||
val position = options.indexOf(lined)
|
||||
lined = if (tapped == cell && position >= 0) {
|
||||
options[(position + 1) % options.size]
|
||||
} else {
|
||||
options.first()
|
||||
}
|
||||
tapped = cell
|
||||
}
|
||||
}
|
||||
|
||||
// Checked against the live list rather than trusted: the selection is held
|
||||
// across recompositions and a remote player may have moved in between.
|
||||
val ready = lined?.takeIf { it in legal }
|
||||
val alternatives = tapped?.let { candidatesAt(it).size } ?: 0
|
||||
|
||||
ScreenScaffold(title = "Ta Yü", onBack = onExit, actions = endGameAction) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight
|
||||
val boardSide = if (wide) {
|
||||
minOf(maxHeight, maxWidth * 0.60f)
|
||||
} else {
|
||||
minOf(maxWidth, maxHeight * 0.66f)
|
||||
}
|
||||
|
||||
val board: @Composable () -> Unit = {
|
||||
Board(
|
||||
state = current,
|
||||
legal = legal,
|
||||
lined = ready,
|
||||
side = boardSide,
|
||||
onTap = onCellTapped,
|
||||
)
|
||||
}
|
||||
|
||||
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
|
||||
Column(
|
||||
modifier = panelModifier
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusPanel(current, localSeat)
|
||||
DrawnTilePanel(
|
||||
state = current,
|
||||
yourTurn = current.turn == localSeat && legal.isNotEmpty(),
|
||||
ready = ready != null,
|
||||
alternatives = alternatives,
|
||||
onTurn = { tapped?.let(onCellTapped) },
|
||||
onPlace = { ready?.let { controller.submit(it) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.weight(1f).fillMaxSize())
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.fillMaxWidth().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
// One dialog at a time, most pressing first: a table nobody can
|
||||
// finish comes before the result of one that did.
|
||||
val over = abandoned
|
||||
when {
|
||||
over != null ->
|
||||
TayuLeftTheTableDialog(
|
||||
notice = over,
|
||||
onExit = onExit,
|
||||
onStay = controller::dismissAbandoned,
|
||||
)
|
||||
|
||||
confirmingEnd ->
|
||||
TayuConfirmEndDialog(onEnd = onExit, onKeepPlaying = { confirmingEnd = false })
|
||||
|
||||
current.phase == TayuPhase.GAME_OVER -> ResultDialog(current, localSeat, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The board
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Where everything sits on the canvas.
|
||||
*
|
||||
* The tap handler and the drawing both go through this, so a tap always lands
|
||||
* on the cell it looks like it landed on.
|
||||
*/
|
||||
private class BoardGeometry(
|
||||
/** Room outside the grid for the edge marks and the exits. */
|
||||
val margin: Float,
|
||||
val cellSize: Float,
|
||||
) {
|
||||
|
||||
/** A whole board filling a square canvas [sidePx] across. */
|
||||
constructor(sidePx: Float) : this(
|
||||
margin = sidePx * MARGIN_FRACTION,
|
||||
cellSize = (sidePx - 2 * sidePx * MARGIN_FRACTION) / BOARD_SIZE,
|
||||
)
|
||||
|
||||
fun centreOf(cell: Int): Offset = Offset(
|
||||
x = margin + (TayuBoard.colOf(cell) + 0.5f) * cellSize,
|
||||
y = margin + (TayuBoard.rowOf(cell) + 0.5f) * cellSize,
|
||||
)
|
||||
|
||||
/** Steps [distance] cells from [from] in [direction], in canvas units. */
|
||||
fun step(from: Offset, direction: Facing, distance: Float): Offset = Offset(
|
||||
x = from.x + direction.dc * cellSize * distance,
|
||||
y = from.y + direction.dr * cellSize * distance,
|
||||
)
|
||||
|
||||
fun cellAt(offset: Offset): Int? {
|
||||
// Rejected explicitly rather than left to the conversion: truncation
|
||||
// rounds towards zero, so a tap in the margin above the grid would
|
||||
// otherwise land on the first row instead of on nothing.
|
||||
val x = offset.x - margin
|
||||
val y = offset.y - margin
|
||||
if (x < 0f || y < 0f) return null
|
||||
val col = (x / cellSize).toInt()
|
||||
val row = (y / cellSize).toInt()
|
||||
return if (TayuBoard.onBoard(row, col)) TayuBoard.cellAt(row, col) else null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MARGIN_FRACTION = 0.045f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: TayuState,
|
||||
legal: List<TayuMove>,
|
||||
lined: TayuMove?,
|
||||
side: Dp,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
val sidePx = with(LocalDensity.current) { side.toPx() }
|
||||
val geometry = remember(sidePx) { BoardGeometry(sidePx) }
|
||||
val handler by rememberUpdatedState(onTap)
|
||||
|
||||
// Only hinted while nothing is lined up: once a placement is chosen the
|
||||
// hints would compete with the thing the player is looking at.
|
||||
val hints = remember(legal, lined) {
|
||||
if (lined != null) emptySet() else legal.flatMap { cellsOf(it) }.toSet()
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(side)
|
||||
.pointerInput(geometry) {
|
||||
detectTapGestures { offset -> geometry.cellAt(offset)?.let(handler) }
|
||||
}
|
||||
) {
|
||||
drawRect(color = FeltGreenDark)
|
||||
drawEdges(geometry)
|
||||
for (cell in 0 until BOARD_CELLS) {
|
||||
drawCircle(Indentation, geometry.cellSize * 0.10f, geometry.centreOf(cell))
|
||||
}
|
||||
drawMarkedExits(geometry)
|
||||
|
||||
for (tile in state.placed) {
|
||||
drawTile(
|
||||
mask = tile.mask,
|
||||
cells = tile.cells(),
|
||||
facing = tile.facing,
|
||||
geometry = geometry,
|
||||
body = TileStone,
|
||||
edge = TileEdge,
|
||||
water = River,
|
||||
)
|
||||
}
|
||||
drawExits(state, geometry)
|
||||
|
||||
hints.forEach { cell ->
|
||||
drawCircle(
|
||||
color = GhostAmber.copy(alpha = 0.35f),
|
||||
radius = geometry.cellSize * 0.16f,
|
||||
center = geometry.centreOf(cell),
|
||||
)
|
||||
}
|
||||
|
||||
lined?.let { move ->
|
||||
drawTile(
|
||||
mask = state.drawn ?: return@let,
|
||||
cells = cellsOf(move),
|
||||
facing = move.facing,
|
||||
geometry = geometry,
|
||||
body = GhostAmber.copy(alpha = 0.30f),
|
||||
edge = GhostAmber,
|
||||
water = GhostAmber,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The four edges, tinted with the colour of the side trying to reach them. */
|
||||
private fun DrawScope.drawEdges(geometry: BoardGeometry) {
|
||||
val northSouth = TeamColours[0].copy(alpha = 0.55f)
|
||||
val eastWest = TeamColours[1].copy(alpha = 0.55f)
|
||||
val thickness = geometry.cellSize * 0.14f
|
||||
val start = geometry.margin
|
||||
val end = geometry.margin + BOARD_SIZE * geometry.cellSize
|
||||
|
||||
drawLine(northSouth, Offset(start, start), Offset(end, start), thickness)
|
||||
drawLine(northSouth, Offset(start, end), Offset(end, end), thickness)
|
||||
drawLine(eastWest, Offset(end, start), Offset(end, end), thickness)
|
||||
drawLine(eastWest, Offset(start, start), Offset(start, end), thickness)
|
||||
}
|
||||
|
||||
/** Rings on the exits that count double. */
|
||||
private fun DrawScope.drawMarkedExits(geometry: BoardGeometry) {
|
||||
for (edge in Facing.entries) {
|
||||
val colour = if (edge == Facing.NORTH || edge == Facing.SOUTH) {
|
||||
TeamColours[0]
|
||||
} else {
|
||||
TeamColours[1]
|
||||
}
|
||||
for (position in TayuBoard.markedExits) {
|
||||
val at = geometry.step(
|
||||
from = geometry.centreOf(TayuBoard.edgeCell(edge, position)),
|
||||
direction = edge,
|
||||
distance = 0.78f,
|
||||
)
|
||||
drawCircle(
|
||||
color = colour,
|
||||
radius = geometry.cellSize * 0.30f,
|
||||
center = at,
|
||||
style = Stroke(width = geometry.cellSize * 0.12f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Water already off the board: one dot per exit, in the colour of the edge. */
|
||||
private fun DrawScope.drawExits(state: TayuState, geometry: BoardGeometry) {
|
||||
for (tile in state.placed) {
|
||||
val cells = tile.cells()
|
||||
for (slot in TayuTiles.mouthSlots(tile.mask)) {
|
||||
val cell = cells[TileSlots.cellOf(slot)]
|
||||
val direction = absoluteFacing(tile.facing, TileSlots.flankOf(slot))
|
||||
val row = TayuBoard.rowOf(cell) + direction.dr
|
||||
val col = TayuBoard.colOf(cell) + direction.dc
|
||||
if (TayuBoard.onBoard(row, col)) continue
|
||||
|
||||
val centre = geometry.centreOf(cell)
|
||||
val outside = geometry.step(centre, direction, 0.78f)
|
||||
drawLine(
|
||||
color = River,
|
||||
start = geometry.step(centre, direction, 0.5f),
|
||||
end = outside,
|
||||
strokeWidth = geometry.cellSize * 0.20f,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
val colour = if (direction == Facing.NORTH || direction == Facing.SOUTH) {
|
||||
TeamColours[0]
|
||||
} else {
|
||||
TeamColours[1]
|
||||
}
|
||||
drawCircle(ExitRim, geometry.cellSize * 0.22f, outside)
|
||||
drawCircle(colour, geometry.cellSize * 0.16f, outside)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tile: its body, then a channel from each of its three mouths in to the
|
||||
* pool at its middle.
|
||||
*
|
||||
* Each channel is drawn as two straight runs — mouth to the centre of its own
|
||||
* cell, then on to the middle of the tile — so the water turns square corners
|
||||
* instead of cutting diagonally across the stone.
|
||||
*/
|
||||
private fun DrawScope.drawTile(
|
||||
mask: Int,
|
||||
cells: List<Int>,
|
||||
facing: Facing,
|
||||
geometry: BoardGeometry,
|
||||
body: Color,
|
||||
edge: Color,
|
||||
water: Color,
|
||||
) {
|
||||
val centres = cells.map { geometry.centreOf(it) }
|
||||
val inset = geometry.cellSize * 0.09f
|
||||
val half = geometry.cellSize / 2
|
||||
|
||||
val left = centres.minOf { it.x } - half + inset
|
||||
val top = centres.minOf { it.y } - half + inset
|
||||
val right = centres.maxOf { it.x } + half - inset
|
||||
val bottom = centres.maxOf { it.y } + half - inset
|
||||
|
||||
drawRoundRect(
|
||||
color = body,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(right - left, bottom - top),
|
||||
cornerRadius = CornerRadius(geometry.cellSize * 0.22f),
|
||||
)
|
||||
drawRoundRect(
|
||||
color = edge,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(right - left, bottom - top),
|
||||
cornerRadius = CornerRadius(geometry.cellSize * 0.22f),
|
||||
style = Stroke(width = geometry.cellSize * 0.07f),
|
||||
)
|
||||
|
||||
val channel = geometry.cellSize * 0.20f
|
||||
val middle = centres[1]
|
||||
for (slot in TayuTiles.mouthSlots(mask)) {
|
||||
val index = TileSlots.cellOf(slot)
|
||||
val direction = absoluteFacing(facing, TileSlots.flankOf(slot))
|
||||
val own = centres[index]
|
||||
drawLine(
|
||||
color = water,
|
||||
start = geometry.step(own, direction, 0.5f),
|
||||
end = own,
|
||||
strokeWidth = channel,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
if (index != 1) {
|
||||
drawLine(water, own, middle, strokeWidth = channel, cap = StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
drawCircle(water, geometry.cellSize * 0.17f, middle)
|
||||
if (TayuTiles.touchesThreeFlanks(mask)) {
|
||||
// The reissued tiles ring the centre stud on exactly these, so the ring
|
||||
// is here too — it marks a tile that can reach three sides at once.
|
||||
drawCircle(
|
||||
color = edge,
|
||||
radius = geometry.cellSize * 0.30f,
|
||||
center = middle,
|
||||
style = Stroke(width = geometry.cellSize * 0.05f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun StatusPanel(state: TayuState, localSeat: Int) {
|
||||
val exits = TayuRules.exitsOf(state)
|
||||
val yours = state.axisOf(localSeat)
|
||||
val theirs = if (yours == Axis.NORTH_SOUTH) Axis.EAST_WEST else Axis.NORTH_SOUTH
|
||||
|
||||
Column {
|
||||
Text("You run ${yours.label}", fontWeight = FontWeight.Bold)
|
||||
listOf(yours, theirs).forEach { axis ->
|
||||
val team = if (axis == Axis.NORTH_SOUTH) 0 else 1
|
||||
Text(
|
||||
text = "${axis.label.replaceFirstChar { it.uppercase() }}: " +
|
||||
"${exits.on(axis.first)} × ${exits.on(axis.second)} = " +
|
||||
"${exits.product(axis)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TeamColours.getOrElse(team) { Color.Gray },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = when {
|
||||
state.phase == TayuPhase.GAME_OVER -> "The bag is empty."
|
||||
state.turn == localSeat -> "Your turn"
|
||||
else -> "Seat ${state.turn} is playing…"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "${state.bagCount} tiles left" +
|
||||
if (state.setAside.isEmpty()) {
|
||||
"."
|
||||
} else {
|
||||
", ${state.setAside.size} set aside as unplaceable."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The tile just drawn, plus the two controls that lay it down. */
|
||||
@Composable
|
||||
private fun DrawnTilePanel(
|
||||
state: TayuState,
|
||||
yourTurn: Boolean,
|
||||
ready: Boolean,
|
||||
alternatives: Int,
|
||||
onTurn: () -> Unit,
|
||||
onPlace: () -> Unit,
|
||||
) {
|
||||
val drawn = state.drawn
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Drawn from the bag", style = MaterialTheme.typography.labelLarge)
|
||||
if (drawn == null) {
|
||||
Text("Nothing left to draw.", style = MaterialTheme.typography.bodySmall)
|
||||
return@Column
|
||||
}
|
||||
|
||||
TilePreview(mask = drawn, cellSize = 30.dp)
|
||||
Text(
|
||||
text = when {
|
||||
!yourTurn -> "Waiting for the other player."
|
||||
!ready -> "Tap a highlighted cell to line the tile up there."
|
||||
alternatives > 1 ->
|
||||
"Tap the same cell again, or turn the tile, to try another way round."
|
||||
|
||||
else -> "Only one way this tile will fit there."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (yourTurn) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onTurn, enabled = alternatives > 1) {
|
||||
Text("Turn the tile")
|
||||
}
|
||||
Button(onClick = onPlace, enabled = ready) { Text("Lay the tile") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn tile, lying east–west as it came out of the bag.
|
||||
*
|
||||
* Deliberately not turned to match the placement being lined up: the board
|
||||
* already shows that, and a preview that kept spinning would be harder to read
|
||||
* than one that holds still.
|
||||
*/
|
||||
@Composable
|
||||
private fun TilePreview(mask: Int, cellSize: Dp) {
|
||||
val cellPx = with(LocalDensity.current) { cellSize.toPx() }
|
||||
// No margin: the three cells start at the canvas origin, so the board's own
|
||||
// drawing code lays the tile out here without knowing it is off the board.
|
||||
val geometry = remember(cellPx) { BoardGeometry(margin = 0f, cellSize = cellPx) }
|
||||
val cells = remember {
|
||||
(0 until TILE_LENGTH).map { TayuBoard.cellAt(0, it) }
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.width(cellSize * TILE_LENGTH).height(cellSize)) {
|
||||
drawTile(
|
||||
mask = mask,
|
||||
cells = cells,
|
||||
facing = Facing.EAST,
|
||||
geometry = geometry,
|
||||
body = TileStone,
|
||||
edge = TileEdge,
|
||||
water = River,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: TayuState, localSeat: Int, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.phase) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
val exits = TayuRules.exitsOf(state)
|
||||
val winner = TayuRules.winnerOf(state)
|
||||
val headline = when {
|
||||
winner == null -> "A draw"
|
||||
winner == state.teamOf(localSeat) -> "You win"
|
||||
else -> "${axisOfTeam(winner).label.replaceFirstChar { it.uppercase() }} wins"
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(headline, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
text = "North and south: ${exits.north} × ${exits.south} = " +
|
||||
"${exits.product(Axis.NORTH_SOUTH)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "East and west: ${exits.east} × ${exits.west} = " +
|
||||
"${exits.product(Axis.EAST_WEST)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "${state.placed.size} tiles laid" +
|
||||
if (state.setAside.isEmpty()) {
|
||||
"."
|
||||
} else {
|
||||
", ${state.setAside.size} that would not fit anywhere."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dismissed = true }) { Text("Look at the board") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaving on purpose. Ta Yü runs to the whole bag, so a table that has stopped
|
||||
* being fun needs a way out that does not mean force-quitting the app.
|
||||
*/
|
||||
@Composable
|
||||
private fun TayuConfirmEndDialog(onEnd: () -> Unit, onKeepPlaying: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeepPlaying,
|
||||
title = { Text("End this game?") },
|
||||
text = {
|
||||
Text(
|
||||
"This game ends here and the board is not saved. Anyone else at the " +
|
||||
"table is told you have left."
|
||||
)
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onEnd) { Text("End the game") } },
|
||||
dismissButton = { TextButton(onClick = onKeepPlaying) { Text("Keep playing") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** The other end has gone, so no further tile is ever going to arrive. */
|
||||
@Composable
|
||||
private fun TayuLeftTheTableDialog(notice: String, onExit: () -> Unit, onStay: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onStay,
|
||||
title = { Text("The game has ended") },
|
||||
text = { Text("$notice There are no more tiles to lay.") },
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Leave the game") } },
|
||||
dismissButton = { TextButton(onClick = onStay) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ class CatalogTest {
|
||||
GameCatalog.CRAZY_EIGHTS,
|
||||
GameCatalog.WIZARD,
|
||||
GameCatalog.CHESS,
|
||||
GameCatalog.TAYU,
|
||||
),
|
||||
GameCatalog.playable.map { it.id }.toSet(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.prolibertate.games.game.tayu
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Whether the AI is actually playing Ta Yü, and whether the two settings differ.
|
||||
*
|
||||
* It needs saying because this game hides a weak opponent well. Scores sit at
|
||||
* zero for most of a game — a product needs *both* your edges — so an opponent
|
||||
* placing tiles at random looks exactly like one that is thinking, right up
|
||||
* until the board fills and the scores come out lopsided. Playing the settings
|
||||
* off against random placement is the only way to tell them apart.
|
||||
*
|
||||
* The margins here are wide on purpose. The numbers observed while tuning were
|
||||
* 20/20 for full strength against random and 16/20 against the gentle setting,
|
||||
* from either side of the board; the assertions sit well below that so a change
|
||||
* of weights has to be a real regression to trip them.
|
||||
*/
|
||||
class TayuAiTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(seed: Long, options: TayuOptions = TayuOptions()) = TableConfig(
|
||||
gameId = "tayu",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
/**
|
||||
* Plays one game out and returns the two scores. A null brain places at
|
||||
* random from the legal moves, which is the yardstick the AI has to beat.
|
||||
*/
|
||||
private fun duel(seed: Long, north: TayuAi?, east: TayuAi?): Pair<Int, Int> {
|
||||
var state = TayuRules.initialState(config(seed))
|
||||
val random = Random(seed * 31 + 7)
|
||||
var guard = 0
|
||||
while (!TayuRules.isFinished(state) && guard++ < 400) {
|
||||
val seat = TayuRules.currentSeat(state) ?: break
|
||||
val legal = TayuRules.legalMoves(state, seat)
|
||||
if (legal.isEmpty()) break
|
||||
val brain = if (seat == 0) north else east
|
||||
val move = brain?.chooseMove(state, seat, legal) ?: legal[random.nextInt(legal.size)]
|
||||
state = TayuRules.applyMove(state, seat, move)
|
||||
}
|
||||
return TayuRules.scoreOf(state, 0) to TayuRules.scoreOf(state, 1)
|
||||
}
|
||||
|
||||
/** Games out of [games] the north–south side won. */
|
||||
private fun northWins(games: Int, north: TayuAi?, east: TayuAi?): Int =
|
||||
(1..games).count { seed ->
|
||||
val (northScore, eastScore) = duel(seed.toLong(), north, east)
|
||||
northScore > eastScore
|
||||
}
|
||||
|
||||
private val full = TayuAi(TayuLevel.FULL)
|
||||
private val gentle = TayuAi(TayuLevel.GENTLE)
|
||||
|
||||
@Test
|
||||
fun `full strength beats random placement from either side of the board`() {
|
||||
val games = 20
|
||||
val asNorthSouth = northWins(games, north = full, east = null)
|
||||
val asEastWest = games - northWins(games, north = null, east = full)
|
||||
println("full strength won $asNorthSouth/$games as north–south, $asEastWest/$games as east–west")
|
||||
|
||||
// Ta Yü is a game of position rather than luck of the draw, so an
|
||||
// opponent that understands it should win nearly every game against
|
||||
// random placement — from either axis.
|
||||
assertTrue("only $asNorthSouth/$games as north–south", asNorthSouth >= games - 1)
|
||||
assertTrue("only $asEastWest/$games as east–west", asEastWest >= games - 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the gentle setting is weaker without being random`() {
|
||||
val games = 20
|
||||
val fullOverGentle = northWins(games, north = full, east = gentle)
|
||||
val fullOverGentleReversed = games - northWins(games, north = gentle, east = full)
|
||||
val gentleOverRandom = northWins(games, north = gentle, east = null)
|
||||
println(
|
||||
"full over gentle $fullOverGentle/$games and $fullOverGentleReversed/$games; " +
|
||||
"gentle over random $gentleOverRandom/$games"
|
||||
)
|
||||
|
||||
assertTrue("gentle is not weaker: $fullOverGentle/$games", fullOverGentle >= 13)
|
||||
assertTrue(
|
||||
"gentle is not weaker from the other side: $fullOverGentleReversed/$games",
|
||||
fullOverGentleReversed >= 13,
|
||||
)
|
||||
// But it must still be playing the game, not flailing — otherwise it is
|
||||
// not a gentler opponent, just a broken one.
|
||||
assertTrue("gentle is no better than random: $gentleOverRandom/$games", gentleOverRandom >= 12)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neither axis is favoured when both sides play the same way`() {
|
||||
// If random against random came out lopsided, the board or the scoring
|
||||
// would favour one axis and every result above would mean nothing.
|
||||
val games = 20
|
||||
val wins = northWins(games, north = null, east = null)
|
||||
println("random against random: north–south won $wins/$games")
|
||||
assertTrue("north–south wins $wins/$games at random", wins in 5..15)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ai reaches both of its own edges nearly every game`() {
|
||||
// The point of the multiplicative scoring: an opponent that only ever
|
||||
// broke through on one side would score nothing however much of the
|
||||
// board it covered.
|
||||
val games = 10
|
||||
val scored = (1..games).count { seed ->
|
||||
duel(seed.toLong(), north = full, east = null).first > 0
|
||||
}
|
||||
println("full strength scored in $scored/$games games")
|
||||
assertTrue("scored in only $scored/$games games", scored >= 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a table plays at full strength unless it is turned down`() {
|
||||
assertEquals(TayuLevel.FULL, TayuOptions().level)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package org.prolibertate.games.game.tayu
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
class TayuRulesTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun config(
|
||||
options: TayuOptions = TayuOptions(),
|
||||
seed: Long = 7L,
|
||||
) = TableConfig(
|
||||
gameId = "tayu",
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
// -- The tile set -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `there are exactly twenty-eight distinct tiles`() {
|
||||
// This is the number the whole reconstruction rests on: 84 tiles in the
|
||||
// reissue is three of each, and the original's 112 is four of each.
|
||||
assertEquals(28, TayuTiles.all.size)
|
||||
assertEquals(84, 3 * TayuTiles.all.size)
|
||||
assertEquals(112, 4 * TayuTiles.all.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every tile has three mouths and no two tiles are the same`() {
|
||||
TayuTiles.all.forEach { mask ->
|
||||
assertEquals("$mask", TayuTiles.MOUTHS_PER_TILE, mask.countOneBits())
|
||||
}
|
||||
assertEquals(TayuTiles.all.size, TayuTiles.all.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no tile is unchanged by being turned end for end`() {
|
||||
// Which is why the 56 arrangements pair off cleanly into 28 tiles.
|
||||
TayuTiles.all.forEach { mask ->
|
||||
assertFalse("$mask is its own turn", mask == TayuTiles.turned(mask))
|
||||
assertEquals("turning twice returns", mask, TayuTiles.turned(TayuTiles.turned(mask)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the set covers every arrangement of three mouths once turns are allowed`() {
|
||||
val reachable = TayuTiles.all.flatMap { listOf(it, TayuTiles.turned(it)) }.toSet()
|
||||
val everyArrangement = (0 until (1 shl TileSlots.COUNT))
|
||||
.filter { it.countOneBits() == TayuTiles.MOUTHS_PER_TILE }
|
||||
.toSet()
|
||||
assertEquals(56, everyArrangement.size)
|
||||
assertEquals(everyArrangement, reachable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `twelve tiles reach three flanks at once`() {
|
||||
// The ones the reissue rings on the centre stud.
|
||||
assertEquals(12, TayuTiles.all.count { TayuTiles.touchesThreeFlanks(it) })
|
||||
}
|
||||
|
||||
// -- Geometry -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `turning the tile turns its mouths with it`() {
|
||||
// A mouth on the left flank of a tile pointing east faces north; the
|
||||
// same mouth on a tile pointing north faces west.
|
||||
assertEquals(Facing.NORTH, absoluteFacing(Facing.EAST, Flank.LEFT))
|
||||
assertEquals(Facing.WEST, absoluteFacing(Facing.NORTH, Flank.LEFT))
|
||||
assertEquals(Facing.EAST, absoluteFacing(Facing.EAST, Flank.FRONT))
|
||||
assertEquals(Facing.WEST, absoluteFacing(Facing.EAST, Flank.BACK))
|
||||
assertEquals(Facing.SOUTH, absoluteFacing(Facing.EAST, Flank.RIGHT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit positions run clockwise and invert cleanly`() {
|
||||
Facing.entries.forEach { edge ->
|
||||
for (position in 0 until BOARD_SIZE) {
|
||||
val cell = TayuBoard.edgeCell(edge, position)
|
||||
assertEquals(
|
||||
"$edge $position",
|
||||
position,
|
||||
TayuBoard.exitPosition(edge, TayuBoard.rowOf(cell), TayuBoard.colOf(cell)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marked exits count double and there are three to a side`() {
|
||||
assertEquals(3, TayuBoard.markedExits.size)
|
||||
val marked = TayuBoard.edgeCell(Facing.NORTH, 8)
|
||||
assertEquals(2, TayuBoard.exitValue(Facing.NORTH, 0, TayuBoard.colOf(marked)))
|
||||
val plain = TayuBoard.edgeCell(Facing.NORTH, 0)
|
||||
assertEquals(1, TayuBoard.exitValue(Facing.NORTH, 0, TayuBoard.colOf(plain)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the four centre cells are the middle of the board`() {
|
||||
assertEquals(4, TayuBoard.centre.size)
|
||||
assertTrue(TayuBoard.cellAt(8, 8) in TayuBoard.centre)
|
||||
assertTrue(TayuBoard.cellAt(9, 9) in TayuBoard.centre)
|
||||
assertFalse(TayuBoard.cellAt(7, 8) in TayuBoard.centre)
|
||||
}
|
||||
|
||||
// -- Setup --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the opening tile is drawn and every seat has an axis`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
assertNotNull("a tile is waiting to be placed", state.drawn)
|
||||
assertEquals(0, state.turn)
|
||||
assertEquals(84 - 1, state.bagCount)
|
||||
assertTrue(state.placed.isEmpty())
|
||||
assertEquals(Axis.NORTH_SOUTH, state.axisOf(0))
|
||||
assertEquals(Axis.EAST_WEST, state.axisOf(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a four handed table pairs opposite seats`() {
|
||||
val state = TayuRules.initialState(config(TayuOptions(playerCount = 4)))
|
||||
assertEquals(listOf(0, 1, 0, 1), state.teams)
|
||||
assertEquals(state.axisOf(0), state.axisOf(2))
|
||||
assertEquals(state.axisOf(1), state.axisOf(3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the bag holds the chosen number of copies of every tile`() {
|
||||
val state = TayuRules.initialState(config(TayuOptions(tileCopies = 4)))
|
||||
val counts = (state.bag + listOfNotNull(state.drawn)).groupingBy { it }.eachCount()
|
||||
assertEquals(28, counts.size)
|
||||
assertTrue("four of each", counts.values.all { it == 4 })
|
||||
}
|
||||
|
||||
// -- Opening placement --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the opening tile must cover the centre`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
val moves = TayuRules.legalMoves(state, 0)
|
||||
assertTrue(moves.isNotEmpty())
|
||||
moves.forEach { move ->
|
||||
assertTrue(
|
||||
"$move misses the centre",
|
||||
cellsOf(move).any { it in TayuBoard.centre },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every tile can open the game`() {
|
||||
// Nothing in the bag can jam the first turn, so the opening seat always
|
||||
// has something to do.
|
||||
val state = TayuRules.initialState(config())
|
||||
TayuTiles.all.forEach { tile ->
|
||||
assertTrue("$tile cannot open", TayuRules.placementsFor(state, tile).isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `laying the opening tile fills three cells and passes the turn`() {
|
||||
var state = TayuRules.initialState(config())
|
||||
val move = TayuRules.legalMoves(state, 0).first()
|
||||
state = TayuRules.applyMove(state, 0, move)
|
||||
|
||||
assertEquals(1, state.placed.size)
|
||||
assertEquals(3, state.tileAt.count { it != NO_TILE })
|
||||
cellsOf(move).forEach { assertEquals(0, state.tileAt[it]) }
|
||||
assertEquals(1, state.turn)
|
||||
assertNotNull("the next seat has drawn", state.drawn)
|
||||
}
|
||||
|
||||
// -- Mouth accounting ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a river may not dead-end against the flank of another tile`() {
|
||||
// A tile lying east-west across the centre with a single mouth on its
|
||||
// north flank, above its middle cell.
|
||||
val existing = tile(
|
||||
mouths = listOf(mouth(1, Flank.LEFT), mouth(0, Flank.BACK), mouth(2, Flank.FRONT)),
|
||||
)
|
||||
val state = boardWith(
|
||||
PlacedTile(existing, row = 9, col = 8, facing = Facing.EAST, seat = 0),
|
||||
)
|
||||
|
||||
// Directly above it, a tile whose south flank is blank where the mouth
|
||||
// below is pointing: the river would run into stone.
|
||||
val blankUnderneath = tile(
|
||||
mouths = listOf(mouth(0, Flank.LEFT), mouth(1, Flank.LEFT), mouth(2, Flank.LEFT)),
|
||||
)
|
||||
val moves = TayuRules.placementsFor(state.copy(drawn = blankUnderneath), blankUnderneath)
|
||||
assertTrue(
|
||||
"a blank flank must not cover the mouth at (9,9)",
|
||||
moves.none { move -> TayuBoard.cellAt(8, 9) in cellsOf(move) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tile must join a river already down`() {
|
||||
val existing = tile(
|
||||
mouths = listOf(mouth(0, Flank.BACK), mouth(2, Flank.FRONT), mouth(1, Flank.LEFT)),
|
||||
)
|
||||
val state = boardWith(PlacedTile(existing, 9, 8, Facing.EAST, seat = 0))
|
||||
val next = TayuTiles.all.first()
|
||||
|
||||
TayuRules.placementsFor(state.copy(drawn = next), next).forEach { move ->
|
||||
val cells = cellsOf(move)
|
||||
val touches = cells.any { cell ->
|
||||
Facing.entries.any { direction ->
|
||||
val row = TayuBoard.rowOf(cell) + direction.dr
|
||||
val col = TayuBoard.colOf(cell) + direction.dc
|
||||
TayuBoard.onBoard(row, col) &&
|
||||
state.tileAt[TayuBoard.cellAt(row, col)] != NO_TILE
|
||||
}
|
||||
}
|
||||
assertTrue("$move floats free of the board", touches)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tile may not overlap one already down`() {
|
||||
val existing = TayuTiles.all.first()
|
||||
val state = boardWith(PlacedTile(existing, 9, 8, Facing.EAST, seat = 0))
|
||||
val occupied = PlacedTile(existing, 9, 8, Facing.EAST, seat = 0).cells().toSet()
|
||||
|
||||
TayuRules.placementsFor(state.copy(drawn = existing), existing).forEach { move ->
|
||||
assertTrue("$move overlaps", cellsOf(move).none { it in occupied })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `placements stay on the board`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
TayuTiles.all.forEach { tile ->
|
||||
TayuRules.placementsFor(state, tile).forEach { move ->
|
||||
cellsOf(move).forEach { cell ->
|
||||
assertTrue(cell in 0 until BOARD_CELLS)
|
||||
}
|
||||
// Three distinct cells in a straight line.
|
||||
assertEquals(3, cellsOf(move).distinct().size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the four orientations of one tile are four different placements`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
val tile = TayuTiles.all.first { TayuTiles.touchesThreeFlanks(it) }
|
||||
val atCentre = TayuRules.placementsFor(state, tile)
|
||||
.filter { it.row == 8 && it.col == 8 }
|
||||
assertEquals("no duplicates and nothing missing", 4, atCentre.size)
|
||||
assertEquals(4, atCentre.map { it.facing }.distinct().size)
|
||||
}
|
||||
|
||||
// -- Exits and scoring --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a mouth pointing off the board is an exit`() {
|
||||
// A tile in the north-west corner: its back mouth points west off the
|
||||
// board, and one left-flank mouth points north off it.
|
||||
val mask = tile(
|
||||
mouths = listOf(mouth(0, Flank.BACK), mouth(0, Flank.LEFT), mouth(2, Flank.FRONT)),
|
||||
)
|
||||
val state = boardWith(PlacedTile(mask, row = 0, col = 0, facing = Facing.EAST, seat = 0))
|
||||
val exits = TayuRules.exitsOf(state)
|
||||
|
||||
assertEquals("north exit at column 0", 1, exits.north)
|
||||
assertEquals("west exit at row 0", 1, exits.west)
|
||||
assertEquals(0, exits.south)
|
||||
assertEquals(0, exits.east)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a marked exit counts twice`() {
|
||||
// Position 8 on the north edge is marked, which is column 8.
|
||||
val mask = tile(
|
||||
mouths = listOf(mouth(0, Flank.LEFT), mouth(0, Flank.BACK), mouth(2, Flank.FRONT)),
|
||||
)
|
||||
val state = boardWith(PlacedTile(mask, row = 0, col = 8, facing = Facing.EAST, seat = 0))
|
||||
assertEquals(2, TayuRules.exitsOf(state).north)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reaching one edge only scores nothing`() {
|
||||
val mask = tile(
|
||||
mouths = listOf(mouth(0, Flank.LEFT), mouth(1, Flank.LEFT), mouth(2, Flank.LEFT)),
|
||||
)
|
||||
val state = boardWith(PlacedTile(mask, row = 0, col = 0, facing = Facing.EAST, seat = 0))
|
||||
val exits = TayuRules.exitsOf(state)
|
||||
assertEquals(3, exits.north)
|
||||
assertEquals(0, exits.south)
|
||||
assertEquals(
|
||||
"a river down one side only is worth nothing",
|
||||
0,
|
||||
exits.product(Axis.NORTH_SOUTH),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a score is one edge multiplied by the other`() {
|
||||
val exits = EdgeExits(north = 5, east = 2, south = 4, west = 3)
|
||||
assertEquals(20, exits.product(Axis.NORTH_SOUTH))
|
||||
assertEquals(6, exits.product(Axis.EAST_WEST))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the higher score wins and equal scores draw`() {
|
||||
val northSouth = boardWith(
|
||||
// North and south exits for team 0 only.
|
||||
PlacedTile(
|
||||
tile(listOf(mouth(0, Flank.LEFT), mouth(0, Flank.RIGHT), mouth(2, Flank.FRONT))),
|
||||
row = 0,
|
||||
col = 0,
|
||||
facing = Facing.EAST,
|
||||
seat = 0,
|
||||
),
|
||||
)
|
||||
// Only one of the two north-south edges is reached, so still a draw at 0.
|
||||
assertNull(TayuRules.winnerOf(northSouth))
|
||||
}
|
||||
|
||||
// -- Turn order and the end of the game ---------------------------------
|
||||
|
||||
@Test
|
||||
fun `the seat on the clock always has a legal move`() {
|
||||
var state = TayuRules.initialState(config())
|
||||
val random = Random(4L)
|
||||
var guard = 0
|
||||
while (!TayuRules.isFinished(state) && guard++ < 500) {
|
||||
val seat = TayuRules.currentSeat(state)
|
||||
assertNotNull("somebody must be on the clock while playing", seat)
|
||||
val legal = TayuRules.legalMoves(state, seat!!)
|
||||
assertTrue("a seat on the clock with nothing to do", legal.isNotEmpty())
|
||||
state = TayuRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertTrue("the game never ended", TayuRules.isFinished(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the game ends with the bag empty and every tile accounted for`() {
|
||||
var state = TayuRules.initialState(config(TayuOptions(tileCopies = 2)))
|
||||
val total = state.options.tileCount
|
||||
val random = Random(11L)
|
||||
var guard = 0
|
||||
while (!TayuRules.isFinished(state) && guard++ < 500) {
|
||||
val seat = TayuRules.currentSeat(state)!!
|
||||
val legal = TayuRules.legalMoves(state, seat)
|
||||
state = TayuRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
|
||||
}
|
||||
assertEquals(0, state.bagCount)
|
||||
assertNull(state.drawn)
|
||||
assertEquals(TayuPhase.GAME_OVER, state.phase)
|
||||
assertEquals(
|
||||
"laid plus set aside must account for the whole bag",
|
||||
total,
|
||||
state.placed.size + state.setAside.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an illegal placement is refused`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
// The far corner cannot be the opening tile: it is nowhere near the centre.
|
||||
val corner = TayuMove(row = 0, col = 0, facing = Facing.EAST)
|
||||
assertFalse(corner in TayuRules.legalMoves(state, 0))
|
||||
runCatching { TayuRules.applyMove(state, 0, corner) }
|
||||
.onSuccess { error("an illegal placement was accepted") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a seat that is not on the clock has no moves`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
assertTrue(TayuRules.legalMoves(state, 1).isEmpty())
|
||||
runCatching { TayuRules.applyMove(state, 1, TayuRules.legalMoves(state, 0).first()) }
|
||||
.onSuccess { error("a move out of turn was accepted") }
|
||||
}
|
||||
|
||||
// -- The AI -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `ai play produces only legal moves and reaches a result`() {
|
||||
val ai = TayuAi()
|
||||
repeat(2) { iteration ->
|
||||
var state = TayuRules.initialState(
|
||||
config(TayuOptions(tileCopies = 2), seed = 300L + iteration)
|
||||
)
|
||||
var guard = 0
|
||||
while (!TayuRules.isFinished(state) && guard++ < 500) {
|
||||
val seat = TayuRules.currentSeat(state)!!
|
||||
val legal = TayuRules.legalMoves(state, seat)
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("ai returned an illegal move $move", legal.contains(move))
|
||||
state = TayuRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertTrue("ai game ran away", guard < 500)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ai drives water towards its own edges`() {
|
||||
// Over a full game the AI should reach its own two edges more often than
|
||||
// it hands exits to the opponent, which is the whole point of the pull
|
||||
// term in its heuristic.
|
||||
val ai = TayuAi()
|
||||
var state = TayuRules.initialState(config(TayuOptions(tileCopies = 2), seed = 42L))
|
||||
var guard = 0
|
||||
while (!TayuRules.isFinished(state) && guard++ < 500) {
|
||||
val seat = TayuRules.currentSeat(state)!!
|
||||
val legal = TayuRules.legalMoves(state, seat)
|
||||
state = TayuRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||
}
|
||||
val exits = TayuRules.exitsOf(state)
|
||||
assertTrue(
|
||||
"nobody reached any edge at all: $exits",
|
||||
exits.north + exits.south + exits.east + exits.west > 0,
|
||||
)
|
||||
}
|
||||
|
||||
// -- Redaction and wire format -----------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a seat view hides the bag order but not the board`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
val view = TayuRules.viewFor(state, seat = 1)
|
||||
assertTrue("the bag order is hidden", view.bag.isEmpty())
|
||||
assertEquals("but not how much is left", state.bagCount, view.bagCount)
|
||||
assertEquals("a drawn tile is face up", state.drawn, view.drawn)
|
||||
assertEquals(state.tileAt, view.tileAt)
|
||||
assertEquals(state.placed, view.placed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state and moves survive a round trip`() {
|
||||
var state = TayuRules.initialState(config())
|
||||
state = TayuRules.applyMove(state, 0, TayuRules.legalMoves(state, 0).first())
|
||||
assertEquals(state, TayuRules.decodeState(TayuRules.encodeState(state)))
|
||||
|
||||
Facing.entries.forEach { facing ->
|
||||
val move = TayuMove(row = 4, col = 5, facing = facing)
|
||||
assertEquals(move, TayuRules.decodeMove(TayuRules.encodeMove(move)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `summary reports both products`() {
|
||||
val state = TayuRules.initialState(config())
|
||||
val summary = TayuRules.summary(state)
|
||||
assertTrue(summary, summary.contains("North–south"))
|
||||
assertTrue(summary, summary.contains("East–west"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported tables are refused`() {
|
||||
listOf(1, 3, 5).forEach { players ->
|
||||
runCatching { TayuOptions(playerCount = players) }
|
||||
.onSuccess { error("$players players should not be allowed") }
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------------
|
||||
|
||||
/** The slot a mouth sits in, from the cell it is on and the flank it faces. */
|
||||
private fun mouth(index: Int, flank: Flank): Int =
|
||||
(0 until TileSlots.COUNT).first {
|
||||
TileSlots.cellOf(it) == index && TileSlots.flankOf(it) == flank
|
||||
}
|
||||
|
||||
/** A tile mask built from slots, checked to be one that really exists. */
|
||||
private fun tile(mouths: List<Int>): Int {
|
||||
val mask = mouths.fold(0) { acc, slot -> acc or (1 shl slot) }
|
||||
require(mask.countOneBits() == TayuTiles.MOUTHS_PER_TILE) { "three mouths, not $mouths" }
|
||||
require(mask in TayuTiles.all || TayuTiles.turned(mask) in TayuTiles.all) {
|
||||
"no such tile"
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
/** A board with tiles already laid, ready for the next placement. */
|
||||
private fun boardWith(vararg tiles: PlacedTile): TayuState {
|
||||
val tileAt = MutableList(BOARD_CELLS) { NO_TILE }
|
||||
tiles.forEachIndexed { index, tile -> tile.cells().forEach { tileAt[it] = index } }
|
||||
val options = TayuOptions()
|
||||
return TayuState(
|
||||
options = options,
|
||||
seed = 1L,
|
||||
teams = listOf(0, 1),
|
||||
tileAt = tileAt,
|
||||
placed = tiles.toList(),
|
||||
bag = emptyList(),
|
||||
drawn = TayuTiles.all.first(),
|
||||
bagCount = 0,
|
||||
setAside = emptyList(),
|
||||
turn = 0,
|
||||
phase = TayuPhase.PLAYING,
|
||||
log = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+162
-108
@@ -1,59 +1,56 @@
|
||||
# Ta Yü — the ruleset to implement
|
||||
# Ta Yü — implemented rules
|
||||
|
||||
This is the ruleset the engine enforces. Anything marked **toggle** is exposed
|
||||
in the game setup screen.
|
||||
|
||||
Ta Yü is a connection game by Niek (Nicolas) Neuwahl, published by Kosmos in
|
||||
1999 and nominated for the Spiel des Jahres that year; Goliath reissued it in
|
||||
2009. It is long out of print, which is why it is being rebuilt here.
|
||||
2009. It has been out of print for years, which is why it is rebuilt here.
|
||||
|
||||
**This game is not implemented yet.** This document is the specification the
|
||||
engine will be written against, and it is deliberately explicit about which
|
||||
parts are attested by sources and which parts are reconstructed. Read the
|
||||
[Provenance](#provenance) section before treating any number here as gospel.
|
||||
Players lay 1×3 river tiles outwards from the centre of a large board. Each tile
|
||||
carries three river mouths, and a mouth may never be walled off against the
|
||||
blank flank of another tile — that constraint is what makes every placement
|
||||
depend on everything already down. One side runs water to the north and south
|
||||
edges, the other to the east and west. Scoring multiplies your two opposite
|
||||
edges together, so a river that reaches only one of them is worth nothing at
|
||||
all.
|
||||
|
||||
Players lay 1×3 river tiles outwards from the centre of a large board. Each
|
||||
tile carries three river mouths, and a river mouth may never be walled off
|
||||
against the blank flank of another tile — so every placement is constrained by
|
||||
everything already down. One side is trying to run water out to the north and
|
||||
south edges, the other to the east and west. Scoring multiplies the two
|
||||
opposite edges together, so a river that reaches only one of your edges is
|
||||
worth nothing at all.
|
||||
**The rulebook could not be obtained, so parts of this are reconstructed.**
|
||||
Which parts, and how much to trust each, is set out under
|
||||
[Provenance](#provenance). Read that before treating a number here as gospel.
|
||||
|
||||
## Table
|
||||
|
||||
- **Players — *toggle*.** Two or four.
|
||||
- **Two.** One player takes north–south, the other east–west.
|
||||
- **Two.** Seat 0 takes north–south, seat 1 east–west.
|
||||
- **Four.** Two partnerships: seats 0 and 2 play north–south, seats 1 and 3
|
||||
play east–west, so partners are never seated next to each other.
|
||||
- The three-player "flood" variant is **not** being implemented — see
|
||||
east–west, so partners are never seated next to each other.
|
||||
- **Opponent — *toggle*.** Gentle or full strength. See
|
||||
[the computer opponent](#the-computer-opponent).
|
||||
- **Tiles — *toggle*.** Two, three or four copies of every tile — 56, 84 or 112.
|
||||
Three is the reissued game and four is the 1999 original. The publisher itself
|
||||
cut it from four to three to shorten the game, which is the same reasoning
|
||||
behind offering two.
|
||||
|
||||
The three-player "flood" variant is **not** implemented — see
|
||||
[Known gaps](#known-gaps).
|
||||
|
||||
## Board
|
||||
|
||||
An **18 × 18** grid of cells. Rows are numbered from the north edge, columns
|
||||
from the west edge.
|
||||
An **18 × 18** grid of cells. Rows count from the north edge, columns from the
|
||||
west.
|
||||
|
||||
Each of the four edges has 18 possible **exits** — one per edge cell — where a
|
||||
river can run off the board. Three exits on each edge are **marked** and count
|
||||
double.
|
||||
|
||||
```
|
||||
north edge (row 0)
|
||||
┌────────────────────┐
|
||||
west │ │ east
|
||||
edge │ 18 × 18 │ edge
|
||||
│ │
|
||||
└────────────────────┘
|
||||
south edge (row 17)
|
||||
```
|
||||
Each edge has 18 possible **exits**, one per edge cell, where a river can run
|
||||
off the board. Three exits on each edge are **marked** and count double.
|
||||
|
||||
## Tiles
|
||||
|
||||
84 tiles, drawn blind from a bag. Each tile is **1 × 3** and carries exactly
|
||||
**three river mouths** on its perimeter, all joined to one another by channels
|
||||
across the face of the tile — so water entering any mouth can leave by either
|
||||
of the other two.
|
||||
Each tile is **1 × 3** and carries exactly **three river mouths** on its
|
||||
perimeter, all joined to one another across the face of the tile — so water
|
||||
entering any mouth can leave by either of the other two.
|
||||
|
||||
A 1×3 tile has eight places a mouth can sit. Taking the tile lying east–west
|
||||
and its cells numbered 0, 1, 2 from the west:
|
||||
with its cells numbered 0, 1, 2 from the west:
|
||||
|
||||
| Slot | Where |
|
||||
| --- | --- |
|
||||
@@ -67,135 +64,192 @@ end, which pairs them off: `N0↔S2`, `N1↔S1`, `N2↔S0`, `W↔E`. No arrangem
|
||||
its own mirror under that turn, so the 56 collapse to exactly **28 distinct
|
||||
tiles**.
|
||||
|
||||
**84 = 3 × 28.** The set is three copies of every tile that can exist. (The
|
||||
1999 Kosmos edition had 112 tiles — 4 × 28 — and the reissue cut it to three
|
||||
copies each to shorten the game. Both counts landing on a whole multiple of 28
|
||||
is the main reason to trust this reconstruction of the geometry.)
|
||||
**84 = 3 × 28.** The reissued set is three copies of every tile that can exist,
|
||||
and the 1999 original's 112 is four copies. Both counts landing on a whole
|
||||
multiple of 28 is the main reason to trust this reconstruction of the geometry.
|
||||
|
||||
Of the 28, **12 have their three mouths on three different flanks** of the
|
||||
rectangle, which matches the reissued tiles being marked with a concentric ring
|
||||
on the centre stud for exactly that case.
|
||||
on the centre stud — so those tiles are drawn with a ring here too.
|
||||
|
||||
The engine generates the set rather than listing it, so the tile table cannot
|
||||
drift out of step with the count.
|
||||
The engine generates the set rather than listing it, so the tiles and the count
|
||||
cannot drift apart.
|
||||
|
||||
## Turn
|
||||
|
||||
1. **Draw** one tile from the bag, face up. There are no hands and no hidden
|
||||
tiles — everyone sees what you drew, and the only concealed information in
|
||||
the game is the order of the bag.
|
||||
2. **Place** it, in any of its four orientations (east–west or north–south,
|
||||
either way round).
|
||||
3. If the drawn tile has no legal placement anywhere, it is **set aside** and
|
||||
the turn passes. It does not come back.
|
||||
tiles: everyone sees what you drew, and the only concealed information in the
|
||||
whole game is the order of the bag.
|
||||
2. **Place** it, in any of its four orientations.
|
||||
3. If the drawn tile has no legal placement anywhere it is **set aside** and the
|
||||
turn passes. It does not come back.
|
||||
|
||||
### Placing the first tile
|
||||
Because a tile is only ever drawn for a seat that can play it, the player on the
|
||||
clock always has at least one legal placement.
|
||||
|
||||
The opening tile must cover at least one of the four centre cells — (8,8),
|
||||
(8,9), (9,8), (9,9). Everything else grows out from it.
|
||||
### The opening tile
|
||||
|
||||
### Placing every tile after the first
|
||||
The first tile must cover at least one of the four centre cells — (8,8), (8,9),
|
||||
(9,8), (9,9). Every one of the 28 tiles can legally open, so the first turn can
|
||||
never jam.
|
||||
|
||||
### Every tile after the first
|
||||
|
||||
A placement is legal when all of the following hold.
|
||||
|
||||
- **The three cells are empty** and lie in a straight line on the board.
|
||||
- **Every one of the tile's three mouths is accounted for.** Take each mouth in
|
||||
turn and look at what is on the other side of it:
|
||||
- **The three cells are empty** and in a straight line on the board.
|
||||
- **Every one of the tile's three mouths is accounted for.** Take each mouth and
|
||||
look at what lies across it:
|
||||
- *an empty cell* — fine, the river runs on into open ground;
|
||||
- *off the board* — fine, and that is a scoring exit (see below);
|
||||
- *a tile that has a mouth facing back* — fine, the two join up;
|
||||
- *off the board* — fine, and that is a scoring exit;
|
||||
- *a tile with a mouth facing back* — fine, the two join up;
|
||||
- *a tile with a blank flank there* — **illegal**. A river may never dead-end
|
||||
against the side of another tile.
|
||||
- **The tile's blank edges must not wall anything off either.** If a blank part
|
||||
of the new tile lands against an existing tile's mouth, the placement is
|
||||
illegal, for the same reason read from the other direction.
|
||||
- **The tile's own blank edges must not wall anything off either.** A blank part
|
||||
of the new tile landing against an existing tile's mouth is illegal for the
|
||||
same reason read from the other direction.
|
||||
- **The tile must join the network.** At least one of its mouths must meet a
|
||||
mouth of a tile already on the board.
|
||||
mouth of a tile already down.
|
||||
|
||||
Because every tile joins the network by at least one mouth, and because all
|
||||
three mouths of a tile are connected across its face, **everything on the board
|
||||
is always one single river system** running back to the opening tile. The
|
||||
engine gets connectivity for free and never has to trace it.
|
||||
The engine enforces the two blanking rules as a single comparison: where a new
|
||||
tile abuts an existing one, both sides must agree — mouth to mouth, or blank to
|
||||
blank.
|
||||
|
||||
One consequence worth knowing, because it is what keeps the engine simple:
|
||||
since every tile joins the network by a mouth, and all three mouths of a tile
|
||||
are connected across its face, **everything on the board is always one single
|
||||
river system** running back to the opening tile. Nothing ever has to trace
|
||||
connectivity, and a mouth at the board's edge is a scoring exit by construction.
|
||||
`TayuRulesTest` and the offline verification harness both check this holds over
|
||||
whole games.
|
||||
|
||||
## End of the game
|
||||
|
||||
The game ends when the bag is empty, or when no tile left in the bag can be
|
||||
placed anywhere.
|
||||
The game ends when the bag is empty, or when nothing left in it can be placed
|
||||
anywhere. In practice a full 84-tile bag ends with a handful set aside — the
|
||||
board jams before the bag runs dry.
|
||||
|
||||
## Scoring
|
||||
|
||||
Count each side's exits — mouths that run off the board — on each of its two
|
||||
edges.
|
||||
|
||||
- An ordinary exit counts **1**.
|
||||
- One of the three **marked** exits on that edge counts **2**.
|
||||
|
||||
Then, for each side:
|
||||
Count each side's exits on each of its two edges. An ordinary exit counts **1**;
|
||||
one of the three **marked** exits on that edge counts **2**. Then, for each
|
||||
side:
|
||||
|
||||
```
|
||||
score = (exits on one edge) × (exits on the opposite edge)
|
||||
```
|
||||
|
||||
North–south multiplies its north count by its south count; east–west multiplies
|
||||
east by west. **A side that reaches only one of its two edges scores nothing**,
|
||||
which is the whole tension of the game: breadth on one edge is worthless
|
||||
without a matching breakthrough on the other.
|
||||
North–south multiplies its north count by its south; east–west multiplies east
|
||||
by west. **A side that reaches only one of its two edges scores nothing** — that
|
||||
is the whole tension of the game, and it happens in real play, not just in
|
||||
theory.
|
||||
|
||||
Higher score wins. Equal scores are a draw.
|
||||
|
||||
### Where the marked exits sit
|
||||
|
||||
Positions **3, 8 and 13** along each edge, counted clockwise from that edge's
|
||||
first cell, giving the board four-fold rotational symmetry so no side is
|
||||
better placed than another.
|
||||
first cell, giving the board four-fold rotational symmetry so no side is better
|
||||
served than another. This is a **choice, not a reproduction** — see below.
|
||||
|
||||
This is a **choice, not a reproduction** — see below.
|
||||
## Playing it on a phone
|
||||
|
||||
18 × 18 cells works out under 20dp a cell on a phone, which is too fine to tap
|
||||
accurately, so laying a tile is two steps rather than one:
|
||||
|
||||
- **Tap the board** to line a placement up. Cells that the drawn tile could
|
||||
cover are dotted, and the placement appears as an amber ghost.
|
||||
- **Tap the same cell again**, or press **Turn the tile**, to cycle through the
|
||||
other legal ways the tile could cover that cell. This is also how the tile
|
||||
gets turned — it has four orientations but only some of them fit, and a rotate
|
||||
control that landed on illegal positions would be worse than useless.
|
||||
- **Lay the tile** commits it.
|
||||
|
||||
A tap on a cell the tile cannot cover is ignored rather than treated as a
|
||||
deselection, so a fingertip landing one cell wide does not throw away a
|
||||
placement that took several taps to line up.
|
||||
|
||||
Rivers are drawn as channels running from each mouth into a pool at the middle
|
||||
of the tile. Exits show as a dot outside the board in the colour of the side
|
||||
that edge belongs to, and the four edges are tinted the same way, so it is
|
||||
possible to see at a glance which rivers are working for whom.
|
||||
|
||||
## The computer opponent
|
||||
|
||||
It rates every legal placement on the board that placement would produce,
|
||||
weighing what its own axis would score, what the opponent's would score, and
|
||||
which way the still-open river mouths are pointing.
|
||||
|
||||
That last term is what makes it play sensibly at all. Both scores sit at zero
|
||||
for most of a game — a product needs both edges — so an opponent that looked
|
||||
only at the score would have nothing to choose between its first fifty
|
||||
placements.
|
||||
|
||||
Two settings, because only two could be shown to differ:
|
||||
|
||||
| Setting | What it does | Measured |
|
||||
| --- | --- | --- |
|
||||
| **Gentle** | Rates a placement only on what it scores at that moment, which is usually nothing, so it lays a plausible but aimless river | Beats random placement about 3 games in 4; loses to full strength about 4 in 5 |
|
||||
| **Full strength** | Adds blocking, and notices which way the open mouths point | Won 20 of 20 against random placement, from either axis |
|
||||
|
||||
`TayuAiTest` holds both to those figures, and also checks that random against
|
||||
random comes out even — if it did not, the board or the scoring would favour an
|
||||
axis and every other result would be meaningless.
|
||||
|
||||
A third setting that blocked harder was written and dropped: it played full
|
||||
strength dead even over 30 games, so shipping it as a step up would have been a
|
||||
claim that could not be backed.
|
||||
|
||||
## Provenance
|
||||
|
||||
Everything above is reconstructed from secondary sources. The rulebook itself
|
||||
could not be reached from this machine: outbound web requests are blocked by
|
||||
the environment's network policy, so only search-result summaries were
|
||||
available, and BoardGameGeek's rules files were out of reach entirely.
|
||||
Everything here is reconstructed from secondary sources. The rulebook itself
|
||||
could not be reached: outbound web requests are blocked by the build
|
||||
environment's network policy, so only search-result summaries were available,
|
||||
and BoardGameGeek's rules files were out of reach entirely.
|
||||
|
||||
**Attested by sources:** designer and year; 84 tiles in the reissue and 112 in
|
||||
the original; 1×3 tiles with three connections each; the board's round
|
||||
indentations and the opening tile going in one of the four centre ones; the
|
||||
indentations, and the opening tile going in one of the four centre ones; the
|
||||
mouth-accounting placement rule and the ban on a river ending against a blank;
|
||||
north–south versus east–west; the multiplicative scoring; marked exits counting
|
||||
north–south against east–west; the multiplicative scoring; marked exits counting
|
||||
double, three to a side; the game ending when nothing can be placed; the
|
||||
three-player flood variant existing.
|
||||
existence of a three-player flood variant.
|
||||
|
||||
**Reconstructed here, and the parts to check against a real copy:**
|
||||
|
||||
- **The board is 18 × 18.** One source describes the physical board as having
|
||||
18 × 18 indentations; several others say 19 × 19. 18 × 18 is used because it
|
||||
is the one that makes "the four centre indentations" true — a 19 × 19 grid
|
||||
has a single centre cell, not four. If a rulebook ever settles this the other
|
||||
way, `BOARD_SIZE` is the only number that has to change.
|
||||
is the reading under which "the four centre indentations" is true — a 19 × 19
|
||||
grid has a single centre cell, not four. `BOARD_SIZE` in `TayuModel.kt` is the
|
||||
only thing that has to change if a rulebook settles it the other way.
|
||||
- **The 28-tile set and its geometry.** Derived, not read off a component list.
|
||||
It is trusted because 84 and 112 are both exact multiples of 28, and because
|
||||
12 of the 28 touch three flanks, matching the ringed tiles in the reissue.
|
||||
- **All three mouths on a tile interconnect.** Consistent with every
|
||||
description found, but not stated outright anywhere.
|
||||
- **All three mouths on a tile interconnect.** Consistent with every description
|
||||
found, but never stated outright.
|
||||
- **The marked exits at 3, 8 and 13.** Their real positions are unknown. Three
|
||||
mirror-symmetric marks are impossible on an even-length edge, so the real
|
||||
board cannot be reflectively symmetric per side either; a rotationally
|
||||
symmetric layout was picked as the fairest guess. This is cosmetic to the
|
||||
rules and structural only to the final score.
|
||||
- **An unplaceable drawn tile is set aside and the turn passes.** The
|
||||
alternative reading is that the game simply ends there.
|
||||
board cannot be reflectively symmetric within a side either; a rotationally
|
||||
symmetric layout was picked as the fairest guess. Cosmetic to the rules, and
|
||||
structural only to the final score.
|
||||
- **An unplaceable drawn tile is set aside and the turn passes.** The other
|
||||
reading is that the game simply ends there.
|
||||
|
||||
This is the same posture as the Sequence board layout: where the real thing
|
||||
could not be verified, ship an honest substitute and say so, rather than a
|
||||
guess dressed up as authentic.
|
||||
could not be verified, ship an honest substitute and say so, rather than a guess
|
||||
dressed up as authentic.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **The three-player flood variant is not implemented.** Sources agree it
|
||||
exists and that the third player tries to stop the other two reaching the
|
||||
edges, but nothing found says how the flood *scores*, and inventing a scoring
|
||||
rule would be making up a different game. Two- and four-player tables cover
|
||||
the family case; this can be added if the rulebook turns up.
|
||||
- **No draw-a-replacement rule.** A tile that cannot be placed is gone for
|
||||
good rather than swapped, which is the simpler of the two readings.
|
||||
- **The three-player flood variant is not implemented.** Sources agree it exists
|
||||
and that the third player tries to stop the other two reaching the edges, but
|
||||
nothing found says how the flood *scores*, and inventing a scoring rule would
|
||||
be shipping a different game. Two- and four-player tables cover the family
|
||||
case; this can be added if the rulebook turns up.
|
||||
- **No draw-a-replacement rule.** A tile that cannot be placed is gone for good
|
||||
rather than swapped, which is the simpler of the two readings.
|
||||
- **A full 84-tile game is long** — 84 placements, and every one of them is a
|
||||
real decision. The two- and three-copy settings exist for that reason.
|
||||
- **The AI does not look ahead.** It rates the board one placement deep. It
|
||||
plays a strong positional game because the heuristic is aimed at the right
|
||||
thing, not because it is searching.
|
||||
|
||||
Reference in New Issue
Block a user