DarshanScripts commited on
Commit
da234aa
·
verified ·
1 Parent(s): ba81392

Upload stratego\env\backup\edited_env\StrategoDuel\env.py with huggingface_hub

Browse files
stratego//env//backup//edited_env//StrategoDuel//env.py ADDED
@@ -0,0 +1,637 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import random
3
+ from typing import Optional, Dict, Tuple, List, Any
4
+
5
+ import textarena as ta
6
+
7
+
8
+ class StrategoDuelEnv(ta.Env):
9
+ """
10
+ Stratego Duel (6x6) Environment for TextArena.
11
+
12
+ - 2 Players: Player 0 (bottom), Player 1 (top)
13
+ - Board: 6x6 (rows A-F, columns 0-5)
14
+ - Lakes (blocked): (2,2), (2,3), (3,2), (3,3)
15
+ - Win by: Capturing opponent Flag or eliminating all movable pieces
16
+ - Special rules:
17
+ * Bombs & Flags cannot move
18
+ * Scout can move multiple squares in straight lines
19
+ * Miner can defuse Bomb
20
+ * Spy kills Marshal on attack
21
+ * Two-Squares Rule: cannot move back & forth between two squares more than 3 times in a row
22
+ """
23
+
24
+ def __init__(self):
25
+ # Piece counts (reduced for 6x6)
26
+ self.piece_counts: Dict[str, int] = {
27
+ "Flag": 1,
28
+ "Bomb": 2,
29
+ "Spy": 1,
30
+ "Scout": 1,
31
+ "Miner": 1,
32
+ "General": 1,
33
+ "Marshal": 1,
34
+ }
35
+
36
+ # Piece strength (higher = stronger)
37
+ self.piece_ranks: Dict[str, int] = {
38
+ "Flag": 0,
39
+ "Bomb": 11,
40
+ "Spy": 1,
41
+ "Scout": 2,
42
+ "Miner": 3,
43
+ "General": 9,
44
+ "Marshal": 10,
45
+ }
46
+
47
+ # Lake positions (blocked cells)
48
+ self.lakes: List[Tuple[int, int]] = [(2, 2), (2, 3), (3, 2), (3, 3)]
49
+
50
+ # Track piece positions for each player: {player_id: [(row, col), ...]}
51
+ self.player_pieces: Dict[int, List[Tuple[int, int]]] = {0: [], 1: []}
52
+
53
+ # 6x6 board, None / "~" / piece dict
54
+ self.board: List[List[Optional[Dict[str, Any]]]] = [
55
+ [None for _ in range(6)] for _ in range(6)
56
+ ]
57
+
58
+ # Turn counter (for turn limit)
59
+ self.turn_count: int = 0
60
+
61
+ # --- Two-Squares Rule (Repetition) ---
62
+ # Stores last move for each player as (sr, sc, dr, dc)
63
+ self.last_move: Dict[int, Optional[Tuple[int, int, int, int]]] = {
64
+ 0: None,
65
+ 1: None,
66
+ }
67
+ # Counts consecutive back-and-forth repetitions per player
68
+ self.repetition_count: Dict[int, int] = {0: 0, 1: 0}
69
+
70
+ # TextArena uses this key to render the final board in terminal
71
+ @property
72
+ def terminal_render_keys(self) -> List[str]:
73
+ return ["rendered_board"]
74
+
75
+ # -------------------------------------------------------------------------
76
+ # Core TextArena interface
77
+ # -------------------------------------------------------------------------
78
+ def reset(self, num_players: int, seed: Optional[int] = None):
79
+ """Reset environment for a new game."""
80
+ self.state = ta.TwoPlayerState(num_players=num_players, seed=seed)
81
+ self.turn_count = 0
82
+
83
+ # Reset repetition tracking
84
+ self.last_move = {0: None, 1: None}
85
+ self.repetition_count = {0: 0, 1: 0}
86
+
87
+ # Clear board / piece tracking
88
+ self.board = [[None for _ in range(6)] for _ in range(6)]
89
+ self.player_pieces = {0: [], 1: []}
90
+
91
+ # Place pieces
92
+ self.board = self._populate_board()
93
+
94
+ # Render initial full board (for logging / God mode)
95
+ rendered_board = self._render_board(player_id=None, full_board=True)
96
+
97
+ game_state = {
98
+ "board": self.board,
99
+ "player_pieces": self.player_pieces,
100
+ "rendered_board": rendered_board,
101
+ }
102
+
103
+ # Set initial game state & prompt function
104
+ self.state.reset(
105
+ game_state=game_state, player_prompt_function=self._generate_player_prompt
106
+ )
107
+
108
+ # Provide first observation for Player 0
109
+ self._observe_current_state()
110
+
111
+ def step(self, action: str) -> Tuple[bool, ta.Info]:
112
+ """Process a player's action: validate, apply move, resolve battle, check win."""
113
+ self.turn_count += 1
114
+ pid = self.state.current_player_id
115
+
116
+ # If no moves were available in previous observation, end game
117
+ if self.state.game_state.get(f"available_moves_p{pid}", 1) == 0:
118
+ # Opponent wins if they still have movable pieces
119
+ winner = 1 - pid if self._has_movable_pieces(1 - pid) else -1
120
+ self.state.set_winner(player_id=winner, reason="No moves/Stalemate")
121
+ return self.state.step()
122
+
123
+ # Log the player's raw action
124
+ self.state.add_observation(
125
+ from_id=pid,
126
+ to_id=pid,
127
+ message=action,
128
+ observation_type=ta.ObservationType.PLAYER_ACTION,
129
+ )
130
+
131
+ # Parse move: [A0 B0]
132
+ match = re.search(r"\[([A-F])([0-5]) ([A-F])([0-5])\]", action, re.IGNORECASE)
133
+ if not match:
134
+ self.state.set_invalid_move(
135
+ reason=f"Invalid format '{action}'. Use [A0 B0]."
136
+ )
137
+ # Illegal move ends game: opponent wins
138
+ try:
139
+ self.state.game_info[pid]["invalid_move"] = True
140
+ except Exception:
141
+ pass
142
+ self.state.set_winner(player_id=1 - pid, reason="Illegal move (format).")
143
+ return self.state.step()
144
+ else:
145
+ sr = ord(match.group(1).upper()) - 65
146
+ sc = int(match.group(2))
147
+ dr = ord(match.group(3).upper()) - 65
148
+ dc = int(match.group(4))
149
+
150
+ if not self._validate_move(pid, sr, sc, dr, dc):
151
+ self.state.set_invalid_move(
152
+ reason="Illegal move according to Stratego Duel rules."
153
+ )
154
+ # Illegal move ends game: opponent wins
155
+ try:
156
+ self.state.game_info[pid]["invalid_move"] = True
157
+ except Exception:
158
+ pass
159
+ self.state.set_winner(player_id=1 - pid, reason="Illegal move.")
160
+ return self.state.step()
161
+
162
+ # Validate basic movement rules
163
+ if self._validate_move(pid, sr, sc, dr, dc):
164
+ # --- Two-Squares Rule (Back-and-forth repetition) ---
165
+ is_repetition = False
166
+ last = self.last_move[pid]
167
+
168
+ # If current move is exact reverse of last move (A->B, then B->A)
169
+ if last is not None:
170
+ last_sr, last_sc, last_dr, last_dc = last
171
+ if sr == last_dr and sc == last_dc and dr == last_sr and dc == last_sc:
172
+ is_repetition = True
173
+
174
+ if is_repetition:
175
+ self.repetition_count[pid] += 1
176
+ else:
177
+ # New path resets repetition count
178
+ self.repetition_count[pid] = 0
179
+
180
+ self.last_move[pid] = (sr, sc, dr, dc)
181
+
182
+ # If exceeded repetition limit, move is illegal
183
+ if self.repetition_count[pid] >= 3:
184
+ self.state.set_invalid_move(
185
+ reason="Illegal Repetition: Cannot move back and forth more than 3 consecutive times."
186
+ )
187
+ return self.state.step()
188
+
189
+ attacker = self.board[sr][sc]
190
+ target = self.board[dr][dc]
191
+
192
+ # --- Empty Target: Simple Move ---
193
+ if target is None:
194
+ self.board[dr][dc], self.board[sr][sc] = attacker, None
195
+ self.player_pieces[pid].remove((sr, sc))
196
+ self.player_pieces[pid].append((dr, dc))
197
+
198
+ self.state.add_observation(
199
+ from_id=-1,
200
+ to_id=pid,
201
+ message="Move success.",
202
+ observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION,
203
+ )
204
+ self.state.add_observation(
205
+ from_id=-1,
206
+ to_id=1 - pid,
207
+ message="Opponent moved.",
208
+ observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION,
209
+ )
210
+
211
+ # --- Battle ---
212
+ else:
213
+ # Any battle breaks the repetition chain
214
+ self.repetition_count[pid] = 0
215
+ self.last_move[pid] = None
216
+
217
+ att_rank = self.piece_ranks[attacker["rank"]]
218
+ tgt_rank = self.piece_ranks[target["rank"]]
219
+
220
+ # 1) Equal ranks → both die
221
+ if att_rank == tgt_rank:
222
+ self.board[sr][sc] = None
223
+ self.board[dr][dc] = None
224
+ self.player_pieces[pid].remove((sr, sc))
225
+ self.player_pieces[1 - pid].remove((dr, dc))
226
+
227
+ # 2) Target is Bomb
228
+ elif target["rank"] == "Bomb":
229
+ if attacker["rank"] == "Miner":
230
+ # Miner defuses Bomb and moves in
231
+ self.board[dr][dc], self.board[sr][sc] = attacker, None
232
+ self.player_pieces[pid].remove((sr, sc))
233
+ self.player_pieces[pid].append((dr, dc))
234
+ self.player_pieces[1 - pid].remove((dr, dc))
235
+ else:
236
+ # Attacker dies
237
+ self.board[sr][sc] = None
238
+ self.player_pieces[pid].remove((sr, sc))
239
+
240
+ # 3) Target is Flag → Attacker wins game
241
+ elif target["rank"] == "Flag":
242
+ self.state.set_winner(player_id=pid, reason="Flag Captured!")
243
+ return self.state.step()
244
+
245
+ # 4) Spy vs Marshal (Spy attacks Marshal → Spy wins)
246
+ elif attacker["rank"] == "Spy" and target["rank"] == "Marshal":
247
+ self.board[dr][dc], self.board[sr][sc] = attacker, None
248
+ self.player_pieces[pid].remove((sr, sc))
249
+ self.player_pieces[pid].append((dr, dc))
250
+ self.player_pieces[1 - pid].remove((dr, dc))
251
+
252
+ # 5) Normal compare: higher rank wins
253
+ elif att_rank > tgt_rank:
254
+ # Attacker wins, moves in
255
+ self.board[dr][dc], self.board[sr][sc] = attacker, None
256
+ self.player_pieces[pid].remove((sr, sc))
257
+ self.player_pieces[pid].append((dr, dc))
258
+ self.player_pieces[1 - pid].remove((dr, dc))
259
+ else:
260
+ # Defender wins, attacker dies
261
+ self.board[sr][sc] = None
262
+ self.player_pieces[pid].remove((sr, sc))
263
+
264
+ msg = "Battle occurred."
265
+ self.state.add_observation(
266
+ from_id=-1,
267
+ to_id=pid,
268
+ message=msg,
269
+ observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION,
270
+ )
271
+ self.state.add_observation(
272
+ from_id=-1,
273
+ to_id=1 - pid,
274
+ message=msg,
275
+ observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION,
276
+ )
277
+
278
+ # --- Global Win / Draw Conditions ---
279
+ winner = self._check_winner()
280
+ if winner is not None:
281
+ self.state.set_winner(player_id=winner, reason="Elimination.")
282
+ elif self._check_stalemate():
283
+ self.state.set_winner(player_id=-1, reason="Stalemate.")
284
+ elif self.turn_count > 1000:
285
+ self.state.set_winner(player_id=-1, reason="Turn limit.")
286
+
287
+ # Update full-board render into game_state (for terminal rendering)
288
+ self.state.game_state["rendered_board"] = self._render_board(
289
+ player_id=pid, full_board=True
290
+ )
291
+
292
+ # Let TextArena advance the state
293
+ done, info = self.state.step()
294
+
295
+ # If game is not over, give next player a fresh observation
296
+ if not done:
297
+ self._observe_current_state()
298
+
299
+ return done, info
300
+
301
+ # -------------------------------------------------------------------------
302
+ # Observation / Prompt / Rendering
303
+ # -------------------------------------------------------------------------
304
+ def _generate_player_prompt(self, player_id: int, game_state: Dict[str, Any]) -> str:
305
+ """Generate instruction prompt for the current player (LLM)."""
306
+ prompt = (
307
+ f"You are Player {player_id} in Stratego Duel (6x6).\n"
308
+ "Goal: Capture the enemy Flag or eliminate all of the opponent's movable pieces.\n"
309
+ "\n"
310
+ "### BOARD\n"
311
+ "- Grid: 6 rows (A-F) x 6 columns (0-5).\n"
312
+ "- Lakes (~) are blocked and cannot be entered.\n"
313
+ "\n"
314
+ "### MOVE FORMAT\n"
315
+ "- You MUST output exactly one move in the format: [Source Destination]\n"
316
+ "- Example: `[A0 B0]` moves the piece at A0 to B0.\n"
317
+ "\n"
318
+ "### PIECE RULES\n"
319
+ "- Flag (FL): Cannot move. If captured, you lose.\n"
320
+ "- Bomb (BM): Cannot move. Defeats any attacker except Miner.\n"
321
+ "- Spy (SP): If Spy attacks Marshal (MS), Spy wins.\n"
322
+ "- Scout (SC): Can move any number of empty squares in a straight line.\n"
323
+ "- Miner (MN): Can defuse Bombs.\n"
324
+ "- General (GN), Marshal (MS): Stronger ranks defeat weaker ones.\n"
325
+ "- Battles: Higher rank wins. Same rank → both pieces are removed.\n"
326
+ "\n"
327
+ "### TWO-SQUARES RULE\n"
328
+ "- You may NOT move a piece back and forth between the same two squares\n"
329
+ " more than 3 times in a row (e.g., [A0 B0], [B0 A0], [A0 B0], [B0 A0] is illegal).\n"
330
+ "\n"
331
+ "Here is the current board:\n"
332
+ )
333
+ return prompt
334
+
335
+ def _observe_current_state(self):
336
+ """
337
+ Compute all available moves for the current player and
338
+ send a formatted board + move list observation.
339
+ """
340
+ BOARD_SIZE = 6
341
+ player_id = self.state.current_player_id
342
+ available_moves: List[str] = []
343
+
344
+ for row in range(BOARD_SIZE):
345
+ for col in range(BOARD_SIZE):
346
+ piece = self.board[row][col]
347
+
348
+ # Only consider current player's pieces
349
+ if not (isinstance(piece, dict) and piece["player"] == player_id):
350
+ continue
351
+
352
+ rank = piece["rank"].lower()
353
+ # Bombs & Flags cannot move
354
+ if rank in ["bomb", "flag"]:
355
+ continue
356
+
357
+ is_scout = rank == "scout"
358
+
359
+ # 4-directional movement
360
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
361
+ if is_scout:
362
+ # Scout: move multiple squares until blocked
363
+ distance = 1
364
+ while True:
365
+ new_row = row + dr * distance
366
+ new_col = col + dc * distance
367
+ if not (0 <= new_row < 6 and 0 <= new_col < 6):
368
+ break
369
+ if (new_row, new_col) in self.lakes:
370
+ break
371
+
372
+ target = self.board[new_row][new_col]
373
+
374
+ # Empty cell: can move, keep going
375
+ if target is None:
376
+ move_str = (
377
+ f"[{chr(row + 65)}{col} "
378
+ f"{chr(new_row + 65)}{new_col}]"
379
+ )
380
+ available_moves.append(move_str)
381
+ distance += 1
382
+ # Lake marker (string "~") – shouldn't normally happen, but be safe
383
+ elif target == "~":
384
+ break
385
+ # Enemy piece: can attack, but stop afterwards
386
+ elif isinstance(target, dict) and target["player"] != player_id:
387
+ move_str = (
388
+ f"[{chr(row + 65)}{col} "
389
+ f"{chr(new_row + 65)}{new_col}]"
390
+ )
391
+ available_moves.append(move_str)
392
+ break
393
+ # Own piece or anything else: blocked
394
+ else:
395
+ break
396
+ else:
397
+ # Normal piece: single-step move
398
+ new_row = row + dr
399
+ new_col = col + dc
400
+ if not (0 <= new_row < 6 and 0 <= new_col < 6):
401
+ continue
402
+ if (new_row, new_col) in self.lakes:
403
+ continue
404
+
405
+ target = self.board[new_row][new_col]
406
+ # Empty or enemy piece is allowed
407
+ if target is None or (
408
+ isinstance(target, dict) and target["player"] != player_id
409
+ ):
410
+ move_str = (
411
+ f"[{chr(row + 65)}{col} "
412
+ f"{chr(new_row + 65)}{new_col}]"
413
+ )
414
+ available_moves.append(move_str)
415
+
416
+ # Save number of available moves into game_state
417
+ self.state.game_state[f"available_moves_p{player_id}"] = len(available_moves)
418
+
419
+ # Observation message: board in ``` block + move list
420
+ obs_msg = (
421
+ "Current Board:\n"
422
+ "```\n"
423
+ f"{self._render_board(player_id=player_id, full_board=False)}"
424
+ "```\n"
425
+ f"Available Moves: {', '.join(available_moves)}"
426
+ )
427
+
428
+ self.state.add_observation(
429
+ message=obs_msg, observation_type=ta.ObservationType.GAME_BOARD
430
+ )
431
+
432
+ def _render_board(self, player_id: Optional[int], full_board: bool = False) -> str:
433
+ """
434
+ Render the 6x6 board as a text grid.
435
+
436
+ - Column header: 0 1 2 3 4 5
437
+ - Rows labeled A-F
438
+ - Lakes: ~
439
+ - Empty: .
440
+ - full_board=True → show all pieces with owner (P0 lower-case, P1 upper-case)
441
+ - full_board=False → fog of war (only show current player's ranks, others '?')
442
+ """
443
+ BOARD_SIZE = 6
444
+ abbr = {
445
+ "Flag": "FL",
446
+ "Bomb": "BM",
447
+ "Spy": "SP",
448
+ "Scout": "SC",
449
+ "Miner": "MN",
450
+ "General": "GN",
451
+ "Marshal": "MS",
452
+ }
453
+
454
+ lines: List[str] = []
455
+
456
+ # Column headers with 3-character spacing
457
+ header = " " + " ".join(f"{i:>3}" for i in range(BOARD_SIZE))
458
+ lines.append(header + "\n")
459
+
460
+ for r in range(BOARD_SIZE):
461
+ row_label = chr(r + 65) # A-F
462
+ row_cells: List[str] = [f"{row_label:<3}"] # left aligned
463
+
464
+ for c in range(BOARD_SIZE):
465
+ if (r, c) in self.lakes:
466
+ cell = " ~ "
467
+ else:
468
+ cell_data = self.board[r][c]
469
+ if cell_data is None:
470
+ cell = " . "
471
+ elif cell_data == "~":
472
+ cell = " ~ "
473
+ else:
474
+ # piece dict
475
+ code = abbr.get(cell_data["rank"], "??")
476
+ owner = cell_data["player"]
477
+
478
+ if full_board:
479
+ # P0 lower-case, P1 upper-case for debugging
480
+ cell = f" {code.lower() if owner == 0 else code.upper()} "
481
+ else:
482
+ # Fog of war
483
+ if player_id is not None and owner == player_id:
484
+ cell = f" {code.upper()} "
485
+ else:
486
+ cell = " ? "
487
+ row_cells.append(cell)
488
+
489
+ lines.append("".join(row_cells) + "\n")
490
+
491
+ return "".join(lines)
492
+
493
+ # -------------------------------------------------------------------------
494
+ # Game logic helpers
495
+ # -------------------------------------------------------------------------
496
+ def _populate_board(self) -> List[List[Optional[Dict[str, Any]]]]:
497
+ """
498
+ Place all pieces on the board for both players.
499
+
500
+ - Player 0: rows 0-1
501
+ - Player 1: rows 4-5
502
+ - Flag placed randomly in those rows
503
+ - Bombs placed preferably around the Flag
504
+ - Remaining pieces placed randomly in own rows (not on lakes)
505
+ """
506
+ for player in range(2):
507
+ rows = range(0, 2) if player == 0 else range(4, 6)
508
+
509
+ # 1) Place Flag
510
+ while True:
511
+ r = random.choice(list(rows))
512
+ c = random.randint(0, 5)
513
+ if (r, c) not in self.lakes and self.board[r][c] is None:
514
+ self.board[r][c] = {"rank": "Flag", "player": player}
515
+ self.player_pieces[player].append((r, c))
516
+ flag_pos = (r, c)
517
+ break
518
+
519
+ # 2) Place Bombs (prefer near Flag)
520
+ bombs_remaining = self.piece_counts["Bomb"]
521
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
522
+ if bombs_remaining <= 0:
523
+ break
524
+ br = flag_pos[0] + dr
525
+ bc = flag_pos[1] + dc
526
+ if (
527
+ 0 <= br < 6
528
+ and 0 <= bc < 6
529
+ and br in rows
530
+ and (br, bc) not in self.lakes
531
+ and self.board[br][bc] is None
532
+ ):
533
+ self.board[br][bc] = {"rank": "Bomb", "player": player}
534
+ self.player_pieces[player].append((br, bc))
535
+ bombs_remaining -= 1
536
+
537
+ # 3) Build remaining piece list
538
+ all_pieces: List[str] = []
539
+ for rank, count in self.piece_counts.items():
540
+ if rank == "Flag":
541
+ continue
542
+ if rank == "Bomb":
543
+ # Only leftover bombs (if some not placed around flag)
544
+ if bombs_remaining > 0:
545
+ all_pieces.extend(["Bomb"] * bombs_remaining)
546
+ else:
547
+ all_pieces.extend([rank] * count)
548
+
549
+ # 4) Randomly place the remaining pieces
550
+ for rank in all_pieces:
551
+ while True:
552
+ r = random.choice(list(rows))
553
+ c = random.randint(0, 5)
554
+ if (r, c) not in self.lakes and self.board[r][c] is None:
555
+ self.board[r][c] = {"rank": rank, "player": player}
556
+ self.player_pieces[player].append((r, c))
557
+ break
558
+
559
+ # Mark lakes explicitly on the board
560
+ for r, c in self.lakes:
561
+ self.board[r][c] = "~"
562
+
563
+ return self.board
564
+
565
+ def _validate_move(self, pid: int, sr: int, sc: int, dr: int, dc: int) -> bool:
566
+ """Check if a move from (sr, sc) to (dr, dc) by player pid is legal."""
567
+ # Bounds
568
+ if not (0 <= sr < 6 and 0 <= sc < 6 and 0 <= dr < 6 and 0 <= dc < 6):
569
+ return False
570
+
571
+ # Must move own piece
572
+ if self.board[sr][sc] is None or self.board[sr][sc]["player"] != pid:
573
+ return False
574
+
575
+ # Cannot move into lakes
576
+ if (dr, dc) in self.lakes:
577
+ return False
578
+
579
+ # Cannot capture own piece
580
+ if (
581
+ self.board[dr][dc] is not None
582
+ and self.board[dr][dc] != "~"
583
+ and isinstance(self.board[dr][dc], dict)
584
+ and self.board[dr][dc]["player"] == pid
585
+ ):
586
+ return False
587
+
588
+ rank = self.board[sr][sc]["rank"]
589
+
590
+ # Bombs & Flags cannot move
591
+ if rank in ["Bomb", "Flag"]:
592
+ return False
593
+
594
+ # Scout: can move multiple squares in straight line
595
+ if rank == "Scout":
596
+ # Must be in same row or column
597
+ if sr != dr and sc != dc:
598
+ return False
599
+ # Path-blocking checks can be added here if desired.
600
+ # For now we assume _observe_current_state only generates valid paths.
601
+ return True
602
+
603
+ # Normal pieces: one-step orthogonal move
604
+ if abs(sr - dr) + abs(sc - dc) != 1:
605
+ return False
606
+
607
+ return True
608
+
609
+ def _check_winner(self) -> Optional[int]:
610
+ """
611
+ Check if a player has no movable pieces left.
612
+ Returns:
613
+ - 0 or 1 if that player has WON
614
+ - None otherwise
615
+ """
616
+ for p in range(2):
617
+ # If player p has NO movable pieces, opponent wins
618
+ if not self._has_movable_pieces(p):
619
+ return 1 - p
620
+ return None
621
+
622
+ def _has_movable_pieces(self, pid: int) -> bool:
623
+ """True if player pid has at least one non-Bomb/Flag piece on the board."""
624
+ for (r, c) in self.player_pieces[pid]:
625
+ cell = self.board[r][c]
626
+ if (
627
+ cell
628
+ and cell != "~"
629
+ and isinstance(cell, dict)
630
+ and cell["rank"] not in ["Bomb", "Flag"]
631
+ ):
632
+ return True
633
+ return False
634
+
635
+ def _check_stalemate(self) -> bool:
636
+ """Stalemate if neither player has any movable pieces."""
637
+ return not self._has_movable_pieces(0) and not self._has_movable_pieces(1)