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:
commit
c52f30dc59
18 files changed
+1743
No files matched your search
Whitespace-only changes.
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user