DarshanScripts's picture
Upload stratego/env/backup/edited_env/Stratego/env.py with huggingface_hub
663bf7c verified
import re, random
from typing import Optional, Dict, Tuple, List, Any
import textarena as ta
class StrategoEnv(ta.Env):
""" A two-player implementation of the board game Stratego """
def __init__(self):
"""
Initialize the environment.
"""
## set up the board items
self.piece_counts = {
'Flag': 1, 'Bomb': 6, 'Spy': 1, 'Scout': 8, 'Miner': 5,
'Sergeant': 4, 'Lieutenant': 4, 'Captain': 4, 'Major': 3,
'Colonel': 2, 'General': 1, 'Marshal': 1
}
self.piece_ranks = {
'Flag': 0, 'Bomb': 11, 'Spy': 1, 'Scout': 2, 'Miner': 3,
'Sergeant': 4, 'Lieutenant': 5, 'Captain': 6, 'Major': 7,
'Colonel': 8, 'General': 9, 'Marshal': 10
}
self.lakes = [(4, 2), (4, 3), (5, 2), (5, 3), (4, 6), (4, 7), (5, 6), (5, 7)]
self.player_pieces = {0: [], 1: []}
self.board = [[None for _ in range(10)] for _ in range(10)]
#(13 Nov 2025) New Comment : to initializes a turn counter, which can be used, when declaring a draw if the game goes on for too long without a winner.
self.turn_count = 0
@property
def terminal_render_keys(self):
return ["rendered_board"]
def reset(self, num_players: int, seed: Optional[int]=None):
""" Reset the environment to start a new game """
self.state = ta.TwoPlayerState(num_players=num_players, seed=seed)
# (13 Nov 2025) New Comment : reset the turn counter at the start of a new game.
self.turn_count = 0
## populate the board
self.board = self._populate_board()
## initialise the game state
rendered_board = self._render_board(player_id=None, full_board=True)
game_state={"board": self.board, "player_pieces": self.player_pieces, "rendered_board": rendered_board}
self.state.reset(game_state=game_state, player_prompt_function=self._generate_player_prompt)
self._observe_current_state()
def _generate_player_prompt(self, player_id: int, game_state: Dict[str, Any]):
"""
Generates the player prompt for the current player.
Args:
player_id (int): The ID of the current player.
game_state (Dict[str, Any]): The current game state.
"""
prompt = (
f"You are Player {player_id} in Stratego.\n"
"Your goal is to capture your opponent's Flag or eliminate all of their movable pieces.\n"
"Your army has been placed for you on the board, including your Flag, Bombs, and other pieces of varying ranks.\n"
"\n"
"### Gameplay Instructions\n"
"1. **Movement Rules:**\n"
" - On your turn, you can move one piece by one step to an adjacent square (up, down, left, or right) that is already occupied with your pieces.\n"
" - Example: A piece can move from A1 to B1 or A1 to A2 if B1 and A2 are not placed with the player's own pieces.\n"
" - If the selected piece is a Bomb or a Flag, it cannot be moved.\n"
# " - **Scout Movement:** Scouts, on the other hand, can move multiple steps in a straight line (horizontally or vertically), but strictly only on one condition.\n"
# " - The condition is that Scouts cannot jump over any piece (your own or your opponent's).\n"
# " - Example: If there is a piece between the Scout and its destination, the Scout cannot move to the destination.\n"
# " - This will be indicated as an invalid move which makes you lose the game.\n"
"2. **Battles:**\n"
" - If you move onto a square occupied by an opponent's piece, then a battle will occur:\n"
" - The piece with the higher rank wins and eliminates the opponent's piece.\n"
" - If the ranks are equal, both pieces are removed from the board.\n"
" - **Special Cases:**\n"
" - Bombs eliminate most attacking pieces except Miners, which defuse Bombs.\n"
" - Spies can defeat the Marshal if the Spy attacks first but lose to all other pieces.\n"
"3. **Strategic Goals:**\n"
" - Identify your opponent's pieces through their movements and battles.\n"
" - Protect your Flag while attempting to capture your opponent's Flag.\n"
" - Use Scouts strategically to gain information about your opponent's pieces and attack weak ones.\n"
"\n"
"### How to Make a Move:\n"
"1. Specify the coordinates of the piece you want to move and its destination.\n"
"2. Use the format: [A0 B0], where A0 is the source position, and B0 is the destination.\n"
" - Example: To move a piece from row 0, column 0 to row 1, column 0, input [A0 B0].\n"
"3. Ensure the destination is valid according to the movement rules above.\n"
"\n"
"### Important Notes:\n"
"- The board will show your pieces and their positions, e.g. MN, MS.\n"
"- The board will also show known positions of your opponent's pieces without revealing their ranks, e.g. ?.\n"
"- Grids with ~ are lakes and cannot be moved onto.\n"
"- As a suggestion, start your game by moving your pieces that are on the front lines to gain information about your opponent's pieces. Player 0 and player 1's frontlines are row D and G respectively.\n"
"\n"
"Here is the current board state:\n"
)
return prompt
def _observe_current_state(self):
"""
Observe the current state of the game and update the state with the rendered board
and gives the available moves for the current player.
"""
player_id = self.state.current_player_id
available_moves = []
for row in range(10):
for col in range(10):
piece = self.board[row][col]
if isinstance(piece, dict) and piece['player'] == player_id:
# Skip immovable pieces
if piece['rank'].lower() in ['bomb', 'flag']:
continue
# Check if this is a scout (can move multiple squares)
is_scout = piece['rank'].lower() == 'scout'
# Check all four directions
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
if is_scout:
# Scout can move multiple squares in this direction
distance = 1
while True:
new_row = row + (dr * distance)
new_col = col + (dc * distance)
# Check if still within board bounds
if not (0 <= new_row < 10 and 0 <= new_col < 10):
break
target = self.board[new_row][new_col]
if target is None:
# Empty square - scout can move here and continue
available_moves.append(f"[{chr(row + 65)}{col} {chr(new_row + 65)}{new_col}]")
distance += 1
elif isinstance(target, dict) and target['player'] != player_id:
# Enemy piece - scout can attack but cannot continue past
available_moves.append(f"[{chr(row + 65)}{col} {chr(new_row + 65)}{new_col}]")
break
else:
# Own piece or other obstacle - scout cannot move here or past
break
else:
# Regular piece - can only move one square
new_row, new_col = row + dr, col + dc
if 0 <= new_row < 10 and 0 <= new_col < 10:
target = self.board[new_row][new_col]
if (target is None or
(isinstance(target, dict) and target['player'] != player_id)):
available_moves.append(f"[{chr(row + 65)}{col} {chr(new_row + 65)}{new_col}]")
# new comment(13 Nov 2025) Store the number of available moves in the game state.
# This is critical for detecting a "no moves remaining" loss or a stalemate/draw.
num_available_moves = len(available_moves)
self.state.game_state[f'available_moves_p{player_id}'] = num_available_moves
#Previous code lines for the observation message
self.state.add_observation(
message=f"Current Board:\n\n{self._render_board(player_id=player_id, full_board=False)}\nAvailable Moves: " + ", ".join(available_moves),
observation_type=ta.ObservationType.GAME_BOARD
)
def _populate_board(self):
"""
Populates the board with pieces for each player strategically.
"""
for player in range(2):
# Define rows for each player
back_rows = range(0, 2) if player == 0 else range(8, 10)
front_rows = range(2, 4) if player == 0 else range(7, 9)
all_rows = range(0, 4) if player == 0 else range(6, 10)
# Place the Flag strategically
while True:
row = random.choice(back_rows)
col = random.randint(0, 9)
if (row, col) not in self.lakes and self.board[row][col] is None:
self.board[row][col] = {'rank': 'Flag', 'player': player}
self.player_pieces[player].append((row, col))
flag_position = (row, col)
break
# Place Bombs around the Flag if possible
bombs_to_place = self.piece_counts['Bomb']
bomb_positions = [
(flag_position[0] + dr, flag_position[1] + dc)
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)] # Adjacent cells
if 0 <= flag_position[0] + dr < 10 and 0 <= flag_position[1] + dc < 10
]
for pos in bomb_positions:
if bombs_to_place > 0 and self.board[pos[0]][pos[1]] is None and pos not in self.lakes:
self.board[pos[0]][pos[1]] = {'rank': 'Bomb', 'player': player}
self.player_pieces[player].append(pos)
bombs_to_place -= 1
# Place remaining Bombs at the frontline
for _ in range(bombs_to_place):
while True:
row = random.choice(front_rows)
col = random.randint(0, 9)
if self.board[row][col] is None and (row, col) not in self.lakes:
self.board[row][col] = {'rank': 'Bomb', 'player': player}
self.player_pieces[player].append((row, col))
break
# Place other pieces randomly
for piece, count in self.piece_counts.items():
if piece in ['Flag', 'Bomb']:
continue # Skip already placed pieces
for _ in range(count):
while True:
row = random.choice(all_rows)
col = random.randint(0, 9)
if self.board[row][col] is None and (row, col) not in self.lakes:
self.board[row][col] = {'rank': piece, 'player': player}
self.player_pieces[player].append((row, col))
break
# Place the lakes
for row, col in self.lakes:
self.board[row][col] = "~"
return self.board
def _render_board(self, player_id, full_board: bool = False):
"""
Renders the board state with fixed-width formatting for uniform alignment.
Args:
player_id (int): The player viewing the board.
full_board (bool): Whether to render the full board or just the visible pieces.
"""
# Define abbreviations for each piece
piece_abbreviations = {
'Flag': 'FL', 'Bomb': 'BM', 'Spy': 'SP', 'Scout': 'SC', 'Miner': 'MN',
'Sergeant': 'SG', 'Lieutenant': 'LT', 'Captain': 'CP', 'Major': 'MJ',
'Colonel': 'CL', 'General': 'GN', 'Marshal': 'MS'
}
res = []
column_headers = " " + " ".join([f"{i:>3}" for i in range(10)]) # Align column numbers
res.append(column_headers + "\n")
for row in range(10):
row_label = chr(row + 65) # Convert row index to a letter (A, B, C, ...)
row_render = [f"{row_label:<3}"] # Add row label with fixed width
for col in range(10):
if (row, col) in self.lakes:
cell = " ~ " # Lakes
elif self.board[row][col] is None:
cell = " . " # Empty space
else:
piece = self.board[row][col]
abbreviation = piece_abbreviations[piece['rank']]
if full_board:
cell = f" {abbreviation.lower() if piece['player'] == 0 else abbreviation.upper()} " # Full board view
elif piece['player'] == player_id:
displayed_piece = abbreviation.upper()
cell = f" {displayed_piece} "
else:
cell = " ? " # Hidden opponent piece
row_render.append(cell)
res.append("".join(row_render) + "\n")
return "".join(res)
def step(self, action: str) -> Tuple[bool, ta.Info]:
# new comment(13 Nov 2025) Increment turn counter
self.turn_count += 1
player_id = self.state.current_player_id
# new comment(13 Nov 2025) This block fixes Bug #3 (No Moves Remaining).
# We check if the player has 0 moves *before* parsing their action.
# This prevents an 'Invalid action' penalty when they have no valid moves.
num_moves = self.state.game_state.get(f'available_moves_p{player_id}', 1) # Default to 1 to avoid error
if num_moves == 0:
# The current player cannot move. Check if the *other* player can.
if self._has_movable_pieces(1 - player_id):
# Opponent still has pieces, so current player loses.
reason = f"Player {player_id} has no valid moves remaining. Player {1 - player_id} wins!"
self.state.set_winner(player_id=(1 - player_id), reason=reason)
else:
# Neither player can move. This is a stalemate (draw).
reason = "Stalemate: Neither player has any valid moves remaining. The game is a draw."
self.state.set_winner(player_id=-1, reason=reason) # -1 means draw
# Immediately end the game
return self.state.step()
# previous code for executing the action
""" Execute an action in the environment """
player_id = self.state.current_player_id
## update the observation
self.state.add_observation(from_id=player_id, to_id=player_id, message=action, observation_type=ta.ObservationType.PLAYER_ACTION)
## action search pattern
action_search_pattern = re.compile(r"\[([A-J])([0-9]) ([A-J])([0-9])\]", re.IGNORECASE)
match = action_search_pattern.search(action)
if match is None:
reason=f"Invalid action format. Player {player_id} did not input a move in the format [A0 B0]."
self.state.set_invalid_move(reason=reason)
try:
self.state.game_info[player_id]["invalid_move"] = True
except Exception:
pass
self.state.set_winner(player_id=(1 - player_id), reason=reason)
return self.state.step()
else:
src_row, src_col, dest_row, dest_col = match.groups()
src_row, dest_row = src_row.upper(), dest_row.upper()
source = f"{src_row}{src_col}"
dest = f"{dest_row}{dest_col}"
src_row, src_col = ord(src_row) - 65, int(src_col)
dest_row, dest_col = ord(dest_row) - 65, int(dest_col)
## check if the source and destination are valid
if self._validate_move(player_id=player_id, src_row=src_row, src_col=src_col, dest_row=dest_row, dest_col=dest_col):
attacking_piece = self.board[src_row][src_col]
target_piece = self.board[dest_row][dest_col]
if target_piece is None:
## move to an empty square
self.board[dest_row][dest_col] = attacking_piece
self.board[src_row][src_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
self.player_pieces[player_id].append((dest_row, dest_col))
## add the observation to both players separately
message=f"You have moved your piece from {source} to {dest}."
self.state.add_observation(from_id=-1, to_id=player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
message=f"Player {player_id} has moved a piece from {source} to {dest}."
self.state.add_observation(from_id=-1, to_id=1-player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
else:
## battle
attacking_rank = self.piece_ranks[attacking_piece['rank']]
target_rank = self.piece_ranks[target_piece['rank']]
if attacking_rank == target_rank:
## both pieces are removed
self.board[src_row][src_col] = None
self.board[dest_row][dest_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
self.player_pieces[1 - player_id].remove((dest_row, dest_col))
## add the observation to both players separately
message=f"You have moved your piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the ranks are the same, both pieces lost."
self.state.add_observation(from_id=-1, to_id=player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
message=f"Player {player_id} has moved a piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the ranks are the same, both pieces lost."
self.state.add_observation(from_id=-1, to_id=1 - player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
elif target_piece['rank'] == 'Bomb':
if attacking_piece['rank'] == 'Miner':
## Miner defuses the bomb
self.board[dest_row][dest_col] = attacking_piece
self.board[src_row][src_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
self.player_pieces[player_id].append((dest_row, dest_col))
# (12 Nov 2025)👇 ADD THIS LINE: Remove the Bomb's coordinate from the defender's list
self.player_pieces[1 - player_id].remove((dest_row, dest_col))
## add the observation to both players separately
message=f"You have moved your piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As miners can defuse bombs, you won the battle."
self.state.add_observation(from_id=-1, to_id=player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
message=f"Player {player_id} has moved a piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As miners can defuse bombs, you lost the battle."
self.state.add_observation(from_id=-1, to_id=1-player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
else:
## attacking piece is destroyed
self.board[src_row][src_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
## add the observation to both players separately
message=f"You have moved your piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is not a miner, you lost the battle."
self.state.add_observation(from_id=-1, to_id=player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
message=f"Player {player_id} has moved a piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is not a miner, you won the battle."
self.state.add_observation(from_id=-1, to_id=1-player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
elif target_piece['rank'] == 'Flag':
self.board[dest_row][dest_col] = attacking_piece
self.board[src_row][src_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
self.player_pieces[player_id].append((dest_row, dest_col))
self.player_pieces[1 - player_id].remove((dest_row, dest_col))
## game over
# Changes below: for the Winner setting(12 Nov 2025)
reason=f"Player {player_id} has captured the opponent's flag!"
self.state.set_winner(player_id=player_id,reason=reason)
# Immediately end the game and return the final state
return self.state.step()
elif attacking_piece['rank'] == 'Spy' and target_piece['rank'] == 'Marshal':
## Spy beats Marshal only if spy attacks first
self.board[dest_row][dest_col] = attacking_piece
self.board[src_row][src_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
self.player_pieces[player_id].append((dest_row, dest_col))
self.player_pieces[1 - player_id].remove((dest_row, dest_col))
## add the observation to both players separately
message=f"You have moved your piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is a spy and the destination is a marshall, you won the battle."
self.state.add_observation(from_id=-1, to_id=player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
message=f"Player {player_id} has moved a piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is a spy and the destination is a marshall, you lost the battle."
self.state.add_observation(from_id=-1, to_id=1-player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
elif attacking_rank > target_rank:
## attacker wins
self.board[dest_row][dest_col] = attacking_piece
self.board[src_row][src_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
self.player_pieces[player_id].append((dest_row, dest_col))
self.player_pieces[1 - player_id].remove((dest_row, dest_col))
## add the observation to both players separately
message=f"You have moved your piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is a higher rank than the destination, you won the battle."
self.state.add_observation(from_id=-1, to_id=player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
message=f"Player {player_id} has moved a piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is a higher rank than the destination, you lost the battle."
self.state.add_observation(from_id=-1, to_id=1-player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
else:
## defender wins
self.board[src_row][src_col] = None
self.player_pieces[player_id].remove((src_row, src_col))
## add the observation to both players separately
message=f"You have moved your piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is a lower rank than the destination, you lost the battle."
self.state.add_observation(from_id=-1, to_id=player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
message=f"Player {player_id} has moved a piece from {source} to {dest}. The attacking piece was {attacking_piece['rank']} and the destination piece was {target_piece['rank']}. As the attacker is a lower rank than the destination, you won the battle."
self.state.add_observation(from_id=-1, to_id=1-player_id, message=message, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
else:
# invalid move -> immediate loss
try:
self.state.game_info[player_id]["invalid_move"] = True
except Exception:
pass
self.state.set_winner(player_id=(1 - player_id), reason="Illegal move.")
return self.state.step()
# new comment(13 Nov 2025) This block checks for win/draw conditions
# *after* a move has been successfully made.
# 1. Check for Elimination Win (opponent has no movable pieces left)
winner = self._check_winner()
if winner is not None:
reason=f"Player {winner} wins! Player {1 - winner} has no more movable pieces."
self.state.set_winner(player_id=winner, reason=reason)
# 2. Check for Stalemate (Draw)
elif self._check_stalemate():
reason = "Stalemate: Neither player has any valid moves remaining. The game is a draw."
self.state.set_winner(player_id=-1, reason=reason) # -1 means draw
# 3. Check for Turn Limit (Draw) - This fixes Bug #2
elif self.turn_count > 1000: # You can adjust this number
reason = f"Game ended in a draw (turn limit of {self.turn_count} moves exceeded)."
self.state.set_winner(player_id=-1, reason=reason)
## update the rendered board
self.state.game_state["rendered_board"] = self._render_board(player_id=player_id, full_board=True)
result = self.state.step()
# We must observe the *next* player's state *before* returning
if not result[0]: # If game is not done
self._observe_current_state()
return result
def _validate_move(self, player_id, src_row, src_col, dest_row, dest_col):
"""
Validates the move based on the game rules.
Args:
player_id (int): The ID of the player making the move.
src_row (int): The row of the source position.
src_col (int): The column of the source position.
dest_row (int): The row of the destination position.
dest_col (int): The column of the destination position.
"""
if not (0 <= src_row < 10 and 0 <= src_col < 10 and 0 <= dest_row < 10 and 0 <= dest_col < 10):
reason=f"Invalid action format. Player {player_id} did not input valid coordinates."
self.state.set_invalid_move(reason=reason)
return False
if self.board[src_row][src_col] is None or self.board[src_row][src_col]['player'] != player_id:
reason=f"Invalid action format. Player {player_id} must move one of their own pieces."
self.state.set_invalid_move(reason=reason)
return False
if abs(src_row - dest_row) + abs(src_col - dest_col) != 1 and self.board[src_row][src_col]['rank'].lower() == 'scout':
## check if there's a piece in between the source and destination
if src_row == dest_row:
for col in range(min(src_col, dest_col) + 1, max(src_col, dest_col)):
if self.board[src_row][col] is not None:
reason=f"Invalid action format. Player {player_id} cannot move a scout through other pieces."
self.state.set_invalid_move(reason=reason)
return False
elif src_col == dest_col:
for row in range(min(src_row, dest_row) + 1, max(src_row, dest_row)):
if self.board[row][src_col] is not None:
reason=f"Invalid action format. Player {player_id} cannot move a scout through other pieces."
self.state.set_invalid_move(reason=reason)
return False
else:
reason=f"Invalid action format. Player {player_id} cannot move a scout diagonally."
self.state.set_invalid_move(reason=reason)
return False
if abs(src_row - dest_row) + abs(src_col - dest_col) != 1 and self.board[src_row][src_col]['rank'].lower() != 'scout':
## ! - by right, only scouts can move more than one square at a time but we are not implementing that yet
reason=f"Invalid action format. Pieces, apart from scouts, can only move one square at a time."
self.state.set_invalid_move(reason=reason)
return False
if self.board[dest_row][dest_col] is not None:
if (dest_row, dest_col) in self.lakes:
reason=f"Invalid action format. Player {player_id} cannot move into the lake."
self.state.set_invalid_move(reason=reason)
return False
elif self.board[dest_row][dest_col]['player'] == player_id:
reason=f"Invalid action format. Player {player_id} cannot move onto their own piece."
self.state.set_invalid_move(reason=reason)
return False
if self.board[src_row][src_col]['rank'].lower() in ['bomb','flag']:
reason=f"Invalid action format. Player {player_id} cannot move a bomb or flag."
self.state.set_invalid_move(reason=reason)
return False
return True
#Working on below for new code to deal with Non Type error
# def _check_winner(self):
# """
# determine which player has no more pieces that are not bombs or flags.
# """
# for player in range(2):
# if all([self.board[row][col]['rank'] in ['Bomb', 'Flag'] for row, col in self.player_pieces[player]]):
# return 1 - player
# return None
def _check_winner(self):
"""
Determine which player has no more pieces that are not bombs or flags.
FIX: Skips coordinates that are empty on the board (already removed).
"""
for player in range(2):
# NEW LOGIC: Filter out None/empty squares before checking rank
movable_pieces_remain = any([
self.board[row][col] is not None and self.board[row][col]['rank'] not in ['Bomb', 'Flag']
for row, col in self.player_pieces[player]
])
# Original logic: If NO movable pieces remain, the opponent (1 - player) wins.
if not movable_pieces_remain:
return 1 - player
return None
# new comment(13 Nov 2025) These are new helper methods for win/draw checking.
def _has_movable_pieces(self, player_id: int) -> bool:
"""Helper function to check if a player has any movable pieces left."""
# This uses the same logic as your _check_winner, just isolated
return any([
self.board[row][col] is not None and self.board[row][col]['rank'] not in ['Bomb', 'Flag']
for row, col in self.player_pieces[player_id]
])
def _check_stalemate(self) -> bool:
"""
Checks for two types of stalemate (draw):
1. Neither player has any movable pieces left.
2. Both players have 0 available moves (e.g., all pieces are blocked).
"""
# 1. Check if both players are eliminated (e.g., last two pieces trade)
p0_has_movable = self._has_movable_pieces(0)
p1_has_movable = self._has_movable_pieces(1)
if not p0_has_movable and not p1_has_movable:
return True # Both players lost all pieces
# 2. Check if both players are blocked (0 moves)
# This relies on _observe_current_state being called
p0_move_count = self.state.game_state.get('available_moves_p0', 1) # Default to 1
p1_move_count = self.state.game_state.get('available_moves_p1', 1)
if p0_move_count == 0 and p1_move_count == 0:
return True # Both players are blocked
return False