Compare commits
2
Commits
421cbd4c70
...
dc73a71993
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc73a71993 | ||
|
|
42334c3bf8 |
@@ -599,6 +599,10 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
|||||||
/** What a seat would think of building on [vertex]: production and ports. */
|
/** What a seat would think of building on [vertex]: production and ports. */
|
||||||
/** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */
|
/** What a spot beside a cloth village or an unclaimed tribe chip is worth beyond its cards. */
|
||||||
private fun scenarioLure(state: CatanState, vertex: Int): Double {
|
private fun scenarioLure(state: CatanState, vertex: Int): Double {
|
||||||
|
// The Harbormaster: a town on a harbour is a point toward a title worth two.
|
||||||
|
if (CatanRules.harbormasterGame(state) &&
|
||||||
|
state.topo.VERTEX_EDGES[vertex].any { e -> state.ports.any { it.edge == e } }
|
||||||
|
) return HARBOR_LURE
|
||||||
val sea = state.sea ?: return 0.0
|
val sea = state.sea ?: return 0.0
|
||||||
var lure = 0.0
|
var lure = 0.0
|
||||||
for (h in state.topo.VERTEX_HEXES[vertex]) {
|
for (h in state.topo.VERTEX_HEXES[vertex]) {
|
||||||
@@ -709,5 +713,6 @@ class CatanAi(private val profiles: AiProfiles = AiProfiles.DEFAULT) : GameAi<Ca
|
|||||||
const val CLOTH_WORTH = 2.5
|
const val CLOTH_WORTH = 2.5
|
||||||
const val CHIP_LURE = 0.5
|
const val CHIP_LURE = 0.5
|
||||||
const val CANAL_LURE = 0.6
|
const val CANAL_LURE = 0.6
|
||||||
|
const val HARBOR_LURE = 0.5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,6 +429,9 @@ object CatanBoard {
|
|||||||
* tokens go to four of the six board-corner hexes, which are pairwise
|
* tokens go to four of the six board-corner hexes, which are pairwise
|
||||||
* non-adjacent, and at most one corner can be desert.
|
* non-adjacent, and at most one corner can be desert.
|
||||||
*/
|
*/
|
||||||
|
/** The seed of the First Island scenario's fixed layout. */
|
||||||
|
const val FIRST_ISLAND_SEED = 1L
|
||||||
|
|
||||||
fun generate(seed: Long): Generated {
|
fun generate(seed: Long): Generated {
|
||||||
val random = Random(seed)
|
val random = Random(seed)
|
||||||
val terrains = TERRAIN_TILES.shuffled(random)
|
val terrains = TERRAIN_TILES.shuffled(random)
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ object CatanCk {
|
|||||||
points[seat] += ck.defenderVp[seat] + ck.progressVp[seat]
|
points[seat] += ck.defenderVp[seat] + ck.progressVp[seat]
|
||||||
}
|
}
|
||||||
ck.merchant?.let { points[it.seat] += 1 }
|
ck.merchant?.let { points[it.seat] += 1 }
|
||||||
|
state.harbormaster?.let { points[it] += CatanRules.HARBORMASTER_POINTS }
|
||||||
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
||||||
return points.toList()
|
return points.toList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ enum class Terrain(val produces: Resource?) {
|
|||||||
FORTRESS(null),
|
FORTRESS(null),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Base-island scenarios: the board and goals of a game without Seafarers. Seafarers games choose a [SeaScenario] instead. */
|
||||||
|
@Serializable
|
||||||
|
enum class BaseScenario(val title: String, val blurb: String) {
|
||||||
|
RANDOM_MAP("Random map", "The usual island, its tiles and numbers shuffled for every game."),
|
||||||
|
FIRST_ISLAND(
|
||||||
|
"First Island",
|
||||||
|
"The same fixed island every game, so the set-up is not a surprise: good for a first game or a rematch.",
|
||||||
|
),
|
||||||
|
HARBORMASTER(
|
||||||
|
"The Harbormaster",
|
||||||
|
"Harbours matter. A settlement on a harbour counts a point and a city two; the first to three has the " +
|
||||||
|
"Harbormaster title, worth 2 victory points, and whoever gets more takes it.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
/** Axial coordinate of one hex on the board. See [CatanBoard]. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HexCoord(val q: Int, val r: Int)
|
data class HexCoord(val q: Int, val r: Int)
|
||||||
@@ -197,6 +212,7 @@ data class CatanOptions(
|
|||||||
val pointsToWin: Int = 10,
|
val pointsToWin: Int = 10,
|
||||||
val expansions: Set<CatanExpansion> = emptySet(),
|
val expansions: Set<CatanExpansion> = emptySet(),
|
||||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||||
|
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
|
||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
require(playerCount in 3..4) { "The island seats three or four players" }
|
require(playerCount in 3..4) { "The island seats three or four players" }
|
||||||
@@ -214,6 +230,7 @@ private class CatanOptionsWire(
|
|||||||
val pointsToWin: Int = 10,
|
val pointsToWin: Int = 10,
|
||||||
val expansions: Set<CatanExpansion> = emptySet(),
|
val expansions: Set<CatanExpansion> = emptySet(),
|
||||||
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
val scenario: SeaScenario = SeaScenario.NEW_SHORES,
|
||||||
|
val baseScenario: BaseScenario = BaseScenario.RANDOM_MAP,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Reads the legacy single `ruleSet` field as an expansion set. */
|
/** Reads the legacy single `ruleSet` field as an expansion set. */
|
||||||
@@ -222,7 +239,7 @@ object CatanOptionsSerializer : KSerializer<CatanOptions> {
|
|||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
|
override fun serialize(encoder: Encoder, value: CatanOptions) = encoder.encodeSerializableValue(
|
||||||
CatanOptionsWire.serializer(),
|
CatanOptionsWire.serializer(),
|
||||||
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario),
|
CatanOptionsWire(value.playerCount, value.pointsToWin, value.expansions, value.scenario, value.baseScenario),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): CatanOptions {
|
override fun deserialize(decoder: Decoder): CatanOptions {
|
||||||
@@ -234,7 +251,7 @@ object CatanOptionsSerializer : KSerializer<CatanOptions> {
|
|||||||
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
|
obj = JsonObject(obj - "ruleSet" + ("expansions" to JsonArray(expansions)))
|
||||||
}
|
}
|
||||||
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
|
val wire = input.json.decodeFromJsonElement(CatanOptionsWire.serializer(), obj)
|
||||||
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario)
|
return CatanOptions(wire.playerCount, wire.pointsToWin, wire.expansions, wire.scenario, wire.baseScenario)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +323,8 @@ data class CatanState(
|
|||||||
val log: List<String>,
|
val log: List<String>,
|
||||||
/** Everything Cities & Knights adds; null in the base game. */
|
/** Everything Cities & Knights adds; null in the base game. */
|
||||||
val ck: CkState? = null,
|
val ck: CkState? = null,
|
||||||
|
/** The Harbormaster scenario: the seat holding the title, or null. */
|
||||||
|
val harbormaster: Int? = null,
|
||||||
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
|
/** Everything Seafarers adds (ships, the pirate, islands); null in the other rule sets. */
|
||||||
val sea: SeaState? = null,
|
val sea: SeaState? = null,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
|
val seaBoard = if (sea) SeaBoards.generate(scenario, config.seed) else null
|
||||||
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
|
val topo = if (seaBoard != null) SeaBoards.topology(scenario) else CatanBoard.base
|
||||||
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
|
val board = seaBoard?.let { CatanBoard.Generated(it.terrains, it.numbers, it.robber, it.ports) }
|
||||||
?: CatanBoard.generate(config.seed)
|
?: CatanBoard.generate(
|
||||||
|
if (options.baseScenario == BaseScenario.FIRST_ISLAND) CatanBoard.FIRST_ISLAND_SEED else config.seed,
|
||||||
|
)
|
||||||
// A different random stream than the island, so the same seed still
|
// A different random stream than the island, so the same seed still
|
||||||
// produces both a fixed island and a fixed deck.
|
// produces both a fixed island and a fixed deck.
|
||||||
val ck = options.citiesAndKnights
|
val ck = options.citiesAndKnights
|
||||||
@@ -532,7 +534,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val newIndex = state.setupIndex + 1
|
val newIndex = state.setupIndex + 1
|
||||||
s = s.copy(setupIndex = newIndex)
|
s = refreshHarbormaster(s.copy(setupIndex = newIndex))
|
||||||
return when {
|
return when {
|
||||||
newIndex >= 2 * n -> s.copy(
|
newIndex >= 2 * n -> s.copy(
|
||||||
phase = CatanPhase.AWAIT_ROLL,
|
phase = CatanPhase.AWAIT_ROLL,
|
||||||
@@ -1023,16 +1025,64 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
state.longestRoadOwner?.let { points[it] += 5 }
|
state.longestRoadOwner?.let { points[it] += 5 }
|
||||||
state.largestArmyOwner?.let { points[it] += 5 }
|
state.largestArmyOwner?.let { points[it] += 5 }
|
||||||
|
state.harbormaster?.let { points[it] += HARBORMASTER_POINTS }
|
||||||
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
if (state.isSea) for (seat in points.indices) points[seat] += SeaRules.bonusPoints(state, seat)
|
||||||
return points.toList()
|
return points.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// The Harbormaster
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The scenario's title: points for a harbour town and for the Harbormaster. */
|
||||||
|
const val HARBORMASTER_MINIMUM = 3
|
||||||
|
const val HARBORMASTER_POINTS = 2
|
||||||
|
|
||||||
|
/** Whether this table plays the Harbormaster (never in a Seafarers game, which has its own maps). */
|
||||||
|
internal fun harbormasterGame(state: CatanState): Boolean =
|
||||||
|
state.options.baseScenario == BaseScenario.HARBORMASTER && !state.isSea
|
||||||
|
|
||||||
|
/** Harbour points: a settlement on a harbour counts 1, a city 2, for each harbour it touches. */
|
||||||
|
fun harbourPoints(state: CatanState, seat: Int): Int {
|
||||||
|
var total = 0
|
||||||
|
for (port in state.ports) {
|
||||||
|
val (a, b) = state.topo.EDGE_VERTICES[port.edge]
|
||||||
|
val own = listOfNotNull(state.buildings[a], state.buildings[b]).firstOrNull { it.owner == seat }
|
||||||
|
if (own != null) total += if (own.type == BuildingType.CITY) 2 else 1
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives the title to the first seat with three harbour points, and to a seat with strictly more than the holder. */
|
||||||
|
internal fun refreshHarbormaster(state: CatanState): CatanState {
|
||||||
|
if (!harbormasterGame(state)) return state
|
||||||
|
val points = List(state.playerCount) { harbourPoints(state, it) }
|
||||||
|
val holder = state.harbormaster
|
||||||
|
val best = points.max()
|
||||||
|
val leaders = points.indices.filter { points[it] == best }
|
||||||
|
val next = when {
|
||||||
|
holder == null -> leaders.singleOrNull()?.takeIf { best >= HARBORMASTER_MINIMUM }
|
||||||
|
best > points[holder] -> leaders.singleOrNull() ?: holder
|
||||||
|
else -> holder
|
||||||
|
}
|
||||||
|
if (next == holder) return state
|
||||||
|
return state.copy(
|
||||||
|
harbormaster = next,
|
||||||
|
log = state.log + if (holder == null) {
|
||||||
|
"Seat $next is now harbormaster."
|
||||||
|
} else {
|
||||||
|
"Seat $next has taken the title harbormaster from seat $holder."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun victoryPoints(state: CatanState, seat: Int): Int =
|
fun victoryPoints(state: CatanState, seat: Int): Int =
|
||||||
publicVictoryPoints(state)[seat] +
|
publicVictoryPoints(state)[seat] +
|
||||||
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
state.devHands[seat].count { it.type == DevCardType.VICTORY_POINT }
|
||||||
|
|
||||||
internal fun checkWin(state: CatanState): CatanState {
|
internal fun checkWin(settled: CatanState): CatanState {
|
||||||
if (state.phase == CatanPhase.GAME_OVER) return state
|
if (settled.phase == CatanPhase.GAME_OVER) return settled
|
||||||
|
val state = refreshHarbormaster(settled)
|
||||||
val seat = state.turn
|
val seat = state.turn
|
||||||
val points = victoryPoints(state, seat)
|
val points = victoryPoints(state, seat)
|
||||||
if (points < state.options.pointsToWin) return state
|
if (points < state.options.pointsToWin) return state
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import org.prolibertate.games.game.president.PresidentOptions
|
|||||||
import org.prolibertate.games.game.catan.CatanOptions
|
import org.prolibertate.games.game.catan.CatanOptions
|
||||||
import org.prolibertate.games.game.catan.CatanExpansion
|
import org.prolibertate.games.game.catan.CatanExpansion
|
||||||
import org.prolibertate.games.game.catan.SeaScenario
|
import org.prolibertate.games.game.catan.SeaScenario
|
||||||
|
import org.prolibertate.games.game.catan.BaseScenario
|
||||||
import org.prolibertate.games.game.sequence.SequenceOptions
|
import org.prolibertate.games.game.sequence.SequenceOptions
|
||||||
import org.prolibertate.games.game.tayu.TayuLevel
|
import org.prolibertate.games.game.tayu.TayuLevel
|
||||||
import org.prolibertate.games.game.tayu.TayuOptions
|
import org.prolibertate.games.game.tayu.TayuOptions
|
||||||
@@ -442,6 +443,15 @@ private fun CatanOptionsEditor(options: CatanOptions, onChange: (CatanOptions) -
|
|||||||
onSelect = { onChange(options.copy(scenario = it)) },
|
onSelect = { onChange(options.copy(scenario = it)) },
|
||||||
)
|
)
|
||||||
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
|
Text(options.scenario.blurb, style = MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
ChipRow(
|
||||||
|
label = "Island",
|
||||||
|
values = BaseScenario.entries,
|
||||||
|
selected = options.baseScenario,
|
||||||
|
display = { it.title },
|
||||||
|
onSelect = { onChange(options.copy(baseScenario = it)) },
|
||||||
|
)
|
||||||
|
Text(options.baseScenario.blurb, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
ChipRow(
|
ChipRow(
|
||||||
label = "Players",
|
label = "Players",
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ fun CatanPlayerCard(
|
|||||||
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
|
if (at.seat == seat) add(if (compact) track.name.first().toString() else "${CatanText.track(track)} metropolis")
|
||||||
}
|
}
|
||||||
if (ck.merchant?.seat == seat) add("Merchant")
|
if (ck.merchant?.seat == seat) add("Merchant")
|
||||||
|
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
|
||||||
addAll(seaAwards(state, seat, compact))
|
addAll(seaAwards(state, seat, compact))
|
||||||
}
|
}
|
||||||
CardLine(
|
CardLine(
|
||||||
@@ -281,6 +282,7 @@ fun CatanPlayerCard(
|
|||||||
val awards = buildList {
|
val awards = buildList {
|
||||||
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||||
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
||||||
|
if (state.harbormaster == seat) add(if (compact) "Harbor" else "Harbormaster")
|
||||||
addAll(seaAwards(state, seat, compact))
|
addAll(seaAwards(state, seat, compact))
|
||||||
}
|
}
|
||||||
// Always drawn, so the card does not grow when somebody takes an award.
|
// Always drawn, so the card does not grow when somebody takes an award.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package org.prolibertate.games.game.catan
|
||||||
|
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
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
|
||||||
|
|
||||||
|
/** The base-island scenarios: First Island's fixed board and the Harbormaster. */
|
||||||
|
class CatanBaseScenarioTest {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true }
|
||||||
|
|
||||||
|
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 game(
|
||||||
|
scenario: BaseScenario,
|
||||||
|
seed: Long = 5L,
|
||||||
|
expansions: Set<CatanExpansion> = emptySet(),
|
||||||
|
players: Int = 3,
|
||||||
|
) = CatanRules.initialState(config(CatanOptions(players, 10, expansions, baseScenario = scenario), seed))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first island is the same board for every seed and the random map is not`() {
|
||||||
|
val a = game(BaseScenario.FIRST_ISLAND, 1L)
|
||||||
|
val b = game(BaseScenario.FIRST_ISLAND, 99L)
|
||||||
|
assertEquals(a.terrains, b.terrains)
|
||||||
|
assertEquals(a.numbers, b.numbers)
|
||||||
|
assertEquals(a.ports, b.ports)
|
||||||
|
assertNotEquals(game(BaseScenario.RANDOM_MAP, 1L).terrains, game(BaseScenario.RANDOM_MAP, 99L).terrains)
|
||||||
|
// The deck and the seating still follow the table's own seed.
|
||||||
|
assertNotEquals(a.devDeck, b.devDeck)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a save from before base scenarios reads as a random map`() {
|
||||||
|
val old = """{"playerCount":3,"pointsToWin":10,"expansions":[],"scenario":"NEW_SHORES"}"""
|
||||||
|
assertEquals(BaseScenario.RANDOM_MAP, json.decodeFromString<CatanOptions>(old).baseScenario)
|
||||||
|
val options = CatanOptions(3, 10, baseScenario = BaseScenario.HARBORMASTER)
|
||||||
|
assertEquals(options, json.decodeFromString<CatanOptions>(json.encodeToString(options)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertices of the harbours, one per harbour, in board order. */
|
||||||
|
private fun CatanState.harbourVertices(): List<Int> = ports.map { topo.EDGE_VERTICES[it.edge].first }
|
||||||
|
|
||||||
|
private fun CatanState.with(seat: Int, vertex: Int, type: BuildingType) =
|
||||||
|
copy(buildings = buildings.toMutableList().also { it[vertex] = CatanBuilding(type, seat) })
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three harbour points make a harbormaster and more takes the title`() {
|
||||||
|
var s = game(BaseScenario.HARBORMASTER).copy(phase = CatanPhase.PLAYING, turn = 0, turnNumber = 1)
|
||||||
|
val v = s.harbourVertices()
|
||||||
|
s = s.with(0, v[0], BuildingType.SETTLEMENT).with(0, v[1], BuildingType.SETTLEMENT)
|
||||||
|
assertEquals(2, CatanRules.harbourPoints(s, 0))
|
||||||
|
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
|
||||||
|
|
||||||
|
// A city counts two: 1 + 2 = 3.
|
||||||
|
s = CatanRules.refreshHarbormaster(s.with(0, v[1], BuildingType.CITY))
|
||||||
|
assertEquals(3, CatanRules.harbourPoints(s, 0))
|
||||||
|
assertEquals(0, s.harbormaster)
|
||||||
|
assertEquals(CatanRules.publicVictoryPoints(s.copy(harbormaster = null))[0] + 2, CatanRules.publicVictoryPoints(s)[0])
|
||||||
|
|
||||||
|
// Equal points do not take it; strictly more does.
|
||||||
|
s = CatanRules.refreshHarbormaster(
|
||||||
|
s.with(1, v[2], BuildingType.SETTLEMENT).with(1, v[3], BuildingType.SETTLEMENT).with(1, v[4], BuildingType.SETTLEMENT),
|
||||||
|
)
|
||||||
|
assertEquals(0, s.harbormaster)
|
||||||
|
s = CatanRules.refreshHarbormaster(s.with(1, v[5], BuildingType.SETTLEMENT))
|
||||||
|
assertEquals(1, s.harbormaster)
|
||||||
|
assertTrue(s.log.any { "taken the title harbormaster from seat 0" in it })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the title does not exist in other scenarios or in a Seafarers game`() {
|
||||||
|
var s = game(BaseScenario.RANDOM_MAP).copy(phase = CatanPhase.PLAYING)
|
||||||
|
val v = s.harbourVertices()
|
||||||
|
for (i in 0..3) s = s.with(0, v[i], BuildingType.SETTLEMENT)
|
||||||
|
assertNull(CatanRules.refreshHarbormaster(s).harbormaster)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `engines finish games on the base scenarios, with and without Cities and Knights`() {
|
||||||
|
var titles = 0
|
||||||
|
for (scenario in listOf(BaseScenario.FIRST_ISLAND, BaseScenario.HARBORMASTER)) {
|
||||||
|
for (expansions in listOf(emptySet(), setOf(CatanExpansion.CITIES_AND_KNIGHTS))) {
|
||||||
|
for ((players, seed) in listOf(3 to 31L, 4 to 32L)) {
|
||||||
|
val opts = CatanOptions(players, if (expansions.isEmpty()) 10 else 13, expansions, baseScenario = scenario)
|
||||||
|
var state = CatanRules.initialState(config(opts, seed))
|
||||||
|
val ai = CatanAi()
|
||||||
|
var moves = 0
|
||||||
|
while (!CatanRules.isFinished(state)) {
|
||||||
|
assertTrue("$scenario $expansions seed $seed ran long", moves++ < 20_000)
|
||||||
|
val seat = CatanRules.currentSeat(state)!!
|
||||||
|
val legal = CatanRules.legalMoves(state, seat)
|
||||||
|
state = CatanRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
|
||||||
|
}
|
||||||
|
if (state.harbormaster != null) titles++
|
||||||
|
assertEquals(state, CatanRules.decodeState(CatanRules.encodeState(state)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue("nobody ever became harbormaster", titles > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,26 @@ A table plays one of two rule sets, chosen on the setup screen
|
|||||||
building, robber and trade rules and changes or replaces the rest; its state lives
|
building, robber and trade rules and changes or replaces the rest; its state lives
|
||||||
behind `CatanState.ck`, which is `null` in the base game.
|
behind `CatanState.ck`, which is `null` in the base game.
|
||||||
|
|
||||||
|
## Base-island scenarios
|
||||||
|
|
||||||
|
Without Seafarers the setup screen offers an **Island** choice (`CatanOptions.baseScenario`; it works with Cities &
|
||||||
|
Knights too). Both extras are the author's reading, not transcriptions of the printed scenarios:
|
||||||
|
|
||||||
|
- **Random map** (default) — the usual shuffled island.
|
||||||
|
- **First Island** — the same island every game: one fixed shuffle (`CatanBoard.FIRST_ISLAND_SEED`) of the standard
|
||||||
|
tiles, numbers and harbours, so a first game or a rematch has no surprise set-up. It is not the printed beginner
|
||||||
|
layout. The development deck and the seating still follow the table's own seed.
|
||||||
|
- **The Harbormaster** — the Catan Universe menu text is "Because trade becomes more and more important, harbours
|
||||||
|
have an ever increasing significance." Its card: 2 victory points to the first player with at least three harbour
|
||||||
|
points; another player with more takes it at once. What a harbour point is was not in the app's texts, so it is
|
||||||
|
inferred: a settlement on a harbour counts **1**, a city **2**, for each harbour it touches. The title is checked after
|
||||||
|
every settlement, city and set-up placement; **equal points do not take it**, only strictly more (like Longest
|
||||||
|
Road); it is **+2 victory points** to the holder (`CatanState.harbormaster`). The AI values a harbour spot a
|
||||||
|
little more here (`CatanAi.HARBOR_LURE`).
|
||||||
|
|
||||||
|
Seafarers games choose a map instead (see [RULES-catan-seafarers.md](RULES-catan-seafarers.md)); the Harbormaster does not apply there.
|
||||||
|
The app's other base scenario, Ore for Wool, is not built: its rules are not in the texts that could be read.
|
||||||
|
|
||||||
## Table
|
## Table
|
||||||
|
|
||||||
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
|
- **Rules — *toggle*.** Base game or Cities & Knights (`CatanOptions.ruleSet`).
|
||||||
|
|||||||
Reference in New Issue
Block a user