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]>
112 lines
3.7 KiB
Python
112 lines
3.7 KiB
Python
"""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],
|
|
}
|