The vibe is real
This commit is contained in:
commit
3138dfc99f
17 changed files with 1637 additions and 0 deletions
216
.gitignore
vendored
Normal file
216
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[codz]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
# Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
# poetry.lock
|
||||||
|
# poetry.toml
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||||
|
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||||
|
# pdm.lock
|
||||||
|
# pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# pixi
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||||
|
# pixi.lock
|
||||||
|
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||||
|
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||||
|
.pixi
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
*.rdb
|
||||||
|
*.aof
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# RabbitMQ
|
||||||
|
mnesia/
|
||||||
|
rabbitmq/
|
||||||
|
rabbitmq-data/
|
||||||
|
|
||||||
|
# ActiveMQ
|
||||||
|
activemq-data/
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.envrc
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
# .idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
|
# .vscode/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Marimo
|
||||||
|
marimo/_static/
|
||||||
|
marimo/_lsp/
|
||||||
|
__marimo__/
|
||||||
|
|
||||||
|
# Streamlit
|
||||||
|
.streamlit/secrets.toml
|
||||||
0
quarto/__init__.py
Normal file
0
quarto/__init__.py
Normal file
3
quarto/client/__init__.py
Normal file
3
quarto/client/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Client package for Quarto AI bot.
|
||||||
|
"""
|
||||||
208
quarto/client/ai.py
Normal file
208
quarto/client/ai.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from typing import Optional, Tuple, List
|
||||||
|
|
||||||
|
from quarto.shared.game import (
|
||||||
|
GameState,
|
||||||
|
InitialMove,
|
||||||
|
NormalMove,
|
||||||
|
Player,
|
||||||
|
generate_initial_moves,
|
||||||
|
generate_normal_moves,
|
||||||
|
detect_quarto,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QuartoAI:
|
||||||
|
"""
|
||||||
|
Time-bounded AI using iterative deepening alpha-beta search.
|
||||||
|
Designed to avoid obvious blunders and always return a legal move.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Evaluation parameters
|
||||||
|
self.win_value = 10_000
|
||||||
|
self.lose_value = -10_000
|
||||||
|
|
||||||
|
def choose_initial_piece(self, state: GameState, time_limit: float) -> InitialMove:
|
||||||
|
moves = generate_initial_moves(state)
|
||||||
|
if not moves:
|
||||||
|
raise RuntimeError("No initial moves available")
|
||||||
|
# For initial piece selection, use a simple heuristic: avoid obviously strong pieces
|
||||||
|
# like "balanced" ones (7, 8) – but this is minor; just pick first for now.
|
||||||
|
return moves[0]
|
||||||
|
|
||||||
|
def choose_normal_move(self, state: GameState, time_limit: float) -> NormalMove:
|
||||||
|
legal_moves = generate_normal_moves(state)
|
||||||
|
if not legal_moves:
|
||||||
|
raise RuntimeError("No legal moves available")
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
deadline = start + max(0.1, time_limit - 0.2)
|
||||||
|
|
||||||
|
best_move = legal_moves[0]
|
||||||
|
best_val = -math.inf
|
||||||
|
|
||||||
|
depth = 1
|
||||||
|
while True:
|
||||||
|
if time.time() >= deadline:
|
||||||
|
break
|
||||||
|
val, move = self._search_root(state, depth, deadline)
|
||||||
|
if move is not None:
|
||||||
|
best_move = move
|
||||||
|
best_val = val
|
||||||
|
depth += 1
|
||||||
|
# Early stopping if decisive win found
|
||||||
|
if best_val >= self.win_value - 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
return best_move
|
||||||
|
|
||||||
|
def _search_root(
|
||||||
|
self, state: GameState, depth: int, deadline: float
|
||||||
|
) -> Tuple[float, Optional[NormalMove]]:
|
||||||
|
legal_moves = generate_normal_moves(state)
|
||||||
|
if not legal_moves:
|
||||||
|
return self._evaluate(state), None
|
||||||
|
|
||||||
|
best_val = -math.inf
|
||||||
|
best_move: Optional[NormalMove] = None
|
||||||
|
alpha = -math.inf
|
||||||
|
beta = math.inf
|
||||||
|
|
||||||
|
for move in legal_moves:
|
||||||
|
if time.time() >= deadline:
|
||||||
|
break
|
||||||
|
# Clone state for simulation
|
||||||
|
sim_state = self._clone_state(state)
|
||||||
|
self._apply_simulated_move(sim_state, move)
|
||||||
|
val = -self._alphabeta(sim_state, depth - 1, -beta, -alpha, deadline)
|
||||||
|
if val > best_val:
|
||||||
|
best_val = val
|
||||||
|
best_move = move
|
||||||
|
if val > alpha:
|
||||||
|
alpha = val
|
||||||
|
if alpha >= beta:
|
||||||
|
break
|
||||||
|
|
||||||
|
return best_val, best_move
|
||||||
|
|
||||||
|
def _alphabeta(
|
||||||
|
self,
|
||||||
|
state: GameState,
|
||||||
|
depth: int,
|
||||||
|
alpha: float,
|
||||||
|
beta: float,
|
||||||
|
deadline: float,
|
||||||
|
) -> float:
|
||||||
|
if time.time() >= deadline:
|
||||||
|
# Return heuristic evaluation at cutoff
|
||||||
|
return self._evaluate(state)
|
||||||
|
|
||||||
|
if state.game_over or depth == 0:
|
||||||
|
return self._evaluate(state)
|
||||||
|
|
||||||
|
legal_moves = generate_normal_moves(state)
|
||||||
|
if not legal_moves:
|
||||||
|
return self._evaluate(state)
|
||||||
|
|
||||||
|
value = -math.inf
|
||||||
|
for move in legal_moves:
|
||||||
|
if time.time() >= deadline:
|
||||||
|
break
|
||||||
|
sim_state = self._clone_state(state)
|
||||||
|
self._apply_simulated_move(sim_state, move)
|
||||||
|
score = -self._alphabeta(sim_state, depth - 1, -beta, -alpha, deadline)
|
||||||
|
if score > value:
|
||||||
|
value = score
|
||||||
|
if score > alpha:
|
||||||
|
alpha = score
|
||||||
|
if alpha >= beta:
|
||||||
|
break
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _evaluate(self, state: GameState) -> float:
|
||||||
|
"""
|
||||||
|
Static evaluation from perspective of current_player.
|
||||||
|
"""
|
||||||
|
if state.game_over:
|
||||||
|
if state.winner is None:
|
||||||
|
return 0.0
|
||||||
|
return self.win_value if state.winner == state.current_player else self.lose_value
|
||||||
|
|
||||||
|
# Heuristic: count potential lines for both players
|
||||||
|
board = state.board
|
||||||
|
my_score = 0
|
||||||
|
opp_score = 0
|
||||||
|
|
||||||
|
from quarto.shared.game import LINES
|
||||||
|
|
||||||
|
for line in LINES:
|
||||||
|
pieces = [board.squares[i] for i in line]
|
||||||
|
if all(p is not None for p in pieces):
|
||||||
|
# Completed line; check if it is a Quarto
|
||||||
|
if detect_quarto(board):
|
||||||
|
# If Quarto exists and nobody claimed, it's neutral until claimed.
|
||||||
|
# Slight bonus to current player for potential claim.
|
||||||
|
my_score += 20
|
||||||
|
continue
|
||||||
|
# For partially filled lines, count how many shared attributes are still possible.
|
||||||
|
occupied = [p for p in pieces if p is not None]
|
||||||
|
if not occupied:
|
||||||
|
continue
|
||||||
|
common_bits = 0b1111
|
||||||
|
p0 = occupied[0]
|
||||||
|
for p in occupied[1:]:
|
||||||
|
common_bits &= ~(p ^ p0)
|
||||||
|
if common_bits:
|
||||||
|
# More shared bits and more empty slots => more potential
|
||||||
|
empty_slots = sum(1 for p in pieces if p is None)
|
||||||
|
my_score += (bin(common_bits).count("1") * empty_slots)
|
||||||
|
|
||||||
|
# Very rough balancing: opponent score approximated similarly by symmetry
|
||||||
|
# but we don't track explicit opponent perspective, so we just scale.
|
||||||
|
return float(my_score - opp_score)
|
||||||
|
|
||||||
|
def _clone_state(self, state: GameState) -> GameState:
|
||||||
|
new_state = GameState()
|
||||||
|
new_state.board = state.board.clone()
|
||||||
|
new_state.current_player = state.current_player
|
||||||
|
new_state.assigned_piece = state.assigned_piece
|
||||||
|
new_state.remaining_pieces = set(state.remaining_pieces)
|
||||||
|
new_state.move_count = state.move_count
|
||||||
|
new_state.game_over = state.game_over
|
||||||
|
new_state.winner = state.winner
|
||||||
|
new_state.last_move_was_claim = state.last_move_was_claim
|
||||||
|
new_state.last_move_was_draw_marker = state.last_move_was_draw_marker
|
||||||
|
return new_state
|
||||||
|
|
||||||
|
def _apply_simulated_move(self, state: GameState, move: NormalMove) -> None:
|
||||||
|
"""
|
||||||
|
Apply a move in simulation only; we don't need full validation here
|
||||||
|
because moves are generated from generate_normal_moves.
|
||||||
|
"""
|
||||||
|
from quarto.shared.game import _apply_normal_move_no_rules, detect_quarto
|
||||||
|
|
||||||
|
# Apply the move structure
|
||||||
|
_apply_normal_move_no_rules(state, move)
|
||||||
|
|
||||||
|
# Post-apply rules for claims and draw markers in simulation
|
||||||
|
if move.claim_quarto:
|
||||||
|
# Evaluate claim after placement
|
||||||
|
has_quarto = detect_quarto(state.board)
|
||||||
|
if has_quarto:
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = state.current_player
|
||||||
|
else:
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = state.current_player.other()
|
||||||
|
elif move.final_pass:
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = None
|
||||||
|
else:
|
||||||
|
# If board full and no pieces left, it's draw
|
||||||
|
if state.board.is_full() and not state.remaining_pieces:
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = None
|
||||||
215
quarto/client/core.py
Normal file
215
quarto/client/core.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from quarto.client.net import ClientConnection
|
||||||
|
from quarto.client.protocol import (
|
||||||
|
parse_server_line,
|
||||||
|
format_hello,
|
||||||
|
format_queue,
|
||||||
|
format_move_initial,
|
||||||
|
format_move_normal,
|
||||||
|
HelloServer,
|
||||||
|
NewGame,
|
||||||
|
MoveBroadcast,
|
||||||
|
GameOver,
|
||||||
|
UnknownCommand,
|
||||||
|
)
|
||||||
|
from quarto.client.ai import QuartoAI
|
||||||
|
from quarto.shared.game import (
|
||||||
|
GameState,
|
||||||
|
new_game_state,
|
||||||
|
InitialMove,
|
||||||
|
NormalMove,
|
||||||
|
Player,
|
||||||
|
apply_initial_move,
|
||||||
|
validate_and_apply_normal_move,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientApp:
|
||||||
|
def __init__(self, host: str, port: int, name: str) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.name = name
|
||||||
|
self.conn = ClientConnection(host, port)
|
||||||
|
self.ai = QuartoAI()
|
||||||
|
self.state: Optional[GameState] = None
|
||||||
|
self.our_player: Optional[Player] = None
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
self.conn.connect()
|
||||||
|
try:
|
||||||
|
self._handshake()
|
||||||
|
while True:
|
||||||
|
self._queue_and_play_one_game()
|
||||||
|
finally:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def _handshake(self) -> None:
|
||||||
|
self.conn.send_line(format_hello(self.name))
|
||||||
|
line = self.conn.read_line()
|
||||||
|
if line is None:
|
||||||
|
print("Connection closed during HELLO", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
msg = parse_server_line(line)
|
||||||
|
if not isinstance(msg, HelloServer):
|
||||||
|
print(f"Expected HELLO from server, got: {line}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _queue_and_play_one_game(self) -> None:
|
||||||
|
self.conn.send_line(format_queue())
|
||||||
|
# Wait for NEWGAME
|
||||||
|
while True:
|
||||||
|
line = self.conn.read_line()
|
||||||
|
if line is None:
|
||||||
|
print("Disconnected while waiting for NEWGAME", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
msg = parse_server_line(line)
|
||||||
|
if isinstance(msg, NewGame):
|
||||||
|
self._start_game(msg)
|
||||||
|
break
|
||||||
|
# ignore others silently
|
||||||
|
|
||||||
|
self._game_loop()
|
||||||
|
|
||||||
|
def _start_game(self, newgame: NewGame) -> None:
|
||||||
|
self.state = new_game_state()
|
||||||
|
if newgame.player1 == self.name:
|
||||||
|
self.our_player = Player.PLAYER1
|
||||||
|
elif newgame.player2 == self.name:
|
||||||
|
self.our_player = Player.PLAYER2
|
||||||
|
else:
|
||||||
|
# Fallback: assume we are player1 if name mismatch
|
||||||
|
self.our_player = Player.PLAYER1
|
||||||
|
|
||||||
|
# If we are player1, we must immediately choose initial piece
|
||||||
|
if self.our_player == Player.PLAYER1:
|
||||||
|
assert self.state is not None
|
||||||
|
init_move = self.ai.choose_initial_piece(self.state, 5.0)
|
||||||
|
# Do NOT apply locally; wait for server broadcast MOVE~M
|
||||||
|
self.conn.send_line(format_move_initial(init_move.chosen_piece))
|
||||||
|
|
||||||
|
def _game_loop(self) -> None:
|
||||||
|
assert self.state is not None
|
||||||
|
while True:
|
||||||
|
line = self.conn.read_line()
|
||||||
|
if line is None:
|
||||||
|
print("Disconnected during game", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
msg = parse_server_line(line)
|
||||||
|
|
||||||
|
if isinstance(msg, MoveBroadcast):
|
||||||
|
self._handle_move_broadcast(msg)
|
||||||
|
elif isinstance(msg, GameOver):
|
||||||
|
# End of game; just break and re-queue
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Ignore HELLO/NEWGAME/Unknown during game
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _handle_move_broadcast(self, msg: MoveBroadcast) -> None:
|
||||||
|
assert self.state is not None
|
||||||
|
state = self.state
|
||||||
|
|
||||||
|
if msg.square is None:
|
||||||
|
# Initial move: a piece has been chosen for the next player.
|
||||||
|
# Both players receive this broadcast and must update state.
|
||||||
|
chosen_piece = msg.value
|
||||||
|
init_move = InitialMove(chosen_piece=chosen_piece)
|
||||||
|
apply_initial_move(state, init_move)
|
||||||
|
else:
|
||||||
|
# Normal move
|
||||||
|
square = msg.square
|
||||||
|
m_value = msg.value
|
||||||
|
if m_value == 16:
|
||||||
|
move = NormalMove(square=square, next_piece=None,
|
||||||
|
claim_quarto=True, final_pass=False)
|
||||||
|
elif m_value == 17:
|
||||||
|
move = NormalMove(square=square, next_piece=None,
|
||||||
|
claim_quarto=False, final_pass=True)
|
||||||
|
else:
|
||||||
|
move = NormalMove(square=square, next_piece=m_value,
|
||||||
|
claim_quarto=False, final_pass=False)
|
||||||
|
|
||||||
|
# Keep our state consistent using shared validation logic
|
||||||
|
valid, _winner = validate_and_apply_normal_move(state, move)
|
||||||
|
if not valid:
|
||||||
|
# According to spec, server ensures opponent moves are valid;
|
||||||
|
# if this happens, log for debugging.
|
||||||
|
print(f"Received invalid MOVE from server: {msg}", file=sys.stderr)
|
||||||
|
|
||||||
|
# After applying the broadcast move, if it's now our turn and game not over, act.
|
||||||
|
if not state.game_over and state.current_player == self.our_player:
|
||||||
|
self._do_our_turn()
|
||||||
|
|
||||||
|
def _do_our_turn(self) -> None:
|
||||||
|
assert self.state is not None
|
||||||
|
state = self.state
|
||||||
|
if state.assigned_piece is None and state.move_count == 0:
|
||||||
|
# Very beginning and we are player1; handled in _start_game
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use AI to choose move within 5 seconds
|
||||||
|
move = self.ai.choose_normal_move(state, 5.0)
|
||||||
|
|
||||||
|
# Validate locally via simulation before sending
|
||||||
|
tmp_state = self._clone_state(state)
|
||||||
|
valid, _winner = validate_and_apply_normal_move(tmp_state, move)
|
||||||
|
if not valid:
|
||||||
|
print("AI produced invalid move; falling back to first legal move", file=sys.stderr)
|
||||||
|
from quarto.shared.game import generate_normal_moves
|
||||||
|
|
||||||
|
legal_moves = generate_normal_moves(state)
|
||||||
|
if not legal_moves:
|
||||||
|
print("No legal moves available", file=sys.stderr)
|
||||||
|
return
|
||||||
|
move = legal_moves[0]
|
||||||
|
|
||||||
|
# Do NOT apply the move to self.state here.
|
||||||
|
# We wait for the server's MOVE broadcast, which we will handle
|
||||||
|
# in _handle_move_broadcast.
|
||||||
|
|
||||||
|
# Send to server
|
||||||
|
if move.claim_quarto:
|
||||||
|
m_value = 16
|
||||||
|
elif move.final_pass:
|
||||||
|
m_value = 17
|
||||||
|
else:
|
||||||
|
assert move.next_piece is not None
|
||||||
|
m_value = move.next_piece
|
||||||
|
self.conn.send_line(format_move_normal(move.square, m_value))
|
||||||
|
|
||||||
|
def _clone_state(self, state: GameState) -> GameState:
|
||||||
|
from quarto.shared.game import GameState as GS, Board
|
||||||
|
|
||||||
|
ns = GS()
|
||||||
|
ns.board = Board(state.board.squares.copy())
|
||||||
|
ns.current_player = state.current_player
|
||||||
|
ns.assigned_piece = state.assigned_piece
|
||||||
|
ns.remaining_pieces = set(state.remaining_pieces)
|
||||||
|
ns.move_count = state.move_count
|
||||||
|
ns.game_over = state.game_over
|
||||||
|
ns.winner = state.winner
|
||||||
|
ns.last_move_was_claim = state.last_move_was_claim
|
||||||
|
ns.last_move_was_draw_marker = state.last_move_was_draw_marker
|
||||||
|
return ns
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--host", default="localhost")
|
||||||
|
parser.add_argument("--port", type=int, default=12345)
|
||||||
|
parser.add_argument("--name", default="QuartoBot",
|
||||||
|
help="Client name/description to send in HELLO")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
client = ClientApp(host=args.host, port=args.port, name=args.name)
|
||||||
|
client.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
quarto/client/main.py
Normal file
4
quarto/client/main.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from quarto.client.core import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
47
quarto/client/net.py
Normal file
47
quarto/client/net.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ClientConnection:
|
||||||
|
def __init__(self, host: str, port: int, timeout: float = 30.0) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self.sock: Optional[socket.socket] = None
|
||||||
|
self._buffer = b""
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(self.timeout)
|
||||||
|
s.connect((self.host, self.port))
|
||||||
|
self.sock = s
|
||||||
|
|
||||||
|
def send_line(self, line: str) -> None:
|
||||||
|
if self.sock is None:
|
||||||
|
raise RuntimeError("Not connected")
|
||||||
|
data = (line + "\n").encode("utf-8")
|
||||||
|
self.sock.sendall(data)
|
||||||
|
|
||||||
|
def read_line(self) -> Optional[str]:
|
||||||
|
if self.sock is None:
|
||||||
|
raise RuntimeError("Not connected")
|
||||||
|
while True:
|
||||||
|
if b"\n" in self._buffer:
|
||||||
|
line, self._buffer = self._buffer.split(b"\n", 1)
|
||||||
|
return line.decode("utf-8", errors="replace").rstrip("\r")
|
||||||
|
try:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
except socket.timeout:
|
||||||
|
return None
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
self._buffer += chunk
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self.sock is not None:
|
||||||
|
try:
|
||||||
|
self.sock.close()
|
||||||
|
finally:
|
||||||
|
self.sock = None
|
||||||
82
quarto/client/protocol.py
Normal file
82
quarto/client/protocol.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HelloServer:
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NewGame:
|
||||||
|
player1: str
|
||||||
|
player2: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MoveBroadcast:
|
||||||
|
square: Optional[int]
|
||||||
|
value: int # piece index or 16/17
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameOver:
|
||||||
|
reason: str # "VICTORY" | "DRAW" | "DISCONNECT"
|
||||||
|
winner: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UnknownCommand:
|
||||||
|
raw: str
|
||||||
|
|
||||||
|
|
||||||
|
ServerMessage = Union[HelloServer, NewGame, MoveBroadcast, GameOver, UnknownCommand]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_server_line(line: str) -> ServerMessage:
|
||||||
|
parts = line.strip().split("~")
|
||||||
|
if not parts:
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
cmd = parts[0]
|
||||||
|
if cmd == "HELLO" and len(parts) == 2:
|
||||||
|
return HelloServer(description=parts[1])
|
||||||
|
if cmd == "NEWGAME" and len(parts) == 3:
|
||||||
|
return NewGame(player1=parts[1], player2=parts[2])
|
||||||
|
if cmd == "MOVE":
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
value = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
return MoveBroadcast(square=None, value=value)
|
||||||
|
if len(parts) == 3:
|
||||||
|
try:
|
||||||
|
square = int(parts[1])
|
||||||
|
value = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
return MoveBroadcast(square=square, value=value)
|
||||||
|
if cmd == "GAMEOVER":
|
||||||
|
if len(parts) == 2:
|
||||||
|
return GameOver(reason=parts[1], winner=None)
|
||||||
|
if len(parts) == 3:
|
||||||
|
return GameOver(reason=parts[1], winner=parts[2])
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
|
||||||
|
|
||||||
|
def format_hello(description: str) -> str:
|
||||||
|
return f"HELLO~{description}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_queue() -> str:
|
||||||
|
return "QUEUE"
|
||||||
|
|
||||||
|
|
||||||
|
def format_move_initial(piece: int) -> str:
|
||||||
|
return f"MOVE~{piece}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_move_normal(square: int, m_value: int) -> str:
|
||||||
|
return f"MOVE~{square}~{m_value}"
|
||||||
3
quarto/server/__init__.py
Normal file
3
quarto/server/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Server package for Quarto tournament server.
|
||||||
|
"""
|
||||||
159
quarto/server/core.py
Normal file
159
quarto/server/core.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .net import ServerListener, ServerClient
|
||||||
|
from .protocol import (
|
||||||
|
parse_client_line,
|
||||||
|
ClientHello,
|
||||||
|
ClientQueue,
|
||||||
|
ClientMove,
|
||||||
|
UnknownCommand,
|
||||||
|
format_hello,
|
||||||
|
)
|
||||||
|
from .match import Matchmaker
|
||||||
|
from .session import SessionManager
|
||||||
|
|
||||||
|
|
||||||
|
class ServerApp:
|
||||||
|
def __init__(self, port: int) -> None:
|
||||||
|
self.port = port
|
||||||
|
self.clients: list[ServerClient] = []
|
||||||
|
self.listener = ServerListener(port)
|
||||||
|
self.matchmaker = Matchmaker()
|
||||||
|
self.sessions = SessionManager()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
def on_new_client(client: ServerClient) -> None:
|
||||||
|
self.clients.append(client)
|
||||||
|
|
||||||
|
t = threading.Thread(target=self._handle_client, args=(client,), daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
self.listener.accept_loop(on_new_client)
|
||||||
|
|
||||||
|
def _handle_client(self, client: ServerClient) -> None:
|
||||||
|
# Expect HELLO
|
||||||
|
line = client.read_line()
|
||||||
|
if line is None:
|
||||||
|
client.close()
|
||||||
|
return
|
||||||
|
cmd = parse_client_line(line)
|
||||||
|
if isinstance(cmd, ClientHello):
|
||||||
|
desired_desc = cmd.description or ""
|
||||||
|
|
||||||
|
# Enforce unique descriptions: if name is already taken, add suffix
|
||||||
|
existing_names = {c.description for c in self.clients if c.description is not None}
|
||||||
|
final_desc = desired_desc
|
||||||
|
if final_desc in existing_names:
|
||||||
|
i = 2
|
||||||
|
while f"{desired_desc}#{i}" in existing_names:
|
||||||
|
i += 1
|
||||||
|
final_desc = f"{desired_desc}#{i}"
|
||||||
|
|
||||||
|
client.description = final_desc
|
||||||
|
client.send_line(format_hello("QuartoServer"))
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[SERVER] Client #{client.id} connected from {client.addr}, "
|
||||||
|
f"description='{client.description}' (requested '{desired_desc}')",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
client.close()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = client.read_line()
|
||||||
|
if line is None:
|
||||||
|
# disconnect
|
||||||
|
self._handle_disconnect(client)
|
||||||
|
break
|
||||||
|
cmd = parse_client_line(line)
|
||||||
|
if isinstance(cmd, ClientQueue):
|
||||||
|
self._handle_queue(client)
|
||||||
|
elif isinstance(cmd, ClientMove):
|
||||||
|
self._handle_move(client, cmd)
|
||||||
|
elif isinstance(cmd, UnknownCommand):
|
||||||
|
# Silently ignore unknown commands
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Unknown type; ignore
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
def _handle_queue(self, client: ServerClient) -> None:
|
||||||
|
# If already in a game, ignore
|
||||||
|
if client.in_game:
|
||||||
|
return
|
||||||
|
self.matchmaker.enqueue(client)
|
||||||
|
pair = self.matchmaker.dequeue_pair()
|
||||||
|
if pair is None:
|
||||||
|
return
|
||||||
|
c1, c2 = pair
|
||||||
|
c1.in_game = True
|
||||||
|
c2.in_game = True
|
||||||
|
session = self.sessions.create_session(c1, c2)
|
||||||
|
session.send_newgame()
|
||||||
|
|
||||||
|
# LOG: new game created
|
||||||
|
print(
|
||||||
|
f"[SERVER] New game #{session.id} created: "
|
||||||
|
f"P1=#{c1.id} '{session.player1_name}' vs "
|
||||||
|
f"P2=#{c2.id} '{session.player2_name}'",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_move(self, client: ServerClient, move: ClientMove) -> None:
|
||||||
|
session = self.sessions.session_for_client(client)
|
||||||
|
if session is None:
|
||||||
|
# Not in a game; ignore
|
||||||
|
return
|
||||||
|
|
||||||
|
# LOG: raw MOVE from client
|
||||||
|
print(
|
||||||
|
f"[SERVER] Game #{session.id} - Client #{client.id} "
|
||||||
|
f"('{session.player1_name if client is session.player1 else session.player2_name}') "
|
||||||
|
f"sends MOVE: square={move.square}, m_value={move.m_value}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.handle_client_move(client, move)
|
||||||
|
# If session ended, clean up
|
||||||
|
if session.state.game_over:
|
||||||
|
self.sessions.end_session(session)
|
||||||
|
session.player1.in_game = False
|
||||||
|
session.player2.in_game = False
|
||||||
|
|
||||||
|
def _handle_disconnect(self, client: ServerClient) -> None:
|
||||||
|
# If in a game, inform session
|
||||||
|
session = self.sessions.session_for_client(client)
|
||||||
|
if session is not None:
|
||||||
|
session.handle_disconnect(client)
|
||||||
|
self.sessions.end_session(session)
|
||||||
|
session.player1.in_game = False
|
||||||
|
session.player2.in_game = False
|
||||||
|
# If in lobby, remove from queue
|
||||||
|
self.matchmaker.remove(client)
|
||||||
|
if client in self.clients:
|
||||||
|
self.clients.remove(client)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Quarto tournament server")
|
||||||
|
parser.add_argument("--port", type=int, default=5000, help="Listen port")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
app = ServerApp(args.port)
|
||||||
|
try:
|
||||||
|
app.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Server shutting down", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
quarto/server/main.py
Normal file
4
quarto/server/main.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from quarto.server.core import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
35
quarto/server/match.py
Normal file
35
quarto/server/match.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections import deque
|
||||||
|
from typing import Deque, Optional, Tuple
|
||||||
|
|
||||||
|
from .net import ServerClient
|
||||||
|
|
||||||
|
|
||||||
|
class Matchmaker:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queue: Deque[ServerClient] = deque()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def enqueue(self, client: ServerClient) -> None:
|
||||||
|
with self._lock:
|
||||||
|
# Avoid duplicates
|
||||||
|
if client in self._queue:
|
||||||
|
return
|
||||||
|
self._queue.append(client)
|
||||||
|
|
||||||
|
def dequeue_pair(self) -> Optional[Tuple[ServerClient, ServerClient]]:
|
||||||
|
with self._lock:
|
||||||
|
if len(self._queue) >= 2:
|
||||||
|
c1 = self._queue.popleft()
|
||||||
|
c2 = self._queue.popleft()
|
||||||
|
return c1, c2
|
||||||
|
return None
|
||||||
|
|
||||||
|
def remove(self, client: ServerClient) -> None:
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self._queue.remove(client)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
70
quarto/server/net.py
Normal file
70
quarto/server/net.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServerClient:
|
||||||
|
sock: socket.socket
|
||||||
|
addr: tuple
|
||||||
|
id: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||||
|
buffer: bytes = b""
|
||||||
|
in_game: bool = False
|
||||||
|
|
||||||
|
def send_line(self, line: str) -> None:
|
||||||
|
data = (line + "\n").encode("utf-8")
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self.sock.sendall(data)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def read_line(self) -> Optional[str]:
|
||||||
|
while True:
|
||||||
|
if b"\n" in self.buffer:
|
||||||
|
line, self.buffer = self.buffer.split(b"\n", 1)
|
||||||
|
return line.decode("utf-8", errors="replace").rstrip("\r")
|
||||||
|
try:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
self.buffer += chunk
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
try:
|
||||||
|
self.sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServerListener:
|
||||||
|
def __init__(self, port: int) -> None:
|
||||||
|
self.port = port
|
||||||
|
self._next_id = 1
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _alloc_id(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
cid = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def accept_loop(self, on_new_client: Callable[[ServerClient], None]) -> None:
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.bind(("", self.port))
|
||||||
|
srv.listen()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
conn, addr = srv.accept()
|
||||||
|
client = ServerClient(sock=conn, addr=addr, id=self._alloc_id())
|
||||||
|
on_new_client(client)
|
||||||
|
finally:
|
||||||
|
srv.close()
|
||||||
73
quarto/server/protocol.py
Normal file
73
quarto/server/protocol.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClientHello:
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class ClientQueue:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClientMove:
|
||||||
|
square: Optional[int] # None for initial move MOVE~M
|
||||||
|
m_value: int # 0-17
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UnknownCommand:
|
||||||
|
raw: str
|
||||||
|
|
||||||
|
|
||||||
|
ClientCommand = Union[ClientHello, ClientQueue, ClientMove, UnknownCommand]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_client_line(line: str) -> ClientCommand:
|
||||||
|
parts = line.strip().split("~")
|
||||||
|
if not parts:
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
cmd = parts[0]
|
||||||
|
if cmd == "HELLO" and len(parts) == 2:
|
||||||
|
return ClientHello(description=parts[1])
|
||||||
|
if cmd == "QUEUE" and len(parts) == 1:
|
||||||
|
return ClientQueue()
|
||||||
|
if cmd == "MOVE":
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
m_value = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
return ClientMove(square=None, m_value=m_value)
|
||||||
|
if len(parts) == 3:
|
||||||
|
try:
|
||||||
|
square = int(parts[1])
|
||||||
|
m_value = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
return ClientMove(square=square, m_value=m_value)
|
||||||
|
return UnknownCommand(raw=line)
|
||||||
|
|
||||||
|
|
||||||
|
def format_hello(description: str) -> str:
|
||||||
|
return f"HELLO~{description}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_newgame(player1: str, player2: str) -> str:
|
||||||
|
return f"NEWGAME~{player1}~{player2}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_move_broadcast(square: Optional[int], m_value: int) -> str:
|
||||||
|
if square is None:
|
||||||
|
return f"MOVE~{m_value}"
|
||||||
|
return f"MOVE~{square}~{m_value}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_gameover(reason: str, winner: Optional[str]) -> str:
|
||||||
|
if winner is None:
|
||||||
|
return f"GAMEOVER~{reason}"
|
||||||
|
return f"GAMEOVER~{reason}~{winner}"
|
||||||
238
quarto/server/session.py
Normal file
238
quarto/server/session.py
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
from .net import ServerClient
|
||||||
|
from .protocol import ClientMove, format_newgame, format_move_broadcast, format_gameover
|
||||||
|
from quarto.shared.game import (
|
||||||
|
GameState,
|
||||||
|
new_game_state,
|
||||||
|
Player,
|
||||||
|
InitialMove,
|
||||||
|
NormalMove,
|
||||||
|
apply_initial_move,
|
||||||
|
validate_and_apply_normal_move,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameSession:
|
||||||
|
id: int
|
||||||
|
player1: ServerClient
|
||||||
|
player2: ServerClient
|
||||||
|
state: GameState
|
||||||
|
current_player: Player
|
||||||
|
lock: threading.Lock
|
||||||
|
|
||||||
|
@property
|
||||||
|
def player1_name(self) -> str:
|
||||||
|
return self.player1.description or f"Player{self.player1.id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def player2_name(self) -> str:
|
||||||
|
return self.player2.description or f"Player{self.player2.id}"
|
||||||
|
|
||||||
|
def send_newgame(self) -> None:
|
||||||
|
msg = format_newgame(self.player1_name, self.player2_name)
|
||||||
|
self.player1.send_line(msg)
|
||||||
|
self.player2.send_line(msg)
|
||||||
|
|
||||||
|
def client_for_player(self, player: Player) -> ServerClient:
|
||||||
|
return self.player1 if player is Player.PLAYER1 else self.player2
|
||||||
|
|
||||||
|
def player_for_client(self, client: ServerClient) -> Optional[Player]:
|
||||||
|
if client is self.player1:
|
||||||
|
return Player.PLAYER1
|
||||||
|
if client is self.player2:
|
||||||
|
return Player.PLAYER2
|
||||||
|
return None
|
||||||
|
|
||||||
|
def handle_client_move(self, client: ServerClient, move_cmd: ClientMove) -> None:
|
||||||
|
with self.lock:
|
||||||
|
# Ignore moves once game over
|
||||||
|
if self.state.game_over:
|
||||||
|
return
|
||||||
|
|
||||||
|
player = self.player_for_client(client)
|
||||||
|
if player is None:
|
||||||
|
return
|
||||||
|
if player is not self.current_player:
|
||||||
|
# Not this player's turn; ignore
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initial move if move_count == 0 and square is None
|
||||||
|
if self.state.move_count == 0 and move_cmd.square is None:
|
||||||
|
m_value = move_cmd.m_value
|
||||||
|
if not (0 <= m_value <= 15):
|
||||||
|
return
|
||||||
|
if m_value not in self.state.remaining_pieces:
|
||||||
|
return
|
||||||
|
init_move = InitialMove(chosen_piece=m_value)
|
||||||
|
apply_initial_move(self.state, init_move)
|
||||||
|
|
||||||
|
# LOG: initial move
|
||||||
|
print(
|
||||||
|
f"[SERVER] Game #{self.id} - {self.player1_name if player is Player.PLAYER1 else self.player2_name} "
|
||||||
|
f"(P{'1' if player is Player.PLAYER1 else '2'}) chooses initial piece {m_value}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast MOVE~M
|
||||||
|
msg = format_move_broadcast(square=None, m_value=m_value)
|
||||||
|
self.player1.send_line(msg)
|
||||||
|
self.player2.send_line(msg)
|
||||||
|
# After initial move, it's player2's turn
|
||||||
|
self.current_player = Player.PLAYER2
|
||||||
|
return
|
||||||
|
|
||||||
|
# From now on, we expect a normal move
|
||||||
|
if move_cmd.square is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
square = move_cmd.square
|
||||||
|
m_value = move_cmd.m_value
|
||||||
|
|
||||||
|
if m_value == 16:
|
||||||
|
nm = NormalMove(square=square, next_piece=None,
|
||||||
|
claim_quarto=True, final_pass=False)
|
||||||
|
move_desc = f"place at {square} and CLAIM QUARTO (M=16)"
|
||||||
|
elif m_value == 17:
|
||||||
|
nm = NormalMove(square=square, next_piece=None,
|
||||||
|
claim_quarto=False, final_pass=True)
|
||||||
|
move_desc = f"place at {square} and FINAL PASS / DRAW MARKER (M=17)"
|
||||||
|
else:
|
||||||
|
nm = NormalMove(square=square, next_piece=m_value,
|
||||||
|
claim_quarto=False, final_pass=False)
|
||||||
|
move_desc = f"place at {square}, give piece {m_value}"
|
||||||
|
|
||||||
|
valid, winner = validate_and_apply_normal_move(self.state, nm)
|
||||||
|
if not valid:
|
||||||
|
# Per spec, ignore invalid moves silently
|
||||||
|
print(
|
||||||
|
f"[SERVER] Game #{self.id} - INVALID MOVE from "
|
||||||
|
f"{self.player1_name if player is Player.PLAYER1 else self.player2_name}: "
|
||||||
|
f"square={square}, m_value={m_value}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# LOG: valid move applied
|
||||||
|
print(
|
||||||
|
f"[SERVER] Game #{self.id} - "
|
||||||
|
f"{self.player1_name if player is Player.PLAYER1 else self.player2_name} "
|
||||||
|
f"(P{'1' if player is Player.PLAYER1 else '2'}) {move_desc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast MOVE as received
|
||||||
|
self.player1.send_line(format_move_broadcast(square=square, m_value=m_value))
|
||||||
|
self.player2.send_line(format_move_broadcast(square=square, m_value=m_value))
|
||||||
|
|
||||||
|
# After applying normal move, the GameState has already handled
|
||||||
|
# board full -> draw, claims, etc.
|
||||||
|
if self.state.game_over:
|
||||||
|
if self.state.winner is None:
|
||||||
|
# DRAW
|
||||||
|
# If this was not an explicit 17, we may need to broadcast 17 then draw
|
||||||
|
if not nm.final_pass and m_value != 17:
|
||||||
|
# Convert to final-pass broadcast per spec 8:
|
||||||
|
# board full & draw => broadcast M=17 immediately, then GAMEOVER
|
||||||
|
self.player1.send_line(format_move_broadcast(square=square, m_value=17))
|
||||||
|
self.player2.send_line(format_move_broadcast(square=square, m_value=17))
|
||||||
|
go = format_gameover("DRAW", None)
|
||||||
|
self.player1.send_line(go)
|
||||||
|
self.player2.send_line(go)
|
||||||
|
|
||||||
|
# LOG: draw
|
||||||
|
print(
|
||||||
|
f"[SERVER] Game #{self.id} ended in DRAW",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
winner_name = (
|
||||||
|
self.player1_name if self.state.winner is Player.PLAYER1 else self.player2_name
|
||||||
|
)
|
||||||
|
go = format_gameover("VICTORY", winner_name)
|
||||||
|
self.player1.send_line(go)
|
||||||
|
self.player2.send_line(go)
|
||||||
|
|
||||||
|
# LOG: victory
|
||||||
|
print(
|
||||||
|
f"[SERVER] Game #{self.id} ended in VICTORY for {winner_name}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Game continues: switch current_player already done in GameState
|
||||||
|
self.current_player = self.state.current_player
|
||||||
|
|
||||||
|
def handle_disconnect(self, client: ServerClient) -> None:
|
||||||
|
with self.lock:
|
||||||
|
if self.state.game_over:
|
||||||
|
return
|
||||||
|
# Determine remaining player
|
||||||
|
if client is self.player1 and self.player2 is not None:
|
||||||
|
winner_name = self.player2_name
|
||||||
|
elif client is self.player2 and self.player1 is not None:
|
||||||
|
winner_name = self.player1_name
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
self.state.game_over = True
|
||||||
|
self.state.winner = None # gameover reason is DISCONNECT, winner is remaining
|
||||||
|
msg = format_gameover("DISCONNECT", winner_name)
|
||||||
|
if client is not self.player1:
|
||||||
|
self.player1.send_line(msg)
|
||||||
|
if client is not self.player2:
|
||||||
|
self.player2.send_line(msg)
|
||||||
|
|
||||||
|
# LOG: disconnect
|
||||||
|
print(
|
||||||
|
f"[SERVER] Game #{self.id} ended due to DISCONNECT; remaining player '{winner_name}'",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""
|
||||||
|
Keeps track of active sessions and mapping from clients to sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._next_id = 1
|
||||||
|
self._sessions: Dict[int, GameSession] = {}
|
||||||
|
self._by_client: Dict[int, int] = {} # client.id -> session.id
|
||||||
|
|
||||||
|
def create_session(self, p1: ServerClient, p2: ServerClient) -> GameSession:
|
||||||
|
with self._lock:
|
||||||
|
sid = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
state = new_game_state()
|
||||||
|
session = GameSession(
|
||||||
|
id=sid,
|
||||||
|
player1=p1,
|
||||||
|
player2=p2,
|
||||||
|
state=state,
|
||||||
|
current_player=Player.PLAYER1,
|
||||||
|
lock=threading.Lock(),
|
||||||
|
)
|
||||||
|
self._sessions[sid] = session
|
||||||
|
self._by_client[p1.id] = sid
|
||||||
|
self._by_client[p2.id] = sid
|
||||||
|
return session
|
||||||
|
|
||||||
|
def session_for_client(self, client: ServerClient) -> Optional[GameSession]:
|
||||||
|
with self._lock:
|
||||||
|
sid = self._by_client.get(client.id)
|
||||||
|
if sid is None:
|
||||||
|
return None
|
||||||
|
return self._sessions.get(sid)
|
||||||
|
|
||||||
|
def end_session(self, session: GameSession) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._sessions.pop(session.id, None)
|
||||||
|
self._by_client.pop(session.player1.id, None)
|
||||||
|
self._by_client.pop(session.player2.id, None)
|
||||||
3
quarto/shared/__init__.py
Normal file
3
quarto/shared/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Shared package for Quarto game logic used by both client and server.
|
||||||
|
"""
|
||||||
277
quarto/shared/game.py
Normal file
277
quarto/shared/game.py
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class Player(Enum):
|
||||||
|
PLAYER1 = auto()
|
||||||
|
PLAYER2 = auto()
|
||||||
|
|
||||||
|
def other(self) -> "Player":
|
||||||
|
return Player.PLAYER1 if self is Player.PLAYER2 else Player.PLAYER2
|
||||||
|
|
||||||
|
|
||||||
|
# Precomputed Quarto lines (rows, columns, diagonals)
|
||||||
|
LINES: List[Tuple[int, int, int, int]] = [
|
||||||
|
(0, 1, 2, 3),
|
||||||
|
(4, 5, 6, 7),
|
||||||
|
(8, 9, 10, 11),
|
||||||
|
(12, 13, 14, 15),
|
||||||
|
(0, 4, 8, 12),
|
||||||
|
(1, 5, 9, 13),
|
||||||
|
(2, 6, 10, 14),
|
||||||
|
(3, 7, 11, 15),
|
||||||
|
(0, 5, 10, 15),
|
||||||
|
(3, 6, 9, 12),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Board:
|
||||||
|
squares: List[Optional[int]] = field(default_factory=lambda: [None] * 16)
|
||||||
|
|
||||||
|
def clone(self) -> "Board":
|
||||||
|
return Board(self.squares.copy())
|
||||||
|
|
||||||
|
def is_full(self) -> bool:
|
||||||
|
return all(s is not None for s in self.squares)
|
||||||
|
|
||||||
|
def place_piece(self, index: int, piece: int) -> None:
|
||||||
|
if self.squares[index] is not None:
|
||||||
|
raise ValueError("Square already occupied")
|
||||||
|
self.squares[index] = piece
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameState:
|
||||||
|
board: Board = field(default_factory=Board)
|
||||||
|
current_player: Player = Player.PLAYER1
|
||||||
|
assigned_piece: Optional[int] = None # piece given to current player
|
||||||
|
remaining_pieces: Set[int] = field(default_factory=lambda: set(range(16)))
|
||||||
|
move_count: int = 0
|
||||||
|
game_over: bool = False
|
||||||
|
winner: Optional[Player] = None
|
||||||
|
last_move_was_claim: bool = False
|
||||||
|
last_move_was_draw_marker: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InitialMove:
|
||||||
|
chosen_piece: int # 0-15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NormalMove:
|
||||||
|
square: int # 0-15
|
||||||
|
next_piece: Optional[int] # 0-15 or None if claim/draw marker
|
||||||
|
claim_quarto: bool # M=16
|
||||||
|
final_pass: bool # M=17
|
||||||
|
|
||||||
|
|
||||||
|
def new_game_state() -> GameState:
|
||||||
|
return GameState()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_quarto(board: Board) -> bool:
|
||||||
|
"""
|
||||||
|
Detect if there is any Quarto line on the board.
|
||||||
|
A line is a Quarto if all 4 squares occupied and pieces share at least one attribute bit.
|
||||||
|
"""
|
||||||
|
b = board.squares
|
||||||
|
for a, c, d, e in LINES:
|
||||||
|
p0 = b[a]
|
||||||
|
if p0 is None:
|
||||||
|
continue
|
||||||
|
p1, p2, p3 = b[c], b[d], b[e]
|
||||||
|
if p1 is None or p2 is None or p3 is None:
|
||||||
|
continue
|
||||||
|
# bits same between p and p0: ~(p ^ p0); intersect across all
|
||||||
|
common_bits = 0b1111
|
||||||
|
for p in (p1, p2, p3):
|
||||||
|
common_bits &= ~(p ^ p0)
|
||||||
|
if common_bits & 0b1111:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_initial_moves(state: GameState) -> List[InitialMove]:
|
||||||
|
if state.move_count != 0 or state.assigned_piece is not None:
|
||||||
|
return []
|
||||||
|
return [InitialMove(piece) for piece in sorted(state.remaining_pieces)]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_initial_move(state: GameState, move: InitialMove) -> None:
|
||||||
|
if state.move_count != 0:
|
||||||
|
raise ValueError("Initial move only allowed at start")
|
||||||
|
if move.chosen_piece not in state.remaining_pieces:
|
||||||
|
raise ValueError("Chosen piece not available")
|
||||||
|
# Give piece to opponent
|
||||||
|
state.remaining_pieces.remove(move.chosen_piece)
|
||||||
|
state.assigned_piece = move.chosen_piece
|
||||||
|
state.current_player = state.current_player.other()
|
||||||
|
state.move_count += 1
|
||||||
|
state.last_move_was_claim = False
|
||||||
|
state.last_move_was_draw_marker = False
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_normal_move_no_rules(state: GameState, move: NormalMove) -> None:
|
||||||
|
"""
|
||||||
|
Apply a normal move assuming it is already validated.
|
||||||
|
This is used by both rules engine and AI search.
|
||||||
|
"""
|
||||||
|
# Place assigned piece
|
||||||
|
piece_to_place = state.assigned_piece
|
||||||
|
if piece_to_place is None:
|
||||||
|
raise ValueError("No assigned piece to place")
|
||||||
|
state.board.place_piece(move.square, piece_to_place)
|
||||||
|
|
||||||
|
# Remove placed piece from remaining if it hasn't been removed yet
|
||||||
|
state.remaining_pieces.discard(piece_to_place)
|
||||||
|
|
||||||
|
state.move_count += 1
|
||||||
|
|
||||||
|
state.last_move_was_claim = move.claim_quarto
|
||||||
|
state.last_move_was_draw_marker = move.final_pass
|
||||||
|
|
||||||
|
if move.claim_quarto:
|
||||||
|
# Claim: decide winner after this placement from the active game rules code
|
||||||
|
state.game_over = True
|
||||||
|
# winner will be set by validator using detect_quarto
|
||||||
|
elif move.final_pass:
|
||||||
|
# Final pass: board must be full; game is a draw by rules
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = None
|
||||||
|
else:
|
||||||
|
# Normal continuing move: set next assigned piece and switch current player
|
||||||
|
if move.next_piece is None:
|
||||||
|
raise ValueError("next_piece must not be None when not claim/draw")
|
||||||
|
state.assigned_piece = move.next_piece
|
||||||
|
state.remaining_pieces.remove(move.next_piece)
|
||||||
|
state.current_player = state.current_player.other()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_normal_moves(state: GameState) -> List[NormalMove]:
|
||||||
|
"""
|
||||||
|
Generate all logically legal moves (from the client's perspective)
|
||||||
|
for the current player, given assigned_piece.
|
||||||
|
This does not validate Quarto claims (AI can also skip generating
|
||||||
|
losing claims if desired).
|
||||||
|
"""
|
||||||
|
if state.assigned_piece is None or state.game_over:
|
||||||
|
return []
|
||||||
|
|
||||||
|
moves: List[NormalMove] = []
|
||||||
|
board = state.board
|
||||||
|
assigned = state.assigned_piece
|
||||||
|
|
||||||
|
empties = [i for i, p in enumerate(board.squares) if p is None]
|
||||||
|
remaining_without_assigned = sorted(state.remaining_pieces - {assigned})
|
||||||
|
|
||||||
|
for sq in empties:
|
||||||
|
# Option 1: normal move with giving a next piece
|
||||||
|
for np in remaining_without_assigned:
|
||||||
|
moves.append(NormalMove(square=sq, next_piece=np,
|
||||||
|
claim_quarto=False, final_pass=False))
|
||||||
|
|
||||||
|
# Option 2: claim Quarto after placing here, if it would be a real Quarto
|
||||||
|
# We simulate placement
|
||||||
|
temp_board = board.clone()
|
||||||
|
temp_board.place_piece(sq, assigned)
|
||||||
|
if detect_quarto(temp_board):
|
||||||
|
moves.append(NormalMove(square=sq, next_piece=None,
|
||||||
|
claim_quarto=True, final_pass=False))
|
||||||
|
|
||||||
|
# Option 3: if this placement fills the board and there are no remaining pieces,
|
||||||
|
# use final_pass (M=17) draw marker
|
||||||
|
would_be_full = all(
|
||||||
|
(temp_board.squares[i] is not None) for i in range(16)
|
||||||
|
)
|
||||||
|
if would_be_full and not remaining_without_assigned:
|
||||||
|
moves.append(NormalMove(square=sq, next_piece=None,
|
||||||
|
claim_quarto=False, final_pass=True))
|
||||||
|
|
||||||
|
return moves
|
||||||
|
|
||||||
|
|
||||||
|
def validate_and_apply_normal_move(state: GameState, move: NormalMove) -> Tuple[bool, Optional[Player]]:
|
||||||
|
"""
|
||||||
|
Server-side: validate a MOVE~N~M relative to GameState and apply it
|
||||||
|
if valid. Returns (valid, winner_after_move). winner_after_move is:
|
||||||
|
- None if no winner yet or draw
|
||||||
|
- Player enum if there is a winner
|
||||||
|
Draw is represented by state.game_over and winner == None.
|
||||||
|
"""
|
||||||
|
if state.game_over:
|
||||||
|
return False, None
|
||||||
|
if state.assigned_piece is None:
|
||||||
|
return False, None
|
||||||
|
if not (0 <= move.square < 16):
|
||||||
|
return False, None
|
||||||
|
if state.board.squares[move.square] is not None:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
assigned = state.assigned_piece
|
||||||
|
|
||||||
|
# Validate markers and next_piece consistency
|
||||||
|
if move.claim_quarto and move.final_pass:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
remaining_without_assigned = state.remaining_pieces - {assigned}
|
||||||
|
|
||||||
|
if move.final_pass:
|
||||||
|
# M=17: allowed only if placing assigned piece fills board and no remaining pieces
|
||||||
|
temp_board = state.board.clone()
|
||||||
|
temp_board.place_piece(move.square, assigned)
|
||||||
|
if not temp_board.is_full():
|
||||||
|
return False, None
|
||||||
|
if remaining_without_assigned:
|
||||||
|
return False, None
|
||||||
|
# Valid final-pass; apply
|
||||||
|
_apply_normal_move_no_rules(state, move)
|
||||||
|
# Per rules: even if a Quarto exists, it's still a draw
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = None
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if move.claim_quarto:
|
||||||
|
# M=16: claim Quarto; no next_piece allowed
|
||||||
|
if move.next_piece is not None:
|
||||||
|
return False, None
|
||||||
|
# Apply placement temporarily and check Quarto
|
||||||
|
temp_board = state.board.clone()
|
||||||
|
temp_board.place_piece(move.square, assigned)
|
||||||
|
has_quarto = detect_quarto(temp_board)
|
||||||
|
if not has_quarto:
|
||||||
|
# Mis-claim: loss for current player
|
||||||
|
_apply_normal_move_no_rules(state, move)
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = state.current_player.other()
|
||||||
|
return True, state.winner
|
||||||
|
else:
|
||||||
|
# Correct claim: current player wins
|
||||||
|
_apply_normal_move_no_rules(state, move)
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = state.current_player
|
||||||
|
return True, state.winner
|
||||||
|
|
||||||
|
# Normal move with next_piece
|
||||||
|
if move.next_piece is None:
|
||||||
|
return False, None
|
||||||
|
if move.next_piece == assigned:
|
||||||
|
return False, None
|
||||||
|
if move.next_piece not in remaining_without_assigned:
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
_apply_normal_move_no_rules(state, move)
|
||||||
|
|
||||||
|
# After a normal move, check if board full and no remaining pieces, but note:
|
||||||
|
# per protocol, last move should have been M=17; if client sent a normal piece,
|
||||||
|
# the server will instead post-process and convert to M=17 + DRAW (spec 8).
|
||||||
|
if state.board.is_full() and not state.remaining_pieces and not state.game_over:
|
||||||
|
# No one claimed; automatic draw per rule 8.
|
||||||
|
state.game_over = True
|
||||||
|
state.winner = None
|
||||||
|
|
||||||
|
return True, state.winner
|
||||||
Loading…
Add table
Reference in a new issue