From 97728daf0adc01f58a0fee380cf06ead85052c66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 14:22:24 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TWwLCrXa9aFVg8dZsCdLBZ --- .../games/game/catan/CatanModel.kt | 2 + .../games/game/catan/CatanRules.kt | 16 +++++-- .../games/game/mastermind/MastermindAi.kt | 20 ++++---- .../prolibertate/games/ui/GameSetupScreen.kt | 48 ------------------- .../games/game/catan/CatanRulesTest.kt | 16 +++++-- .../games/game/catan/CatanSeafarersTest.kt | 2 +- .../games/ui/game/catan/CatanFixtures.kt | 3 +- 7 files changed, 41 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index a38d599..1aeb977 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -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]. */ diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index 20f8083..5c45806 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -73,12 +73,17 @@ object CatanRules : GameRules { .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 { 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 { 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) diff --git a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt index fc2561a..eb31ef1 100644 --- a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindAi.kt @@ -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 { @@ -38,7 +37,12 @@ class MastermindAi( legal: List, ): 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. diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 148671d..5fcbb2c 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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), diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt index f31b86f..44eed0b 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt @@ -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() 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().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()) diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt index 93e496e..770f3af 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanSeafarersTest.kt @@ -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() + val moves = CatanRules.legalMoves(s, s.firstSeat).filterIsInstance() assertTrue(moves.isNotEmpty()) assertTrue(moves.all { s.topo.vertexIsland[it.vertex] == s.sea!!.home }) assertTrue(moves.all { s.topo.edgeLand[it.edge] }) diff --git a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt index af25535..c5e3e55 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanFixtures.kt @@ -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( -- 2.47.3