From 459041bb85bc21d91b2e1f868abb894052a9a652 Mon Sep 17 00:00:00 2001 From: Hans Goor Date: Fri, 13 Feb 2026 20:40:40 +0100 Subject: [PATCH] The vibe is real --- quarto/__init__.py | 0 quarto/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 193 bytes quarto/client/__init__.py | 3 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 252 bytes quarto/client/__pycache__/ai.cpython-314.pyc | Bin 0 -> 9574 bytes .../client/__pycache__/core.cpython-314.pyc | Bin 0 -> 11071 bytes .../client/__pycache__/main.cpython-314.pyc | Bin 0 -> 316 bytes quarto/client/__pycache__/net.cpython-314.pyc | Bin 0 -> 3753 bytes .../__pycache__/protocol.cpython-314.pyc | Bin 0 -> 4568 bytes quarto/client/ai.py | 208 +++++++++++++ quarto/client/core.py | 215 ++++++++++++++ quarto/client/main.py | 4 + quarto/client/net.py | 47 +++ quarto/client/protocol.py | 82 ++++++ quarto/server/__init__.py | 3 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 263 bytes .../server/__pycache__/core.cpython-314.pyc | Bin 0 -> 8743 bytes .../server/__pycache__/main.cpython-314.pyc | Bin 0 -> 316 bytes .../server/__pycache__/match.cpython-314.pyc | Bin 0 -> 3010 bytes quarto/server/__pycache__/net.cpython-314.pyc | Bin 0 -> 5330 bytes .../__pycache__/protocol.cpython-314.pyc | Bin 0 -> 4151 bytes .../__pycache__/session.cpython-314.pyc | Bin 0 -> 13514 bytes quarto/server/core.py | 159 ++++++++++ quarto/server/main.py | 4 + quarto/server/match.py | 35 +++ quarto/server/net.py | 70 +++++ quarto/server/protocol.py | 73 +++++ quarto/server/session.py | 238 +++++++++++++++ quarto/shared/__init__.py | 3 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 287 bytes .../shared/__pycache__/game.cpython-314.pyc | Bin 0 -> 13923 bytes quarto/shared/game.py | 277 ++++++++++++++++++ 32 files changed, 1421 insertions(+) create mode 100644 quarto/__init__.py create mode 100644 quarto/__pycache__/__init__.cpython-314.pyc create mode 100644 quarto/client/__init__.py create mode 100644 quarto/client/__pycache__/__init__.cpython-314.pyc create mode 100644 quarto/client/__pycache__/ai.cpython-314.pyc create mode 100644 quarto/client/__pycache__/core.cpython-314.pyc create mode 100644 quarto/client/__pycache__/main.cpython-314.pyc create mode 100644 quarto/client/__pycache__/net.cpython-314.pyc create mode 100644 quarto/client/__pycache__/protocol.cpython-314.pyc create mode 100644 quarto/client/ai.py create mode 100644 quarto/client/core.py create mode 100644 quarto/client/main.py create mode 100644 quarto/client/net.py create mode 100644 quarto/client/protocol.py create mode 100644 quarto/server/__init__.py create mode 100644 quarto/server/__pycache__/__init__.cpython-314.pyc create mode 100644 quarto/server/__pycache__/core.cpython-314.pyc create mode 100644 quarto/server/__pycache__/main.cpython-314.pyc create mode 100644 quarto/server/__pycache__/match.cpython-314.pyc create mode 100644 quarto/server/__pycache__/net.cpython-314.pyc create mode 100644 quarto/server/__pycache__/protocol.cpython-314.pyc create mode 100644 quarto/server/__pycache__/session.cpython-314.pyc create mode 100644 quarto/server/core.py create mode 100644 quarto/server/main.py create mode 100644 quarto/server/match.py create mode 100644 quarto/server/net.py create mode 100644 quarto/server/protocol.py create mode 100644 quarto/server/session.py create mode 100644 quarto/shared/__init__.py create mode 100644 quarto/shared/__pycache__/__init__.cpython-314.pyc create mode 100644 quarto/shared/__pycache__/game.cpython-314.pyc create mode 100644 quarto/shared/game.py diff --git a/quarto/__init__.py b/quarto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quarto/__pycache__/__init__.cpython-314.pyc b/quarto/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f59b9775b729eba486c873e826b7f2e11234f89c GIT binary patch literal 193 zcmdPqGAx!=F q_{_Y_lK6PNg34PQHo5sJr8%i~MXW&UKn^MfF+MRfGBOr116cq%1v4!G literal 0 HcmV?d00001 diff --git a/quarto/client/__init__.py b/quarto/client/__init__.py new file mode 100644 index 0000000..3a7338f --- /dev/null +++ b/quarto/client/__init__.py @@ -0,0 +1,3 @@ +""" +Client package for Quarto AI bot. +""" diff --git a/quarto/client/__pycache__/__init__.cpython-314.pyc b/quarto/client/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c8367ea5a75ce46326658b8ee15623cc3e95858a GIT binary patch literal 252 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 literal 0 HcmV?d00001 diff --git a/quarto/client/__pycache__/main.cpython-314.pyc b/quarto/client/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..77add6b7696f5484c610634aadd1b7a475ca04f1 GIT binary patch literal 316 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 literal 0 HcmV?d00001 diff --git a/quarto/client/ai.py b/quarto/client/ai.py new file mode 100644 index 0000000..ba325f1 --- /dev/null +++ b/quarto/client/ai.py @@ -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 diff --git a/quarto/client/core.py b/quarto/client/core.py new file mode 100644 index 0000000..4a3dac2 --- /dev/null +++ b/quarto/client/core.py @@ -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() diff --git a/quarto/client/main.py b/quarto/client/main.py new file mode 100644 index 0000000..7cca4f1 --- /dev/null +++ b/quarto/client/main.py @@ -0,0 +1,4 @@ +from quarto.client.core import main + +if __name__ == "__main__": + main() diff --git a/quarto/client/net.py b/quarto/client/net.py new file mode 100644 index 0000000..6fee17d --- /dev/null +++ b/quarto/client/net.py @@ -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 diff --git a/quarto/client/protocol.py b/quarto/client/protocol.py new file mode 100644 index 0000000..d1a5255 --- /dev/null +++ b/quarto/client/protocol.py @@ -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}" diff --git a/quarto/server/__init__.py b/quarto/server/__init__.py new file mode 100644 index 0000000..ad10b54 --- /dev/null +++ b/quarto/server/__init__.py @@ -0,0 +1,3 @@ +""" +Server package for Quarto tournament server. +""" diff --git a/quarto/server/__pycache__/__init__.cpython-314.pyc b/quarto/server/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..453a1a169cf6bac1298fcf6268c20c657078871f GIT binary patch literal 263 zcmYjML8`(q6ioU>KLtHQz?BHe>hC@tK~WLhJ&JvnQf=x>s_4oacn+`Btrrk_0h6le zY~Bnr%0s&a%MAT*^KwuAUMfzjBjdlh|8UOhAmA-(Ir#v%!%XS` literal 0 HcmV?d00001 diff --git a/quarto/server/__pycache__/core.cpython-314.pyc b/quarto/server/__pycache__/core.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1bb68ae724012c659ecec14e69574779ef947317 GIT binary patch literal 8743 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 literal 0 HcmV?d00001 diff --git a/quarto/server/__pycache__/match.cpython-314.pyc b/quarto/server/__pycache__/match.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10b0304f6f95ee701cf41a88df369213a8e3e8ed GIT binary patch literal 3010 zcmb_eOHUj}5bk;Hd(DDb9^thH2YZdh+6l@-phV;evO~xuv90AXTJJWn@-VaR>BVwR z9HPi}qF_g|AgB0flvD1$kY8YABs(KpiDc#A8y2=U@+nn411t!cBubB_y1Kf$r@H!^ zue;|!JVKycI=4`~s}S-S8=FP-6lo(1$~>7Qx-?9NBrbKJE)UCGhFTd`hE%Q!n>wrw z1$ba6$b&;69s--BYs2B82#<7-BsoO%KqJwEeX=(*kLq$4Nr#?bGcCE%oMqWO$BVY* zj6ho}(2Hf7R$T1^RNT;~CG5+YuJ%Q_WKwX6eNNdNW$&9sYVo``ttb6&NE;tPd!9@X zF6jg!j&MbnAPTvWmqoPWv6>S^QA+XlcASsfzDCWE@hljE>E4zC7WFkYG%5~d$;?0Z8lx)ezBZm z+%~4EmGRUJObEHkz(?{ky`{@;%rN|nQ^Rl@x0lvi-4TU&oP%On6(VchPf;u#7S#rr ziAV%0So}nUH_kz0o{VoMi1dkW0;#dKY~!kLB%^^%ZM!~HJ!Z>OCtYM&8cAymO>V#d zWB@k8e1_3np=eFJ>S;TF!PN{CD#)wqP;*LTwVT!AkdR>%ts;jdAKbBI&nTAL41V+E zVb6)5-@KxJ7k!v)U!445@?P@QE9&9b{``VyPp!{_TGJ6(bWv#eOY5MaB~BB z0a_M<28*LOfT9LOT4D*PJSd2uSsnJ_E?~d&3W#}9A)0pNDT*a?9jn`1w7dZQ9{@ZF z3Bn5jhBwwgL3oa$byw+^45=)a6=N&!9pr3cw0a(Y#M@98U=gjPh3MkxgW0ikK&uPq z-(%FM=J{01Gg6Z(BC+eD7v1?PG4AYQwWUC4f${i7!NQ_GJn zYc+=-BpNC~a-ivIc0EL(`_$D__hap=vG%38wODs03|%w$L!d?g<*S}eA{-MuI95%9$5Yj)4*7Ov`-S%No zZ(|)CMA?E;3O4TFKxy2E2vWePx;fo;y$J+h&N~3F_i(fK_8i3+c9l(fDM|lfjC{84 zbpU=@29}0r8-iU3@Le_?SbQH?f`m^WxZz zvHQuRtI4DHlE<#gdsrHqug?kmUdygWNPSZ!T7_j~pqqoWg9F;iNeSgZ^u72_G~=!c zG^a!~FXutl0Z#j(vmKnO=q`x_ms}c0x=Y?Jh}KK!?pHsKe@b^P|0{KkZyjm@Qh?mF z0%f(4(~U(R9s8hY7rA*5^2v?SS$mGUkuy27Oh07IW~>V{RmE`Z1Q^`fDt7Y*iHv?M z60=2a>#`D0K1kFrMsGy#CpuRXowwdtOB}0cg695u79jQQ&xjOH{+6g;SD=0&7;qtb z1xT(N=DH~j#Mg!4-tzNyl*a8B&$2Sd@F`ya-`uSZ-**Tlza}0G#nSbPj=lJWJ9e d()pMq9+9@+NqUu}|0HddK(mzGARxrb{stD=HNgM? literal 0 HcmV?d00001 diff --git a/quarto/server/__pycache__/net.cpython-314.pyc b/quarto/server/__pycache__/net.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11b8d30beef26abbcaa14420c8b69ffdd0f3b090 GIT binary patch literal 5330 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 literal 0 HcmV?d00001 diff --git a/quarto/server/__pycache__/session.cpython-314.pyc b/quarto/server/__pycache__/session.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78fbc3e92f3516823f2383dbd48ebbf1ddd4d102 GIT binary patch literal 13514 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 P_= 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() diff --git a/quarto/server/main.py b/quarto/server/main.py new file mode 100644 index 0000000..25c09da --- /dev/null +++ b/quarto/server/main.py @@ -0,0 +1,4 @@ +from quarto.server.core import main + +if __name__ == "__main__": + main() diff --git a/quarto/server/match.py b/quarto/server/match.py new file mode 100644 index 0000000..563edb1 --- /dev/null +++ b/quarto/server/match.py @@ -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 diff --git a/quarto/server/net.py b/quarto/server/net.py new file mode 100644 index 0000000..2a197de --- /dev/null +++ b/quarto/server/net.py @@ -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() diff --git a/quarto/server/protocol.py b/quarto/server/protocol.py new file mode 100644 index 0000000..25322c5 --- /dev/null +++ b/quarto/server/protocol.py @@ -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}" diff --git a/quarto/server/session.py b/quarto/server/session.py new file mode 100644 index 0000000..51ce15b --- /dev/null +++ b/quarto/server/session.py @@ -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) diff --git a/quarto/shared/__init__.py b/quarto/shared/__init__.py new file mode 100644 index 0000000..548f586 --- /dev/null +++ b/quarto/shared/__init__.py @@ -0,0 +1,3 @@ +""" +Shared package for Quarto game logic used by both client and server. +""" diff --git a/quarto/shared/__pycache__/__init__.cpython-314.pyc b/quarto/shared/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f2596a08d1114b99347a4bf8e40df4caeba2973 GIT binary patch literal 287 zcmYjMK~BRk5VS)DRU~-BN}MXB_yZM3Zd7U!;%K??;#y6!!FGzmi68I|E<7k#K7f=D zV5hBME<2+cX=ZP(E^`*~_4HxyCL#W8u5?UW4>kifwHc(a;>j0|O;L+{T75QSu`wJ;Gl~R7PA=ULL9;59A zcBpZW6*a=g7WCqk+;*hG*iiiW1v&FS{xa^625WJk!jEHxl(x1(%A)D|>3m1Jt?<^J V$JX?zPhb9=an6S<;}bWD@)yHaRXzX! literal 0 HcmV?d00001 diff --git a/quarto/shared/__pycache__/game.cpython-314.pyc b/quarto/shared/__pycache__/game.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e68ef1c1dca15eac1a66a3f0ade3dfe546e8030 GIT binary patch literal 13923 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 literal 0 HcmV?d00001 diff --git a/quarto/shared/game.py b/quarto/shared/game.py new file mode 100644 index 0000000..08f0887 --- /dev/null +++ b/quarto/shared/game.py @@ -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