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,416 @@
|
|
|
1
|
+
"""Tents and Trees 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 TentsConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TentsGame(PuzzleGame):
|
|
11
|
+
"""Tents and Trees puzzle game.
|
|
12
|
+
|
|
13
|
+
Place tents next to trees such that:
|
|
14
|
+
- Each tree has exactly one tent adjacent to it (horizontally or vertically)
|
|
15
|
+
- Each tent is adjacent to exactly one tree
|
|
16
|
+
- Tents cannot touch each other (not even diagonally)
|
|
17
|
+
- Row and column counts show how many tents are in each row/column
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
21
|
+
"""Initialize a new Tents game.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
difficulty: Game difficulty level (easy=6x6, medium=8x8, hard=10x10)
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
27
|
+
|
|
28
|
+
# Use pydantic config based on difficulty
|
|
29
|
+
self.config = TentsConfig.from_difficulty(self.difficulty)
|
|
30
|
+
self.size = self.config.size
|
|
31
|
+
|
|
32
|
+
# Grid: 0 = empty, 1 = tree, 2 = tent (player-placed)
|
|
33
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
34
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
35
|
+
|
|
36
|
+
# Trees are fixed at puzzle generation
|
|
37
|
+
self.trees = [[False for _ in range(self.size)] for _ in range(self.size)]
|
|
38
|
+
|
|
39
|
+
# Row and column counts
|
|
40
|
+
self.row_counts: list[int] = []
|
|
41
|
+
self.col_counts: list[int] = []
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
"""The display name of this puzzle type."""
|
|
46
|
+
return "Tents and Trees"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def description(self) -> str:
|
|
50
|
+
"""A one-line description of this puzzle type."""
|
|
51
|
+
return "Match tents to trees while avoiding adjacency conflicts"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def constraint_types(self) -> list[str]:
|
|
55
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
56
|
+
return ["bipartite_matching", "adjacency_avoidance", "counting", "one_to_one_correspondence"]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def business_analogies(self) -> list[str]:
|
|
60
|
+
"""Business problems this puzzle models."""
|
|
61
|
+
return ["resource_pairing", "spatial_allocation", "conflict_avoidance", "matching_with_constraints"]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
65
|
+
"""Complexity profile of this puzzle."""
|
|
66
|
+
return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def optimal_steps(self) -> int | None:
|
|
70
|
+
"""Minimum steps = tents to place (equals trees)."""
|
|
71
|
+
if not hasattr(self, "solution") or not self.solution:
|
|
72
|
+
return None
|
|
73
|
+
# Solution uses: 0=empty, 1=tent, 2=tree. Count tents only.
|
|
74
|
+
return sum(1 for row in self.solution for cell in row if cell == 1)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
78
|
+
"""Difficulty characteristics for Tents and Trees."""
|
|
79
|
+
from ...models import DifficultyLevel
|
|
80
|
+
|
|
81
|
+
logic_depth = {
|
|
82
|
+
DifficultyLevel.EASY.value: 2,
|
|
83
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
84
|
+
DifficultyLevel.HARD.value: 5,
|
|
85
|
+
}.get(self.difficulty.value, 3)
|
|
86
|
+
return DifficultyProfile(
|
|
87
|
+
logic_depth=logic_depth,
|
|
88
|
+
branching_factor=4.0, # 4 adjacent positions
|
|
89
|
+
state_observability=1.0,
|
|
90
|
+
constraint_density=0.5,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _get_adjacent(self, row: int, col: int) -> list[tuple[int, int]]:
|
|
94
|
+
"""Get orthogonally adjacent cells (no diagonals).
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
row: Row index
|
|
98
|
+
col: Column index
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of (row, col) tuples for valid adjacent cells
|
|
102
|
+
"""
|
|
103
|
+
adjacent = []
|
|
104
|
+
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
|
105
|
+
nr, nc = row + dr, col + dc
|
|
106
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
107
|
+
adjacent.append((nr, nc))
|
|
108
|
+
return adjacent
|
|
109
|
+
|
|
110
|
+
def _get_all_adjacent(self, row: int, col: int) -> list[tuple[int, int]]:
|
|
111
|
+
"""Get all adjacent cells including diagonals.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
row: Row index
|
|
115
|
+
col: Column index
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of (row, col) tuples for all adjacent cells
|
|
119
|
+
"""
|
|
120
|
+
adjacent = []
|
|
121
|
+
for dr in [-1, 0, 1]:
|
|
122
|
+
for dc in [-1, 0, 1]:
|
|
123
|
+
if dr == 0 and dc == 0:
|
|
124
|
+
continue
|
|
125
|
+
nr, nc = row + dr, col + dc
|
|
126
|
+
if 0 <= nr < self.size and 0 <= nc < self.size:
|
|
127
|
+
adjacent.append((nr, nc))
|
|
128
|
+
return adjacent
|
|
129
|
+
|
|
130
|
+
async def generate_puzzle(self) -> None:
|
|
131
|
+
"""Generate a new Tents and Trees puzzle."""
|
|
132
|
+
# Reset grids
|
|
133
|
+
self.grid = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
134
|
+
self.solution = [[0 for _ in range(self.size)] for _ in range(self.size)]
|
|
135
|
+
self.trees = [[False for _ in range(self.size)] for _ in range(self.size)]
|
|
136
|
+
|
|
137
|
+
# Place trees and tents
|
|
138
|
+
max_attempts = 100
|
|
139
|
+
for _ in range(max_attempts):
|
|
140
|
+
if self._try_generate():
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
# Calculate row and column counts from solution
|
|
144
|
+
self.row_counts = [sum(1 for c in range(self.size) if self.solution[r][c] == 2) for r in range(self.size)]
|
|
145
|
+
self.col_counts = [sum(1 for r in range(self.size) if self.solution[r][c] == 2) for c in range(self.size)]
|
|
146
|
+
|
|
147
|
+
# Copy trees to player grid
|
|
148
|
+
for r in range(self.size):
|
|
149
|
+
for c in range(self.size):
|
|
150
|
+
if self.trees[r][c]:
|
|
151
|
+
self.grid[r][c] = 1
|
|
152
|
+
|
|
153
|
+
self.moves_made = 0
|
|
154
|
+
self.game_started = True
|
|
155
|
+
|
|
156
|
+
def _try_generate(self) -> bool:
|
|
157
|
+
"""Try to generate a valid puzzle.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True if generation succeeded
|
|
161
|
+
"""
|
|
162
|
+
# Reset
|
|
163
|
+
self.trees = [[False for _ in range(self.size)] for _ in range(self.size)]
|
|
164
|
+
tent_solution = [[False for _ in range(self.size)] for _ in range(self.size)]
|
|
165
|
+
|
|
166
|
+
# Number of tree-tent pairs based on difficulty
|
|
167
|
+
num_pairs = self.config.num_trees
|
|
168
|
+
|
|
169
|
+
# Place tree-tent pairs
|
|
170
|
+
placed_pairs = 0
|
|
171
|
+
attempts = 0
|
|
172
|
+
max_attempts = 500
|
|
173
|
+
|
|
174
|
+
while placed_pairs < num_pairs and attempts < max_attempts:
|
|
175
|
+
attempts += 1
|
|
176
|
+
|
|
177
|
+
# Pick random position for tree
|
|
178
|
+
tree_r = self._rng.randint(0, self.size - 1)
|
|
179
|
+
tree_c = self._rng.randint(0, self.size - 1)
|
|
180
|
+
|
|
181
|
+
# Skip if already has tree or tent
|
|
182
|
+
if self.trees[tree_r][tree_c] or tent_solution[tree_r][tree_c]:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Skip if this position is adjacent to an existing tree or tent
|
|
186
|
+
# (would create ambiguous tree-tent associations)
|
|
187
|
+
tree_adjacent = self._get_adjacent(tree_r, tree_c)
|
|
188
|
+
if any(self.trees[ar][ac] for ar, ac in tree_adjacent):
|
|
189
|
+
continue
|
|
190
|
+
if any(tent_solution[ar][ac] for ar, ac in tree_adjacent):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
# Find valid tent positions (adjacent, not touching other tents)
|
|
194
|
+
valid_tent_positions = []
|
|
195
|
+
|
|
196
|
+
for tent_r, tent_c in tree_adjacent:
|
|
197
|
+
# Check if position is empty
|
|
198
|
+
if self.trees[tent_r][tent_c] or tent_solution[tent_r][tent_c]:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# Check if tent would touch another tent (including diagonally)
|
|
202
|
+
all_adj = self._get_all_adjacent(tent_r, tent_c)
|
|
203
|
+
if any(tent_solution[ar][ac] for ar, ac in all_adj):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Check if this tent position would be adjacent to another tree
|
|
207
|
+
# (would create ambiguous tent-tree associations)
|
|
208
|
+
tent_adj = self._get_adjacent(tent_r, tent_c)
|
|
209
|
+
other_trees = sum(1 for ar, ac in tent_adj if self.trees[ar][ac])
|
|
210
|
+
if other_trees > 0:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
valid_tent_positions.append((tent_r, tent_c))
|
|
214
|
+
|
|
215
|
+
if valid_tent_positions:
|
|
216
|
+
# Place tree and tent
|
|
217
|
+
tent_r, tent_c = self._rng.choice(valid_tent_positions)
|
|
218
|
+
self.trees[tree_r][tree_c] = True
|
|
219
|
+
tent_solution[tent_r][tent_c] = True
|
|
220
|
+
placed_pairs += 1
|
|
221
|
+
|
|
222
|
+
# Transfer to solution grid
|
|
223
|
+
for r in range(self.size):
|
|
224
|
+
for c in range(self.size):
|
|
225
|
+
if self.trees[r][c]:
|
|
226
|
+
self.solution[r][c] = 1
|
|
227
|
+
elif tent_solution[r][c]:
|
|
228
|
+
self.solution[r][c] = 2
|
|
229
|
+
else:
|
|
230
|
+
self.solution[r][c] = 0
|
|
231
|
+
|
|
232
|
+
return placed_pairs >= num_pairs
|
|
233
|
+
|
|
234
|
+
async def validate_move(self, row: int, col: int, action: str = "place") -> MoveResult:
|
|
235
|
+
"""Place or remove a tent.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
row: Row index (1-indexed, user-facing)
|
|
239
|
+
col: Column index (1-indexed, user-facing)
|
|
240
|
+
action: "place" or "remove" (default: "place")
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
MoveResult with success status and message
|
|
244
|
+
"""
|
|
245
|
+
# Convert to 0-indexed
|
|
246
|
+
row -= 1
|
|
247
|
+
col -= 1
|
|
248
|
+
|
|
249
|
+
# Validate coordinates
|
|
250
|
+
if not (0 <= row < self.size and 0 <= col < self.size):
|
|
251
|
+
return MoveResult(success=False, message=f"Invalid coordinates. Use row and column between 1-{self.size}.")
|
|
252
|
+
|
|
253
|
+
# Check if it's a tree
|
|
254
|
+
if self.trees[row][col]:
|
|
255
|
+
return MoveResult(success=False, message="Cannot place tent on a tree.")
|
|
256
|
+
|
|
257
|
+
action = action.lower()
|
|
258
|
+
|
|
259
|
+
if action == "remove":
|
|
260
|
+
if self.grid[row][col] != 2:
|
|
261
|
+
return MoveResult(success=False, message="No tent to remove at this position.")
|
|
262
|
+
self.grid[row][col] = 0
|
|
263
|
+
self.moves_made += 1
|
|
264
|
+
return MoveResult(success=True, message="Tent removed.", state_changed=True)
|
|
265
|
+
|
|
266
|
+
elif action == "place":
|
|
267
|
+
if self.grid[row][col] == 2:
|
|
268
|
+
return MoveResult(success=False, message="Tent already placed here.")
|
|
269
|
+
|
|
270
|
+
# Check if tent would touch another tent (including diagonally)
|
|
271
|
+
all_adj = self._get_all_adjacent(row, col)
|
|
272
|
+
if any(self.grid[ar][ac] == 2 for ar, ac in all_adj):
|
|
273
|
+
return MoveResult(success=False, message="Tents cannot touch each other (not even diagonally).")
|
|
274
|
+
|
|
275
|
+
self.grid[row][col] = 2
|
|
276
|
+
self.moves_made += 1
|
|
277
|
+
return MoveResult(success=True, message="Tent placed!", state_changed=True)
|
|
278
|
+
|
|
279
|
+
else:
|
|
280
|
+
return MoveResult(success=False, message="Invalid action. Use 'place' or 'remove'.")
|
|
281
|
+
|
|
282
|
+
def is_complete(self) -> bool:
|
|
283
|
+
"""Check if the puzzle is complete and correct."""
|
|
284
|
+
# Check row and column counts
|
|
285
|
+
for r in range(self.size):
|
|
286
|
+
count = sum(1 for c in range(self.size) if self.grid[r][c] == 2)
|
|
287
|
+
if count != self.row_counts[r]:
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
for c in range(self.size):
|
|
291
|
+
count = sum(1 for r in range(self.size) if self.grid[r][c] == 2)
|
|
292
|
+
if count != self.col_counts[c]:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
# Check each tree has exactly one adjacent tent
|
|
296
|
+
for r in range(self.size):
|
|
297
|
+
for c in range(self.size):
|
|
298
|
+
if self.trees[r][c]:
|
|
299
|
+
adjacent = self._get_adjacent(r, c)
|
|
300
|
+
tent_count = sum(1 for ar, ac in adjacent if self.grid[ar][ac] == 2)
|
|
301
|
+
if tent_count != 1:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
# Check each tent has exactly one adjacent tree
|
|
305
|
+
for r in range(self.size):
|
|
306
|
+
for c in range(self.size):
|
|
307
|
+
if self.grid[r][c] == 2:
|
|
308
|
+
adjacent = self._get_adjacent(r, c)
|
|
309
|
+
tree_count = sum(1 for ar, ac in adjacent if self.trees[ar][ac])
|
|
310
|
+
if tree_count != 1:
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
# Check no tents touch
|
|
314
|
+
for r in range(self.size):
|
|
315
|
+
for c in range(self.size):
|
|
316
|
+
if self.grid[r][c] == 2:
|
|
317
|
+
all_adj = self._get_all_adjacent(r, c)
|
|
318
|
+
if any(self.grid[ar][ac] == 2 for ar, ac in all_adj):
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
324
|
+
"""Get a hint for the next move.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
328
|
+
"""
|
|
329
|
+
# Find a tent location from solution that hasn't been placed
|
|
330
|
+
for r in range(self.size):
|
|
331
|
+
for c in range(self.size):
|
|
332
|
+
if self.solution[r][c] == 2 and self.grid[r][c] != 2:
|
|
333
|
+
hint_data = (r + 1, c + 1, "place")
|
|
334
|
+
hint_message = f"Try placing a tent at row {r + 1}, column {c + 1}"
|
|
335
|
+
return hint_data, hint_message
|
|
336
|
+
|
|
337
|
+
# Find incorrectly placed tent
|
|
338
|
+
for r in range(self.size):
|
|
339
|
+
for c in range(self.size):
|
|
340
|
+
if self.grid[r][c] == 2 and self.solution[r][c] != 2:
|
|
341
|
+
hint_data = (r + 1, c + 1, "remove")
|
|
342
|
+
hint_message = f"Remove the tent at row {r + 1}, column {c + 1}"
|
|
343
|
+
return hint_data, hint_message
|
|
344
|
+
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
def render_grid(self) -> str:
|
|
348
|
+
"""Render the current puzzle state as ASCII art.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
String representation of the puzzle grid
|
|
352
|
+
"""
|
|
353
|
+
lines = []
|
|
354
|
+
|
|
355
|
+
# Header with column counts
|
|
356
|
+
header = " |"
|
|
357
|
+
for c in range(self.size):
|
|
358
|
+
header += f" {self.col_counts[c]}"
|
|
359
|
+
lines.append(header)
|
|
360
|
+
lines.append(" +" + "--" * self.size)
|
|
361
|
+
|
|
362
|
+
# Grid rows with row counts
|
|
363
|
+
for r in range(self.size):
|
|
364
|
+
row_str = f" {self.row_counts[r]} |"
|
|
365
|
+
for c in range(self.size):
|
|
366
|
+
if self.trees[r][c]:
|
|
367
|
+
row_str += " T"
|
|
368
|
+
elif self.grid[r][c] == 2:
|
|
369
|
+
row_str += " A" # A for tent (like a triangle)
|
|
370
|
+
else:
|
|
371
|
+
row_str += " ."
|
|
372
|
+
lines.append(row_str)
|
|
373
|
+
|
|
374
|
+
lines.append("\nLegend: T = tree, A = tent (player placed), . = empty")
|
|
375
|
+
lines.append("Numbers on edges = required tents in that row/column")
|
|
376
|
+
|
|
377
|
+
return "\n".join(lines)
|
|
378
|
+
|
|
379
|
+
def get_rules(self) -> str:
|
|
380
|
+
"""Get the rules description for Tents and Trees.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Multi-line string describing the puzzle rules
|
|
384
|
+
"""
|
|
385
|
+
return """TENTS AND TREES RULES:
|
|
386
|
+
- Place tents in empty cells
|
|
387
|
+
- Each tree must have exactly one tent adjacent to it (horizontally or vertically)
|
|
388
|
+
- Each tent must be adjacent to exactly one tree
|
|
389
|
+
- Tents cannot touch each other, not even diagonally
|
|
390
|
+
- Row and column numbers show how many tents are in that row/column"""
|
|
391
|
+
|
|
392
|
+
def get_commands(self) -> str:
|
|
393
|
+
"""Get the available commands for Tents and Trees.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Multi-line string describing available commands
|
|
397
|
+
"""
|
|
398
|
+
return """TENTS AND TREES COMMANDS:
|
|
399
|
+
place <row> <col> - Place a tent (e.g., 'place 2 3')
|
|
400
|
+
remove <row> <col> - Remove a tent (e.g., 'remove 2 3')
|
|
401
|
+
show - Display the current grid
|
|
402
|
+
hint - Get a hint for the next move
|
|
403
|
+
check - Check your progress
|
|
404
|
+
solve - Show the solution (ends game)
|
|
405
|
+
menu - Return to game selection
|
|
406
|
+
quit - Exit the server"""
|
|
407
|
+
|
|
408
|
+
def get_stats(self) -> str:
|
|
409
|
+
"""Get current game statistics.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
String with game stats
|
|
413
|
+
"""
|
|
414
|
+
placed = sum(1 for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 2)
|
|
415
|
+
required = sum(self.row_counts)
|
|
416
|
+
return f"Moves made: {self.moves_made} | Tents placed: {placed}/{required} | Seed: {self.seed}"
|