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:
@@ -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.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<Int>,
|
||||
second: List<Int>,
|
||||
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>) =
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user