Fold per-game opponent levels into computer difficulty; randomise Katan first seat
The per-game Casual/Club/Strong rows duplicated the new Computer difficulty setting, so they are gone from setup (Tayu's Gentle stays: it is a table rule). The saved level is still the Medium baseline, so old saves load unchanged. Mastermind now takes its level from the difficulty. Katan's first seat is drawn from the table seed instead of always being seat 0. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ
This commit is contained in:
@@ -198,6 +198,8 @@ data class CatanState(
|
||||
val turn: Int,
|
||||
/** How many setup placements are done, 0 until 2 * playerCount. */
|
||||
val setupIndex: Int,
|
||||
/** The seat that places first and takes the first turn; drawn from the table seed. */
|
||||
val firstSeat: Int = 0,
|
||||
/** Monotonic turn counter, 0 during setup, 1 from the first real turn. */
|
||||
val turnNumber: Int,
|
||||
/** Terrain per hex, indexed like [CatanBoard.HEXES]. */
|
||||
|
||||
@@ -73,12 +73,17 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
.shuffled(Random(config.seed + 1))
|
||||
}
|
||||
|
||||
// Seat 0 used to lead every game; a separate stream from the board and
|
||||
// the deck keeps the same seed giving the same table.
|
||||
val firstSeat = Random(config.seed + 2).nextInt(options.playerCount)
|
||||
|
||||
return CatanState(
|
||||
options = options,
|
||||
seed = config.seed,
|
||||
phase = CatanPhase.SETUP_FIRST,
|
||||
turn = 0,
|
||||
turn = firstSeat,
|
||||
setupIndex = 0,
|
||||
firstSeat = firstSeat,
|
||||
turnNumber = 0,
|
||||
terrains = board.terrains,
|
||||
numbers = board.numbers,
|
||||
@@ -121,8 +126,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
|
||||
override fun currentSeat(state: CatanState): Int? = when (state.phase) {
|
||||
CatanPhase.GAME_OVER -> null
|
||||
CatanPhase.SETUP_FIRST -> state.setupIndex
|
||||
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
|
||||
CatanPhase.SETUP_FIRST -> (state.firstSeat + state.setupIndex) % state.playerCount
|
||||
CatanPhase.SETUP_SECOND ->
|
||||
(state.firstSeat + 2 * state.playerCount - 1 - state.setupIndex) % state.playerCount
|
||||
CatanPhase.DISCARDING -> state.discardSeat
|
||||
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
|
||||
?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 }
|
||||
@@ -512,9 +518,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
return when {
|
||||
newIndex >= 2 * n -> s.copy(
|
||||
phase = CatanPhase.AWAIT_ROLL,
|
||||
turn = 0,
|
||||
turn = state.firstSeat,
|
||||
turnNumber = 1,
|
||||
log = s.log + "Setup done — seat 0 leads.",
|
||||
log = s.log + "Setup done — seat ${state.firstSeat} leads.",
|
||||
)
|
||||
|
||||
newIndex == n -> s.copy(phase = CatanPhase.SETUP_SECOND)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.prolibertate.games.game.mastermind
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.AiLevel
|
||||
import org.prolibertate.games.game.engine.AiProfiles
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
@@ -18,15 +19,13 @@ import org.prolibertate.games.game.engine.GameAi
|
||||
* plays the one whose *worst* possible answer leaves the smallest number of
|
||||
* candidates behind, so it cannot be given a reply that tells it little.
|
||||
*
|
||||
* The table already carries its own three-step level ([MastermindOptions.level]),
|
||||
* chosen at setup and shown to the player, and it means exactly what an
|
||||
* [org.prolibertate.games.game.engine.AiLevel] would here. Layering a second
|
||||
* knob on top would make the two disagree, so the shared [profiles] are
|
||||
* accepted for a uniform constructor but this game keeps playing at the level
|
||||
* the table names. [level] overrides that, which is what the tests use.
|
||||
* The computer difficulty chosen at setup picks the level: Easy plays Casual,
|
||||
* Medium plays whatever the table's saved [MastermindOptions.level] says (Club
|
||||
* for a new game) and Hard plays Strong. [level] overrides all of that, which is
|
||||
* what the tests use.
|
||||
*/
|
||||
class MastermindAi(
|
||||
@Suppress("unused") private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val profiles: AiProfiles = AiProfiles.DEFAULT,
|
||||
private val level: MastermindLevel? = null,
|
||||
) : GameAi<MastermindState, MastermindMove> {
|
||||
|
||||
@@ -38,7 +37,12 @@ class MastermindAi(
|
||||
legal: List<MastermindMove>,
|
||||
): MastermindMove {
|
||||
require(legal.isNotEmpty()) { "No legal guess for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
val profile = profiles.of(seat)
|
||||
val setting = level ?: when (profile.level) {
|
||||
AiLevel.EASY -> MastermindLevel.CASUAL
|
||||
AiLevel.MEDIUM -> state.options.level
|
||||
AiLevel.HARD -> MastermindLevel.STRONG
|
||||
}
|
||||
|
||||
// Deterministic for a given position, so a host and a client watching
|
||||
// the same game agree on what the computer did.
|
||||
|
||||
@@ -35,11 +35,8 @@ import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.SavedMatch
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.backgammon.BackgammonLevel
|
||||
import org.prolibertate.games.game.backgammon.BackgammonOptions
|
||||
import org.prolibertate.games.game.checkers.CheckersLevel
|
||||
import org.prolibertate.games.game.checkers.CheckersOptions
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.cribbage.CribbageOptions
|
||||
@@ -57,11 +54,8 @@ import org.prolibertate.games.game.pegsolitaire.PegBoard
|
||||
import org.prolibertate.games.game.pegsolitaire.PegGoal
|
||||
import org.prolibertate.games.game.pegsolitaire.PegSolitaireOptions
|
||||
import org.prolibertate.games.game.kaiser.KaiserOptions
|
||||
import org.prolibertate.games.game.mastermind.MastermindLevel
|
||||
import org.prolibertate.games.game.mastermind.MastermindOptions
|
||||
import org.prolibertate.games.game.morris.MorrisLevel
|
||||
import org.prolibertate.games.game.morris.MorrisOptions
|
||||
import org.prolibertate.games.game.pirates.PiratesLevel
|
||||
import org.prolibertate.games.game.pirates.PiratesOptions
|
||||
import org.prolibertate.games.game.president.PresidentOptions
|
||||
import org.prolibertate.games.game.catan.CatanOptions
|
||||
@@ -785,13 +779,6 @@ private fun ChessOptionsEditor(
|
||||
display = { if (it) "White" else "Black" },
|
||||
onSelect = onColour,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = ChessLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Fifty-move rule",
|
||||
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
|
||||
@@ -815,13 +802,6 @@ private fun ChessOptionsEditor(
|
||||
@Composable
|
||||
private fun CheckersOptionsEditor(options: CheckersOptions, onChange: (CheckersOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = CheckersLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Flying kings",
|
||||
subtitle = "A king slides the length of a diagonal and takes from a distance, " +
|
||||
@@ -856,13 +836,6 @@ private fun BackgammonOptionsEditor(
|
||||
onChange: (BackgammonOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = BackgammonLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Count gammons",
|
||||
subtitle = "A loser who bore nothing off has lost double, and one still on the " +
|
||||
@@ -884,13 +857,6 @@ private fun MastermindOptionsEditor(
|
||||
onChange: (MastermindOptions) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MastermindLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Colours",
|
||||
values = listOf(6, 8),
|
||||
@@ -955,13 +921,6 @@ private fun PiratesOptionsEditor(
|
||||
display = { if (it) "the two pirates" else "the twenty-four Bulgars" },
|
||||
onSelect = onSide,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = PiratesLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "A pirate who can take must take",
|
||||
subtitle = "The rule that huffing exists to enforce, applied directly",
|
||||
@@ -987,13 +946,6 @@ private fun PiratesOptionsEditor(
|
||||
@Composable
|
||||
private fun MorrisOptionsEditor(options: MorrisOptions, onChange: (MorrisOptions) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = MorrisLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ChipRow(
|
||||
label = "Pieces each",
|
||||
values = listOf(3, 6, 9),
|
||||
|
||||
@@ -115,8 +115,9 @@ class CatanRulesTest {
|
||||
// -- Setup ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `setup runs in snake order and then seat zero leads`() {
|
||||
fun `setup runs in snake order from the first seat and then that seat leads`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
val first = state.firstSeat
|
||||
val order = mutableListOf<Int>()
|
||||
while (state.phase == CatanPhase.SETUP_FIRST || state.phase == CatanPhase.SETUP_SECOND) {
|
||||
val seat = CatanRules.currentSeat(state)!!
|
||||
@@ -126,12 +127,21 @@ class CatanRulesTest {
|
||||
CatanRules.legalMoves(state, seat).filterIsInstance<SetupPlace>().first(),
|
||||
)
|
||||
}
|
||||
assertEquals(listOf(0, 1, 2, 2, 1, 0), order)
|
||||
val forward = (0 until 3).map { (first + it) % 3 }
|
||||
assertEquals(forward + forward.reversed(), order)
|
||||
assertEquals(CatanPhase.AWAIT_ROLL, state.phase)
|
||||
assertEquals(0, state.turn)
|
||||
assertEquals(first, state.turn)
|
||||
assertEquals(1, state.turnNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first seat comes from the seed and varies between tables`() {
|
||||
val firsts = (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat }
|
||||
assertEquals(firsts, (1L..40L).map { CatanRules.initialState(config(seed = it)).firstSeat })
|
||||
assertEquals(setOf(0, 1, 2), firsts.toSet())
|
||||
assertEquals(firsts[0], CatanRules.initialState(config(seed = 1L)).turn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the second settlement earns one card from each adjacent land`() {
|
||||
var state = CatanRules.initialState(config())
|
||||
|
||||
@@ -96,7 +96,7 @@ class CatanSeafarersTest {
|
||||
@Test
|
||||
fun `setup places both settlements on the home island, with roads rather than ships`() {
|
||||
val s = start()
|
||||
val moves = CatanRules.legalMoves(s, 0).filterIsInstance<SetupPlace>()
|
||||
val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance<SetupPlace>()
|
||||
assertTrue(moves.isNotEmpty())
|
||||
assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home })
|
||||
assertTrue(moves.all { s.topo.edgeLand[it.edge] })
|
||||
|
||||
@@ -28,7 +28,8 @@ internal object CatanFixtures {
|
||||
|
||||
/** A fresh table, on the first setup placement. */
|
||||
fun start(players: Int = 3, seed: Long = 7L): CatanState =
|
||||
CatanRules.initialState(config(players, seed))
|
||||
// These tests are about the screen, not who starts: pin seat 0 first.
|
||||
CatanRules.initialState(config(players, seed)).let { it.copy(firstSeat = 0, turn = 0) }
|
||||
|
||||
/** A mid-game state on [turn]'s turn, then adjusted by [adjust]. */
|
||||
fun playing(
|
||||
|
||||
Reference in New Issue
Block a user