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

+111
View File
@@ -0,0 +1,111 @@
"""Rules for vanishing tic-tac-toe.
Each seat may have at most MAX_PIECES on the board. With a full set, your oldest
piece is removed as your turn starts, so you have at most MAX_PIECES - 1 pieces down
while placing and may place on the square it left.
"""
LINES = [
(0, 1, 2), (3, 4, 5), (6, 7, 8), # rows
(0, 3, 6), (1, 4, 7), (2, 5, 8), # columns
(0, 4, 8), (2, 4, 6), # diagonals
]
MAX_PIECES = 3
class Game:
def __init__(self):
self.first = 0 # seat that moves first this round; alternates on rematch
self.new_round()
@classmethod
def from_history(cls, history, turn):
"""Rebuild a round from each seat's cells (oldest first). Raises ValueError if invalid."""
try:
hx, ho = (list(h) for h in history)
except (TypeError, ValueError):
raise ValueError("Invalid position")
cells = hx + ho
if (turn not in (0, 1)
or any(type(c) is not int or not 0 <= c < 9 for c in cells)
or len(set(cells)) != len(cells)
or len(hx) > MAX_PIECES or len(ho) > MAX_PIECES):
raise ValueError("Invalid position")
game = cls()
game.history = (hx, ho)
game.turn = turn
for seat, hist in enumerate(game.history):
for c in hist:
game.board[c] = seat
for seat in (0, 1):
line = game._winning_line(seat)
if line:
game.winner, game.win_line = seat, line
if game.winner is None and len(game.history[turn]) == MAX_PIECES:
raise ValueError("Invalid position") # the mover's oldest should already be gone
return game
def new_round(self):
self.board = [None] * 9 # None, 0 (X) or 1 (O)
self.history = ([], []) # per seat, oldest cell first
self.turn = self.first
self.winner = None
self.win_line = None
self.rematch_votes = set()
def doomed(self):
"""The waiting player's oldest piece, which vanishes when their turn starts."""
if self.winner is not None:
return None
hist = self.history[1 - self.turn]
return hist[0] if len(hist) == MAX_PIECES else None
def play(self, seat, cell):
"""Place a piece. Returns an error string, or None on success."""
if self.winner is not None:
return "The round is over"
if seat != self.turn:
return "Not your turn"
if not isinstance(cell, int) or not 0 <= cell < 9:
return "Invalid square"
if self.board[cell] is not None:
return "That square is taken"
self.board[cell] = seat
self.history[seat].append(cell)
line = self._winning_line(seat)
if line:
self.winner = seat
self.win_line = line
else:
self.turn = 1 - seat
hist = self.history[self.turn]
if len(hist) == MAX_PIECES:
self.board[hist.pop(0)] = None
return None
def _winning_line(self, seat):
for line in LINES:
if all(self.board[i] == seat for i in line):
return list(line)
return None
def vote_rematch(self, seat):
if self.winner is None:
return
self.rematch_votes.add(seat)
if self.rematch_votes >= {0, 1}:
self.first = 1 - self.first
self.new_round()
def to_dict(self):
return {
"board": self.board,
"history": self.history,
"turn": self.turn,
"winner": self.winner,
"winLine": self.win_line,
"doomed": self.doomed(),
"rematch": [0 in self.rematch_votes, 1 in self.rematch_votes],
}