Online play over WebSockets with any-string game codes, seats and spectators, and a 1-second heartbeat. Practice mode against Columbina, backed by a full solver, with an accuracy slider, spoilers, undo and piece freshness. Co-Authored-By: Claude Opus 5.5 <[email protected]>
97 lines
3.7 KiB
Python
97 lines
3.7 KiB
Python
import random
|
|
import unittest
|
|
|
|
from game import Game
|
|
from solver import DRAW, LOSS, WIN, Solver, _moves
|
|
|
|
solver = Solver()
|
|
|
|
|
|
def game_at(mine, theirs):
|
|
"""The Game for a solver position, with `mine` as the player to move (seat 0)."""
|
|
return Game.from_history([list(mine), list(theirs)], 0)
|
|
|
|
|
|
class SolverTest(unittest.TestCase):
|
|
def test_perfect_play_is_a_draw(self):
|
|
self.assertEqual(solver.value[((), ())], DRAW)
|
|
self.assertEqual(sorted(solver.best_moves((), ())), list(range(9)))
|
|
|
|
def test_nobody_can_be_forced_to_lose(self):
|
|
# With only two of your pieces down while placing, you can make one threat at a time.
|
|
self.assertNotIn(LOSS, solver.value.values())
|
|
|
|
def test_blocks_a_threat(self):
|
|
# X: 0, 1 threatens 2
|
|
self.assertEqual(solver.best_moves((4,), (0, 1)), [2])
|
|
|
|
def test_blocks_where_own_piece_just_vanished(self):
|
|
# X's newest two (0, 1) threaten 2, where O's oldest piece was until O's turn started
|
|
self.assertEqual(solver.best_moves((5, 6), (7, 0, 1)), [2])
|
|
|
|
def test_takes_an_immediate_win(self):
|
|
# X: 0, 1 with 2 open -> 2 wins now
|
|
self.assertEqual(solver.best_moves((0, 1), (4, 8)), [2])
|
|
|
|
def test_moves_match_game_rules(self):
|
|
rng = random.Random(1)
|
|
positions = rng.sample(sorted(solver.value), 3000)
|
|
for mine, theirs in positions:
|
|
for cell, nxt in _moves(mine, theirs):
|
|
game = game_at(mine, theirs)
|
|
self.assertIsNone(game.play(0, cell))
|
|
if nxt is None:
|
|
self.assertEqual(game.winner, 0)
|
|
else:
|
|
self.assertIsNone(game.winner)
|
|
self.assertEqual(nxt, (tuple(game.history[1]), tuple(game.history[0])))
|
|
|
|
def test_values_are_consistent(self):
|
|
rng = random.Random(2)
|
|
for pos in rng.sample(sorted(solver.value), 3000):
|
|
children = [solver.value[n] if n else "immediate" for _, n in _moves(*pos)]
|
|
value = solver.value[pos]
|
|
if value == WIN:
|
|
self.assertTrue("immediate" in children or LOSS in children)
|
|
elif value == LOSS:
|
|
self.assertTrue(all(c == WIN for c in children))
|
|
else:
|
|
self.assertNotIn(LOSS, children)
|
|
self.assertIn(DRAW, children)
|
|
|
|
def test_accuracy_extremes(self):
|
|
game = game_at((0, 1), (4, 8))
|
|
rng = random.Random(3)
|
|
self.assertTrue(all(solver.choose(game, 1.0, rng) == (2, "perfect") for _ in range(50)))
|
|
picks = {solver.choose(game, 0.0, rng) for _ in range(200)}
|
|
self.assertEqual(picks, {(2, "lucky"), (3, "random"), (5, "random"),
|
|
(6, "random"), (7, "random")})
|
|
|
|
|
|
class FromHistoryTest(unittest.TestCase):
|
|
def test_rebuilds_board_and_winner(self):
|
|
g = Game.from_history([[0, 1, 2], [4, 5]], 1)
|
|
self.assertEqual(g.board[:3], [0, 0, 0])
|
|
self.assertEqual(g.winner, 0)
|
|
self.assertEqual(g.win_line, [0, 1, 2])
|
|
|
|
def test_rejects_invalid(self):
|
|
for history, turn in [
|
|
([[0, 0], []], 0), # duplicate cell
|
|
([[0], [0]], 0), # overlap
|
|
([[9], []], 0), # out of range
|
|
([[0, 1, 2, 3], []], 0), # too many pieces
|
|
([[0, 1, 5], [3, 4, 8]], 0), # mover's oldest should have vanished
|
|
([["a"], []], 0),
|
|
([[True], []], 0),
|
|
([[0]], 0), # one seat only
|
|
([[], []], 2), # bad turn
|
|
(None, 0),
|
|
]:
|
|
with self.assertRaises(ValueError, msg=(history, turn)):
|
|
Game.from_history(history, turn)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|