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