Seafarers: interaction tests, rules and UI docs
This commit is contained in:
@@ -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
|
||||
|
||||
+149
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user