DarshanScripts commited on
Commit
06029fc
·
verified ·
1 Parent(s): fbc3ccd

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

Browse files
stratego//env//backup//edited_env//StrategoCustom//env.py ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import re
3
+ from typing import Any, Dict, Optional, Tuple, List
4
+ import textarena as ta
5
+
6
+ # ==============================================================================
7
+ # CHANGE LOG
8
+ # ==============================================================================
9
+ # Date: 12 Dec 2025
10
+ # Changes:
11
+ # 1. Added support for board sizes 4x4 and 5x5.
12
+ # 2. Updated lake generation: 4x4 and 5x5 boards have NO lakes.
13
+ # 3. Updated piece count logic to handle small boards (fewer pieces, smaller setup zones).
14
+ # 4. Retained all previous fixes (Double Turn, Draw Logic, etc.).
15
+ # ==============================================================================
16
+
17
+ class StrategoCustomEnv(ta.Env):
18
+ """
19
+ Custom Stratego environment supporting board sizes 4–9.
20
+ """
21
+
22
+ def __init__(self, size: int = 9):
23
+ # [CHANGE] Updated range to allow 4 and 5
24
+ if size < 4 or size > 9:
25
+ raise ValueError("StrategoCustomEnv supports only board sizes 4–9.")
26
+
27
+ self.size = size
28
+
29
+ # Rank mapping
30
+ self.piece_ranks: Dict[str, int] = {
31
+ "Flag": 0, "Bomb": 11, "Spy": 1, "Scout": 2, "Miner": 3,
32
+ "Sergeant": 4, "Lieutenant": 5, "Captain": 6, "Major": 7,
33
+ "Colonel": 8, "General": 9, "Marshal": 10,
34
+ }
35
+
36
+ self.board: List[List[Optional[Dict[str, Any]]]] = []
37
+ self.lakes: List[Tuple[int, int]] = []
38
+ self.player_pieces: Dict[int, List[Tuple[int, int]]] = {0: [], 1: []}
39
+
40
+ self.last_move: Dict[int, Optional[Tuple[int, int, int, int]]] = {0: None, 1: None}
41
+ self.repetition_count: Dict[int, int] = {0: 0, 1: 0}
42
+ self.turn_count: int = 0
43
+
44
+ @property
45
+ def terminal_render_keys(self):
46
+ return ["rendered_board"]
47
+
48
+ def reset(self, num_players: int, seed: Optional[int] = None):
49
+ """Reset the environment state."""
50
+ self.state = ta.TwoPlayerState(num_players=num_players, seed=seed)
51
+ self.turn_count = 0
52
+ self.last_move = {0: None, 1: None}
53
+ self.repetition_count = {0: 0, 1: 0}
54
+
55
+ self.board = [[None for _ in range(self.size)] for _ in range(self.size)]
56
+ self.lakes = self._generate_lakes()
57
+ self.player_pieces = {0: [], 1: []}
58
+
59
+ self._populate_board()
60
+
61
+ rendered = self._render_board(player_id=None, full_board=True)
62
+ state_info = {
63
+ "board": self.board,
64
+ "player_pieces": self.player_pieces,
65
+ "rendered_board": rendered,
66
+ }
67
+
68
+ self.state.reset(
69
+ game_state=state_info,
70
+ player_prompt_function=self._generate_player_prompt,
71
+ )
72
+
73
+ self._observe_current_state(player_id=0)
74
+
75
+ def step(self, action: str) -> Tuple[bool, ta.Info]:
76
+ """
77
+ Execute a player's action with strict turn switching logic.
78
+
79
+ IMPORTANT:
80
+ - Invalid moves TERMINATE the game
81
+ - Invalid moves DO NOT declare a winner
82
+ - Termination reason is stored explicitly for debugging/benchmarking
83
+ """
84
+ player_id = self.state.current_player_id
85
+ self.turn_count += 1
86
+
87
+ # ------------------------------------------------------------------
88
+ # 0. Pre-check: current player has no legal moves
89
+ # ------------------------------------------------------------------
90
+ if self.state.game_state.get(f"available_moves_p{player_id}", 1) == 0:
91
+ if self._has_movable_pieces(1 - player_id):
92
+ self.state.set_winner(
93
+ player_id=(1 - player_id),
94
+ reason="Opponent has no legal moves."
95
+ )
96
+ else:
97
+ self.state.set_winner(
98
+ player_id=-1,
99
+ reason="Stalemate (No moves for either player)."
100
+ )
101
+ return self.state.step()
102
+
103
+ # Log raw action
104
+ self.state.add_observation(
105
+ from_id=player_id,
106
+ to_id=player_id,
107
+ message=action,
108
+ observation_type=ta.ObservationType.PLAYER_ACTION
109
+ )
110
+
111
+ # ------------------------------------------------------------------
112
+ # 1. Parse & Validate move format
113
+ # ------------------------------------------------------------------
114
+ action_search_pattern = re.compile(
115
+ r"\[([A-J])([0-9]) ([A-J])([0-9])\]",
116
+ re.IGNORECASE
117
+ )
118
+ match = action_search_pattern.search(action)
119
+
120
+ if match is None:
121
+ # [ADDED] Explicit invalid termination metadata
122
+ self.state.game_state["termination"] = "invalid"
123
+ self.state.game_state["invalid_reason"] = f"Invalid format: {action}"
124
+
125
+ self.state.set_invalid_move(
126
+ reason=f"Invalid format: {action}"
127
+ )
128
+ return self.state.step()
129
+
130
+ src_row_char, src_col_str, dst_row_char, dst_col_str = match.groups()
131
+ src_row = ord(src_row_char.upper()) - 65
132
+ src_col = int(src_col_str)
133
+ dest_row = ord(dst_row_char.upper()) - 65
134
+ dest_col = int(dst_col_str)
135
+
136
+ # ------------------------------------------------------------------
137
+ # 1.b Semantic validation (rules, ownership, movement, etc.)
138
+ # ------------------------------------------------------------------
139
+ if not self._validate_move(player_id, src_row, src_col, dest_row, dest_col):
140
+ # [ADDED] Mark termination as invalid instead of declaring a winner
141
+ self.state.game_state["termination"] = "invalid"
142
+ self.state.game_state["invalid_reason"] = "Illegal move"
143
+
144
+ self.state.set_invalid_move(reason="Illegal move")
145
+ try:
146
+ self.state.game_info[player_id]["invalid_move"] = True
147
+ except Exception:
148
+ pass
149
+ self.state.set_winner(player_id=(1 - player_id), reason="Illegal move.")
150
+ return self.state.step()
151
+
152
+ # ------------------------------------------------------------------
153
+ # 1.c Two-squares repetition rule
154
+ # ------------------------------------------------------------------
155
+ if self._check_repetition(player_id, src_row, src_col, dest_row, dest_col):
156
+ # [ADDED] Explicit invalid termination (repetition)
157
+ self.state.game_state["termination"] = "invalid"
158
+ self.state.game_state["invalid_reason"] = "Two-squares repetition rule violation"
159
+
160
+ self.state.set_invalid_move(
161
+ reason="Illegal move: Two-Squares Rule violation."
162
+ )
163
+ return self.state.step()
164
+
165
+ # ------------------------------------------------------------------
166
+ # 2. Execute Move (Board Update / Battle Resolution)
167
+ # ------------------------------------------------------------------
168
+ attacking_piece = self.board[src_row][src_col]
169
+ target_piece = self.board[dest_row][dest_col]
170
+
171
+ # Reset repetition tracking on capture
172
+ if target_piece is not None:
173
+ self.repetition_count[player_id] = 0
174
+ self.last_move[player_id] = None
175
+ else:
176
+ self.last_move[player_id] = (
177
+ src_row, src_col, dest_row, dest_col
178
+ )
179
+
180
+ if target_piece is None:
181
+ # Normal move to empty square
182
+ self.board[dest_row][dest_col] = attacking_piece
183
+ self.board[src_row][src_col] = None
184
+ self.player_pieces[player_id].remove((src_row, src_col))
185
+ self.player_pieces[player_id].append((dest_row, dest_col))
186
+
187
+ src_str = f"{src_row_char.upper()}{src_col}"
188
+ dst_str = f"{dst_row_char.upper()}{dest_col}"
189
+ self._send_action_descriptions(
190
+ player_id,
191
+ f"You have moved your piece from {src_str} to {dst_str}.",
192
+ f"Player {player_id} has moved a piece from {src_str} to {dst_str}."
193
+ )
194
+ else:
195
+ # Battle
196
+ src_str = f"{src_row_char.upper()}{src_col}"
197
+ dst_str = f"{dst_row_char.upper()}{dest_col}"
198
+ self._resolve_battle(
199
+ player_id,
200
+ attacking_piece,
201
+ target_piece,
202
+ (src_row, src_col),
203
+ (dest_row, dest_col),
204
+ src_str,
205
+ dst_str
206
+ )
207
+
208
+ # ------------------------------------------------------------------
209
+ # 3. Check Win / Draw conditions (NORMAL termination only)
210
+ # ------------------------------------------------------------------
211
+ winner = self._check_winner()
212
+ if winner is not None:
213
+ self.state.set_winner(
214
+ player_id=winner,
215
+ reason=f"Player {winner} wins! Opponent eliminated."
216
+ )
217
+ elif self.turn_count > 200:
218
+ self.state.set_winner(
219
+ player_id=-1,
220
+ reason="Turn limit reached (200)."
221
+ )
222
+
223
+ # ------------------------------------------------------------------
224
+ # 4. Finalize state & switch turn
225
+ # ------------------------------------------------------------------
226
+ self.state.game_state["rendered_board"] = self._render_board(
227
+ player_id=player_id,
228
+ full_board=True
229
+ )
230
+
231
+ result = self.state.step()
232
+
233
+ if not result[0]:
234
+ next_player_id = 1 - player_id
235
+ self._observe_current_state(player_id=next_player_id)
236
+
237
+ return result
238
+
239
+ # --------------------------------------------------------------------------
240
+ # Observation Logic
241
+ # --------------------------------------------------------------------------
242
+
243
+ def _observe_current_state(self, player_id: int = None):
244
+ """Calculates valid moves and updates observation."""
245
+ if player_id is None:
246
+ player_id = self.state.current_player_id
247
+
248
+ moves = []
249
+ for r in range(self.size):
250
+ for c in range(self.size):
251
+ piece = self.board[r][c]
252
+ if isinstance(piece, dict) and piece["player"] == player_id:
253
+ if piece["rank"] in ["Bomb", "Flag"]: continue
254
+
255
+ is_scout = (piece["rank"] == "Scout")
256
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
257
+ dist = 1
258
+ while True:
259
+ nr, nc = r + dr*dist, c + dc*dist
260
+ if not (0 <= nr < self.size and 0 <= nc < self.size): break
261
+ if (nr, nc) in self.lakes: break
262
+
263
+ target = self.board[nr][nc]
264
+ if target is None or (isinstance(target, dict) and target["player"] != player_id):
265
+ moves.append(f"[{chr(65+r)}{c} {chr(65+nr)}{nc}]")
266
+
267
+ if target is not None: break
268
+ if not is_scout: break
269
+ dist += 1
270
+
271
+ self.state.game_state[f"available_moves_p{player_id}"] = len(moves)
272
+
273
+ msg = (
274
+ "Current Board:\n\n"
275
+ f"{self._render_board(player_id, full_board=False)}\n"
276
+ "Available Moves: " + (", ".join(moves) if moves else "NONE")
277
+ )
278
+
279
+ self.state.add_observation(
280
+ message=msg,
281
+ to_id=player_id,
282
+ observation_type=ta.ObservationType.GAME_BOARD
283
+ )
284
+
285
+ # --------------------------------------------------------------------------
286
+ # Win/Draw Logic
287
+ # --------------------------------------------------------------------------
288
+
289
+ def _check_winner(self) -> Optional[int]:
290
+ """
291
+ Check win condition. Returns None if BOTH are blocked (Draw).
292
+ """
293
+ p0_can_move = self._has_movable_pieces(0)
294
+ p1_can_move = self._has_movable_pieces(1)
295
+
296
+ if not p0_can_move and not p1_can_move:
297
+ return None
298
+
299
+ if not p0_can_move:
300
+ return 1
301
+
302
+ if not p1_can_move:
303
+ return 0
304
+
305
+ return None
306
+
307
+ # --------------------------------------------------------------------------
308
+ # Helpers
309
+ # --------------------------------------------------------------------------
310
+
311
+ def _render_board(self, player_id: Optional[int], full_board: bool = False) -> str:
312
+ abbrev = {
313
+ "Flag": "FL", "Bomb": "BM", "Spy": "SP", "Scout": "SC",
314
+ "Miner": "MN", "Sergeant": "SG", "Lieutenant": "LT",
315
+ "Captain": "CP", "Major": "MJ", "Colonel": "CL",
316
+ "General": "GN", "Marshal": "MS",
317
+ }
318
+ lines = [" " + " ".join(f"{i:>3}" for i in range(self.size)) + "\n"]
319
+ for r in range(self.size):
320
+ row_str = f"{chr(65+r):<3}"
321
+ for c in range(self.size):
322
+ if (r, c) in self.lakes:
323
+ row_str += " ~ "
324
+ continue
325
+
326
+ cell = self.board[r][c]
327
+ if cell is None:
328
+ row_str += " . "
329
+ continue
330
+
331
+ # cell contains a piece dict
332
+ if full_board:
333
+ # show EVERYTHING
334
+ ab = abbrev[cell['rank']]
335
+ if cell['player'] == 0:
336
+ row_str += f" {ab.lower()} "
337
+ else:
338
+ row_str += f" {ab.upper()} "
339
+ else:
340
+ code = abbrev[cell["rank"]]
341
+ owner = cell["player"]
342
+ if full_board or (player_id is not None and owner == player_id):
343
+ row_str += f" {code.upper()} "
344
+ else:
345
+ row_str += " ? "
346
+ lines.append(row_str + "\n")
347
+ return "".join(lines)
348
+
349
+ def _has_movable_pieces(self, pid: int) -> bool:
350
+ for (r, c) in self.player_pieces[pid]:
351
+ cell = self.board[r][c]
352
+ if isinstance(cell, dict) and cell["rank"] not in ["Bomb", "Flag"]:
353
+ return True
354
+ return False
355
+
356
+ def _resolve_battle(self, player_id: int, attacker: Dict, target: Dict,
357
+ src: Tuple[int, int], dst: Tuple[int, int],
358
+ src_str: str, dst_str: str):
359
+ src_r, src_c = src
360
+ dst_r, dst_c = dst
361
+ att_rank_val = self.piece_ranks[attacker['rank']]
362
+ def_rank_val = self.piece_ranks[target['rank']]
363
+
364
+ self.board[src_r][src_c] = None
365
+ self.player_pieces[player_id].remove(src)
366
+ outcome = ""
367
+ reason_msg = ""
368
+
369
+ if target['rank'] == 'Flag':
370
+ self.board[dst_r][dst_c] = attacker
371
+ self.player_pieces[player_id].append(dst)
372
+ self.player_pieces[1 - player_id].remove(dst)
373
+ self.state.set_winner(player_id=player_id, reason=f"Player {player_id} captured the Flag!")
374
+ return
375
+
376
+ elif att_rank_val == def_rank_val:
377
+ self.board[dst_r][dst_c] = None
378
+ self.player_pieces[1 - player_id].remove(dst)
379
+ outcome = "draw"
380
+ reason_msg = "Rank tie. Both pieces lost."
381
+
382
+ elif target['rank'] == 'Bomb':
383
+ if attacker['rank'] == 'Miner':
384
+ self.board[dst_r][dst_c] = attacker
385
+ self.player_pieces[player_id].append(dst)
386
+ self.player_pieces[1 - player_id].remove(dst)
387
+ outcome = "win"
388
+ reason_msg = "Miner defused Bomb."
389
+ else:
390
+ outcome = "loss"
391
+ reason_msg = "Piece destroyed by Bomb."
392
+
393
+ elif attacker['rank'] == 'Spy' and target['rank'] == 'Marshal':
394
+ self.board[dst_r][dst_c] = attacker
395
+ self.player_pieces[player_id].append(dst)
396
+ self.player_pieces[1 - player_id].remove(dst)
397
+ outcome = "win"
398
+ reason_msg = "Spy defeated Marshal."
399
+
400
+ elif att_rank_val > def_rank_val:
401
+ self.board[dst_r][dst_c] = attacker
402
+ self.player_pieces[player_id].append(dst)
403
+ self.player_pieces[1 - player_id].remove(dst)
404
+ outcome = "win"
405
+ reason_msg = f"High rank ({attacker['rank']}) beat ({target['rank']})."
406
+
407
+ else:
408
+ outcome = "loss"
409
+ reason_msg = f"Low rank ({attacker['rank']}) lost to ({target['rank']})."
410
+
411
+ self._send_action_descriptions(player_id,
412
+ f"Battle! {src_str} to {dst_str}. {reason_msg}",
413
+ f"Battle! Opponent moved {src_str} to {dst_str}. {reason_msg}"
414
+ )
415
+
416
+ def _send_action_descriptions(self, player_id, msg_self, msg_opp):
417
+ self.state.add_observation(from_id=-1, to_id=player_id, message=msg_self, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
418
+ self.state.add_observation(from_id=-1, to_id=1-player_id, message=msg_opp, observation_type=ta.ObservationType.GAME_ACTION_DESCRIPTION)
419
+
420
+ def _validate_move(self, player_id: int, src_r: int, src_c: int, dst_r: int, dst_c: int) -> bool:
421
+ if not (0 <= src_r < self.size and 0 <= src_c < self.size and 0 <= dst_r < self.size and 0 <= dst_c < self.size):
422
+ self.state.set_invalid_move("Out of bounds.")
423
+ return False
424
+ piece = self.board[src_r][src_c]
425
+ if not (isinstance(piece, dict) and piece["player"] == player_id):
426
+ self.state.set_invalid_move("Not your piece.")
427
+ return False
428
+ if piece["rank"] in ["Bomb", "Flag"]:
429
+ self.state.set_invalid_move("Immobile piece.")
430
+ return False
431
+ if (dst_r, dst_c) in self.lakes:
432
+ self.state.set_invalid_move("Lake.")
433
+ return False
434
+ dst = self.board[dst_r][dst_c]
435
+ if isinstance(dst, dict) and dst["player"] == player_id:
436
+ self.state.set_invalid_move("Friendly fire.")
437
+ return False
438
+ if piece["rank"] == "Scout":
439
+ if not (src_r == dst_r or src_c == dst_c):
440
+ self.state.set_invalid_move("Scout not straight.")
441
+ return False
442
+ # Check path
443
+ dr = 0 if src_r == dst_r else (1 if dst_r > src_r else -1)
444
+ dc = 0 if src_c == dst_c else (1 if dst_c > src_c else -1)
445
+ curr_r, curr_c = src_r + dr, src_c + dc
446
+ while (curr_r, curr_c) != (dst_r, dst_c):
447
+ if self.board[curr_r][curr_c] is not None:
448
+ self.state.set_invalid_move("Scout blocked.")
449
+ return False
450
+ curr_r += dr
451
+ curr_c += dc
452
+ else:
453
+ if abs(src_r - dst_r) + abs(src_c - dst_c) != 1:
454
+ self.state.set_invalid_move("Invalid distance.")
455
+ return False
456
+ return True
457
+
458
+ def _check_repetition(self, player_id, src_r, src_c, dst_r, dst_c) -> bool:
459
+ last = self.last_move[player_id]
460
+ if last is not None:
461
+ l_sr, l_sc, l_dr, l_dc = last
462
+ if src_r == l_dr and src_c == l_dc and dst_r == l_sr and dst_c == l_sc:
463
+ self.repetition_count[player_id] += 1
464
+ else:
465
+ self.repetition_count[player_id] = 0
466
+ return self.repetition_count[player_id] >= 3
467
+
468
+ def _generate_lakes(self) -> list[Tuple[int, int]]:
469
+ """
470
+ Generate lake positions.
471
+ [CHANGE] 4x4 and 5x5 boards have NO lakes.
472
+ """
473
+ size = self.size
474
+ lakes = []
475
+
476
+ # 4x4 and 5x5: No lakes
477
+ if size < 6:
478
+ return []
479
+
480
+ if size == 6: lakes = [(2, 2), (2, 3), (3, 2), (3, 3)]
481
+ elif size == 7: lakes.extend([(2, 1), (3, 1), (3, 3), (4, 3), (2, 5), (3, 5)])
482
+ elif size == 8:
483
+ for r in [3, 4]:
484
+ for c in [1, 2, 5, 6]: lakes.append((r, c))
485
+ elif size == 9:
486
+ for r in [3, 4]:
487
+ for c in [2, 3, 5, 6]: lakes.append((r, c))
488
+ return lakes
489
+
490
+ def _generate_player_prompt(self, player_id: int, game_state: Dict[str, Any]):
491
+ lake_text = "- Lakes (~) are impassable.\n" if self.size >= 6 else ""
492
+ return (f"You are Player {player_id} in Stratego ({self.size}x{self.size}).\n"
493
+ "Goal: Capture Flag or eliminate enemies.\n"
494
+ "Rules: Move 1 sq (Scouts far). No Diagonals. Rank beats Rank.\n"
495
+ f"{lake_text}"
496
+ "Spy>Marshal. Miner>Bomb.\n"
497
+ "Board Key: Your pieces Uppercase. Enemy '?'.")
498
+
499
+ def _generate_piece_counts(self) -> Dict[str, int]:
500
+ """
501
+ [CHANGE] Updated to handle small boards (4x4, 5x5) appropriately.
502
+ """
503
+ ranks = ["Flag", "Bomb", "Spy", "Scout", "Miner", "Sergeant", "Lieutenant", "Captain", "Major", "Colonel", "General", "Marshal"]
504
+
505
+ # Setup zones for small boards
506
+ if self.size < 6:
507
+ setup_rows = 1 # Only 1 row of pieces for 4x4 and 5x5
508
+ elif self.size in (6, 7):
509
+ setup_rows = 2
510
+ elif self.size in (8, 9):
511
+ setup_rows = 3
512
+ else:
513
+ setup_rows = max(2, self.size // 4)
514
+
515
+ slots = self.size * setup_rows
516
+ counts = {r: 1 for r in ranks}
517
+ total = len(ranks)
518
+
519
+ # Priority for removal if we have too many pieces (e.g. 4x4 = 16 slots, but 12 ranks)
520
+ # Actually 4x4 with setup_rows=1 has only 4 slots per player!
521
+ # We need aggressive reduction for very small boards.
522
+
523
+ if self.size == 4:
524
+ # Minimalist setup for 4x4: Flag, Bomb, Spy, Marshal (Total 4)
525
+ return {"Flag": 1, "Bomb": 1, "Spy": 1, "Marshal": 1}
526
+
527
+ if self.size == 5:
528
+ # Setup for 5x5: Flag, Bomb, Spy, Marshal, Scout (Total 5)
529
+ return {"Flag": 1, "Bomb": 1, "Spy": 1, "Marshal": 1, "Scout": 1}
530
+
531
+ # Standard Logic for 6+
532
+ removals = ["Spy", "General", "Colonel", "Major", "Captain"]
533
+ i = 0
534
+ while total > slots:
535
+ r = removals[i % len(removals)]
536
+ if counts[r] > 0: counts[r] -= 1; total -= 1
537
+ i += 1
538
+
539
+ filler = ["Sergeant", "Scout", "Miner", "Bomb"]
540
+ i = 0
541
+ while total < slots:
542
+ p = filler[i % len(filler)]
543
+ counts[p] += 1; total += 1
544
+ i += 1
545
+ return counts
546
+
547
+ def _place_piece(self, r, c, rank, player, counts_dict):
548
+ """Helper to set piece on board and update trackers."""
549
+ self.board[r][c] = {"rank": rank, "player": player}
550
+ self.player_pieces[player].append((r, c))
551
+ if counts_dict and rank in counts_dict:
552
+ counts_dict[rank] -= 1
553
+
554
+ def _populate_board(self):
555
+ size = self.size
556
+
557
+ # [CHANGE] Setup depth calculation
558
+ if size < 6: setup_rows = 1
559
+ elif size in (6, 7): setup_rows = 2
560
+ elif size in (8, 9): setup_rows = 3
561
+ else: setup_rows = max(2, size // 3)
562
+
563
+ for player in (0, 1):
564
+ counts = self._generate_piece_counts()
565
+
566
+ # For small boards with 1 setup row, back/front logic simplifies
567
+ if setup_rows == 1:
568
+ if player == 0:
569
+ back_rows = [0]
570
+ front_rows = [] # No front rows
571
+ else:
572
+ back_rows = [size - 1]
573
+ front_rows = []
574
+ else:
575
+ half = max(1, setup_rows // 2)
576
+ if player == 0:
577
+ back_rows = list(range(0, half))
578
+ front_rows = list(range(half, setup_rows))
579
+ else:
580
+ start = size - setup_rows
581
+ back_rows = list(range(start, start + half))
582
+ front_rows = list(range(start + half, start + setup_rows))
583
+
584
+ def get_free_spots(rows):
585
+ spots = []
586
+ for r in rows:
587
+ for c in range(size):
588
+ if (r, c) not in self.lakes and self.board[r][c] is None:
589
+ spots.append((r, c))
590
+ random.shuffle(spots)
591
+ return spots
592
+
593
+ free_back = get_free_spots(back_rows)
594
+ free_front = get_free_spots(front_rows)
595
+
596
+ flag_row = 0 if player == 0 else size - 1
597
+ flag_candidates = [(flag_row, c) for c in range(size) if (flag_row, c) not in self.lakes and self.board[flag_row][c] is None]
598
+ if not flag_candidates: flag_candidates = free_back[:]
599
+
600
+ if flag_candidates:
601
+ fx, fy = random.choice(flag_candidates)
602
+ self._place_piece(fx, fy, "Flag", player, counts)
603
+ if (fx, fy) in free_back: free_back.remove((fx, fy))
604
+ if (fx, fy) in free_front: free_front.remove((fx, fy))
605
+
606
+ bombs_to_place = counts.get("Bomb", 0)
607
+ for nr, nc in [(fx+1, fy), (fx-1, fy), (fx, fy+1), (fx, fy-1)]:
608
+ if bombs_to_place > 0 and 0 <= nr < size and 0 <= nc < size and (nr, nc) not in self.lakes and self.board[nr][nc] is None:
609
+ self._place_piece(nr, nc, "Bomb", player, counts)
610
+ bombs_to_place -= 1
611
+ if (nr, nc) in free_back: free_back.remove((nr, nc))
612
+ if (nr, nc) in free_front: free_front.remove((nr, nc))
613
+
614
+ all_slots = free_back + free_front
615
+ random.shuffle(all_slots)
616
+ remaining = []
617
+ for rk, cnt in counts.items(): remaining.extend([rk]*cnt)
618
+ random.shuffle(remaining)
619
+ while all_slots and remaining:
620
+ r, c = all_slots.pop()
621
+ self._place_piece(r, c, remaining.pop(), player, None)
622
+
623
+ for r, c in self.lakes: self.board[r][c] = "~"