Seafarers: Four Islands and Through the Desert maps
Add two map-only scenarios. A scenario now says where the starting settlements may go (SeaStart); SeaState.starts holds the starting islands, and the first-landing bonus skips all of them. Saves without starts fall back to the home island. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
This commit is contained in:
@@ -646,7 +646,7 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
||||
if (!seen.add(v)) continue
|
||||
if (CatanRules.vertexOpenForSettlement(state, v)) {
|
||||
val island = topo.vertexIsland[v]
|
||||
val fresh = island >= 0 && island != sea.home && sea.islandBonus[island] == NO_PLAYER
|
||||
val fresh = island >= 0 && !sea.isStart(island) && sea.islandBonus[island] == NO_PLAYER
|
||||
val value = (spotValue(state, v) + if (fresh) 0.5 else 0.0) * Math.pow(0.85, depth.toDouble())
|
||||
best = maxOf(best, value)
|
||||
}
|
||||
|
||||
@@ -465,12 +465,17 @@ data class SeaState(
|
||||
val shipMoved: Boolean = false,
|
||||
/** Who holds the "new island" bonus for each island, or [NO_PLAYER]. The home island's is never awarded. */
|
||||
val islandBonus: List<Int>,
|
||||
/** The island the players start on. */
|
||||
/** The island that counts as home: the largest, and the pirate-free start of the original map. */
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on. Empty in a save from before there were several: just [home]. */
|
||||
val starts: List<Int> = emptyList(),
|
||||
/** Gold owed after a roll, front first: each seat chooses [GoldOwed.count] cards. */
|
||||
val gold: List<GoldOwed> = emptyList(),
|
||||
)
|
||||
|
||||
/** Whether [island] is one the game starts on, and so never earns the new-island bonus. */
|
||||
fun SeaState.isStart(island: Int): Boolean = island == home || island in starts
|
||||
|
||||
@Serializable
|
||||
data class GoldOwed(val seat: Int, val count: Int)
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
pirate = it.pirate,
|
||||
islandBonus = List(topo.islandCount) { NO_PLAYER },
|
||||
home = it.home,
|
||||
starts = it.starts,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -207,8 +208,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
val moves = mutableListOf<SetupPlace>()
|
||||
for (v in 0 until state.topo.VERTEX_COUNT) {
|
||||
if (!vertexOpenForSettlement(state, v)) continue
|
||||
// Seafarers: everyone starts on the home island.
|
||||
if (state.sea != null && state.topo.vertexIsland[v] != state.sea.home) continue
|
||||
// Seafarers: the starting settlements go on a starting island.
|
||||
if (state.sea != null && !state.sea.isStart(state.topo.vertexIsland[v])) continue
|
||||
for (e in state.topo.VERTEX_EDGES[v]) {
|
||||
if (state.roads[e] != NO_PLAYER || !state.topo.edgeLand[e]) continue
|
||||
moves += SetupPlace(v, e)
|
||||
|
||||
@@ -5,11 +5,29 @@ import kotlin.random.Random
|
||||
|
||||
/** The Seafarers maps on offer. Each is a fixed arrangement of land and sea; the tiles and numbers on it are shuffled. */
|
||||
@Serializable
|
||||
enum class SeaScenario(val title: String, val blurb: String) {
|
||||
enum class SeaScenario(val title: String, val blurb: String, val start: SeaStart = SeaStart.HOME_ONLY) {
|
||||
NEW_SHORES(
|
||||
"Heading for New Shores",
|
||||
"One large home island and three small ones to find by ship. Gold fields on two of the small islands.",
|
||||
),
|
||||
FOUR_ISLANDS(
|
||||
"Four Islands",
|
||||
"Four equal islands, one start each, and two gold islets in the middle. Settle your own island, then sail.",
|
||||
SeaStart.MAIN_ISLANDS,
|
||||
),
|
||||
THROUGH_THE_DESERT(
|
||||
"Through the Desert",
|
||||
"One island cut by a broad desert that yields nothing. Reach the far side by road, or sail round to the islets.",
|
||||
),
|
||||
}
|
||||
|
||||
/** Where the starting settlements may go. */
|
||||
enum class SeaStart {
|
||||
/** The largest island only. */
|
||||
HOME_ONLY,
|
||||
|
||||
/** Any island big enough to live on (four hexes or more); the small ones are prizes to sail to. */
|
||||
MAIN_ISLANDS,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +51,34 @@ object SeaBoards {
|
||||
"..........",
|
||||
)
|
||||
|
||||
private val FOUR_ISLANDS_MAP = listOf(
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"..###..###..",
|
||||
"..###..###..",
|
||||
"............",
|
||||
)
|
||||
|
||||
private val THROUGH_THE_DESERT_MAP = listOf(
|
||||
"............",
|
||||
".###DD###...",
|
||||
".###DDD###..",
|
||||
"..###DD###..",
|
||||
"............",
|
||||
"..$.....$...",
|
||||
"............",
|
||||
"...##...#...",
|
||||
"............",
|
||||
)
|
||||
|
||||
private fun rows(scenario: SeaScenario): List<String> = when (scenario) {
|
||||
SeaScenario.NEW_SHORES -> NEW_SHORES_MAP
|
||||
SeaScenario.FOUR_ISLANDS -> FOUR_ISLANDS_MAP
|
||||
SeaScenario.THROUGH_THE_DESERT -> THROUGH_THE_DESERT_MAP
|
||||
}
|
||||
|
||||
private val topologies = HashMap<SeaScenario, Topology>()
|
||||
@@ -72,8 +116,13 @@ object SeaBoards {
|
||||
val pirate: Int,
|
||||
val ports: List<Port>,
|
||||
val home: Int,
|
||||
/** The islands the starting settlements may go on; always includes [home]. */
|
||||
val starts: List<Int> = listOf(home),
|
||||
)
|
||||
|
||||
/** An island of this many land hexes or more can be started on. */
|
||||
private const val MAIN_ISLAND_HEXES = 4
|
||||
|
||||
private val RESOURCE_TILES = listOf(
|
||||
Terrain.FOREST, Terrain.PASTURE, Terrain.FIELDS, Terrain.HILLS, Terrain.MOUNTAINS,
|
||||
)
|
||||
@@ -139,6 +188,13 @@ object SeaBoards {
|
||||
} ?: 0
|
||||
} ?: sea.first()
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home)
|
||||
val starts = when (scenario.start) {
|
||||
SeaStart.HOME_ONLY -> listOf(home)
|
||||
SeaStart.MAIN_ISLANDS -> (0 until topo.islandCount).filter { island ->
|
||||
topo.islandOf.count { it == island } >= MAIN_ISLAND_HEXES
|
||||
}.ifEmpty { listOf(home) }
|
||||
}
|
||||
|
||||
return Generated(terrains, numbers.toList(), desert, pirate, ports, home, starts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ internal object SeaRules {
|
||||
fun claimIsland(state: CatanState, seat: Int, vertex: Int): CatanState {
|
||||
val sea = state.sea ?: return state
|
||||
val island = state.topo.vertexIsland[vertex]
|
||||
if (island < 0 || island == sea.home || sea.islandBonus[island] != NO_PLAYER) return state
|
||||
if (island < 0 || sea.isStart(island) || sea.islandBonus[island] != NO_PLAYER) return state
|
||||
val bonus = sea.islandBonus.toMutableList().also { it[island] = seat }
|
||||
return state.copy(
|
||||
sea = sea.copy(islandBonus = bonus),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.prolibertate.games.game.catan
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/** What every Seafarers map owes the engine, and the shape of each one. */
|
||||
class CatanSeaScenariosTest {
|
||||
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
private fun options(scenario: SeaScenario, players: Int = 3) = CatanOptions(
|
||||
playerCount = players,
|
||||
pointsToWin = 12,
|
||||
expansions = setOf(CatanExpansion.SEAFARERS),
|
||||
scenario = scenario,
|
||||
)
|
||||
|
||||
private fun config(options: CatanOptions, seed: Long) = TableConfig(
|
||||
gameId = GameCatalog.CATAN,
|
||||
seats = (0 until options.playerCount).map {
|
||||
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
|
||||
},
|
||||
optionsJson = json.encodeToString(options),
|
||||
seed = seed,
|
||||
)
|
||||
|
||||
private fun sizes(scenario: SeaScenario): List<Int> {
|
||||
val topo = SeaBoards.topology(scenario)
|
||||
return (0 until topo.islandCount).map { i -> topo.islandOf.count { it == i } }.sorted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each map has the islands it is named for`() {
|
||||
assertEquals(listOf(2, 3, 3, 21), sizes(SeaScenario.NEW_SHORES))
|
||||
assertEquals(listOf(1, 1, 6, 6, 6, 6), sizes(SeaScenario.FOUR_ISLANDS))
|
||||
assertEquals(listOf(1, 1, 1, 2, 25), sizes(SeaScenario.THROUGH_THE_DESERT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every map deals numbers, harbours and a pirate at sea, with the reds apart`() {
|
||||
for (scenario in SeaScenario.entries) {
|
||||
repeat(5) { i ->
|
||||
val s = CatanRules.initialState(config(options(scenario), 300L + i))
|
||||
val topo = s.topo
|
||||
val deserts = s.terrains.count { it == Terrain.DESERT }
|
||||
for (h in 0 until topo.HEX_COUNT) {
|
||||
val land = s.terrains[h] != Terrain.SEA
|
||||
assertEquals("$scenario hex $h", topo.land[h], land)
|
||||
assertEquals("$scenario hex $h", land && s.terrains[h] != Terrain.DESERT, s.numbers[h] != 0)
|
||||
}
|
||||
assertTrue("$scenario has no desert", deserts > 0 || scenario == SeaScenario.FOUR_ISLANDS)
|
||||
assertEquals(Terrain.SEA, s.terrains[s.sea!!.pirate])
|
||||
assertEquals("$scenario harbours", 9, s.ports.size)
|
||||
val reds = (0 until topo.HEX_COUNT).filter { s.numbers[it] == 6 || s.numbers[it] == 8 }
|
||||
for (a in reds) for (b in reds) if (a < b) assertFalse("$scenario reds touch", topo.hexesAdjacent(a, b))
|
||||
assertEquals(scenario, s.sea!!.scenario)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `four islands start on any main island and the islets earn the bonus`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.FOUR_ISLANDS), 5L))
|
||||
val sea = s.sea!!
|
||||
assertEquals(4, sea.starts.size)
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertEquals(sea.starts.toSet(), moves.map { s.topo.vertexIsland[it.vertex] }.toSet())
|
||||
for (island in 0 until s.topo.islandCount) assertEquals(island in sea.starts, sea.isStart(island))
|
||||
// A settlement on a starting island scores no first-landing bonus.
|
||||
val onMain = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] == sea.starts.first() }
|
||||
assertEquals(s, SeaRules.claimIsland(s, 0, onMain))
|
||||
val islet = (0 until s.topo.VERTEX_COUNT).first { s.topo.vertexIsland[it] >= 0 && s.topo.vertexIsland[it] !in sea.starts }
|
||||
assertEquals(0, SeaRules.bonusPoints(s, 0))
|
||||
assertEquals(0, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, onMain), 0))
|
||||
assertEquals(SeaRules.ISLAND_BONUS, SeaRules.bonusPoints(SeaRules.claimIsland(s, 0, islet), 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `through the desert starts everyone on the one big island`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.THROUGH_THE_DESERT), 5L))
|
||||
assertEquals(listOf(s.sea!!.home), s.sea!!.starts)
|
||||
assertEquals(25, s.topo.islandOf.count { it == s.sea!!.home })
|
||||
assertTrue(s.terrains.count { it == Terrain.DESERT } >= 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save from before there were several starts still starts on the home island`() {
|
||||
val s = CatanRules.initialState(config(options(SeaScenario.NEW_SHORES), 5L))
|
||||
val old = s.copy(sea = s.sea!!.copy(starts = emptyList()))
|
||||
assertTrue(old.sea!!.isStart(old.sea!!.home))
|
||||
val moves = CatanRules.legalMoves(old, old.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.all { old.topo.vertexIsland[it.vertex] == old.sea!!.home })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engines finish a game on every map at three and four players`() {
|
||||
for (scenario in SeaScenario.entries) {
|
||||
for ((players, seed) in listOf(3 to 11L, 4 to 12L, 3 to 13L)) {
|
||||
val opts = options(scenario, players)
|
||||
val ai = CatanAi()
|
||||
var state = CatanRules.initialState(config(opts, seed))
|
||||
var moves = 0
|
||||
while (!CatanRules.isFinished(state)) {
|
||||
assertTrue("$scenario seed $seed ran past the cap; log: ${state.log.takeLast(6)}", moves++ < 20_000)
|
||||
val seat = CatanRules.currentSeat(state) ?: error("$scenario seed $seed: nobody to move in ${state.phase}")
|
||||
val legal = CatanRules.legalMoves(state, seat)
|
||||
assertTrue("$scenario seed $seed: no legal move for $seat in ${state.phase}", legal.isNotEmpty())
|
||||
val move = ai.chooseMove(state, seat, legal)
|
||||
assertTrue("$scenario seed $seed: illegal $move", CatanRules.isLegalMove(state, seat, move))
|
||||
state = CatanRules.applyMove(state, seat, move)
|
||||
}
|
||||
assertEquals("$scenario seed $seed", CatanPhase.GAME_OVER, state.phase)
|
||||
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,23 @@ settle. Code: `game/catan/CatanSeaRules.kt` (the additions), `CatanSeaBoard.kt`
|
||||
|
||||
Where the published rules leave room, or where this game differs, it says so below.
|
||||
|
||||
## The map
|
||||
## The maps
|
||||
|
||||
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
|
||||
Each map is a fixed arrangement of land and sea; the **resources on the plain 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).
|
||||
Pick the map at setup; each scenario names where the starting settlements may go (`SeaStart`).
|
||||
|
||||
* **Heading for New Shores** (default). A large home island (21 hexes, one desert) and three small islands (3, 3
|
||||
and 2 hexes) across the water, with gold fields on two of the small ones. Start on the home island.
|
||||
* **Four Islands.** Four equal islands (six hexes each) and two one-hex gold islets between them. Each player
|
||||
may start on **any** of the four main islands, so a table can spread out or crowd one; the islets are the
|
||||
prize for the first ship to arrive.
|
||||
* **Through the Desert.** One 25-hex island cut across by a broad desert (seven hexes that pay nothing and
|
||||
hold the robber), with three islets and gold to sail to. Everyone starts on the island. The desert is
|
||||
land, so a road can cross it, but nothing beside it pays, which is what makes the far side a race.
|
||||
|
||||
A *new island* for the bonus is any island that is not a starting island for that map.
|
||||
|
||||
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
|
||||
@@ -60,8 +70,8 @@ Everything else (development cards, trading, harbours, Largest Army, the discard
|
||||
|
||||
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.
|
||||
the scenarios that need new rules (fog, cloth, wonders, the forgotten tribe, pirate islands) and trade ships.
|
||||
A map-only scenario is a new text map and a new `SeaScenario` entry.
|
||||
|
||||
## How the AI plays it
|
||||
|
||||
|
||||
Reference in New Issue
Block a user