quarto_gen/quarto/shared/game.py
2026-02-13 21:25:05 +01:00

277 lines
9.3 KiB
Python

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