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:
Claude
2026-08-06 18:09:10 +00:00
parent 9f71e0844b
commit cdd515afc6
12 changed files with 2386 additions and 118 deletions
@@ -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(),
)
}
}