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(