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,333 @@
|
|
|
1
|
+
"""Logic Grid 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 LogicGridConfig
|
|
8
|
+
from .constants import CATEGORIES, COLORS, DRINKS, PEOPLE, PETS
|
|
9
|
+
from .models import LogicGridCategories, PersonAttributes
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LogicGridGame(PuzzleGame):
|
|
13
|
+
"""Logic Grid puzzle game (like Einstein's Riddle or Zebra Puzzle).
|
|
14
|
+
|
|
15
|
+
Use logical deduction to determine which attributes belong together.
|
|
16
|
+
Each person/house has exactly one of each attribute type.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
20
|
+
"""Initialize a new Logic Grid game.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
difficulty: Game difficulty level (easy=3x3, medium=4x4, hard=5x5)
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
26
|
+
|
|
27
|
+
# Use pydantic config based on difficulty
|
|
28
|
+
self.config = LogicGridConfig.from_difficulty(self.difficulty)
|
|
29
|
+
self.num_people = self.config.num_people
|
|
30
|
+
|
|
31
|
+
# Categories using Pydantic model with constants
|
|
32
|
+
self.categories = LogicGridCategories(
|
|
33
|
+
person=PEOPLE[: self.num_people],
|
|
34
|
+
color=COLORS[: self.num_people],
|
|
35
|
+
pet=PETS[: self.num_people],
|
|
36
|
+
drink=DRINKS[: self.num_people],
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Solution: dict mapping person -> PersonAttributes
|
|
40
|
+
self.solution: dict[str, PersonAttributes] = {}
|
|
41
|
+
|
|
42
|
+
# Player grid: dict of (category1, value1, category2, value2) -> bool | None
|
|
43
|
+
# True = definitely connected, False = definitely not connected, None = unknown
|
|
44
|
+
self.player_grid: dict[tuple[str, str, str, str], bool | None] = {}
|
|
45
|
+
|
|
46
|
+
# Clues: list of clue strings
|
|
47
|
+
self.clues: list[str] = []
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str:
|
|
51
|
+
"""The display name of this puzzle type."""
|
|
52
|
+
return "Logic Grid"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def description(self) -> str:
|
|
56
|
+
"""A one-line description of this puzzle type."""
|
|
57
|
+
return "Deductive reasoning puzzle - match attributes using logic"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def constraint_types(self) -> list[str]:
|
|
61
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
62
|
+
return [
|
|
63
|
+
"all_different_per_attribute",
|
|
64
|
+
"cross_attribute_links",
|
|
65
|
+
"transitive_closure",
|
|
66
|
+
"bi-directional_inference",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def business_analogies(self) -> list[str]:
|
|
71
|
+
"""Business problems this puzzle models."""
|
|
72
|
+
return ["multi_factor_matching", "relationship_mapping", "entity_resolution", "attribute_correlation"]
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
76
|
+
"""Complexity profile of this puzzle."""
|
|
77
|
+
return {"reasoning_type": "deductive", "search_space": "medium", "constraint_density": "moderate"}
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def optimal_steps(self) -> int | None:
|
|
81
|
+
"""Minimum steps = attribute assignments (people x attributes)."""
|
|
82
|
+
return self.num_people * 3 # 3 attributes per person
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
86
|
+
"""Difficulty characteristics for Logic Grid."""
|
|
87
|
+
from ...models import DifficultyLevel
|
|
88
|
+
|
|
89
|
+
logic_depth = {
|
|
90
|
+
DifficultyLevel.EASY.value: 3,
|
|
91
|
+
DifficultyLevel.MEDIUM.value: 5,
|
|
92
|
+
DifficultyLevel.HARD.value: 7,
|
|
93
|
+
}.get(self.difficulty.value, 4)
|
|
94
|
+
return DifficultyProfile(
|
|
95
|
+
logic_depth=logic_depth,
|
|
96
|
+
branching_factor=float(self.num_people),
|
|
97
|
+
state_observability=1.0,
|
|
98
|
+
constraint_density=0.6,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _generate_solution(self) -> None:
|
|
102
|
+
"""Generate a random valid solution."""
|
|
103
|
+
people = self.categories.person
|
|
104
|
+
|
|
105
|
+
# Randomly assign each attribute to each person
|
|
106
|
+
colors = self.categories.color[:]
|
|
107
|
+
pets = self.categories.pet[:]
|
|
108
|
+
drinks = self.categories.drink[:]
|
|
109
|
+
|
|
110
|
+
self._rng.shuffle(colors)
|
|
111
|
+
self._rng.shuffle(pets)
|
|
112
|
+
self._rng.shuffle(drinks)
|
|
113
|
+
|
|
114
|
+
self.solution = {}
|
|
115
|
+
for i, person in enumerate(people):
|
|
116
|
+
self.solution[person] = PersonAttributes(
|
|
117
|
+
color=colors[i],
|
|
118
|
+
pet=pets[i],
|
|
119
|
+
drink=drinks[i],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _generate_clues(self) -> None:
|
|
123
|
+
"""Generate clues from the solution."""
|
|
124
|
+
self.clues = []
|
|
125
|
+
people = self.categories.person
|
|
126
|
+
|
|
127
|
+
# Generate direct association clues
|
|
128
|
+
num_direct = self.num_people - 1
|
|
129
|
+
for i in range(num_direct):
|
|
130
|
+
person = people[i]
|
|
131
|
+
attrs = self.solution[person]
|
|
132
|
+
|
|
133
|
+
# Choose two attributes to reveal
|
|
134
|
+
cat1, cat2 = self._rng.sample(["color", "pet", "drink"], 2)
|
|
135
|
+
val1 = getattr(attrs, cat1)
|
|
136
|
+
val2 = getattr(attrs, cat2)
|
|
137
|
+
clue = f"{person} has the {val1} {cat1} and drinks {val2}"
|
|
138
|
+
self.clues.append(clue)
|
|
139
|
+
|
|
140
|
+
# Generate relative/constraint clues
|
|
141
|
+
for _ in range(self.num_people):
|
|
142
|
+
p1, p2 = self._rng.sample(people, 2)
|
|
143
|
+
cat = self._rng.choice(["color", "pet", "drink"])
|
|
144
|
+
|
|
145
|
+
val = getattr(self.solution[p2], cat)
|
|
146
|
+
clue = f"{p1} does not have the {val} {cat}"
|
|
147
|
+
self.clues.append(clue)
|
|
148
|
+
|
|
149
|
+
async def generate_puzzle(self) -> None:
|
|
150
|
+
"""Generate a new Logic Grid puzzle."""
|
|
151
|
+
self._generate_solution()
|
|
152
|
+
self._generate_clues()
|
|
153
|
+
|
|
154
|
+
# Initialize player grid (all unknown)
|
|
155
|
+
self.player_grid = {}
|
|
156
|
+
|
|
157
|
+
self.moves_made = 0
|
|
158
|
+
self.game_started = True
|
|
159
|
+
|
|
160
|
+
async def validate_move(self, cat1: str, val1: str, cat2: str, val2: str, state: bool) -> MoveResult:
|
|
161
|
+
"""Mark a connection in the logic grid.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
cat1: First category
|
|
165
|
+
val1: First value
|
|
166
|
+
cat2: Second category
|
|
167
|
+
val2: Second value
|
|
168
|
+
state: True = connected, False = not connected
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
MoveResult with success status and message
|
|
172
|
+
"""
|
|
173
|
+
# Normalize categories
|
|
174
|
+
cat1 = cat1.lower()
|
|
175
|
+
cat2 = cat2.lower()
|
|
176
|
+
|
|
177
|
+
# Validate categories
|
|
178
|
+
valid_categories = ["person", "color", "pet", "drink"]
|
|
179
|
+
if cat1 not in valid_categories or cat2 not in valid_categories:
|
|
180
|
+
return MoveResult(success=False, message=f"Invalid category. Use: {', '.join(valid_categories)}")
|
|
181
|
+
|
|
182
|
+
if cat1 == cat2:
|
|
183
|
+
return MoveResult(success=False, message="Cannot connect values from the same category")
|
|
184
|
+
|
|
185
|
+
# Validate values
|
|
186
|
+
cat1_values = getattr(self.categories, cat1)
|
|
187
|
+
cat2_values = getattr(self.categories, cat2)
|
|
188
|
+
|
|
189
|
+
if val1 not in cat1_values:
|
|
190
|
+
return MoveResult(success=False, message=f"Invalid {cat1}. Choose from: {', '.join(cat1_values)}")
|
|
191
|
+
|
|
192
|
+
if val2 not in cat2_values:
|
|
193
|
+
return MoveResult(success=False, message=f"Invalid {cat2}. Choose from: {', '.join(cat2_values)}")
|
|
194
|
+
|
|
195
|
+
# Store the connection (normalize order)
|
|
196
|
+
key = (cat1, val1, cat2, val2) if cat1 < cat2 else (cat2, val2, cat1, val1)
|
|
197
|
+
self.player_grid[key] = state
|
|
198
|
+
self.moves_made += 1
|
|
199
|
+
|
|
200
|
+
return MoveResult(
|
|
201
|
+
success=True,
|
|
202
|
+
message=f"Marked {val1} ({cat1}) and {val2} ({cat2}) as {'connected' if state else 'not connected'}",
|
|
203
|
+
state_changed=True,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def is_complete(self) -> bool:
|
|
207
|
+
"""Check if the puzzle is complete and correct."""
|
|
208
|
+
# Check if player has correctly identified all connections
|
|
209
|
+
for person in self.categories.person:
|
|
210
|
+
attrs = self.solution[person]
|
|
211
|
+
|
|
212
|
+
# Check person -> color
|
|
213
|
+
key1 = ("color", attrs.color, "person", person)
|
|
214
|
+
key2 = ("person", person, "color", attrs.color)
|
|
215
|
+
if not self.player_grid.get(key1) and not self.player_grid.get(key2):
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
# Check person -> pet
|
|
219
|
+
key1 = ("person", person, "pet", attrs.pet)
|
|
220
|
+
key2 = ("pet", attrs.pet, "person", person)
|
|
221
|
+
if not self.player_grid.get(key1) and not self.player_grid.get(key2):
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
# Check person -> drink
|
|
225
|
+
key1 = ("drink", attrs.drink, "person", person)
|
|
226
|
+
key2 = ("person", person, "drink", attrs.drink)
|
|
227
|
+
if not self.player_grid.get(key1) and not self.player_grid.get(key2):
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
233
|
+
"""Get a hint for the next move.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
237
|
+
"""
|
|
238
|
+
# Find a connection that hasn't been marked
|
|
239
|
+
for person in self.categories.person:
|
|
240
|
+
attrs = self.solution[person]
|
|
241
|
+
|
|
242
|
+
# Check all categories except person
|
|
243
|
+
for cat in [c for c in CATEGORIES if c != "person"]:
|
|
244
|
+
val = getattr(attrs, cat)
|
|
245
|
+
key1 = (cat, val, "person", person)
|
|
246
|
+
key2 = ("person", person, cat, val)
|
|
247
|
+
|
|
248
|
+
if not self.player_grid.get(key1) and not self.player_grid.get(key2):
|
|
249
|
+
hint_data = (person, cat, val)
|
|
250
|
+
hint_message = f"{person} has the {val} {cat}"
|
|
251
|
+
return hint_data, hint_message
|
|
252
|
+
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
def render_grid(self) -> str:
|
|
256
|
+
"""Render the current puzzle state.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
String representation of the clues and current deductions
|
|
260
|
+
"""
|
|
261
|
+
lines = []
|
|
262
|
+
|
|
263
|
+
lines.append("\n=== LOGIC GRID PUZZLE ===\n")
|
|
264
|
+
|
|
265
|
+
# Show clues
|
|
266
|
+
lines.append("CLUES:")
|
|
267
|
+
for i, clue in enumerate(self.clues, 1):
|
|
268
|
+
lines.append(f" {i}. {clue}")
|
|
269
|
+
|
|
270
|
+
lines.append("\nYOUR DEDUCTIONS:")
|
|
271
|
+
if not self.player_grid:
|
|
272
|
+
lines.append(" (none yet)")
|
|
273
|
+
else:
|
|
274
|
+
for (cat1, val1, cat2, val2), state in sorted(self.player_grid.items()):
|
|
275
|
+
if state is True:
|
|
276
|
+
lines.append(f" ✓ {val1} ({cat1}) ←→ {val2} ({cat2})")
|
|
277
|
+
elif state is False:
|
|
278
|
+
lines.append(f" ✗ {val1} ({cat1}) ←/→ {val2} ({cat2})")
|
|
279
|
+
|
|
280
|
+
lines.append("\nCATEGORIES:")
|
|
281
|
+
for cat in CATEGORIES:
|
|
282
|
+
values = getattr(self.categories, cat)
|
|
283
|
+
lines.append(f" {cat.capitalize()}: {', '.join(values)}")
|
|
284
|
+
|
|
285
|
+
return "\n".join(lines)
|
|
286
|
+
|
|
287
|
+
def get_rules(self) -> str:
|
|
288
|
+
"""Get the rules description for Logic Grid.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Multi-line string describing the puzzle rules
|
|
292
|
+
"""
|
|
293
|
+
return """LOGIC GRID RULES:
|
|
294
|
+
- Use logical deduction to match attributes
|
|
295
|
+
- Each person has exactly one color, one pet, and one drink
|
|
296
|
+
- No two people share the same attribute value
|
|
297
|
+
- Read the clues carefully and mark connections
|
|
298
|
+
- Mark connections as True (✓) or False (✗)
|
|
299
|
+
- Use elimination and deduction to solve"""
|
|
300
|
+
|
|
301
|
+
def get_commands(self) -> str:
|
|
302
|
+
"""Get the available commands for Logic Grid.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Multi-line string describing available commands
|
|
306
|
+
"""
|
|
307
|
+
return """LOGIC GRID COMMANDS:
|
|
308
|
+
connect <cat1> <val1> <cat2> <val2>
|
|
309
|
+
- Mark that val1 and val2 are connected (belong to same person)
|
|
310
|
+
- Example: 'connect person Alice color Red'
|
|
311
|
+
|
|
312
|
+
exclude <cat1> <val1> <cat2> <val2>
|
|
313
|
+
- Mark that val1 and val2 are NOT connected
|
|
314
|
+
- Example: 'exclude person Bob pet Cat'
|
|
315
|
+
|
|
316
|
+
show - Display clues and deductions
|
|
317
|
+
hint - Get a hint
|
|
318
|
+
check - Check if puzzle is solved
|
|
319
|
+
solve - Show the solution (ends game)
|
|
320
|
+
menu - Return to game selection
|
|
321
|
+
quit - Exit the server"""
|
|
322
|
+
|
|
323
|
+
def get_stats(self) -> str:
|
|
324
|
+
"""Get current game statistics.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
String with game stats
|
|
328
|
+
"""
|
|
329
|
+
connections = sum(1 for v in self.player_grid.values() if v is True)
|
|
330
|
+
exclusions = sum(1 for v in self.player_grid.values() if v is False)
|
|
331
|
+
return (
|
|
332
|
+
f"Moves made: {self.moves_made} | Connections: {connections} | Exclusions: {exclusions} | Seed: {self.seed}"
|
|
333
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Logic Grid puzzle game models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LogicGridCategories(BaseModel):
|
|
7
|
+
"""Categories for Logic Grid puzzle."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(frozen=True)
|
|
10
|
+
|
|
11
|
+
person: list[str]
|
|
12
|
+
color: list[str]
|
|
13
|
+
pet: list[str]
|
|
14
|
+
drink: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PersonAttributes(BaseModel):
|
|
18
|
+
"""Attributes for a person in Logic Grid puzzle."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(frozen=False)
|
|
21
|
+
|
|
22
|
+
color: str
|
|
23
|
+
pet: str
|
|
24
|
+
drink: str
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Configuration for Mastermind game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MastermindConfig(BaseModel):
|
|
9
|
+
"""Configuration for Mastermind game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
code_length: int = Field(ge=3, le=6, description="Length of the secret code")
|
|
13
|
+
num_colors: int = Field(ge=4, le=8, description="Number of available colors")
|
|
14
|
+
max_guesses: int = Field(ge=8, le=15, description="Maximum number of guesses allowed")
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "MastermindConfig":
|
|
18
|
+
"""Create config from difficulty level."""
|
|
19
|
+
config_map = {
|
|
20
|
+
DifficultyLevel.EASY: {"code_length": 4, "num_colors": 6, "max_guesses": 12},
|
|
21
|
+
DifficultyLevel.MEDIUM: {"code_length": 5, "num_colors": 7, "max_guesses": 12},
|
|
22
|
+
DifficultyLevel.HARD: {"code_length": 6, "num_colors": 8, "max_guesses": 15},
|
|
23
|
+
}
|
|
24
|
+
params = config_map[difficulty]
|
|
25
|
+
return cls(difficulty=difficulty, **params)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Mastermind 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 MastermindConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MastermindGame(PuzzleGame):
|
|
11
|
+
"""Mastermind code-breaking puzzle game.
|
|
12
|
+
|
|
13
|
+
Guess the secret code using logical deduction from feedback.
|
|
14
|
+
Each guess gives you black pegs (correct color + position)
|
|
15
|
+
and white pegs (correct color, wrong position).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
19
|
+
"""Initialize a new Mastermind game.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
difficulty: Game difficulty level (easy/medium/hard)
|
|
23
|
+
"""
|
|
24
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
25
|
+
|
|
26
|
+
# Use pydantic config based on difficulty
|
|
27
|
+
self.config = MastermindConfig.from_difficulty(self.difficulty)
|
|
28
|
+
self.code_length = self.config.code_length
|
|
29
|
+
self.num_colors = self.config.num_colors
|
|
30
|
+
self.max_guesses = self.config.max_guesses
|
|
31
|
+
|
|
32
|
+
# Color representation (1-8 for easy display)
|
|
33
|
+
self.colors = list(range(1, self.num_colors + 1))
|
|
34
|
+
self.color_names = {
|
|
35
|
+
1: "Red",
|
|
36
|
+
2: "Blue",
|
|
37
|
+
3: "Green",
|
|
38
|
+
4: "Yellow",
|
|
39
|
+
5: "Orange",
|
|
40
|
+
6: "Purple",
|
|
41
|
+
7: "Cyan",
|
|
42
|
+
8: "Magenta",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Game state
|
|
46
|
+
self.secret_code: list[int] = []
|
|
47
|
+
self.guesses: list[list[int]] = []
|
|
48
|
+
self.feedback: list[tuple[int, int]] = [] # (black_pegs, white_pegs)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def name(self) -> str:
|
|
52
|
+
"""The display name of this puzzle type."""
|
|
53
|
+
return "Mastermind"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def description(self) -> str:
|
|
57
|
+
"""A one-line description of this puzzle type."""
|
|
58
|
+
return "Code-breaking with logical deduction and feedback"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def constraint_types(self) -> list[str]:
|
|
62
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
63
|
+
return ["feedback", "elimination", "logical_inference", "pattern_matching", "iterative_refinement"]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def business_analogies(self) -> list[str]:
|
|
67
|
+
"""Business problems this puzzle models."""
|
|
68
|
+
return ["hypothesis_testing", "feedback_loops", "iterative_optimization", "parameter_tuning"]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
72
|
+
"""Complexity profile of this puzzle."""
|
|
73
|
+
return {"reasoning_type": "hybrid", "search_space": "exponential", "constraint_density": "sparse"}
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def optimal_steps(self) -> int | None:
|
|
77
|
+
"""Optimal guesses for Mastermind with hints = 1 (hint reveals code)."""
|
|
78
|
+
# With hints, the code is revealed directly, so optimal is 1
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
83
|
+
"""Difficulty characteristics for Mastermind."""
|
|
84
|
+
from ...models import DifficultyLevel
|
|
85
|
+
|
|
86
|
+
logic_depth = {
|
|
87
|
+
DifficultyLevel.EASY.value: 2,
|
|
88
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
89
|
+
DifficultyLevel.HARD.value: 5,
|
|
90
|
+
}.get(self.difficulty.value, 3)
|
|
91
|
+
branching = self.num_colors**self.code_length # Huge search space
|
|
92
|
+
return DifficultyProfile(
|
|
93
|
+
logic_depth=logic_depth,
|
|
94
|
+
branching_factor=min(10.0, branching / 100), # Normalized
|
|
95
|
+
state_observability=0.3, # Hidden code
|
|
96
|
+
constraint_density=0.2,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def generate_puzzle(self) -> None:
|
|
100
|
+
"""Generate a new Mastermind puzzle."""
|
|
101
|
+
# Generate random secret code
|
|
102
|
+
self.secret_code = [self._rng.choice(self.colors) for _ in range(self.code_length)]
|
|
103
|
+
|
|
104
|
+
# Reset game state
|
|
105
|
+
self.guesses = []
|
|
106
|
+
self.feedback = []
|
|
107
|
+
self.moves_made = 0
|
|
108
|
+
self.game_started = True
|
|
109
|
+
|
|
110
|
+
def _calculate_feedback(self, guess: list[int]) -> tuple[int, int]:
|
|
111
|
+
"""Calculate feedback for a guess.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
guess: The guessed code
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Tuple of (black_pegs, white_pegs)
|
|
118
|
+
- black_pegs: correct color in correct position
|
|
119
|
+
- white_pegs: correct color in wrong position
|
|
120
|
+
"""
|
|
121
|
+
black_pegs = 0
|
|
122
|
+
white_pegs = 0
|
|
123
|
+
|
|
124
|
+
# Create copies to track which positions have been matched
|
|
125
|
+
secret_remaining = list(self.secret_code)
|
|
126
|
+
guess_remaining = list(guess)
|
|
127
|
+
|
|
128
|
+
# First pass: count black pegs (exact matches)
|
|
129
|
+
for i in range(self.code_length):
|
|
130
|
+
if guess[i] == self.secret_code[i]:
|
|
131
|
+
black_pegs += 1
|
|
132
|
+
secret_remaining[i] = -1 # Mark as used
|
|
133
|
+
guess_remaining[i] = -1 # Mark as used
|
|
134
|
+
|
|
135
|
+
# Second pass: count white pegs (color matches in wrong position)
|
|
136
|
+
for i in range(self.code_length):
|
|
137
|
+
if guess_remaining[i] != -1: # Not already matched
|
|
138
|
+
if guess_remaining[i] in secret_remaining:
|
|
139
|
+
white_pegs += 1
|
|
140
|
+
# Remove the first occurrence from secret_remaining
|
|
141
|
+
idx = secret_remaining.index(guess_remaining[i])
|
|
142
|
+
secret_remaining[idx] = -1
|
|
143
|
+
|
|
144
|
+
return black_pegs, white_pegs
|
|
145
|
+
|
|
146
|
+
async def validate_move(self, *guess: int) -> MoveResult:
|
|
147
|
+
"""Make a guess.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
*guess: Variable number of color values (should match code_length)
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
MoveResult with success status and message
|
|
154
|
+
"""
|
|
155
|
+
# Check if game is over
|
|
156
|
+
if len(self.guesses) >= self.max_guesses:
|
|
157
|
+
return MoveResult(
|
|
158
|
+
success=False, message=f"No guesses remaining! The code was: {' '.join(map(str, self.secret_code))}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Validate guess length
|
|
162
|
+
if len(guess) != self.code_length:
|
|
163
|
+
return MoveResult(success=False, message=f"Guess must be exactly {self.code_length} colors.")
|
|
164
|
+
|
|
165
|
+
# Validate all colors are in range
|
|
166
|
+
for color in guess:
|
|
167
|
+
if color not in self.colors:
|
|
168
|
+
return MoveResult(success=False, message=f"Invalid color {color}. Use colors 1-{self.num_colors}.")
|
|
169
|
+
|
|
170
|
+
# Convert tuple to list
|
|
171
|
+
guess_list = list(guess)
|
|
172
|
+
|
|
173
|
+
# Calculate feedback
|
|
174
|
+
black_pegs, white_pegs = self._calculate_feedback(guess_list)
|
|
175
|
+
|
|
176
|
+
# Store guess and feedback
|
|
177
|
+
self.guesses.append(guess_list)
|
|
178
|
+
self.feedback.append((black_pegs, white_pegs))
|
|
179
|
+
self.moves_made += 1
|
|
180
|
+
|
|
181
|
+
# Check if won
|
|
182
|
+
if black_pegs == self.code_length:
|
|
183
|
+
return MoveResult(
|
|
184
|
+
success=True,
|
|
185
|
+
message=f"Congratulations! You cracked the code in {len(self.guesses)} guesses!",
|
|
186
|
+
state_changed=True,
|
|
187
|
+
game_over=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Check if out of guesses
|
|
191
|
+
if len(self.guesses) >= self.max_guesses:
|
|
192
|
+
code_str = " ".join(map(str, self.secret_code))
|
|
193
|
+
return MoveResult(
|
|
194
|
+
success=True, message=f"Game over! The code was: {code_str}", state_changed=True, game_over=True
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return MoveResult(success=True, message=f"Feedback: {black_pegs} black, {white_pegs} white", state_changed=True)
|
|
198
|
+
|
|
199
|
+
def is_complete(self) -> bool:
|
|
200
|
+
"""Check if the puzzle is complete (code cracked)."""
|
|
201
|
+
if not self.feedback:
|
|
202
|
+
return False
|
|
203
|
+
black_pegs, _white_pegs = self.feedback[-1]
|
|
204
|
+
return black_pegs == self.code_length
|
|
205
|
+
|
|
206
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
207
|
+
"""Get a hint for the next move.
|
|
208
|
+
|
|
209
|
+
For evaluation purposes, provides the complete secret code as a guess.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Tuple of (hint_data, hint_message) or None if no hints available
|
|
213
|
+
"""
|
|
214
|
+
if self.is_complete():
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
# Provide the complete secret code as hint for evaluation
|
|
218
|
+
# The validate_move function expects the full code as arguments
|
|
219
|
+
hint_data = tuple(self.secret_code)
|
|
220
|
+
code_str = " ".join(str(c) for c in self.secret_code)
|
|
221
|
+
hint_message = f"The secret code is: {code_str}"
|
|
222
|
+
return hint_data, hint_message
|
|
223
|
+
|
|
224
|
+
def render_grid(self) -> str:
|
|
225
|
+
"""Render the current game state as ASCII art.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
String representation of the game state
|
|
229
|
+
"""
|
|
230
|
+
lines = []
|
|
231
|
+
|
|
232
|
+
# Header
|
|
233
|
+
lines.append(f"Mastermind - Crack the {self.code_length}-color code!")
|
|
234
|
+
lines.append(f"Colors available: 1-{self.num_colors}")
|
|
235
|
+
lines.append(f"Guesses remaining: {self.max_guesses - len(self.guesses)}")
|
|
236
|
+
lines.append("")
|
|
237
|
+
|
|
238
|
+
# Color legend
|
|
239
|
+
lines.append("Color Legend:")
|
|
240
|
+
legend_parts = []
|
|
241
|
+
for color in range(1, self.num_colors + 1):
|
|
242
|
+
legend_parts.append(f"{color}={self.color_names[color][:3]}")
|
|
243
|
+
lines.append(" " + ", ".join(legend_parts))
|
|
244
|
+
lines.append("")
|
|
245
|
+
|
|
246
|
+
# Guess history
|
|
247
|
+
if self.guesses:
|
|
248
|
+
lines.append("Guess History:")
|
|
249
|
+
lines.append(" # | Code | Black | White")
|
|
250
|
+
lines.append(" " + "-" * 38)
|
|
251
|
+
|
|
252
|
+
for i, (guess, (black, white)) in enumerate(zip(self.guesses, self.feedback, strict=True), 1):
|
|
253
|
+
guess_str = " ".join(str(c) for c in guess)
|
|
254
|
+
lines.append(f" {i:2d} | {guess_str:11s} | {black} | {white}")
|
|
255
|
+
else:
|
|
256
|
+
lines.append("No guesses yet. Make your first guess!")
|
|
257
|
+
|
|
258
|
+
return "\n".join(lines)
|
|
259
|
+
|
|
260
|
+
def get_rules(self) -> str:
|
|
261
|
+
"""Get the rules description for Mastermind.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Multi-line string describing the puzzle rules
|
|
265
|
+
"""
|
|
266
|
+
return f"""MASTERMIND RULES:
|
|
267
|
+
- Guess the secret {self.code_length}-color code
|
|
268
|
+
- Each position uses colors 1-{self.num_colors}
|
|
269
|
+
- Colors can repeat in the code
|
|
270
|
+
- After each guess, you get feedback:
|
|
271
|
+
* Black peg = correct color in correct position
|
|
272
|
+
* White peg = correct color in wrong position
|
|
273
|
+
- You have {self.max_guesses} guesses to crack the code
|
|
274
|
+
- Use logic to eliminate possibilities!"""
|
|
275
|
+
|
|
276
|
+
def get_commands(self) -> str:
|
|
277
|
+
"""Get the available commands for Mastermind.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Multi-line string describing available commands
|
|
281
|
+
"""
|
|
282
|
+
example = " ".join(["1"] * self.code_length)
|
|
283
|
+
return f"""MASTERMIND COMMANDS:
|
|
284
|
+
guess <c1> <c2> ... <c{self.code_length}> - Make a guess (e.g., 'guess {example}')
|
|
285
|
+
show - Display current game state
|
|
286
|
+
hint - Get a hint for the code
|
|
287
|
+
solve - Reveal the solution (ends game)
|
|
288
|
+
menu - Return to game selection
|
|
289
|
+
quit - Exit the server"""
|
|
290
|
+
|
|
291
|
+
def get_stats(self) -> str:
|
|
292
|
+
"""Get current game statistics.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
String with game stats
|
|
296
|
+
"""
|
|
297
|
+
return f"Guesses made: {len(self.guesses)}/{self.max_guesses} | Code length: {self.code_length} | Colors: 1-{self.num_colors} | Seed: {self.seed}"
|