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
@@ -0,0 +1,110 @@
|
||||
"""Perfect play for vanishing tic-tac-toe.
|
||||
|
||||
Every position reachable from the empty board (~17k) is solved by retrograde analysis:
|
||||
positions where the player to move can win immediately are wins, a position is a loss
|
||||
once every move leads to an opponent win, and whatever is never resolved is a draw
|
||||
(the players can keep cycling forever). Positions are (mine, theirs): the cells of the
|
||||
player to move and of their opponent, each oldest first.
|
||||
"""
|
||||
|
||||
import random
|
||||
from collections import deque
|
||||
|
||||
from game import LINES, MAX_PIECES
|
||||
|
||||
WIN, LOSS, DRAW = "win", "loss", "draw"
|
||||
|
||||
|
||||
def _wins(cells):
|
||||
return any(all(i in cells for i in line) for line in LINES)
|
||||
|
||||
|
||||
def _moves(mine, theirs):
|
||||
"""(cell, next position) for each legal move; next position is None if the move wins.
|
||||
The opponent's oldest piece goes as their turn starts if they have a full set."""
|
||||
occupied = set(mine) | set(theirs)
|
||||
remaining = theirs[1:] if len(theirs) == MAX_PIECES else theirs
|
||||
for cell in range(9):
|
||||
if cell in occupied:
|
||||
continue
|
||||
placed = mine + (cell,)
|
||||
yield cell, (None if _wins(placed) else (remaining, placed))
|
||||
|
||||
|
||||
class Solver:
|
||||
def __init__(self):
|
||||
self.value = {}
|
||||
self.distance = {} # plies until the game ends with best play from both sides
|
||||
self._solve()
|
||||
|
||||
def _solve(self):
|
||||
start = ((), ())
|
||||
succ, preds = {}, {}
|
||||
queue = deque([start])
|
||||
succ[start] = None
|
||||
while queue:
|
||||
pos = queue.popleft()
|
||||
succ[pos] = list(_moves(*pos))
|
||||
for _, nxt in succ[pos]:
|
||||
if nxt is None:
|
||||
continue
|
||||
preds.setdefault(nxt, []).append(pos)
|
||||
if nxt not in succ:
|
||||
succ[nxt] = None
|
||||
queue.append(nxt)
|
||||
|
||||
unresolved = {pos: sum(n is not None for _, n in moves) for pos, moves in succ.items()}
|
||||
queue = deque()
|
||||
for pos, moves in succ.items():
|
||||
if any(n is None for _, n in moves):
|
||||
self.value[pos], self.distance[pos] = WIN, 1
|
||||
queue.append(pos)
|
||||
while queue:
|
||||
pos = queue.popleft()
|
||||
for prev in preds.get(pos, ()):
|
||||
if prev in self.value:
|
||||
continue
|
||||
if self.value[pos] == LOSS:
|
||||
self.value[prev], self.distance[prev] = WIN, self.distance[pos] + 1
|
||||
queue.append(prev)
|
||||
else:
|
||||
unresolved[prev] -= 1
|
||||
if unresolved[prev] == 0:
|
||||
self.value[prev] = LOSS
|
||||
self.distance[prev] = 1 + max(self.distance[n] for _, n in succ[prev])
|
||||
queue.append(prev)
|
||||
for pos in succ:
|
||||
self.value.setdefault(pos, DRAW)
|
||||
|
||||
def best_moves(self, mine, theirs):
|
||||
"""All optimal cells: win fastest, else draw, else lose as slowly as possible.
|
||||
Returns None for positions not reachable from the empty board."""
|
||||
if (mine, theirs) not in self.value:
|
||||
return None
|
||||
scored = []
|
||||
for cell, nxt in _moves(mine, theirs):
|
||||
if nxt is None:
|
||||
score = (3, 0)
|
||||
elif self.value[nxt] == LOSS:
|
||||
score = (2, -self.distance[nxt])
|
||||
elif self.value[nxt] == DRAW:
|
||||
score = (1, 0)
|
||||
else:
|
||||
score = (0, self.distance[nxt])
|
||||
scored.append((score, cell))
|
||||
best = max(score for score, _ in scored)
|
||||
return [cell for score, cell in scored if score == best]
|
||||
|
||||
def choose(self, game, accuracy, rng=random):
|
||||
"""Pick a move for the player to move: optimal with probability `accuracy`, else random.
|
||||
|
||||
Returns (cell, how): how is "perfect" if the roll chose an optimal move, "lucky" if
|
||||
a random move happened to be optimal anyway, and "random" otherwise.
|
||||
"""
|
||||
mine = tuple(game.history[game.turn])
|
||||
theirs = tuple(game.history[1 - game.turn])
|
||||
best = self.best_moves(mine, theirs) or []
|
||||
if best and rng.random() < accuracy:
|
||||
return rng.choice(best), "perfect"
|
||||
cell = rng.choice([c for c in range(9) if game.board[c] is None])
|
||||
return cell, "lucky" if cell in best else "random"
|
||||
Reference in new issue
Block a user