Merge pull request #10: players set their own Mastermind codes
Verified green on the feature branch before merging: build, unit tests and Android lint (ci.yml run 55).
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user