Moonchess: vanishing tic-tac-toe, online and practice modes

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]>
This commit is contained in:
tukaunuandClaude Opus 5.5 committed 2026-09-28 03:36:46 +02:00
commit c52f30dc59
18 files changed
+1743

No files matched your search

View File
Whitespace-only changes.
+92
View File
@@ -0,0 +1,92 @@
import unittest
from game import Game
def play_all(game, cells):
for cell in cells:
err = game.play(game.turn, cell)
assert err is None, (cell, err)
class GameTest(unittest.TestCase):
def test_plain_win(self):
g = Game()
play_all(g, [0, 3, 1, 4, 2])
self.assertEqual(g.winner, 0)
self.assertEqual(g.win_line, [0, 1, 2])
def test_oldest_vanishes_as_turn_starts(self):
g = Game()
play_all(g, [0, 8, 1, 7, 5]) # X: 0,1,5 O: 8,7
self.assertEqual(g.doomed(), 0) # X's oldest, dimmed during O's turn
play_all(g, [3]) # O: 8,7,3; X's turn starts
self.assertIsNone(g.board[0])
self.assertEqual(g.history[0], [1, 5])
self.assertEqual(g.doomed(), 8) # O's oldest is next
def test_can_place_where_own_piece_vanished(self):
g = Game()
play_all(g, [0, 8, 1, 7, 5, 3])
self.assertIsNone(g.play(0, 0))
self.assertEqual(g.history[0], [1, 5, 0])
self.assertIsNone(g.board[8]) # and O's 8 went as O's turn started
def test_opponents_dimmed_piece_is_taken(self):
g = Game()
play_all(g, [0, 8, 1, 7, 5, 3])
self.assertEqual(g.play(0, 8), "That square is taken")
def test_vanishing_piece_does_not_count_toward_win(self):
g = Game()
# X: 0, 4, 2 ... then 1 would make 0-1-2, but 0 vanishes when 1 is placed.
play_all(g, [0, 3, 4, 6, 2, 7])
self.assertIsNone(g.board[0])
play_all(g, [1])
self.assertIsNone(g.winner)
self.assertEqual(g.history[0], [4, 2, 1])
def test_win_with_rotated_pieces(self):
g = Game()
play_all(g, [0, 3, 1, 4, 8, 6]) # X: 0,1,8 O: 3,4,6
play_all(g, [2]) # X: 1,8,2 (0 vanished) -> no line
self.assertIsNone(g.winner)
play_all(g, [5]) # O: 4,6,5 (3 vanished)
play_all(g, [0]) # X: 8,2,0 (1 vanished)
play_all(g, [3]) # O: 6,5,3 (4 vanished)
play_all(g, [4]) # X: 2,0,4 (8 vanished)
play_all(g, [7]) # O: 5,3,7 (6 vanished)
play_all(g, [6]) # X: 0,4,6 (2 vanished)
self.assertIsNone(g.winner)
play_all(g, [8]) # O: 3,7,8 (5 vanished)
play_all(g, [2]) # X: 4,6,2 (0 vanished) -> diagonal 2-4-6
self.assertEqual(g.winner, 0)
self.assertEqual(g.win_line, [2, 4, 6])
def test_rejects_bad_moves(self):
g = Game()
self.assertEqual(g.play(1, 0), "Not your turn")
self.assertEqual(g.play(0, 9), "Invalid square")
self.assertEqual(g.play(0, "3"), "Invalid square")
play_all(g, [4])
self.assertEqual(g.play(1, 4), "That square is taken")
def test_rematch_needs_both_and_alternates_first(self):
g = Game()
play_all(g, [0, 3, 1, 4, 2])
g.vote_rematch(0)
self.assertEqual(g.winner, 0)
g.vote_rematch(1)
self.assertIsNone(g.winner)
self.assertEqual(g.board, [None] * 9)
self.assertEqual(g.turn, 1)
def test_rematch_ignored_mid_round(self):
g = Game()
g.vote_rematch(0)
g.vote_rematch(1)
self.assertEqual(g.first, 0)
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -0,0 +1,54 @@
from aiohttp.test_utils import AioHTTPTestCase
import practice
EMPTY = {"history": [[], []], "turn": 0}
class PracticeTest(AioHTTPTestCase):
async def get_application(self):
return practice.make_app()
async def play(self, body):
res = await self.client.post("/api/play", json=body)
return res.status, await res.json()
async def test_page_served(self):
res = await self.client.get("/")
self.assertEqual(res.status, 200)
self.assertIn("practice.js", await res.text())
async def test_human_move(self):
status, s = await self.play({**EMPTY, "cell": 4})
self.assertEqual(status, 200)
self.assertEqual(s["board"][4], 0)
self.assertEqual(s["history"], [[4], []])
self.assertEqual(s["turn"], 1)
async def test_perfect_computer_blocks(self):
for _ in range(10):
status, s = await self.play({"history": [[0, 1], [4]], "turn": 1, "accuracy": 1})
self.assertEqual(status, 200)
self.assertEqual(s["computerMove"], {"cell": 2, "how": "perfect"})
async def test_computer_takes_the_win(self):
status, s = await self.play({"history": [[0, 1], [4, 8]], "turn": 0, "accuracy": 1})
self.assertEqual(s["winner"], 0)
self.assertEqual(s["winLine"], [0, 1, 2])
self.assertEqual(s["computerMove"], {"cell": 2, "how": "perfect"})
async def test_human_move_has_no_computer_move(self):
status, s = await self.play({**EMPTY, "cell": 4})
self.assertIsNone(s["computerMove"])
async def test_rejects_bad_requests(self):
for body in [{}, [], {"history": [[0], [0]], "turn": 0},
{**EMPTY, "cell": 9}, {**EMPTY, "accuracy": "lots"}]:
status, s = await self.play(body)
self.assertEqual(status, 400, body)
self.assertIn("error", s)
async def test_no_moves_after_a_win(self):
status, s = await self.play({"history": [[0, 1, 2], [4, 5]], "turn": 1})
self.assertEqual(status, 400)
self.assertEqual(s["error"], "The round is over")
+180
View File
@@ -0,0 +1,180 @@
import asyncio
import unittest
from unittest import mock
from aiohttp.test_utils import AioHTTPTestCase
import server
class ServerTest(AioHTTPTestCase):
async def get_application(self):
server.rooms.clear()
return server.make_app()
async def new_game(self):
return "TESTGAME"
async def join(self, token, client_id=""):
return await self.client.ws_connect("/ws", params={"game": token, "client": client_id})
async def next_state(self, ws):
while True:
msg = await asyncio.wait_for(ws.receive_json(), 2)
if msg["type"] == "state":
return msg
async def settle(self, ws):
"""Return the most recent state after pending broadcasts arrive."""
state = await self.next_state(ws)
while True:
try:
msg = await asyncio.wait_for(ws.receive_json(), 0.2)
except asyncio.TimeoutError:
return state
if msg["type"] == "state":
state = msg
async def test_any_code_creates_a_game(self):
a = await self.join(" friday night ")
s = await self.settle(a)
self.assertEqual(s["token"], "FRIDAY NIGHT")
self.assertEqual(s["seat"], 0)
b = await self.join("Friday Night")
self.assertEqual((await self.settle(b))["seat"], 1)
self.assertEqual(list(server.rooms), ["FRIDAY NIGHT"])
async def test_invalid_codes_are_rejected(self):
for code in ["", " ", "x" * (server.MAX_CODE_LENGTH + 1)]:
ws = await self.join(code)
await ws.receive()
self.assertEqual(ws.close_code, server.CLOSE_INVALID_CODE)
self.assertEqual(server.rooms, {})
async def test_oversized_and_odd_messages(self):
a = await self.join("junk")
await self.settle(a)
await a.send_str("[]") # valid JSON, but not an object: ignored
await a.send_json({"type": "ping"})
self.assertEqual((await asyncio.wait_for(a.receive_json(), 2))["type"], "pong")
await a.send_str("x" * (server.MAX_MESSAGE_SIZE + 1))
while not a.closed:
await asyncio.wait_for(a.receive(), 2)
self.assertEqual(a.close_code, 1009) # message too big
async def test_empty_game_is_deleted_immediately(self):
a = await self.join("gone")
b = await self.join("gone")
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
await self.settle(b)
await a.close()
await b.close()
await asyncio.sleep(0.1)
self.assertNotIn("GONE", server.rooms)
c = await self.join("gone")
self.assertEqual((await self.settle(c))["board"][4], None)
async def test_game_survives_while_anyone_stays(self):
a = await self.join("keep")
b = await self.join("keep")
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
await self.settle(b)
await a.close()
a = await self.join("keep")
self.assertEqual((await self.settle(a))["board"][4], 0)
async def test_index_served(self):
res = await self.client.get("/")
self.assertEqual(res.status, 200)
self.assertIn("Moonchess", await res.text())
async def test_seats_and_spectators(self):
token = await self.new_game()
a = await self.join(token)
self.assertEqual((await self.settle(a))["seat"], 0)
b = await self.join(token)
self.assertEqual((await self.settle(b))["seat"], 1)
c = await self.join(token)
s = await self.settle(c)
self.assertIsNone(s["seat"])
self.assertEqual(s["spectators"], 1)
# spectators can't move
await c.send_json({"type": "move", "cell": 0})
await b.send_json({"type": "ping"})
await self.settle(a)
self.assertEqual(server.rooms[token].game.board, [None] * 9)
# a player leaves; the spectator refreshes and takes the seat
await a.close()
s = await self.settle(b)
self.assertEqual(s["players"], ["empty", "connected"])
await c.close()
d = await self.join(token)
self.assertEqual((await self.settle(d))["seat"], 0)
async def test_moves_need_both_players(self):
token = await self.new_game()
a = await self.join(token)
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
msg = await asyncio.wait_for(a.receive_json(), 2)
self.assertEqual(msg, {"type": "error", "message": "Waiting for an opponent"})
b = await self.join(token)
await self.settle(a)
await a.send_json({"type": "move", "cell": 4})
s = await self.settle(b)
self.assertEqual(s["board"][4], 0)
self.assertEqual(s["turn"], 1)
async def test_reconnect_reclaims_own_slot(self):
token = await self.new_game()
a = await self.join(token, "page-a")
await self.settle(a)
b = await self.join(token, "page-b")
await self.settle(b)
# a's old socket is still "alive" from the server's view; same page reconnects
a2 = await self.join(token, "page-a")
s = await self.settle(a2)
self.assertEqual(s["seat"], 0)
self.assertEqual(s["spectators"], 0)
async def test_silent_client_is_dropped(self):
token = await self.new_game()
with mock.patch.object(server, "HEARTBEAT_TIMEOUT", 0.5):
a = await self.join(token)
await self.settle(a)
b = await self.join(token)
await self.settle(b)
# b keeps pinging, a goes silent
for _ in range(8):
await b.send_json({"type": "ping"})
await asyncio.sleep(0.25)
self.assertEqual(server.rooms[token].players_present(), [False, True])
async def test_silent_seat_shows_disconnected_then_recovers(self):
token = await self.new_game()
with mock.patch.object(server, "STALE_AFTER", 0.5):
a = await self.join(token)
await self.settle(a)
b = await self.join(token)
await self.settle(b)
for _ in range(8): # b keeps pinging, a goes quiet
await b.send_json({"type": "ping"})
await asyncio.sleep(0.25)
s = await self.settle(b)
self.assertEqual(s["players"], ["disconnected", "connected"])
for _ in range(5): # a comes back
await a.send_json({"type": "ping"})
await b.send_json({"type": "ping"})
await asyncio.sleep(0.25)
s = await self.settle(b)
self.assertEqual(s["players"], ["connected", "connected"])
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
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()