Seafarers: interaction tests, rules and UI docs

This commit is contained in:
Claude
2026-10-24 17:18:59 +00:00
parent 5d36b7bd62
commit 3c0469fc81
4 changed files with 243 additions and 2 deletions
+7 -2
View File
@@ -52,9 +52,14 @@ integration:
## Katan
The island game is catalogue id `catan`, shown as **Katan**. It has two rule
sets chosen at setup: the base game (`docs/RULES-catan.md`) and Cities & Knights
sets chosen at setup: the base game (`docs/RULES-catan.md`), Cities & Knights
(`docs/RULES-catan-ck.md`, code in `game/catan/CatanCk*.kt`, state behind
`CatanState.ck`, which is null in the base game). The screen is layered; see
`CatanState.ck`, which is null in the base game) and Seafarers
(`docs/RULES-catan-seafarers.md`, code in `game/catan/CatanSea*.kt`, state behind
`CatanState.sea`). Rules code reads the board from `state.topo`, never from
`CatanBoard` directly, because a Seafarers map is not the 19-hex island. The
Catan Universe reverse engineering that shaped the landscape HUD and trade board
is in `docs/RE-catan-universe.md`. The screen is layered; see
`docs/UI-catan.md` before changing it.
## Bash output is filtered before it reaches the model
@@ -0,0 +1,149 @@
package org.prolibertate.games.ui.game.catan
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.GameCatalog
import org.prolibertate.games.game.catan.BuildShip
import org.prolibertate.games.game.catan.BuildingType
import org.prolibertate.games.game.catan.CatanAi
import org.prolibertate.games.game.catan.CatanBuilding
import org.prolibertate.games.game.catan.CatanOptions
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRuleSet
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.MovePirate
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.MoveShip
import org.prolibertate.games.game.catan.PickGold
import org.prolibertate.games.game.catan.Resource
import org.prolibertate.games.game.catan.Terrain
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/** What the screen offers on a Seafarers board: ships, the pirate, and gold. */
class CatanSeafarersInteractionTest {
private fun fresh(): CatanState {
val options = CatanOptions(playerCount = 3, pointsToWin = 12, ruleSet = CatanRuleSet.SEAFARERS)
return CatanRules.initialState(
TableConfig(
gameId = GameCatalog.CATAN,
seats = (0 until 3).map { PlayerSlot(it, "P$it", PlayerKind.AI, it) },
optionsJson = Json { encodeDefaults = true }.encodeToString(options),
seed = 7L,
),
)
}
/** Seat 0, mid-game, a harbour town on the coast of the home island and plenty of cards. */
private fun harbour(): CatanState {
val s = fresh().copy(
phase = CatanPhase.PLAYING,
turn = 0,
turnNumber = 1,
resources = List(3) { Resource.entries.associateWith { 5 } },
)
val topo = s.topo
val v = (0 until topo.VERTEX_COUNT).first { vertex ->
topo.vertexIsland[vertex] == s.sea!!.home &&
topo.VERTEX_EDGES[vertex].any { topo.edgeSea[it] && !topo.edgeLand[it] }
}
return s.copy(buildings = s.buildings.toMutableList().also { it[v] = CatanBuilding(BuildingType.SETTLEMENT, 0) })
}
@Test
fun `the ship is on the build menu, and arming it lights the sea edges a ship can take`() {
val s = harbour()
val legal = CatanRules.legalMoves(s, 0)
assertTrue(BuildKind.SHIP in CatanInteraction.canBuild(legal))
val targets = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
assertEquals(legal.filterIsInstance<BuildShip>().map { it.edge }.toSet(), targets.edges)
val edge = targets.edges.first { !s.topo.edgeLand[it] }
assertEquals(
TapOutcome.Submit(BuildShip(edge)),
CatanInteraction.onEdgeTap(edge, legal, BuildKind.SHIP, null),
)
// The same tap with the road armed means nothing: a road cannot lie between two sea hexes.
assertEquals(TapOutcome.Ignored, CatanInteraction.onEdgeTap(edge, legal, BuildKind.ROAD, null))
// A build tap waits for ✓ like any other piece.
assertEquals(BuildShip(edge), CatanInteraction.stageable(TapOutcome.Submit(BuildShip(edge)), BuildKind.SHIP))
}
@Test
fun `tapping a ship that can sail picks it up, and the next tap on a lit sea edge sails it`() {
val s0 = harbour()
val built = CatanRules.legalMoves(s0, 0).filterIsInstance<BuildShip>().first()
val s = CatanRules.applyMove(s0, 0, built)
.let { it.copy(sea = it.sea!!.copy(builtThisTurn = emptyList())) }
val legal = CatanRules.legalMoves(s, 0)
val idle = CatanInteraction.targets(s, legal, null, null)
assertEquals(setOf(built.edge), idle.ships)
assertEquals(TapOutcome.PickShip(built.edge), CatanInteraction.onEdgeTap(built.edge, legal, null, null))
val sailing = CatanInteraction.targets(s, legal, null, null, movingShip = built.edge)
val to = sailing.edges.first()
assertEquals(
TapOutcome.Submit(MoveShip(built.edge, to)),
CatanInteraction.onEdgeTap(to, legal, null, null, movingShip = built.edge),
)
}
@Test
fun `after a seven a land hex moves the robber and a sea hex moves the pirate`() {
val s = harbour().copy(phase = CatanPhase.ROBBER)
val legal = CatanRules.legalMoves(s, 0)
val targets = CatanInteraction.targets(s, legal, null, null)
val land = targets.hexes.first { s.terrains[it] != Terrain.SEA }
val sea = targets.hexes.first { s.terrains[it] == Terrain.SEA }
val robber = CatanInteraction.onHexTap(land, legal, topo = s.topo)
assertTrue(robber is TapOutcome.Submit && robber.move is MoveRobber)
val pirate = CatanInteraction.onHexTap(sea, legal, topo = s.topo)
assertTrue(pirate is TapOutcome.Submit && pirate.move is MovePirate)
}
@Test
fun `a gold pick is found by the cards it names`() {
val picks = listOf(PickGold(listOf(Resource.ORE)), PickGold(listOf(Resource.WOOL)))
assertEquals(PickGold(listOf(Resource.WOOL)), CatanInteraction.goldMove(picks, listOf(Resource.WOOL)))
assertEquals(null, CatanInteraction.goldMove(picks, listOf(Resource.WOOL, Resource.WOOL)))
assertEquals(1, CatanInteraction.goldCount(picks))
}
@Test
fun `the engines' own choices are always something the screen can make`() {
// Every legal move in a played-out game is reachable: a tap, a button or a dialog choice.
val ai = CatanAi()
var s = fresh()
var n = 0
while (!CatanRules.isFinished(s) && n++ < 600) {
val seat = CatanRules.currentSeat(s) ?: break
val legal = CatanRules.legalMoves(s, seat)
val ships = legal.filterIsInstance<BuildShip>()
if (s.phase == CatanPhase.PLAYING && ships.isNotEmpty()) {
val armed = CatanInteraction.targets(s, legal, BuildKind.SHIP, null)
ships.forEach { ship ->
assertTrue("ship ${ship.edge} not lit", ship.edge in armed.edges)
assertEquals(TapOutcome.Submit(ship), CatanInteraction.onEdgeTap(ship.edge, legal, BuildKind.SHIP, null))
}
}
legal.filterIsInstance<MoveShip>().forEach { sail ->
val lit = CatanInteraction.targets(s, legal, null, null, movingShip = sail.from)
assertTrue(sail.to in lit.edges)
assertEquals(
TapOutcome.Submit(sail),
CatanInteraction.onEdgeTap(sail.to, legal, null, null, movingShip = sail.from),
)
}
s = CatanRules.applyMove(s, seat, ai.chooseMove(s, seat, legal))
}
}
}
+71
View File
@@ -0,0 +1,71 @@
# Katan: Seafarers
The Seafarers rule set is chosen at setup next to *Base game* and *Cities & Knights*. It is the base
game ([RULES-catan.md](RULES-catan.md)) with a sea, ships, a pirate, gold fields and islands to
settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt` (the map),
`CatanBoard.kt` (`Topology`, the geometry shared by every map), state behind `CatanState.sea`
(null in the other rule sets). It cannot be combined with Cities & Knights.
Where the published rules leave room, or where this game differs, it says so below.
## The map
One map, *Heading for New Shores*: a large **home island** (21 hexes, one desert) and three small
islands (3, 3 and 2 hexes) across the water, inside a ring of sea. Which land hexes are gold, which
are the desert and where the sea lies is fixed; the **resources on the other hexes, the number tokens
and the nine harbours are shuffled** for each game. The red numbers (6, 8) are never on neighbouring
hexes. Players take three or four seats; the default target is **12 points** (menu: 8, 10, 12, 14).
The geometry is the same lattice as the base island, generalised (`Topology`): a hex is land or sea,
a crossing can hold a town if any hex touching it is land, an edge can hold a road if any hex touching
it is land and a ship if any hex touching it is sea (so a coastal edge takes either, never both).
## What changes
**Setup.** Both starting settlements (and their roads) go on the home island. A starting piece is a
road, never a ship. The second settlement collects from the hexes round it as usual (gold pays nothing
at setup).
**Ships.** A ship costs 1 lumber and 1 wool and lies on a sea edge.
* It must join your fleet or a town of yours: one end of the edge touches your town, or another of your ships.
An opponent's town is a wall.
* Roads and ships connect **only at a town of your own**: a road cannot start from a ship's end, nor a
ship from a road's, unless a settlement or city of yours stands on that crossing. A settlement may be
built at the end of a ship route exactly as at the end of a road route.
* No more than 15 ships. Road Building may lay two roads, two ships or one of each.
* **Sailing.** Once per turn you may move one ship, never one built or moved this turn, and only a ship at the
**open end** of a route (one end touches nothing else of yours). It goes to any edge it could be built on
with it lifted off.
**The pirate.** Starts in the open sea. On a seven (and with a Knight card) the player moves either the
robber, to any land hex, or the pirate, to any sea hex. The robber works as before. The pirate **blocks the six
edges round its hex**: no ship may be built or sail onto them (ships already there stay). It robs one card
at random from a player with a ship on an edge of its hex.
**Gold.** A gold field has a number like any hex. When it comes up, each town beside it (one card; a city two)
pays its owner **any resource of their choice**, chosen after the ordinary production is dealt, in seat order from
the roller. The robber blocks a gold field like any hex. If the bank cannot supply the choice it is limited to
what it holds.
**New islands.** The first player to settle on each island other than the home island scores **2 bonus points**,
permanently (`SeaState.islandBonus`). A second settlement there earns nothing extra.
**Longest route.** Longest Road counts roads and ships together, as a path of your pieces that never runs
through an opponent's town and changes between road and ship only at a town of yours. Five or more, as before.
Everything else (development cards, trading, harbours, Largest Army, the discard on a seven) is the base game.
## Not included
The physical expansion has more scenarios and the Catan Universe app adds more still. These are **out of scope here**:
fog islands, treasure islands, the oasis, the great canal, the Explorers & Pirates and Cities & Knights combinations,
the scenarios after the first, and trade ships. The `SeaScenario` enum and `SeaBoards` are built so a second map
is a new text map and a new enum entry.
## How the AI plays it
It settles first and sails second. A ship is built when a settlement spot reachable by sea (up to five steps from its
open end) is worth having, with a bonus for an island nobody has landed on, and ahead of a development card when that
bonus is in play. It chooses the resource it values most from gold, always moves the robber (never the pirate) and does
not sail ships it has already launched. See `CatanAi.shipValue`.
+16
View File
@@ -192,3 +192,19 @@ Landscape screens use the control scheme of Catan Universe's match view, redrawn
ratio under the *give* card. An offer made to you opens the same panel read-only: ✓ accepts,
✕ declines, *Counter…* makes the rows editable.
Cities & Knights goods trades and the counter-offer chooser still use dialogs.
## Seafarers on the screen
| Layer | What it adds |
| --- | --- |
| `CatanGeometry.kt` | `BoardLayout` and `CatanGeometry.fit` take the game's `Topology`, so a map of islands fits the box like the standard island does |
| `CatanStyle.kt` | gold-field colour, the pirate's and the ship sail's colours |
| `CatanBoardView.kt` | sea hexes (a faint grid, no fill), ships (hull with a sail), the pirate (a flagged hull), a ring on ships that can sail |
| `CatanInteraction.kt` | `BuildKind.SHIP`; `PickShip`, `movingShip`, ship targets; the robber's and the pirate's hexes; `goldMove` |
| `CatanDialogs.kt` | `CatanGoldDialog`, the choice of what a gold field pays |
| `CatanScreen.kt` | the picked-up ship, the ship on the build menu / column |
**Interaction model.** Arm *Ship* (or drag it from the build column) and tap a lit sea edge: ✓ places it. To sail,
tap one of your ships that has a ring, then a lit sea edge. After a seven, tap a land hex for the robber or a sea
hex for the pirate. A gold field opens a dialog that cannot be dismissed. `CatanSeafarersInteractionTest` checks
that every build and sail the engine offers has a tap that produces it.