Let each Mastermind player set their own code

A generated code takes away half the game. Whether to repeat a colour,
whether to leave one out, whether to do the thing this opponent will not
expect — those are real decisions, and no random number generator makes
them.

So a game now opens in a SETTING phase: each player chooses the code
their opponent will have to break, one after the other, and the guessing
starts once both are down. They choose in turn rather than at once
because the engine only ever has one seat on the clock, and it costs
nothing — neither can see the other's, so going second is not an
advantage.

The same move carries both: while the codes are being set it is your
code, and after that it is a guess at theirs. Which one it means comes
from the phase rather than from a flag on the move, because the phase is
the thing that actually knows — a flag could disagree with it.

An empty code used to mean one thing and now means two — not chosen yet,
or chosen and hidden from the device this copy was sent to — so the
state carries a flag per player. Whether the other player is ready is
not a secret, and a screen has to be able to say "waiting for them"
rather than sit blank.

The computer sets a code at random and does not try to be clever about
it: a code chosen to beat a particular solver has a pattern in it, and a
pattern is the one thing a code should not have. That single choice is
the only random in the app drawn from a source nobody else can see —
every other one 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 meant to be guessing it.

A code set on a guest's device reaches 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 is redacted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
This commit is contained in:
Claude
2026-08-07 14:49:46 +00:00
parent 284f9f3f95
commit 7cd2b412e2
9 changed files with 420 additions and 90 deletions
+2 -2
View File
@@ -274,8 +274,8 @@ game:
- [Chess](docs/RULES-chess.md) - [Chess](docs/RULES-chess.md)
- [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings** - [Checkers](docs/RULES-checkers.md) — **note the caveat about flying kings**
- [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube** - [Backgammon](docs/RULES-backgammon.md) — **note that there is no doubling cube**
- [Mastermind](docs/RULES-mastermind.md) — played as a duel, both players - [Mastermind](docs/RULES-mastermind.md) — played as a duel: each player sets
guarding a code and breaking one the code the other has to break
- [Nine Men's Morris](docs/RULES-morris.md) - [Nine Men's Morris](docs/RULES-morris.md)
- [Pirates and Bulgars](docs/RULES-pirates.md) — **note the caveats about the - [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 reconstruction**: the book the theme comes from could not be obtained, so the
@@ -120,8 +120,8 @@ object GameCatalog {
minPlayers = 2, minPlayers = 2,
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
blurb = "Break the hidden code from black and white peg clues. " + blurb = "Set a code for your opponent and break the one they set for you. " +
"Both players get one, and whoever cracks theirs first wins.", "Whoever cracks theirs first wins.",
available = true, available = true,
), ),
GameDescriptor( GameDescriptor(
@@ -4,7 +4,7 @@ import kotlin.random.Random
import org.prolibertate.games.game.engine.GameAi 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 * 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 * 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. // the same game agree on what the computer did.
val random = Random(seat * 7919L + state.guesses[seat].size * 31L + state.options.hashCode()) 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)] if (setting == MastermindLevel.CASUAL) return legal[random.nextInt(legal.size)]
val standing = MastermindRules.consistentCodes(state, seat) val standing = MastermindRules.consistentCodes(state, seat)
@@ -7,10 +7,16 @@ import kotlinx.serialization.Serializable
* *
* The pencil-and-paper game is one person setting a code and another breaking * 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 * 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 * **each player sets the code their opponent will have to break**, and then
* turn, and whoever cracks theirs first wins. * 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 * 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, * on their sixth guess, the second player still gets a sixth guess to equal it,
* and equalling it is a draw. * and equalling it is a draw.
@@ -124,7 +130,14 @@ fun allCodes(options: MastermindOptions): List<List<Int>> {
} }
@Serializable @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 @Serializable
enum class MastermindOutcome(val label: String) { 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 * The code each seat is guarding, which is the one their opponent is trying
* to break. Redacted before this ever leaves the host: see * to break. Redacted before this ever leaves the host: see
* [MastermindRules.viewFor]. * [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<List<Int>>, val secrets: List<List<Int>>,
/** Whether each seat has settled on a code. */
val declared: List<Boolean>,
/** What each seat has guessed at their opponent's code, oldest first. */ /** What each seat has guessed at their opponent's code, oldest first. */
val guesses: List<List<Guess>>, val guesses: List<List<Guess>>,
val turn: Int, val turn: Int,
@@ -160,8 +180,11 @@ data class MastermindState(
fun guessesLeft(seat: Int): Int = options.maxGuesses - guesses[seat].size fun guessesLeft(seat: Int): Int = options.maxGuesses - guesses[seat].size
/** True where the secret has been stripped out for a client's view. */ /** True where a code has been set but stripped out of this copy. */
fun isHidden(seat: Int): Boolean = secrets[seat].isEmpty() 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 const val FIRST_SEAT = 0
@@ -8,7 +8,15 @@ import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.engine.GameRules import org.prolibertate.games.game.engine.GameRules
import org.prolibertate.games.game.engine.TableConfig 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 @Serializable
data class MastermindMove(val code: List<Int>) { data class MastermindMove(val code: List<Int>) {
override fun toString(): String = code.joinToString("") { ('A' + it).toString() } override fun toString(): String = code.joinToString("") { ('A' + it).toString() }
@@ -17,11 +25,19 @@ data class MastermindMove(val code: List<Int>) {
/** /**
* Mastermind as a state machine. See RULES-mastermind.md. * 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: * 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 * 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 * opponent's code is never on the wire in the first place. A code set on a
* file may leak it either — note that [summary] talks about counts rather than * guest's device travels to the host as a move and stops there — moves are
* colours. * 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<MastermindState, MastermindMove> { object MastermindRules : GameRules<MastermindState, MastermindMove> {
@@ -35,29 +51,30 @@ object MastermindRules : GameRules<MastermindState, MastermindMove> {
override fun initialState(config: TableConfig): MastermindState { override fun initialState(config: TableConfig): MastermindState {
require(config.seats.size == 2) { "Mastermind is a two-handed game" } require(config.seats.size == 2) { "Mastermind is a two-handed game" }
val options = json.decodeFromString<MastermindOptions>(config.optionsJson) val options = json.decodeFromString<MastermindOptions>(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.length >= 1) { "a code needs at least one peg" }
require(options.colours >= 2) { "a code needs at least two colours" } require(options.colours >= 2) { "a code needs at least two colours" }
require(options.allowDuplicates || options.colours >= options.length) { require(options.allowDuplicates || options.colours >= options.length) {
"${options.length} different colours cannot be drawn from ${options.colours}" "${options.length} different colours cannot be drawn from ${options.colours}"
} }
val random = Random(seed)
return MastermindState( return MastermindState(
options = options, options = options,
// Both codes come off the table's own seed, so a game can be // Nobody has chosen anything yet. The codes are the players' to
// replayed exactly from the config it started with. // set, which is half of what makes it a game rather than a puzzle.
secrets = listOf(secretFrom(options, random), secretFrom(options, random)), secrets = listOf(emptyList(), emptyList()),
declared = listOf(false, false),
guesses = listOf(emptyList(), emptyList()), guesses = listOf(emptyList(), emptyList()),
turn = FIRST_SEAT, turn = FIRST_SEAT,
phase = MastermindPhase.GUESSING, phase = MastermindPhase.SETTING,
outcome = null, outcome = null,
) )
} }
private fun secretFrom(options: MastermindOptions, random: Random): List<Int> { /** A code drawn at random, which is how the computer sets its own. */
fun randomCode(options: MastermindOptions, random: Random): List<Int> {
if (options.allowDuplicates) { if (options.allowDuplicates) {
return List(options.length) { random.nextInt(options.colours) } return List(options.length) { random.nextInt(options.colours) }
} }
@@ -65,11 +82,18 @@ object MastermindRules : GameRules<MastermindState, MastermindMove> {
return List(options.length) { bag.removeAt(random.nextInt(bag.size)) } 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<Int>): 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? = override fun currentSeat(state: MastermindState): Int? =
if (state.phase == MastermindPhase.GAME_OVER) null else state.turn 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 * 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 * only ever waste a turn, and leaving it out keeps the computer from
@@ -77,6 +101,9 @@ object MastermindRules : GameRules<MastermindState, MastermindMove> {
*/ */
override fun legalMoves(state: MastermindState, seat: Int): List<MastermindMove> { override fun legalMoves(state: MastermindState, seat: Int): List<MastermindMove> {
if (state.phase == MastermindPhase.GAME_OVER || state.turn != seat) return emptyList() 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() val tried = state.guesses[seat].map { it.code }.toSet()
return allCodes(state.options).filterNot { it in tried }.map { MastermindMove(it) } return allCodes(state.options).filterNot { it in tried }.map { MastermindMove(it) }
} }
@@ -88,12 +115,13 @@ object MastermindRules : GameRules<MastermindState, MastermindMove> {
): MastermindState { ): MastermindState {
require(state.phase != MastermindPhase.GAME_OVER) { "The game is over" } require(state.phase != MastermindPhase.GAME_OVER) { "The game is over" }
require(state.turn == seat) { "It is not seat $seat's turn" } require(state.turn == seat) { "It is not seat $seat's turn" }
require(move.code.size == state.options.length) { require(isWellFormed(state.options, move.code)) {
"a guess is ${state.options.length} pegs long" "a code is ${state.options.length} pegs from ${state.options.colours} colours" +
} if (state.options.allowDuplicates) "" else ", all different"
require(move.code.all { it in 0 until state.options.colours }) {
"that colour is not in this game"
} }
if (state.phase == MastermindPhase.SETTING) return declare(state, seat, move.code)
require(state.guesses[seat].none { it.code == move.code }) { require(state.guesses[seat].none { it.code == move.code }) {
"that guess has been made already" "that guess has been made already"
} }
@@ -109,6 +137,35 @@ object MastermindRules : GameRules<MastermindState, MastermindMove> {
return state.copy(guesses = guesses, turn = other(seat)).withTerminalCheck() 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<Int>,
): 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. * The game ends at the foot of a round, never in the middle of one.
* *
@@ -136,15 +193,20 @@ object MastermindRules : GameRules<MastermindState, MastermindMove> {
override fun isFinished(state: MastermindState): Boolean = override fun isFinished(state: MastermindState): Boolean =
state.phase == MastermindPhase.GAME_OVER state.phase == MastermindPhase.GAME_OVER
override fun summary(state: MastermindState): String = state.outcome?.label override fun summary(state: MastermindState): String = state.outcome?.label ?: when {
?: "Guess ${state.guesses[state.turn].size + 1} of ${state.options.maxGuesses}" 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. * 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 * A seat keeps its own code — they chose it, and they are guarding it — and
* it once the game is over — and loses the one it is trying to break. The * loses the one they are trying to break. Whether the opponent has chosen
* feedback on every guess stays, because that is the game. * 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 * 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. * protect, and a code breaker wants to see what they were up against.
@@ -29,6 +29,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.Guess
import org.prolibertate.games.game.mastermind.MastermindMove import org.prolibertate.games.game.mastermind.MastermindMove
import org.prolibertate.games.game.mastermind.MastermindPhase 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.MastermindState
import org.prolibertate.games.game.mastermind.other import org.prolibertate.games.game.mastermind.other
import org.prolibertate.games.net.MatchController import org.prolibertate.games.net.MatchController
@@ -66,13 +68,13 @@ private val ExactPeg = Color(0xFF212121)
private val MisplacedPeg = Color(0xFFFFFFFF) private val MisplacedPeg = Color(0xFFFFFFFF)
/** /**
* Mastermind, played as a duel: both players are guarding a code and breaking * Mastermind, played as a duel: each player sets the code their opponent will
* one, a guess each in turn. * 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 * The same row of pegs does both jobs — tap the colours to build it, and the
* answer to each guess is the little pegs beside it — a black one for a colour * button says which one you are doing. The answer to each guess is the little
* in the right place, a white one for a colour in the wrong place, and no * pegs beside it: a dark one for a colour in the right place, a light one for a
* indication whatever of which is which. * colour in the wrong place, and no indication whatever of which is which.
*/ */
@Composable @Composable
fun MastermindScreen( fun MastermindScreen(
@@ -100,7 +102,12 @@ fun MastermindScreen(
return 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 -> ScreenScaffold(title = TITLE, onBack = onExit, actions = endGameAction) { modifier ->
Column( Column(
@@ -110,26 +117,53 @@ fun MastermindScreen(
Text( Text(
text = when { text = when {
current.outcome != null -> current.outcome!!.label 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" yourTurn -> "Your guess — ${current.guessesLeft(localSeat)} left"
else -> "Thinking…" else -> "Thinking…"
}, },
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
text = "A dark peg is a colour in the right place, a light one a colour " + text = if (setting) {
"somewhere else. Which is which is for you to work out.", "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, style = MaterialTheme.typography.bodySmall,
) )
Text("Your guesses", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) 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) GuessList(current, localSeat)
}
if (current.phase == MastermindPhase.GUESSING) { if (current.phase != MastermindPhase.GAME_OVER) {
Builder( Builder(
length = current.options.length, length = current.options.length,
colours = current.options.colours, colours = current.options.colours,
allowDuplicates = current.options.allowDuplicates,
building = building, building = building,
enabled = yourTurn, enabled = yourTurn,
label = if (setting) "Set this code" else "Guess",
ready = readyToSend,
onSubmit = { onSubmit = {
controller.submit(MastermindMove(building.toList())) controller.submit(MastermindMove(building.toList()))
building.clear() building.clear()
@@ -137,6 +171,7 @@ fun MastermindScreen(
) )
} }
if (!setting) {
Text( Text(
text = "Against you", text = "Against you",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
@@ -148,12 +183,13 @@ fun MastermindScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
GuessList(current, other(localSeat)) GuessList(current, other(localSeat))
}
// Your own code, which is yours to see all along — you are guarding // Your own code, which is yours to see all along — you chose it, and
// it, not guessing it. // you are guarding it rather than guessing it.
if (current.secrets[localSeat].isNotEmpty()) { if (current.secrets[localSeat].isNotEmpty()) {
Text( Text(
text = "The code you are guarding", text = "The code you set",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
@@ -262,8 +298,11 @@ private fun FeedbackPegs(feedback: Feedback, length: Int) {
private fun Builder( private fun Builder(
length: Int, length: Int,
colours: Int, colours: Int,
allowDuplicates: Boolean,
building: MutableList<Int>, building: MutableList<Int>,
enabled: Boolean, enabled: Boolean,
label: String,
ready: Boolean,
onSubmit: () -> Unit, onSubmit: () -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -287,8 +326,14 @@ private fun Builder(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
repeat(colours) { colour -> 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( 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) building.add(colour)
} }
) { ) {
@@ -298,8 +343,8 @@ private fun Builder(
} }
PrimaryAction( PrimaryAction(
text = "Guess", text = label,
enabled = enabled && building.size == length, enabled = enabled && ready,
onClick = onSubmit, onClick = onSubmit,
) )
} }
@@ -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)
}
}
}
@@ -2,24 +2,27 @@ package org.prolibertate.games.game.mastermind
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class MastermindRulesTest { class MastermindRulesTest {
private fun start( private fun start(options: MastermindOptions = MastermindOptions()) =
options: MastermindOptions = MastermindOptions(), MastermindRules.initialState(options)
seed: Long = 42L,
) = MastermindRules.initialState(options, seed)
/** 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( private fun withSecrets(
first: List<Int>, first: List<Int>,
second: List<Int>, second: List<Int>,
options: MastermindOptions = MastermindOptions(), 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<Int>) = private fun guess(state: MastermindState, code: List<Int>) =
MastermindRules.applyMove(state, state.turn, MastermindMove(code)) MastermindRules.applyMove(state, state.turn, MastermindMove(code))
@@ -63,34 +66,86 @@ class MastermindRulesTest {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@Test @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() val state = start()
assertEquals(2, state.secrets.size) assertEquals(MastermindPhase.SETTING, state.phase)
state.secrets.forEach { secret ->
assertEquals(4, secret.size)
assertTrue(secret.all { it in 0 until 6 })
}
assertEquals(FIRST_SEAT, state.turn) 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) 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 @Test
fun `the same seed sets the same codes, and a different one does not`() { fun `the code you set is the one your opponent is breaking`() {
assertEquals(start(seed = 7L).secrets, start(seed = 7L).secrets) var state = withSecrets(first = listOf(0, 1, 2, 3), second = listOf(5, 5, 5, 5))
assertNotEquals(start(seed = 7L).secrets, start(seed = 8L).secrets) // 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 @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) val options = MastermindOptions(colours = 6, length = 4, allowDuplicates = false)
(0L until 20L).forEach { seed -> // The falling factorial rather than a power.
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.
assertEquals(6 * 5 * 4 * 3, options.codeSpace()) assertEquals(6 * 5 * 4 * 3, options.codeSpace())
assertEquals(6 * 5 * 4 * 3, allCodes(options).size) assertEquals(6 * 5 * 4 * 3, allCodes(options).size)
assertTrue(allCodes(options).all { it.distinct().size == it.size })
} }
@Test @Test
@@ -106,6 +161,16 @@ class MastermindRulesTest {
// Keeping the codes secret // 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 @Test
fun `a player is shown their own code and not the one they are breaking`() { 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)) val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1))
@@ -258,6 +323,7 @@ class MastermindRulesTest {
@Test @Test
fun `the summary counts guesses without ever naming a colour`() { 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)) val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
assertEquals("Guess 1 of 10", MastermindRules.summary(state)) assertEquals("Guess 1 of 10", MastermindRules.summary(state))
val over = state.copy( val over = state.copy(
+35 -4
View File
@@ -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, 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. 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 Here **each player sets the code their opponent will have to break**, and then
turn. Whoever cracks theirs first wins. 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 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 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 place, so there is nothing to find by digging through what arrived over the
network. 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 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 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 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 ## What the computer does
There is no tree to search — a guess cannot be answered badly, and the opponent **Setting a code**, the computer takes one at random and does not try to be
makes no reply — so the whole game is in choosing what to try next. 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. - **Casual** guesses anything it has not tried, ruled out or not.
- **Club** never guesses a code the answers have already eliminated. That one - **Club** never guesses a code the answers have already eliminated. That one