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,391 @@
|
|
|
1
|
+
"""Futoshiki puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyLevel, DifficultyProfile, MoveResult
|
|
6
|
+
from .._base import PuzzleGame
|
|
7
|
+
from .config import FutoshikiConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FutoshikiGame(PuzzleGame):
|
|
11
|
+
"""Futoshiki (inequality constraints) puzzle game.
|
|
12
|
+
|
|
13
|
+
Similar to Sudoku but uses inequality constraints between adjacent cells.
|
|
14
|
+
Each row and column must contain unique numbers from 1 to N.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
18
|
+
"""Initialize a new Futoshiki game.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
difficulty: Game difficulty level (easy=4x4, medium=5x5, hard=6x6)
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
24
|
+
|
|
25
|
+
# Use pydantic config based on difficulty
|
|
26
|
+
self.config = FutoshikiConfig.from_difficulty(self.difficulty)
|
|
27
|
+
self.size = self.config.size
|
|
28
|
+
|
|
29
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
30
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
31
|
+
self.initial_grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
32
|
+
|
|
33
|
+
# Inequalities: list of ((row1, col1), (row2, col2))
|
|
34
|
+
# Meaning: cell1 > cell2
|
|
35
|
+
self.inequalities: list[tuple[tuple[int, int], tuple[int, int]]] = []
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
"""The display name of this puzzle type."""
|
|
40
|
+
return "Futoshiki"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def description(self) -> str:
|
|
44
|
+
"""A one-line description of this puzzle type."""
|
|
45
|
+
return "Inequality number puzzle - fill grid with constraints"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def constraint_types(self) -> list[str]:
|
|
49
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
50
|
+
return ["all_different", "linear_inequality", "ordering", "comparison_constraints"]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def business_analogies(self) -> list[str]:
|
|
54
|
+
"""Business problems this puzzle models."""
|
|
55
|
+
return ["priority_ranking", "ordering_with_constraints", "relative_positioning", "inequality_systems"]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
59
|
+
"""Complexity profile of this puzzle."""
|
|
60
|
+
return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def optimal_steps(self) -> int | None:
|
|
64
|
+
"""Minimum steps = empty cells to fill."""
|
|
65
|
+
return sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
69
|
+
"""Difficulty characteristics for Futoshiki."""
|
|
70
|
+
|
|
71
|
+
empty = self.optimal_steps or 0
|
|
72
|
+
total = self.size * self.size
|
|
73
|
+
logic_depth = {
|
|
74
|
+
DifficultyLevel.EASY.value: 2,
|
|
75
|
+
DifficultyLevel.MEDIUM.value: 3,
|
|
76
|
+
DifficultyLevel.HARD.value: 5,
|
|
77
|
+
}.get(self.difficulty.value, 3)
|
|
78
|
+
branching = 2.0 + (empty / total) * 2
|
|
79
|
+
density = 1.0 - (empty / total) if total > 0 else 0.5
|
|
80
|
+
return DifficultyProfile(
|
|
81
|
+
logic_depth=logic_depth,
|
|
82
|
+
branching_factor=round(branching, 1),
|
|
83
|
+
state_observability=1.0,
|
|
84
|
+
constraint_density=round(density, 2),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def is_valid_move(self, row: int, col: int, num: int, grid: list[list[int]] | None = None) -> bool:
|
|
88
|
+
"""Check if placing num at (row, col) is valid.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
row: Row index (0-indexed)
|
|
92
|
+
col: Column index (0-indexed)
|
|
93
|
+
num: Number to place (1 to self.size)
|
|
94
|
+
grid: Grid to check against (defaults to self.grid)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if the move is valid, False otherwise
|
|
98
|
+
"""
|
|
99
|
+
if grid is None:
|
|
100
|
+
grid = self.grid
|
|
101
|
+
|
|
102
|
+
# Check row uniqueness
|
|
103
|
+
for c in range(self.size):
|
|
104
|
+
if c != col and grid[row][c] == num:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Check column uniqueness
|
|
108
|
+
for r in range(self.size):
|
|
109
|
+
if r != row and grid[r][col] == num:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# Check inequality constraints involving this cell
|
|
113
|
+
for (r1, c1), (r2, c2) in self.inequalities:
|
|
114
|
+
# Check if this cell is involved
|
|
115
|
+
if (r1, c1) == (row, col):
|
|
116
|
+
# This cell should be > cell2
|
|
117
|
+
if grid[r2][c2] != 0 and num <= grid[r2][c2]:
|
|
118
|
+
return False
|
|
119
|
+
elif (r2, c2) == (row, col):
|
|
120
|
+
# Cell1 should be > this cell
|
|
121
|
+
if grid[r1][c1] != 0 and grid[r1][c1] <= num:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def solve(self, grid: list[list[int]]) -> bool:
|
|
127
|
+
"""Solve the Futoshiki puzzle using backtracking.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
grid: The Futoshiki grid to solve
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
True if solved, False otherwise
|
|
134
|
+
"""
|
|
135
|
+
for row in range(self.size):
|
|
136
|
+
for col in range(self.size):
|
|
137
|
+
if grid[row][col] == 0:
|
|
138
|
+
for num in range(1, self.size + 1):
|
|
139
|
+
grid[row][col] = num
|
|
140
|
+
|
|
141
|
+
if self.is_valid_move(row, col, num, grid) and self.solve(grid):
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
grid[row][col] = 0
|
|
145
|
+
|
|
146
|
+
return False
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
def _generate_inequalities(self) -> None:
|
|
150
|
+
"""Generate inequality constraints from the solution."""
|
|
151
|
+
self.inequalities = []
|
|
152
|
+
|
|
153
|
+
# Determine number of inequalities based on difficulty
|
|
154
|
+
num_inequalities_map = {
|
|
155
|
+
DifficultyLevel.EASY: self.size * 2,
|
|
156
|
+
DifficultyLevel.MEDIUM: self.size * 3,
|
|
157
|
+
DifficultyLevel.HARD: self.size * 4,
|
|
158
|
+
}
|
|
159
|
+
num_inequalities = num_inequalities_map[self.difficulty]
|
|
160
|
+
|
|
161
|
+
# Collect all possible adjacent pairs
|
|
162
|
+
possible_pairs = []
|
|
163
|
+
|
|
164
|
+
# Horizontal pairs
|
|
165
|
+
for row in range(self.size):
|
|
166
|
+
for col in range(self.size - 1):
|
|
167
|
+
possible_pairs.append(((row, col), (row, col + 1)))
|
|
168
|
+
|
|
169
|
+
# Vertical pairs
|
|
170
|
+
for row in range(self.size - 1):
|
|
171
|
+
for col in range(self.size):
|
|
172
|
+
possible_pairs.append(((row, col), (row + 1, col)))
|
|
173
|
+
|
|
174
|
+
# Randomly select inequalities
|
|
175
|
+
self._rng.shuffle(possible_pairs)
|
|
176
|
+
|
|
177
|
+
for (r1, c1), (r2, c2) in possible_pairs[:num_inequalities]:
|
|
178
|
+
val1 = self.solution[r1][c1]
|
|
179
|
+
val2 = self.solution[r2][c2]
|
|
180
|
+
|
|
181
|
+
if val1 > val2:
|
|
182
|
+
self.inequalities.append(((r1, c1), (r2, c2)))
|
|
183
|
+
else:
|
|
184
|
+
self.inequalities.append(((r2, c2), (r1, c1)))
|
|
185
|
+
|
|
186
|
+
async def generate_puzzle(self) -> None:
|
|
187
|
+
"""Generate a new Futoshiki puzzle."""
|
|
188
|
+
# Generate a valid Latin square as solution
|
|
189
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
190
|
+
|
|
191
|
+
# Simple solution generation: shifted rows
|
|
192
|
+
for row in range(self.size):
|
|
193
|
+
for col in range(self.size):
|
|
194
|
+
self.grid[row][col] = (row + col) % self.size + 1
|
|
195
|
+
|
|
196
|
+
# Shuffle rows and columns to make it more random
|
|
197
|
+
row_order = list(range(self.size))
|
|
198
|
+
col_order = list(range(self.size))
|
|
199
|
+
self._rng.shuffle(row_order)
|
|
200
|
+
self._rng.shuffle(col_order)
|
|
201
|
+
|
|
202
|
+
shuffled = [[self.grid[row_order[r]][col_order[c]] for c in range(self.size)] for r in range(self.size)]
|
|
203
|
+
self.solution = shuffled
|
|
204
|
+
|
|
205
|
+
# Generate inequalities
|
|
206
|
+
self._generate_inequalities()
|
|
207
|
+
|
|
208
|
+
# Remove some cells based on difficulty
|
|
209
|
+
cells_to_remove_map = {
|
|
210
|
+
DifficultyLevel.EASY: self.size * 2,
|
|
211
|
+
DifficultyLevel.MEDIUM: self.size * 3,
|
|
212
|
+
DifficultyLevel.HARD: self.size * 4,
|
|
213
|
+
}
|
|
214
|
+
cells_to_remove = cells_to_remove_map[self.difficulty]
|
|
215
|
+
|
|
216
|
+
# Copy solution to grid
|
|
217
|
+
self.grid = [row[:] for row in self.solution]
|
|
218
|
+
|
|
219
|
+
# Randomly remove cells
|
|
220
|
+
cells = [(r, c) for r in range(self.size) for c in range(self.size)]
|
|
221
|
+
self._rng.shuffle(cells)
|
|
222
|
+
|
|
223
|
+
for r, c in cells[:cells_to_remove]:
|
|
224
|
+
self.grid[r][c] = 0
|
|
225
|
+
|
|
226
|
+
self.initial_grid = [row[:] for row in self.grid]
|
|
227
|
+
self.moves_made = 0
|
|
228
|
+
self.game_started = True
|
|
229
|
+
|
|
230
|
+
async def validate_move(self, row: int, col: int, num: int) -> MoveResult:
|
|
231
|
+
"""Place a number on the grid.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
row: Row index (1-indexed, user-facing)
|
|
235
|
+
col: Column index (1-indexed, user-facing)
|
|
236
|
+
num: Number to place (1 to self.size, or 0 to clear)
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
MoveResult with success status and message
|
|
240
|
+
"""
|
|
241
|
+
# Convert to 0-indexed
|
|
242
|
+
row -= 1
|
|
243
|
+
col -= 1
|
|
244
|
+
|
|
245
|
+
# Validate coordinates
|
|
246
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
247
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
248
|
+
|
|
249
|
+
# Check if this cell is part of the initial puzzle
|
|
250
|
+
if self.initial_grid[row][col] != 0:
|
|
251
|
+
return MoveResult(success=False, message="Cannot modify initial puzzle cells.")
|
|
252
|
+
|
|
253
|
+
# Clear the cell
|
|
254
|
+
if num == 0:
|
|
255
|
+
self.grid[row][col] = 0
|
|
256
|
+
return MoveResult(success=True, message="Cell cleared.", state_changed=True)
|
|
257
|
+
|
|
258
|
+
# Validate number
|
|
259
|
+
if not (1 <= num <= self.size):
|
|
260
|
+
return MoveResult(success=False, message=f"Invalid number. Use 1-{self.size} or 0 to clear.")
|
|
261
|
+
|
|
262
|
+
# Check if the move is valid
|
|
263
|
+
old_value = self.grid[row][col]
|
|
264
|
+
self.grid[row][col] = num
|
|
265
|
+
|
|
266
|
+
if not self.is_valid_move(row, col, num):
|
|
267
|
+
self.grid[row][col] = old_value
|
|
268
|
+
return MoveResult(
|
|
269
|
+
success=False, message="Invalid move! This violates uniqueness or inequality constraints."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
self.moves_made += 1
|
|
273
|
+
return MoveResult(success=True, message="Number placed successfully!", state_changed=True)
|
|
274
|
+
|
|
275
|
+
def is_complete(self) -> bool:
|
|
276
|
+
"""Check if the puzzle is complete and correct."""
|
|
277
|
+
for row in range(self.size):
|
|
278
|
+
for col in range(self.size):
|
|
279
|
+
if self.grid[row][col] == 0:
|
|
280
|
+
return False
|
|
281
|
+
if self.grid[row][col] != self.solution[row][col]:
|
|
282
|
+
return False
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
286
|
+
"""Get a hint for the next move.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
290
|
+
"""
|
|
291
|
+
empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
|
|
292
|
+
if not empty_cells:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
row, col = self._rng.choice(empty_cells)
|
|
296
|
+
hint_data = (row + 1, col + 1, self.solution[row][col])
|
|
297
|
+
hint_message = f"Try placing {self.solution[row][col]} at row {row + 1}, column {col + 1}"
|
|
298
|
+
return hint_data, hint_message
|
|
299
|
+
|
|
300
|
+
def render_grid(self) -> str:
|
|
301
|
+
"""Render the current puzzle state as ASCII art.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
String representation of the puzzle grid with inequalities
|
|
305
|
+
"""
|
|
306
|
+
lines = []
|
|
307
|
+
|
|
308
|
+
# Build a map of horizontal and vertical inequalities
|
|
309
|
+
h_ineq = {} # (row, col) -> '>' or '<' between col and col+1
|
|
310
|
+
v_ineq = {} # (row, col) -> '^' or 'v' between row and row+1
|
|
311
|
+
|
|
312
|
+
for (r1, c1), (r2, c2) in self.inequalities:
|
|
313
|
+
if r1 == r2: # Horizontal
|
|
314
|
+
if c1 < c2:
|
|
315
|
+
h_ineq[(r1, c1)] = ">"
|
|
316
|
+
else:
|
|
317
|
+
h_ineq[(r1, c2)] = "<"
|
|
318
|
+
else: # Vertical
|
|
319
|
+
if r1 < r2:
|
|
320
|
+
v_ineq[(r1, c1)] = "v"
|
|
321
|
+
else:
|
|
322
|
+
v_ineq[(r2, c1)] = "^"
|
|
323
|
+
|
|
324
|
+
# Header - align with row format "N | ..."
|
|
325
|
+
header = " | " + " ".join(str(i + 1) for i in range(self.size)) + " |"
|
|
326
|
+
lines.append(header)
|
|
327
|
+
|
|
328
|
+
for row in range(self.size):
|
|
329
|
+
# Main row
|
|
330
|
+
line = f"{row + 1} | "
|
|
331
|
+
for col in range(self.size):
|
|
332
|
+
cell = self.grid[row][col]
|
|
333
|
+
cell_str = str(cell) if cell != 0 else "."
|
|
334
|
+
line += cell_str
|
|
335
|
+
|
|
336
|
+
# Add horizontal inequality
|
|
337
|
+
if col < self.size - 1:
|
|
338
|
+
ineq = h_ineq.get((row, col), " ")
|
|
339
|
+
line += f" {ineq} "
|
|
340
|
+
|
|
341
|
+
line += " |"
|
|
342
|
+
lines.append(line)
|
|
343
|
+
|
|
344
|
+
# Vertical inequality row
|
|
345
|
+
if row < self.size - 1:
|
|
346
|
+
line = " | "
|
|
347
|
+
for col in range(self.size):
|
|
348
|
+
ineq = v_ineq.get((row, col), " ")
|
|
349
|
+
line += ineq
|
|
350
|
+
if col < self.size - 1:
|
|
351
|
+
line += " "
|
|
352
|
+
line += " |"
|
|
353
|
+
lines.append(line)
|
|
354
|
+
|
|
355
|
+
return "\n".join(lines)
|
|
356
|
+
|
|
357
|
+
def get_rules(self) -> str:
|
|
358
|
+
"""Get the rules description for Futoshiki.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Multi-line string describing the puzzle rules
|
|
362
|
+
"""
|
|
363
|
+
return f"""FUTOSHIKI RULES:
|
|
364
|
+
- Fill {self.size}x{self.size} grid with 1-{self.size}
|
|
365
|
+
- No repeats in rows or columns
|
|
366
|
+
- Satisfy inequality signs (>, <, ^, v)"""
|
|
367
|
+
|
|
368
|
+
def get_commands(self) -> str:
|
|
369
|
+
"""Get the available commands for Futoshiki.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Multi-line string describing available commands
|
|
373
|
+
"""
|
|
374
|
+
return """FUTOSHIKI COMMANDS:
|
|
375
|
+
place <row> <col> <num> - Place a number (e.g., 'place 1 2 4')
|
|
376
|
+
clear <row> <col> - Clear a cell
|
|
377
|
+
show - Display the current grid
|
|
378
|
+
hint - Get a hint for the next move
|
|
379
|
+
check - Check your progress
|
|
380
|
+
solve - Show the solution (ends game)
|
|
381
|
+
menu - Return to game selection
|
|
382
|
+
quit - Exit the server"""
|
|
383
|
+
|
|
384
|
+
def get_stats(self) -> str:
|
|
385
|
+
"""Get current game statistics.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
String with game stats
|
|
389
|
+
"""
|
|
390
|
+
empty = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0)
|
|
391
|
+
return f"Moves made: {self.moves_made} | Empty cells: {empty} | Inequalities: {len(self.inequalities)} | Seed: {self.seed}"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration for Hidato game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HidatoConfig(BaseModel):
|
|
9
|
+
"""Configuration for Hidato game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=5, le=9, description="Grid size (NxN)")
|
|
13
|
+
num_clues: int = Field(ge=2, description="Number of clue numbers to reveal")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "HidatoConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"size": 5, "num_clues": 8},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"size": 7, "num_clues": 12},
|
|
21
|
+
DifficultyLevel.HARD: {"size": 9, "num_clues": 15},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|