chuk-puzzles-gym 0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chuk_puzzles_gym/__init__.py +19 -0
- chuk_puzzles_gym/constants.py +9 -0
- chuk_puzzles_gym/eval.py +763 -0
- chuk_puzzles_gym/export/__init__.py +20 -0
- chuk_puzzles_gym/export/dataset.py +376 -0
- chuk_puzzles_gym/games/__init__.py +94 -0
- chuk_puzzles_gym/games/_base/__init__.py +6 -0
- chuk_puzzles_gym/games/_base/commands.py +91 -0
- chuk_puzzles_gym/games/_base/game.py +337 -0
- chuk_puzzles_gym/games/binary/__init__.py +6 -0
- chuk_puzzles_gym/games/binary/config.py +23 -0
- chuk_puzzles_gym/games/binary/game.py +434 -0
- chuk_puzzles_gym/games/bridges/__init__.py +6 -0
- chuk_puzzles_gym/games/bridges/config.py +24 -0
- chuk_puzzles_gym/games/bridges/game.py +489 -0
- chuk_puzzles_gym/games/einstein/__init__.py +6 -0
- chuk_puzzles_gym/games/einstein/config.py +23 -0
- chuk_puzzles_gym/games/einstein/constants.py +13 -0
- chuk_puzzles_gym/games/einstein/game.py +366 -0
- chuk_puzzles_gym/games/einstein/models.py +35 -0
- chuk_puzzles_gym/games/fillomino/__init__.py +6 -0
- chuk_puzzles_gym/games/fillomino/config.py +24 -0
- chuk_puzzles_gym/games/fillomino/game.py +516 -0
- chuk_puzzles_gym/games/futoshiki/__init__.py +6 -0
- chuk_puzzles_gym/games/futoshiki/config.py +23 -0
- chuk_puzzles_gym/games/futoshiki/game.py +391 -0
- chuk_puzzles_gym/games/hidato/__init__.py +6 -0
- chuk_puzzles_gym/games/hidato/config.py +24 -0
- chuk_puzzles_gym/games/hidato/game.py +403 -0
- chuk_puzzles_gym/games/hitori/__init__.py +6 -0
- chuk_puzzles_gym/games/hitori/config.py +23 -0
- chuk_puzzles_gym/games/hitori/game.py +451 -0
- chuk_puzzles_gym/games/kakuro/__init__.py +6 -0
- chuk_puzzles_gym/games/kakuro/config.py +24 -0
- chuk_puzzles_gym/games/kakuro/game.py +399 -0
- chuk_puzzles_gym/games/kenken/__init__.py +6 -0
- chuk_puzzles_gym/games/kenken/config.py +24 -0
- chuk_puzzles_gym/games/kenken/enums.py +13 -0
- chuk_puzzles_gym/games/kenken/game.py +486 -0
- chuk_puzzles_gym/games/kenken/models.py +15 -0
- chuk_puzzles_gym/games/killer_sudoku/__init__.py +6 -0
- chuk_puzzles_gym/games/killer_sudoku/config.py +23 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +502 -0
- chuk_puzzles_gym/games/killer_sudoku/models.py +15 -0
- chuk_puzzles_gym/games/knapsack/__init__.py +6 -0
- chuk_puzzles_gym/games/knapsack/config.py +24 -0
- chuk_puzzles_gym/games/knapsack/enums.py +10 -0
- chuk_puzzles_gym/games/knapsack/game.py +340 -0
- chuk_puzzles_gym/games/knapsack/models.py +13 -0
- chuk_puzzles_gym/games/lights_out/__init__.py +6 -0
- chuk_puzzles_gym/games/lights_out/config.py +24 -0
- chuk_puzzles_gym/games/lights_out/game.py +249 -0
- chuk_puzzles_gym/games/logic_grid/__init__.py +6 -0
- chuk_puzzles_gym/games/logic_grid/config.py +24 -0
- chuk_puzzles_gym/games/logic_grid/constants.py +12 -0
- chuk_puzzles_gym/games/logic_grid/game.py +333 -0
- chuk_puzzles_gym/games/logic_grid/models.py +24 -0
- chuk_puzzles_gym/games/mastermind/__init__.py +6 -0
- chuk_puzzles_gym/games/mastermind/config.py +25 -0
- chuk_puzzles_gym/games/mastermind/game.py +297 -0
- chuk_puzzles_gym/games/minesweeper/__init__.py +6 -0
- chuk_puzzles_gym/games/minesweeper/config.py +24 -0
- chuk_puzzles_gym/games/minesweeper/enums.py +12 -0
- chuk_puzzles_gym/games/minesweeper/game.py +432 -0
- chuk_puzzles_gym/games/nonogram/__init__.py +6 -0
- chuk_puzzles_gym/games/nonogram/config.py +23 -0
- chuk_puzzles_gym/games/nonogram/game.py +296 -0
- chuk_puzzles_gym/games/nurikabe/__init__.py +6 -0
- chuk_puzzles_gym/games/nurikabe/config.py +24 -0
- chuk_puzzles_gym/games/nurikabe/enums.py +14 -0
- chuk_puzzles_gym/games/nurikabe/game.py +586 -0
- chuk_puzzles_gym/games/scheduler/__init__.py +6 -0
- chuk_puzzles_gym/games/scheduler/config.py +25 -0
- chuk_puzzles_gym/games/scheduler/constants.py +15 -0
- chuk_puzzles_gym/games/scheduler/enums.py +10 -0
- chuk_puzzles_gym/games/scheduler/game.py +431 -0
- chuk_puzzles_gym/games/scheduler/models.py +14 -0
- chuk_puzzles_gym/games/shikaku/__init__.py +6 -0
- chuk_puzzles_gym/games/shikaku/config.py +24 -0
- chuk_puzzles_gym/games/shikaku/game.py +419 -0
- chuk_puzzles_gym/games/slitherlink/__init__.py +6 -0
- chuk_puzzles_gym/games/slitherlink/config.py +23 -0
- chuk_puzzles_gym/games/slitherlink/game.py +386 -0
- chuk_puzzles_gym/games/sokoban/__init__.py +6 -0
- chuk_puzzles_gym/games/sokoban/config.py +24 -0
- chuk_puzzles_gym/games/sokoban/game.py +671 -0
- chuk_puzzles_gym/games/star_battle/__init__.py +6 -0
- chuk_puzzles_gym/games/star_battle/config.py +24 -0
- chuk_puzzles_gym/games/star_battle/game.py +390 -0
- chuk_puzzles_gym/games/sudoku/__init__.py +7 -0
- chuk_puzzles_gym/games/sudoku/commands.py +96 -0
- chuk_puzzles_gym/games/sudoku/config.py +22 -0
- chuk_puzzles_gym/games/sudoku/game.py +328 -0
- chuk_puzzles_gym/games/tents/__init__.py +6 -0
- chuk_puzzles_gym/games/tents/config.py +24 -0
- chuk_puzzles_gym/games/tents/game.py +416 -0
- chuk_puzzles_gym/gym_env.py +465 -0
- chuk_puzzles_gym/models/__init__.py +47 -0
- chuk_puzzles_gym/models/base.py +30 -0
- chuk_puzzles_gym/models/config.py +11 -0
- chuk_puzzles_gym/models/enums.py +104 -0
- chuk_puzzles_gym/models/evaluation.py +487 -0
- chuk_puzzles_gym/models/games.py +12 -0
- chuk_puzzles_gym/server.py +1171 -0
- chuk_puzzles_gym/trace/__init__.py +10 -0
- chuk_puzzles_gym/trace/generator.py +726 -0
- chuk_puzzles_gym/utils/__init__.py +4 -0
- chuk_puzzles_gym-0.9.dist-info/METADATA +1471 -0
- chuk_puzzles_gym-0.9.dist-info/RECORD +112 -0
- chuk_puzzles_gym-0.9.dist-info/WHEEL +5 -0
- chuk_puzzles_gym-0.9.dist-info/entry_points.txt +4 -0
- chuk_puzzles_gym-0.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Star Battle puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyProfile, MoveResult
|
|
6
|
+
from .._base import PuzzleGame
|
|
7
|
+
from .config import StarBattleConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StarBattleGame(PuzzleGame):
|
|
11
|
+
"""Star Battle puzzle game.
|
|
12
|
+
|
|
13
|
+
Place stars in the grid such that:
|
|
14
|
+
- Each row contains exactly N stars
|
|
15
|
+
- Each column contains exactly N stars
|
|
16
|
+
- Each region contains exactly N stars
|
|
17
|
+
- Stars cannot touch each other (not even diagonally)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
21
|
+
"""Initialize a new Star Battle game.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
difficulty: Game difficulty level (easy=6x6/1star, medium=8x8/2stars, hard=10x10/2stars)
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
27
|
+
|
|
28
|
+
# Use pydantic config based on difficulty
|
|
29
|
+
self.config = StarBattleConfig.from_difficulty(self.difficulty)
|
|
30
|
+
self.size = self.config.size
|
|
31
|
+
self.stars_per_row = self.config.stars_per_row
|
|
32
|
+
|
|
33
|
+
# Grid: 0 = empty, 1 = star (player-placed)
|
|
34
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
35
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
36
|
+
|
|
37
|
+
# Regions: each cell belongs to a region (0 to num_regions-1)
|
|
38
|
+
self.regions = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
"""The display name of this puzzle type."""
|
|
43
|
+
return "Star Battle"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def description(self) -> str:
|
|
47
|
+
"""A one-line description of this puzzle type."""
|
|
48
|
+
return f"Place {self.stars_per_row} star(s) in each row, column, and region without touching"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def constraint_types(self) -> list[str]:
|
|
52
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
53
|
+
return ["placement_limits", "multi_region_constraints", "adjacency_avoidance", "counting"]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def business_analogies(self) -> list[str]:
|
|
57
|
+
"""Business problems this puzzle models."""
|
|
58
|
+
return ["resource_distribution", "conflict_avoidance", "quota_management", "spatial_planning"]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
62
|
+
"""Complexity profile of this puzzle."""
|
|
63
|
+
return {"reasoning_type": "deductive", "search_space": "large", "constraint_density": "dense"}
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def optimal_steps(self) -> int | None:
|
|
67
|
+
"""Minimum steps = stars to place."""
|
|
68
|
+
if not hasattr(self, "solution") or not self.solution:
|
|
69
|
+
return None
|
|
70
|
+
return sum(sum(row) for row in self.solution)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
74
|
+
"""Difficulty characteristics for Star Battle."""
|
|
75
|
+
from ...models import DifficultyLevel
|
|
76
|
+
|
|
77
|
+
logic_depth = {
|
|
78
|
+
DifficultyLevel.EASY.value: 3,
|
|
79
|
+
DifficultyLevel.MEDIUM.value: 5,
|
|
80
|
+
DifficultyLevel.HARD.value: 6,
|
|
81
|
+
}.get(self.difficulty.value, 4)
|
|
82
|
+
return DifficultyProfile(
|
|
83
|
+
logic_depth=logic_depth,
|
|
84
|
+
branching_factor=3.0,
|
|
85
|
+
state_observability=1.0,
|
|
86
|
+
constraint_density=0.6,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _get_all_adjacent(self, row: int, col: int) -> list[tuple[int, int]]:
|
|
90
|
+
"""Get all adjacent cells including diagonals.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
row: Row index
|
|
94
|
+
col: Column index
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of (row, col) tuples for all adjacent cells
|
|
98
|
+
"""
|
|
99
|
+
adjacent = []
|
|
100
|
+
for dr in [-1, 0, 1]:
|
|
101
|
+
for dc in [-1, 0, 1]:
|
|
102
|
+
if dr == 0 and dc == 0:
|
|
103
|
+
continue
|
|
104
|
+
nr, nc = row + dr, col + dc
|
|
105
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
106
|
+
adjacent.append((nr, nc))
|
|
107
|
+
return adjacent
|
|
108
|
+
|
|
109
|
+
def _generate_regions(self) -> None:
|
|
110
|
+
"""Generate regions for the puzzle."""
|
|
111
|
+
# Simple region generation: create rectangular-ish regions
|
|
112
|
+
# Create a simple grid division
|
|
113
|
+
if self.size == 6:
|
|
114
|
+
# 6x6: create 6 regions of 6 cells each
|
|
115
|
+
patterns = [
|
|
116
|
+
[(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (2, 1)],
|
|
117
|
+
[(0, 2), (0, 3), (1, 2), (1, 3), (2, 2), (2, 3)],
|
|
118
|
+
[(0, 4), (0, 5), (1, 4), (1, 5), (2, 4), (2, 5)],
|
|
119
|
+
[(3, 0), (3, 1), (4, 0), (4, 1), (5, 0), (5, 1)],
|
|
120
|
+
[(3, 2), (3, 3), (4, 2), (4, 3), (5, 2), (5, 3)],
|
|
121
|
+
[(3, 4), (3, 5), (4, 4), (4, 5), (5, 4), (5, 5)],
|
|
122
|
+
]
|
|
123
|
+
for region_id, cells in enumerate(patterns):
|
|
124
|
+
for r, c in cells:
|
|
125
|
+
self.regions[r][c] = region_id
|
|
126
|
+
else:
|
|
127
|
+
# For other sizes, use row-based regions
|
|
128
|
+
for r in range(self.size):
|
|
129
|
+
for c in range(self.size):
|
|
130
|
+
self.regions[r][c] = r
|
|
131
|
+
|
|
132
|
+
async def generate_puzzle(self) -> None:
|
|
133
|
+
"""Generate a new Star Battle puzzle."""
|
|
134
|
+
# Generate regions
|
|
135
|
+
self._generate_regions()
|
|
136
|
+
|
|
137
|
+
# Reset grids
|
|
138
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
139
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
140
|
+
|
|
141
|
+
# Try to place stars that satisfy all constraints
|
|
142
|
+
max_attempts = 100
|
|
143
|
+
for _ in range(max_attempts):
|
|
144
|
+
if self._try_place_stars():
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# If failed, create a simple valid solution
|
|
148
|
+
if sum(sum(row) for row in self.solution) < self.size * self.stars_per_row:
|
|
149
|
+
self._create_simple_solution()
|
|
150
|
+
|
|
151
|
+
self.moves_made = 0
|
|
152
|
+
self.game_started = True
|
|
153
|
+
|
|
154
|
+
def _try_place_stars(self) -> bool:
|
|
155
|
+
"""Try to place stars that satisfy all constraints.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if successful
|
|
159
|
+
"""
|
|
160
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
161
|
+
|
|
162
|
+
# Place stars row by row
|
|
163
|
+
for row in range(self.size):
|
|
164
|
+
stars_placed = 0
|
|
165
|
+
attempts = 0
|
|
166
|
+
max_attempts_per_row = 100
|
|
167
|
+
|
|
168
|
+
while stars_placed < self.stars_per_row and attempts < max_attempts_per_row:
|
|
169
|
+
attempts += 1
|
|
170
|
+
col = self._rng.randint(0, self.size - 1)
|
|
171
|
+
|
|
172
|
+
# Check if we can place a star here
|
|
173
|
+
if self.solution[row][col] == 1:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Check column constraint
|
|
177
|
+
col_count = sum(self.solution[r][col] for r in range(self.size))
|
|
178
|
+
if col_count >= self.stars_per_row:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Check region constraint
|
|
182
|
+
region_id = self.regions[row][col]
|
|
183
|
+
region_count = sum(
|
|
184
|
+
self.solution[r][c]
|
|
185
|
+
for r in range(self.size)
|
|
186
|
+
for c in range(self.size)
|
|
187
|
+
if self.regions[r][c] == region_id
|
|
188
|
+
)
|
|
189
|
+
if region_count >= self.stars_per_row:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Check adjacency (no touching stars)
|
|
193
|
+
adjacent = self._get_all_adjacent(row, col)
|
|
194
|
+
if any(self.solution[ar][ac] == 1 for ar, ac in adjacent):
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# Place the star
|
|
198
|
+
self.solution[row][col] = 1
|
|
199
|
+
stars_placed += 1
|
|
200
|
+
|
|
201
|
+
if stars_placed < self.stars_per_row:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
def _create_simple_solution(self) -> None:
|
|
207
|
+
"""Create a simple valid solution as fallback."""
|
|
208
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
209
|
+
|
|
210
|
+
# Place stars in a diagonal pattern with spacing
|
|
211
|
+
spacing = max(2, self.size // self.stars_per_row)
|
|
212
|
+
for i in range(self.stars_per_row):
|
|
213
|
+
for row in range(self.size):
|
|
214
|
+
col = (row * spacing + i * (self.size // self.stars_per_row)) % self.size
|
|
215
|
+
if self.solution[row][col] == 0:
|
|
216
|
+
# Check adjacency
|
|
217
|
+
adjacent = self._get_all_adjacent(row, col)
|
|
218
|
+
if not any(self.solution[ar][ac] == 1 for ar, ac in adjacent):
|
|
219
|
+
self.solution[row][col] = 1
|
|
220
|
+
|
|
221
|
+
async def validate_move(self, row: int, col: int, action: str = "place") -> MoveResult:
|
|
222
|
+
"""Place or remove a star.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
row: Row index (1-indexed, user-facing)
|
|
226
|
+
col: Column index (1-indexed, user-facing)
|
|
227
|
+
action: "place" or "remove" (default: "place")
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
MoveResult with success status and message
|
|
231
|
+
"""
|
|
232
|
+
# Convert to 0-indexed
|
|
233
|
+
row -= 1
|
|
234
|
+
col -= 1
|
|
235
|
+
|
|
236
|
+
# Validate coordinates
|
|
237
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
238
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
239
|
+
|
|
240
|
+
action = action.lower()
|
|
241
|
+
|
|
242
|
+
if action == "remove":
|
|
243
|
+
if self.grid[row][col] != 1:
|
|
244
|
+
return MoveResult(success=False, message="No star to remove at this position.")
|
|
245
|
+
self.grid[row][col] = 0
|
|
246
|
+
self.moves_made += 1
|
|
247
|
+
return MoveResult(success=True, message="Star removed.", state_changed=True)
|
|
248
|
+
|
|
249
|
+
elif action == "place":
|
|
250
|
+
if self.grid[row][col] == 1:
|
|
251
|
+
return MoveResult(success=False, message="Star already placed here.")
|
|
252
|
+
|
|
253
|
+
# Check if star would touch another star
|
|
254
|
+
adjacent = self._get_all_adjacent(row, col)
|
|
255
|
+
if any(self.grid[ar][ac] == 1 for ar, ac in adjacent):
|
|
256
|
+
return MoveResult(success=False, message="Stars cannot touch each other (not even diagonally).")
|
|
257
|
+
|
|
258
|
+
self.grid[row][col] = 1
|
|
259
|
+
self.moves_made += 1
|
|
260
|
+
return MoveResult(success=True, message="Star placed!", state_changed=True)
|
|
261
|
+
|
|
262
|
+
else:
|
|
263
|
+
return MoveResult(success=False, message="Invalid action. Use 'place' or 'remove'.")
|
|
264
|
+
|
|
265
|
+
def is_complete(self) -> bool:
|
|
266
|
+
"""Check if the puzzle is complete and correct."""
|
|
267
|
+
# Check row counts
|
|
268
|
+
for row in range(self.size):
|
|
269
|
+
count = sum(self.grid[row])
|
|
270
|
+
if count != self.stars_per_row:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
# Check column counts
|
|
274
|
+
for col in range(self.size):
|
|
275
|
+
count = sum(self.grid[row][col] for row in range(self.size))
|
|
276
|
+
if count != self.stars_per_row:
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
# Check region counts
|
|
280
|
+
num_regions = max(max(row) for row in self.regions) + 1
|
|
281
|
+
for region_id in range(num_regions):
|
|
282
|
+
count = sum(
|
|
283
|
+
self.grid[r][c] for r in range(self.size) for c in range(self.size) if self.regions[r][c] == region_id
|
|
284
|
+
)
|
|
285
|
+
if count != self.stars_per_row:
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
# Check no stars touch
|
|
289
|
+
for r in range(self.size):
|
|
290
|
+
for c in range(self.size):
|
|
291
|
+
if self.grid[r][c] == 1:
|
|
292
|
+
adjacent = self._get_all_adjacent(r, c)
|
|
293
|
+
if any(self.grid[ar][ac] == 1 for ar, ac in adjacent):
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
299
|
+
"""Get a hint for the next move.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
303
|
+
"""
|
|
304
|
+
# Find a star location from solution that hasn't been placed
|
|
305
|
+
for r in range(self.size):
|
|
306
|
+
for c in range(self.size):
|
|
307
|
+
if self.solution[r][c] == 1 and self.grid[r][c] != 1:
|
|
308
|
+
hint_data = (r + 1, c + 1, "place")
|
|
309
|
+
hint_message = f"Try placing a star at row {r + 1}, column {c + 1}"
|
|
310
|
+
return hint_data, hint_message
|
|
311
|
+
|
|
312
|
+
# Find incorrectly placed star
|
|
313
|
+
for r in range(self.size):
|
|
314
|
+
for c in range(self.size):
|
|
315
|
+
if self.grid[r][c] == 1 and self.solution[r][c] != 1:
|
|
316
|
+
hint_data = (r + 1, c + 1, "remove")
|
|
317
|
+
hint_message = f"Remove the star at row {r + 1}, column {c + 1}"
|
|
318
|
+
return hint_data, hint_message
|
|
319
|
+
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
def render_grid(self) -> str:
|
|
323
|
+
"""Render the current puzzle state as ASCII art.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
String representation of the puzzle grid
|
|
327
|
+
"""
|
|
328
|
+
lines = []
|
|
329
|
+
|
|
330
|
+
# Header
|
|
331
|
+
header = " |"
|
|
332
|
+
for c in range(self.size):
|
|
333
|
+
header += f" {c + 1}"
|
|
334
|
+
lines.append(header)
|
|
335
|
+
lines.append(" +" + "--" * self.size)
|
|
336
|
+
|
|
337
|
+
# Grid rows
|
|
338
|
+
for r in range(self.size):
|
|
339
|
+
row_str = f" {r + 1} |"
|
|
340
|
+
for c in range(self.size):
|
|
341
|
+
if self.grid[r][c] == 1:
|
|
342
|
+
row_str += " *"
|
|
343
|
+
else:
|
|
344
|
+
# Show region boundaries
|
|
345
|
+
row_str += " ."
|
|
346
|
+
lines.append(row_str)
|
|
347
|
+
|
|
348
|
+
lines.append("\nLegend: * = star, . = empty")
|
|
349
|
+
lines.append(f"Goal: Place {self.stars_per_row} star(s) in each row, column, and region")
|
|
350
|
+
|
|
351
|
+
return "\n".join(lines)
|
|
352
|
+
|
|
353
|
+
def get_rules(self) -> str:
|
|
354
|
+
"""Get the rules description for Star Battle.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Multi-line string describing the puzzle rules
|
|
358
|
+
"""
|
|
359
|
+
return f"""STAR BATTLE RULES:
|
|
360
|
+
- Place {self.stars_per_row} star(s) in each row
|
|
361
|
+
- Place {self.stars_per_row} star(s) in each column
|
|
362
|
+
- Place {self.stars_per_row} star(s) in each region
|
|
363
|
+
- Stars cannot touch each other, not even diagonally
|
|
364
|
+
- All stars must be placed according to these constraints"""
|
|
365
|
+
|
|
366
|
+
def get_commands(self) -> str:
|
|
367
|
+
"""Get the available commands for Star Battle.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Multi-line string describing available commands
|
|
371
|
+
"""
|
|
372
|
+
return """STAR BATTLE COMMANDS:
|
|
373
|
+
place <row> <col> - Place a star (e.g., 'place 2 3')
|
|
374
|
+
remove <row> <col> - Remove a star (e.g., 'remove 2 3')
|
|
375
|
+
show - Display the current grid
|
|
376
|
+
hint - Get a hint for the next move
|
|
377
|
+
check - Check your progress
|
|
378
|
+
solve - Show the solution (ends game)
|
|
379
|
+
menu - Return to game selection
|
|
380
|
+
quit - Exit the server"""
|
|
381
|
+
|
|
382
|
+
def get_stats(self) -> str:
|
|
383
|
+
"""Get current game statistics.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
String with game stats
|
|
387
|
+
"""
|
|
388
|
+
placed = sum(sum(row) for row in self.grid)
|
|
389
|
+
required = self.size * self.stars_per_row
|
|
390
|
+
return f"Moves made: {self.moves_made} | Stars placed: {placed}/{required} | Seed: {self.seed}"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Command handler for Sudoku game."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ...models import GameCommand, MoveResult
|
|
6
|
+
from .._base import CommandResult, GameCommandHandler
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .game import SudokuGame
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SudokuCommandHandler(GameCommandHandler):
|
|
13
|
+
"""Handles commands for Sudoku game."""
|
|
14
|
+
|
|
15
|
+
game: "SudokuGame"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def supported_commands(self) -> set[GameCommand]:
|
|
19
|
+
"""Return the set of GameCommand enums this handler supports."""
|
|
20
|
+
return {GameCommand.PLACE, GameCommand.CLEAR}
|
|
21
|
+
|
|
22
|
+
async def handle_command(self, cmd: GameCommand, args: list[str]) -> CommandResult:
|
|
23
|
+
"""Handle a Sudoku-specific command.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
cmd: The GameCommand enum value
|
|
27
|
+
args: List of string arguments (already split from input)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
CommandResult with the move result and display flags
|
|
31
|
+
"""
|
|
32
|
+
if cmd == GameCommand.PLACE:
|
|
33
|
+
return await self._handle_place(args)
|
|
34
|
+
elif cmd == GameCommand.CLEAR:
|
|
35
|
+
return await self._handle_clear(args)
|
|
36
|
+
else:
|
|
37
|
+
return self.error_result(f"Unknown command: {cmd}")
|
|
38
|
+
|
|
39
|
+
async def _handle_place(self, args: list[str]) -> CommandResult:
|
|
40
|
+
"""Handle the PLACE command.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
args: [row, col, num] - all as strings
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
CommandResult with move result
|
|
47
|
+
"""
|
|
48
|
+
if len(args) != 3:
|
|
49
|
+
return CommandResult(
|
|
50
|
+
result=MoveResult(success=False, message="Usage: place <row> <col> <num>\nExample: place 1 5 7"),
|
|
51
|
+
should_display=False,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
row = self.parse_int(args[0], "row")
|
|
55
|
+
col = self.parse_int(args[1], "col")
|
|
56
|
+
num = self.parse_int(args[2], "num")
|
|
57
|
+
|
|
58
|
+
if row is None or col is None or num is None:
|
|
59
|
+
return self.error_result("Row, column, and number must be integers.")
|
|
60
|
+
|
|
61
|
+
result = await self.game.validate_move(row, col, num)
|
|
62
|
+
|
|
63
|
+
return CommandResult(
|
|
64
|
+
result=result,
|
|
65
|
+
should_display=result.success,
|
|
66
|
+
is_game_over=result.success and self.game.is_complete(),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def _handle_clear(self, args: list[str]) -> CommandResult:
|
|
70
|
+
"""Handle the CLEAR command.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
args: [row, col] - as strings
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
CommandResult with move result
|
|
77
|
+
"""
|
|
78
|
+
if len(args) != 2:
|
|
79
|
+
return CommandResult(
|
|
80
|
+
result=MoveResult(success=False, message="Usage: clear <row> <col>"),
|
|
81
|
+
should_display=False,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
row = self.parse_int(args[0], "row")
|
|
85
|
+
col = self.parse_int(args[1], "col")
|
|
86
|
+
|
|
87
|
+
if row is None or col is None:
|
|
88
|
+
return self.error_result("Row and column must be integers.")
|
|
89
|
+
|
|
90
|
+
# Clear is just a validate_move with num=0
|
|
91
|
+
result = await self.game.validate_move(row, col, 0)
|
|
92
|
+
|
|
93
|
+
return CommandResult(
|
|
94
|
+
result=result,
|
|
95
|
+
should_display=result.success,
|
|
96
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Configuration for Sudoku game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SudokuConfig(BaseModel):
|
|
9
|
+
"""Configuration for Sudoku game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
cells_to_remove: int = Field(ge=0, le=64, description="Number of cells to remove from solution")
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "SudokuConfig":
|
|
16
|
+
"""Create config from difficulty level."""
|
|
17
|
+
cells_map = {
|
|
18
|
+
DifficultyLevel.EASY: 35,
|
|
19
|
+
DifficultyLevel.MEDIUM: 45,
|
|
20
|
+
DifficultyLevel.HARD: 55,
|
|
21
|
+
}
|
|
22
|
+
return cls(difficulty=difficulty, cells_to_remove=cells_map[difficulty])
|