diff --git a/README.md b/README.md index da5c57b..beb34d7 100644 --- a/README.md +++ b/README.md @@ -274,8 +274,8 @@ game: - [Chess](docs/RULES-chess.md) - [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings** - [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube** -- [Mastermind](docs/RULES-mastermind.md) — played as a duel, both players - guarding a code and breaking one +- [Mastermind](docs/RULES-mastermind.md) — played as a duel: each player sets + the code the other has to break - [Nine Men's Morris](docs/RULES-morris.md) - [Pirates and Bulgars](docs/RULES-pirates.md) — **note the caveats about the reconstruction**: the book the theme comes from could not be obtained, so the diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index 436cc32..e91558c 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -120,8 +120,8 @@ object GameCatalog { minPlayers = 2, maxPlayers = 2, teamBased = false, - blurb = "Break the hidden code from black and white peg clues. " + - "Both players get one, and whoever cracks theirs first wins.", + blurb = "Set a code for your opponent and break the one they set for you. " + + "Whoever cracks theirs first wins.", available = true, ), GameDescriptor( 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 ca339c5..ca22325 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 @@ -4,7 +4,7 @@ import kotlin.random.Random import org.prolibertate.games.game.engine.GameAi /** - * A code breaker. + * A code setter and a code breaker. * * There is no tree to search here — the opponent makes no reply, and a guess * cannot be answered badly — so the whole game is in choosing which code to try @@ -32,6 +32,19 @@ class MastermindAi(private val level: MastermindLevel? = null) : // the same game agree on what the computer did. val random = Random(seat * 7919L + state.guesses[seat].size * 31L + state.options.hashCode()) + // Choosing its own code, which is a different job from breaking one. + // + // Two deliberate things here. It does not try to be clever — a code + // picked to beat a particular solver is a code with a pattern in it, + // and a pattern is the one thing a code should not have. And it is the + // only choice in this file drawn from a source nobody else can see: + // every other random here is seeded from the position so a host and a + // client agree, but a code seeded from anything on the wire could be + // worked out by the player who is supposed to be guessing it. + if (state.phase == MastermindPhase.SETTING) { + return MastermindMove(MastermindRules.randomCode(state.options, Random.Default)) + } + if (setting == MastermindLevel.CASUAL) return legal[random.nextInt(legal.size)] val standing = MastermindRules.consistentCodes(state, seat) diff --git a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt index ac12803..dec42e4 100644 --- a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindModel.kt @@ -7,10 +7,16 @@ import kotlinx.serialization.Serializable * * The pencil-and-paper game is one person setting a code and another breaking * it, which makes a poor two-handed game: one player would sit and watch. Here - * **both** players are set a code and both are breaking one, a guess each in - * turn, and whoever cracks theirs first wins. + * **each player sets the code their opponent will have to break**, and then + * both are breaking one, a guess each in turn. Whoever cracks theirs first + * wins. * - * That symmetry is what makes it fair to end the game at the foot of a round + * Setting your own code is the half of Mastermind a generated code takes away. + * Choosing it is a real decision — whether to repeat a colour, whether to leave + * one out — and it is a decision made against a particular opponent, which a + * random number generator cannot do. + * + * The symmetry is what makes it fair to end the game at the foot of a round * rather than the moment somebody gets it: if the first player cracks the code * on their sixth guess, the second player still gets a sixth guess to equal it, * and equalling it is a draw. @@ -124,7 +130,14 @@ fun allCodes(options: MastermindOptions): List> { } @Serializable -enum class MastermindPhase { GUESSING, GAME_OVER } +enum class MastermindPhase { + /** Each player is choosing the code their opponent will have to break. */ + SETTING, + + /** Both codes are set and the guessing has started. */ + GUESSING, + GAME_OVER, +} @Serializable enum class MastermindOutcome(val label: String) { @@ -141,8 +154,15 @@ data class MastermindState( * The code each seat is guarding, which is the one their opponent is trying * to break. Redacted before this ever leaves the host: see * [MastermindRules.viewFor]. + * + * Empty means one of two quite different things — not chosen yet, or + * chosen and hidden from the device this copy was sent to — which is what + * [declared] is for. Without it a screen could not tell "they are still + * thinking" from "they are ready and you cannot see it". */ val secrets: List>, + /** Whether each seat has settled on a code. */ + val declared: List, /** What each seat has guessed at their opponent's code, oldest first. */ val guesses: List>, val turn: Int, @@ -160,8 +180,11 @@ data class MastermindState( fun guessesLeft(seat: Int): Int = options.maxGuesses - guesses[seat].size - /** True where the secret has been stripped out for a client's view. */ - fun isHidden(seat: Int): Boolean = secrets[seat].isEmpty() + /** True where a code has been set but stripped out of this copy. */ + fun isHidden(seat: Int): Boolean = declared[seat] && secrets[seat].isEmpty() + + /** True once this seat has chosen the code their opponent must break. */ + fun hasSet(seat: Int): Boolean = declared[seat] } const val FIRST_SEAT = 0 diff --git a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt index 3936276..7329a74 100644 --- a/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/mastermind/MastermindRules.kt @@ -8,7 +8,15 @@ import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.engine.GameRules import org.prolibertate.games.game.engine.TableConfig -/** A guess at the opponent's code. */ +/** + * A row of pegs. + * + * The same move does both jobs the game asks for: while the codes are being + * set it *is* your code, and after that it is a guess at theirs. Which one it + * means is decided by the phase rather than by a flag on the move, because the + * phase is the thing that actually knows — a flag could disagree with it, and + * then one of the two would be wrong. + */ @Serializable data class MastermindMove(val code: List) { override fun toString(): String = code.joinToString("") { ('A' + it).toString() } @@ -17,11 +25,19 @@ data class MastermindMove(val code: List) { /** * Mastermind as a state machine. See RULES-mastermind.md. * + * A game runs in two phases. First each player chooses the code their opponent + * will have to break, one after the other; then both players guess, a row each + * in turn, until somebody cracks one. + * * This is the one game here with anything to hide, so [viewFor] does real work: * the host holds both codes and sends each device only its own, which means an - * opponent's code is never on the wire in the first place. Nothing else in this - * file may leak it either — note that [summary] talks about counts rather than - * colours. + * opponent's code is never on the wire in the first place. A code set on a + * guest's device travels to the host as a move and stops there — moves are + * intents sent to the host and are never relayed to the other player, so the + * only copy that reaches an opponent's screen is the redacted one. + * + * Nothing else in this file may leak a code either: note that [summary] talks + * about counts rather than colours. */ object MastermindRules : GameRules { @@ -35,29 +51,30 @@ object MastermindRules : GameRules { override fun initialState(config: TableConfig): MastermindState { require(config.seats.size == 2) { "Mastermind is a two-handed game" } val options = json.decodeFromString(config.optionsJson) - return initialState(options, config.seed) + return initialState(options) } - fun initialState(options: MastermindOptions, seed: Long = 0L): MastermindState { + fun initialState(options: MastermindOptions = MastermindOptions()): MastermindState { require(options.length >= 1) { "a code needs at least one peg" } require(options.colours >= 2) { "a code needs at least two colours" } require(options.allowDuplicates || options.colours >= options.length) { "${options.length} different colours cannot be drawn from ${options.colours}" } - val random = Random(seed) return MastermindState( options = options, - // Both codes come off the table's own seed, so a game can be - // replayed exactly from the config it started with. - secrets = listOf(secretFrom(options, random), secretFrom(options, random)), + // Nobody has chosen anything yet. The codes are the players' to + // set, which is half of what makes it a game rather than a puzzle. + secrets = listOf(emptyList(), emptyList()), + declared = listOf(false, false), guesses = listOf(emptyList(), emptyList()), turn = FIRST_SEAT, - phase = MastermindPhase.GUESSING, + phase = MastermindPhase.SETTING, outcome = null, ) } - private fun secretFrom(options: MastermindOptions, random: Random): List { + /** A code drawn at random, which is how the computer sets its own. */ + fun randomCode(options: MastermindOptions, random: Random): List { if (options.allowDuplicates) { return List(options.length) { random.nextInt(options.colours) } } @@ -65,11 +82,18 @@ object MastermindRules : GameRules { return List(options.length) { bag.removeAt(random.nextInt(bag.size)) } } + /** Whether a row of pegs is a code this table allows at all. */ + fun isWellFormed(options: MastermindOptions, code: List): Boolean = + code.size == options.length && + code.all { it in 0 until options.colours } && + (options.allowDuplicates || code.distinct().size == code.size) + override fun currentSeat(state: MastermindState): Int? = if (state.phase == MastermindPhase.GAME_OVER) null else state.turn /** - * Every code this seat has not already tried. + * While the codes are being set, every code this table allows. After that, + * every code this seat has not already guessed. * * Repeating a guess is not illegal by the rules of the game, but it can * only ever waste a turn, and leaving it out keeps the computer from @@ -77,6 +101,9 @@ object MastermindRules : GameRules { */ override fun legalMoves(state: MastermindState, seat: Int): List { if (state.phase == MastermindPhase.GAME_OVER || state.turn != seat) return emptyList() + if (state.phase == MastermindPhase.SETTING) { + return allCodes(state.options).map { MastermindMove(it) } + } val tried = state.guesses[seat].map { it.code }.toSet() return allCodes(state.options).filterNot { it in tried }.map { MastermindMove(it) } } @@ -88,12 +115,13 @@ object MastermindRules : GameRules { ): MastermindState { require(state.phase != MastermindPhase.GAME_OVER) { "The game is over" } require(state.turn == seat) { "It is not seat $seat's turn" } - require(move.code.size == state.options.length) { - "a guess is ${state.options.length} pegs long" - } - require(move.code.all { it in 0 until state.options.colours }) { - "that colour is not in this game" + require(isWellFormed(state.options, move.code)) { + "a code is ${state.options.length} pegs from ${state.options.colours} colours" + + if (state.options.allowDuplicates) "" else ", all different" } + + if (state.phase == MastermindPhase.SETTING) return declare(state, seat, move.code) + require(state.guesses[seat].none { it.code == move.code }) { "that guess has been made already" } @@ -109,6 +137,35 @@ object MastermindRules : GameRules { return state.copy(guesses = guesses, turn = other(seat)).withTerminalCheck() } + /** + * Writes down the code this seat will be guarding. + * + * The two players choose one after the other rather than at the same time, + * because the engine only ever has one seat on the clock. It costs nothing: + * neither can see the other's, so going second is not an advantage — and + * once both are down, the guessing starts with the player who set first. + */ + private fun declare( + state: MastermindState, + seat: Int, + code: List, + ): MastermindState { + require(!state.declared[seat]) { "seat $seat has already set a code" } + + val secrets = state.secrets.toMutableList() + secrets[seat] = code + val declared = state.declared.toMutableList() + declared[seat] = true + + val ready = declared.all { it } + return state.copy( + secrets = secrets, + declared = declared, + phase = if (ready) MastermindPhase.GUESSING else MastermindPhase.SETTING, + turn = if (ready) FIRST_SEAT else other(seat), + ) + } + /** * The game ends at the foot of a round, never in the middle of one. * @@ -136,15 +193,20 @@ object MastermindRules : GameRules { override fun isFinished(state: MastermindState): Boolean = state.phase == MastermindPhase.GAME_OVER - override fun summary(state: MastermindState): String = state.outcome?.label - ?: "Guess ${state.guesses[state.turn].size + 1} of ${state.options.maxGuesses}" + override fun summary(state: MastermindState): String = state.outcome?.label ?: when { + state.phase == MastermindPhase.SETTING -> + "Setting the codes — ${state.declared.count { it }} of 2 chosen" + + else -> "Guess ${state.guesses[state.turn].size + 1} of ${state.options.maxGuesses}" + } /** * Strips out the code this seat is not entitled to see. * - * A seat keeps its own code — it is theirs to guard, and the screen shows - * it once the game is over — and loses the one it is trying to break. The - * feedback on every guess stays, because that is the game. + * A seat keeps its own code — they chose it, and they are guarding it — and + * loses the one they are trying to break. Whether the opponent has chosen + * yet is *not* stripped: that is not a secret, and a screen has to be able + * to say "waiting for them" rather than sitting blank. * * Once the game is finished both codes are shown: there is nothing left to * protect, and a code breaker wants to see what they were up against. diff --git a/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt index 6c4a712..d7c05d8 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/MastermindScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -37,6 +38,7 @@ import org.prolibertate.games.game.mastermind.Feedback import org.prolibertate.games.game.mastermind.Guess import org.prolibertate.games.game.mastermind.MastermindMove import org.prolibertate.games.game.mastermind.MastermindPhase +import org.prolibertate.games.game.mastermind.MastermindRules import org.prolibertate.games.game.mastermind.MastermindState import org.prolibertate.games.game.mastermind.other import org.prolibertate.games.net.MatchController @@ -66,13 +68,13 @@ private val ExactPeg = Color(0xFF212121) private val MisplacedPeg = Color(0xFFFFFFFF) /** - * Mastermind, played as a duel: both players are guarding a code and breaking - * one, a guess each in turn. + * Mastermind, played as a duel: each player sets the code their opponent will + * have to break, and then both are breaking one, a guess each in turn. * - * Tap the colours to build a guess and press the button to commit it. The - * answer to each guess is the little pegs beside it — a black one for a colour - * in the right place, a white one for a colour in the wrong place, and no - * indication whatever of which is which. + * The same row of pegs does both jobs — tap the colours to build it, and the + * button says which one you are doing. The answer to each guess is the little + * pegs beside it: a dark one for a colour in the right place, a light one for a + * colour in the wrong place, and no indication whatever of which is which. */ @Composable fun MastermindScreen( @@ -100,7 +102,12 @@ fun MastermindScreen( return } - val yourTurn = current.turn == localSeat && current.phase == MastermindPhase.GUESSING + val setting = current.phase == MastermindPhase.SETTING + val yourTurn = current.turn == localSeat && current.phase != MastermindPhase.GAME_OVER + // A row has to be a code this table allows — which on a no-repeats table + // rules out a repeated colour — and a guess may not be one already made. + val readyToSend = MastermindRules.isWellFormed(current.options, building.toList()) && + (setting || current.guesses[localSeat].none { it.code == building.toList() }) ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier -> Column( @@ -110,26 +117,53 @@ fun MastermindScreen( Text( text = when { current.outcome != null -> current.outcome!!.label + setting && yourTurn -> "Set the code they will have to break" + setting -> "Waiting for them to set theirs…" yourTurn -> "Your guess — ${current.guessesLeft(localSeat)} left" else -> "Thinking…" }, fontWeight = FontWeight.Bold, ) Text( - text = "A dark peg is a colour in the right place, a light one a colour " + - "somewhere else. Which is which is for you to work out.", + text = if (setting) { + "Choose any code the table allows. They will not see it, and you " + + "will be breaking the one they choose for you." + } else { + "A dark peg is a colour in the right place, a light one a colour " + + "somewhere else. Which is which is for you to work out." + }, style = MaterialTheme.typography.bodySmall, ) - Text("Your guesses", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) - GuessList(current, localSeat) + if (setting) { + // Nothing has been guessed yet, so the only thing worth showing + // is whether the other player is ready. + Text( + text = if (current.hasSet(other(localSeat))) { + "They have set theirs." + } else { + "They have not set theirs yet." + }, + style = MaterialTheme.typography.bodySmall, + ) + } else { + Text( + text = "Your guesses", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + GuessList(current, localSeat) + } - if (current.phase == MastermindPhase.GUESSING) { + if (current.phase != MastermindPhase.GAME_OVER) { Builder( length = current.options.length, colours = current.options.colours, + allowDuplicates = current.options.allowDuplicates, building = building, enabled = yourTurn, + label = if (setting) "Set this code" else "Guess", + ready = readyToSend, onSubmit = { controller.submit(MastermindMove(building.toList())) building.clear() @@ -137,23 +171,25 @@ fun MastermindScreen( ) } - Text( - text = "Against you", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 8.dp), - ) - Text( - text = "${current.guesses[other(localSeat)].size} guesses at your code so far.", - style = MaterialTheme.typography.bodySmall, - ) - GuessList(current, other(localSeat)) + if (!setting) { + Text( + text = "Against you", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + text = "${current.guesses[other(localSeat)].size} guesses at your code so far.", + style = MaterialTheme.typography.bodySmall, + ) + GuessList(current, other(localSeat)) + } - // Your own code, which is yours to see all along — you are guarding - // it, not guessing it. + // Your own code, which is yours to see all along — you chose it, and + // you are guarding it rather than guessing it. if (current.secrets[localSeat].isNotEmpty()) { Text( - text = "The code you are guarding", + text = "The code you set", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 8.dp), @@ -262,8 +298,11 @@ private fun FeedbackPegs(feedback: Feedback, length: Int) { private fun Builder( length: Int, colours: Int, + allowDuplicates: Boolean, building: MutableList, enabled: Boolean, + label: String, + ready: Boolean, onSubmit: () -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -287,8 +326,14 @@ private fun Builder( verticalArrangement = Arrangement.spacedBy(6.dp), ) { repeat(colours) { colour -> + // On a table without repeats, a colour already down is not a + // colour you can put down again — greyed rather than merely + // refused, so it never comes as a surprise. + val spent = !allowDuplicates && colour in building Box( - modifier = Modifier.clickable(enabled = enabled && building.size < length) { + modifier = Modifier.alpha(if (spent) 0.3f else 1f).clickable( + enabled = enabled && !spent && building.size < length, + ) { building.add(colour) } ) { @@ -298,8 +343,8 @@ private fun Builder( } PrimaryAction( - text = "Guess", - enabled = enabled && building.size == length, + text = label, + enabled = enabled && ready, onClick = onSubmit, ) } diff --git a/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindAiTest.kt b/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindAiTest.kt new file mode 100644 index 0000000..197a660 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindAiTest.kt @@ -0,0 +1,90 @@ +package org.prolibertate.games.game.mastermind + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MastermindAiTest { + + private fun choose( + state: MastermindState, + seat: Int = state.turn, + level: MastermindLevel = MastermindLevel.CLUB, + ): MastermindMove = + MastermindAi(level).chooseMove(state, seat, MastermindRules.legalMoves(state, seat)) + + @Test + fun `it sets a code the table allows`() { + listOf( + MastermindOptions(), + MastermindOptions(colours = 8, length = 5), + MastermindOptions(colours = 6, length = 4, allowDuplicates = false), + ).forEach { options -> + val state = MastermindRules.initialState(options) + repeat(10) { + val move = choose(state, FIRST_SEAT) + assertTrue( + "the computer set ${move.code} on $options", + MastermindRules.isWellFormed(options, move.code), + ) + } + } + } + + @Test + fun `the code it sets is not the same every game`() { + // It is the one choice in the game drawn from a source the opponent + // cannot reconstruct — a code that came off the table's own seed could + // be worked out by the player who is meant to be guessing it. + val options = MastermindOptions() + val seen = (1..40).map { + choose(MastermindRules.initialState(options), FIRST_SEAT).code + }.toSet() + assertTrue("it set the same code 40 times running", seen.size > 1) + } + + @Test + fun `a consistent solver breaks a code well inside the limit`() { + // Both codes set by hand, then the computer plays both seats. A player + // that never guesses something the answers have ruled out should crack + // a four-peg, six-colour code in about five. + var state = MastermindRules.initialState(MastermindOptions(maxGuesses = 10)) + state = MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(3, 1, 4, 1))) + state = MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(5, 0, 2, 5))) + + var plies = 0 + while (!MastermindRules.isFinished(state) && plies < 40) { + state = MastermindRules.applyMove(state, state.turn, choose(state)) + plies++ + } + + assertTrue("the game never ended", MastermindRules.isFinished(state)) + assertNotNull(state.outcome) + val broke = state.brokeIn(FIRST_SEAT) ?: state.brokeIn(SECOND_SEAT) + assertNotNull("neither side broke a code in ten guesses", broke) + assertTrue("it took $broke guesses", broke!! <= 8) + } + + @Test + fun `it never guesses something the answers have already ruled out`() { + var state = MastermindRules.initialState(MastermindOptions()) + state = MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 3))) + state = MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(4, 5, 4, 5))) + + repeat(6) { + if (MastermindRules.isFinished(state)) return + val seat = state.turn + val move = choose(state, level = MastermindLevel.STRONG) + // Everything already answered must still hold for this guess. + state.guesses[seat].forEach { answered -> + assertEquals( + "guess ${move.code} contradicts ${answered.code}", + answered.feedback, + scoreGuess(move.code, answered.code), + ) + } + state = MastermindRules.applyMove(state, seat, move) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt index 77c57dd..636baf9 100644 --- a/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/mastermind/MastermindRulesTest.kt @@ -2,24 +2,27 @@ package org.prolibertate.games.game.mastermind import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test class MastermindRulesTest { - private fun start( - options: MastermindOptions = MastermindOptions(), - seed: Long = 42L, - ) = MastermindRules.initialState(options, seed) + private fun start(options: MastermindOptions = MastermindOptions()) = + MastermindRules.initialState(options) - /** Sets both codes by hand, which is the only way to write a test about them. */ + /** + * A game with both codes already set, reached the way a real one is: each + * player choosing theirs in turn. + */ private fun withSecrets( first: List, second: List, options: MastermindOptions = MastermindOptions(), - ) = start(options).copy(secrets = listOf(first, second)) + ): MastermindState { + val set = MastermindRules.applyMove(start(options), FIRST_SEAT, MastermindMove(first)) + return MastermindRules.applyMove(set, SECOND_SEAT, MastermindMove(second)) + } private fun guess(state: MastermindState, code: List) = MastermindRules.applyMove(state, state.turn, MastermindMove(code)) @@ -63,34 +66,86 @@ class MastermindRulesTest { // ----------------------------------------------------------------------- @Test - fun `both players are set a code of the right shape`() { + fun `a new game has no codes in it — the players set them`() { val state = start() - assertEquals(2, state.secrets.size) - state.secrets.forEach { secret -> - assertEquals(4, secret.size) - assertTrue(secret.all { it in 0 until 6 }) - } + assertEquals(MastermindPhase.SETTING, state.phase) assertEquals(FIRST_SEAT, state.turn) + assertTrue(state.secrets.all { it.isEmpty() }) + assertFalse(state.hasSet(FIRST_SEAT)) + assertFalse(state.hasSet(SECOND_SEAT)) + // Nothing is hidden yet, because there is nothing to hide. + assertFalse(state.isHidden(SECOND_SEAT)) + } + + @Test + fun `each player sets their own code, one after the other`() { + var state = start() + assertEquals(allCodes(state.options).size, MastermindRules.legalMoves(state, FIRST_SEAT).size) + + state = MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 3))) + assertEquals(MastermindPhase.SETTING, state.phase) + assertEquals(SECOND_SEAT, state.turn) + assertTrue(state.hasSet(FIRST_SEAT)) + assertEquals(listOf(0, 1, 2, 3), state.secrets[FIRST_SEAT]) + + state = MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(4, 4, 5, 5))) assertEquals(MastermindPhase.GUESSING, state.phase) + // The guessing starts with the player who set first. + assertEquals(FIRST_SEAT, state.turn) + assertEquals(listOf(4, 4, 5, 5), state.secrets[SECOND_SEAT]) } @Test - fun `the same seed sets the same codes, and a different one does not`() { - assertEquals(start(seed = 7L).secrets, start(seed = 7L).secrets) - assertNotEquals(start(seed = 7L).secrets, start(seed = 8L).secrets) + fun `the code you set is the one your opponent is breaking`() { + var state = withSecrets(first = listOf(0, 1, 2, 3), second = listOf(5, 5, 5, 5)) + // The first player guesses at the second player's code, which is 5555. + state = guess(state, listOf(5, 5, 5, 5)) + assertEquals(Feedback(4, 0), state.guesses[FIRST_SEAT].single().feedback) } @Test - fun `a game without duplicates never repeats a colour`() { + fun `a code the table does not allow is refused`() { + val state = start(MastermindOptions(colours = 6, length = 4, allowDuplicates = false)) + assertTrue( + "a repeated colour on a no-repeats table", + runCatching { + MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 0, 1, 2))) + }.exceptionOrNull() is IllegalArgumentException + ) + assertTrue( + "the wrong number of pegs", + runCatching { + MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2))) + }.exceptionOrNull() is IllegalArgumentException + ) + // And a table without repeats offers only codes without repeats. + assertTrue( + MastermindRules.legalMoves(state, FIRST_SEAT) + .all { it.code.distinct().size == it.code.size } + ) + } + + @Test + fun `nobody sets a code twice`() { + val state = MastermindRules.applyMove( + start(), + FIRST_SEAT, + MastermindMove(listOf(0, 1, 2, 3)), + ) + assertTrue( + runCatching { + MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(3, 2, 1, 0))) + }.exceptionOrNull() is IllegalArgumentException + ) + } + + @Test + fun `a table without duplicates counts a smaller space`() { val options = MastermindOptions(colours = 6, length = 4, allowDuplicates = false) - (0L until 20L).forEach { seed -> - MastermindRules.initialState(options, seed).secrets.forEach { secret -> - assertEquals(secret.size, secret.distinct().size) - } - } - // And the code space is the falling factorial rather than a power. + // The falling factorial rather than a power. assertEquals(6 * 5 * 4 * 3, options.codeSpace()) assertEquals(6 * 5 * 4 * 3, allCodes(options).size) + assertTrue(allCodes(options).all { it.distinct().size == it.size }) } @Test @@ -106,6 +161,16 @@ class MastermindRulesTest { // Keeping the codes secret // ----------------------------------------------------------------------- + @Test + fun `a code being chosen is not shown to the other player either`() { + val half = MastermindRules.applyMove(start(), FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 3))) + val asSecond = MastermindRules.viewFor(half, SECOND_SEAT) + assertTrue("set and hidden", asSecond.isHidden(FIRST_SEAT)) + // But that they are ready is not a secret, or the screen could not say so. + assertTrue(asSecond.hasSet(FIRST_SEAT)) + assertFalse(asSecond.hasSet(SECOND_SEAT)) + } + @Test fun `a player is shown their own code and not the one they are breaking`() { val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1)) @@ -258,6 +323,7 @@ class MastermindRulesTest { @Test fun `the summary counts guesses without ever naming a colour`() { + assertEquals("Setting the codes — 0 of 2 chosen", MastermindRules.summary(start())) val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5)) assertEquals("Guess 1 of 10", MastermindRules.summary(state)) val over = state.copy( diff --git a/docs/RULES-mastermind.md b/docs/RULES-mastermind.md index d208949..62d6b4b 100644 --- a/docs/RULES-mastermind.md +++ b/docs/RULES-mastermind.md @@ -7,8 +7,18 @@ A hidden code of coloured pegs, and a fixed number of guesses to break it. The pencil-and-paper game is one person setting a code and another breaking it, which makes a poor two-handed game: one player would sit and watch. -Here **both players are set a code and both are breaking one**, a guess each in -turn. Whoever cracks theirs first wins. +Here **each player sets the code their opponent will have to break**, and then +both are breaking one, a guess each in turn. Whoever cracks theirs first wins. + +Setting your own code is half of Mastermind, and a generated one takes it away. +Whether to repeat a colour, whether to leave one out, whether to do the thing +this particular opponent will not expect — those are real decisions, and no +random number generator makes them. + +The codes are chosen one after the other rather than at the same time, because +the engine only ever has one player on the clock. It costs nothing: neither can +see the other's, so going second is not an advantage. Once both are down, the +guessing starts with whoever set first. That symmetry is why the game ends at the foot of a round rather than the moment somebody gets it. If the player who went first breaks the code on their sixth @@ -56,6 +66,16 @@ its own**: the code you are trying to break is never on your device in the first place, so there is nothing to find by digging through what arrived over the network. +A code set on a guest's device reaches the host as a move, and stops there. +Moves travel one way — they are intents sent to the host, never relayed on to +the other player — so the only copy that ever reaches an opponent's screen is +the redacted one. + +What is *not* hidden is whether the other player has finished choosing. That is +not a secret, and a screen has to be able to say "waiting for them" rather than +sit there blank, so the state carries a plain flag per player alongside the +code. + A guess is scored by the host, which is the only machine that can score it. A device asked to score a guess against a code it was never given refuses rather than quietly answering "nothing" — tested, because that failure would look like @@ -66,8 +86,19 @@ and a code breaker wants to see what they were up against. ## What the computer does -There is no tree to search — a guess cannot be answered badly, and the opponent -makes no reply — so the whole game is in choosing what to try next. +**Setting a code**, the computer takes one at random and does not try to be +clever: a code chosen to beat a particular solver is a code with a pattern in +it, and a pattern is the one thing a code should not have. + +That single choice is drawn from a source nobody else can see. Every other +random number in this app is seeded from the position so that a host and a +client agree on what happened — but a code seeded from anything that travels +over the wire could be reconstructed by the very player who is supposed to be +guessing it. + +**Breaking one**, there is no tree to search — a guess cannot be answered badly, +and the opponent makes no reply — so the whole game is in choosing what to try +next. - **Casual** guesses anything it has not tried, ruled out or not. - **Club** never guesses a code the answers have already eliminated. That one