chuk-puzzles-gym 0.9__py3-none-any.whl → 0.10.1__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/eval.py +21 -0
- chuk_puzzles_gym/games/__init__.py +22 -0
- chuk_puzzles_gym/games/binary/game.py +2 -0
- chuk_puzzles_gym/games/bridges/game.py +2 -0
- chuk_puzzles_gym/games/cryptarithmetic/__init__.py +7 -0
- chuk_puzzles_gym/games/cryptarithmetic/commands.py +75 -0
- chuk_puzzles_gym/games/cryptarithmetic/config.py +23 -0
- chuk_puzzles_gym/games/cryptarithmetic/game.py +388 -0
- chuk_puzzles_gym/games/einstein/game.py +2 -0
- chuk_puzzles_gym/games/fillomino/game.py +2 -0
- chuk_puzzles_gym/games/futoshiki/game.py +2 -0
- chuk_puzzles_gym/games/graph_coloring/__init__.py +7 -0
- chuk_puzzles_gym/games/graph_coloring/commands.py +96 -0
- chuk_puzzles_gym/games/graph_coloring/config.py +24 -0
- chuk_puzzles_gym/games/graph_coloring/game.py +316 -0
- chuk_puzzles_gym/games/hidato/game.py +2 -0
- chuk_puzzles_gym/games/hitori/game.py +2 -0
- chuk_puzzles_gym/games/kakuro/game.py +2 -0
- chuk_puzzles_gym/games/kenken/game.py +2 -0
- chuk_puzzles_gym/games/killer_sudoku/game.py +2 -0
- chuk_puzzles_gym/games/knapsack/game.py +2 -0
- chuk_puzzles_gym/games/lights_out/game.py +2 -0
- chuk_puzzles_gym/games/logic_grid/game.py +2 -0
- chuk_puzzles_gym/games/mastermind/game.py +2 -0
- chuk_puzzles_gym/games/minesweeper/game.py +2 -0
- chuk_puzzles_gym/games/nonogram/game.py +2 -0
- chuk_puzzles_gym/games/nqueens/__init__.py +6 -0
- chuk_puzzles_gym/games/nqueens/config.py +23 -0
- chuk_puzzles_gym/games/nqueens/game.py +321 -0
- chuk_puzzles_gym/games/numberlink/__init__.py +6 -0
- chuk_puzzles_gym/games/numberlink/config.py +23 -0
- chuk_puzzles_gym/games/numberlink/game.py +344 -0
- chuk_puzzles_gym/games/nurikabe/game.py +2 -0
- chuk_puzzles_gym/games/rush_hour/__init__.py +8 -0
- chuk_puzzles_gym/games/rush_hour/commands.py +57 -0
- chuk_puzzles_gym/games/rush_hour/config.py +25 -0
- chuk_puzzles_gym/games/rush_hour/game.py +479 -0
- chuk_puzzles_gym/games/rush_hour/models.py +15 -0
- chuk_puzzles_gym/games/scheduler/game.py +2 -0
- chuk_puzzles_gym/games/shikaku/game.py +2 -0
- chuk_puzzles_gym/games/skyscrapers/__init__.py +6 -0
- chuk_puzzles_gym/games/skyscrapers/config.py +22 -0
- chuk_puzzles_gym/games/skyscrapers/game.py +282 -0
- chuk_puzzles_gym/games/slitherlink/game.py +2 -0
- chuk_puzzles_gym/games/sokoban/game.py +2 -0
- chuk_puzzles_gym/games/star_battle/game.py +2 -0
- chuk_puzzles_gym/games/sudoku/game.py +2 -0
- chuk_puzzles_gym/games/tents/game.py +2 -0
- chuk_puzzles_gym/server.py +18 -70
- chuk_puzzles_gym/trace/generator.py +87 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/METADATA +60 -19
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/RECORD +55 -33
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/WHEEL +1 -1
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/entry_points.txt +0 -0
- {chuk_puzzles_gym-0.9.dist-info → chuk_puzzles_gym-0.10.1.dist-info}/top_level.txt +0 -0
chuk_puzzles_gym/eval.py
CHANGED
|
@@ -235,6 +235,9 @@ async def _apply_hint(game: PuzzleGame, hint_data: tuple) -> MoveResult:
|
|
|
235
235
|
"nonogram",
|
|
236
236
|
"hidato",
|
|
237
237
|
"fillomino",
|
|
238
|
+
"skyscrapers",
|
|
239
|
+
"n-queens",
|
|
240
|
+
"numberlink",
|
|
238
241
|
]:
|
|
239
242
|
if len(hint_data) >= 3:
|
|
240
243
|
row, col, value = hint_data[0], hint_data[1], hint_data[2]
|
|
@@ -342,6 +345,24 @@ async def _apply_hint(game: PuzzleGame, hint_data: tuple) -> MoveResult:
|
|
|
342
345
|
direction = hint_data if isinstance(hint_data, str) else hint_data
|
|
343
346
|
return await game.validate_move(direction)
|
|
344
347
|
|
|
348
|
+
# Graph Coloring - hint is (node, color)
|
|
349
|
+
if game_name in ["graph coloring"]:
|
|
350
|
+
if len(hint_data) >= 2:
|
|
351
|
+
node, color = hint_data[0], hint_data[1]
|
|
352
|
+
return await game.validate_move(node, color)
|
|
353
|
+
|
|
354
|
+
# Cryptarithmetic - hint is (letter, digit)
|
|
355
|
+
if game_name in ["cryptarithmetic"]:
|
|
356
|
+
if len(hint_data) >= 2:
|
|
357
|
+
letter, digit = hint_data[0], hint_data[1]
|
|
358
|
+
return await game.validate_move(letter, digit)
|
|
359
|
+
|
|
360
|
+
# Rush Hour - hint is (vehicle_id, direction)
|
|
361
|
+
if game_name in ["rush hour"]:
|
|
362
|
+
if len(hint_data) >= 2:
|
|
363
|
+
vehicle_id, direction = hint_data[0], hint_data[1]
|
|
364
|
+
return await game.validate_move(vehicle_id, direction)
|
|
365
|
+
|
|
345
366
|
# Generic fallback - try validate_move with hint args as tuple
|
|
346
367
|
if isinstance(hint_data, tuple) and len(hint_data) >= 2:
|
|
347
368
|
return await game.validate_move(*hint_data)
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from .binary import BinaryPuzzleGame
|
|
4
4
|
from .bridges import BridgesGame
|
|
5
|
+
from .cryptarithmetic import CryptarithmeticCommandHandler, CryptarithmeticGame
|
|
5
6
|
from .einstein import EinsteinGame
|
|
6
7
|
from .fillomino import FillominoGame
|
|
7
8
|
from .futoshiki import FutoshikiGame
|
|
9
|
+
from .graph_coloring import GraphColoringCommandHandler, GraphColoringGame
|
|
8
10
|
from .hidato import HidatoGame
|
|
9
11
|
from .hitori import HitoriGame
|
|
10
12
|
from .kakuro import KakuroGame
|
|
@@ -16,9 +18,13 @@ from .logic_grid import LogicGridGame
|
|
|
16
18
|
from .mastermind import MastermindGame
|
|
17
19
|
from .minesweeper import MinesweeperGame
|
|
18
20
|
from .nonogram import NonogramGame
|
|
21
|
+
from .nqueens import NQueensGame
|
|
22
|
+
from .numberlink import NumberlinkGame
|
|
19
23
|
from .nurikabe import NurikabeGame
|
|
24
|
+
from .rush_hour import RushHourCommandHandler, RushHourGame
|
|
20
25
|
from .scheduler import SchedulerGame
|
|
21
26
|
from .shikaku import ShikakuGame
|
|
27
|
+
from .skyscrapers import SkyscrapersGame
|
|
22
28
|
from .slitherlink import SlitherlinkGame
|
|
23
29
|
from .sokoban import SokobanGame
|
|
24
30
|
from .star_battle import StarBattleGame
|
|
@@ -57,11 +63,21 @@ AVAILABLE_GAMES = {
|
|
|
57
63
|
"nurikabe": NurikabeGame,
|
|
58
64
|
"einstein": EinsteinGame,
|
|
59
65
|
"minesweeper": MinesweeperGame,
|
|
66
|
+
# New Games
|
|
67
|
+
"skyscrapers": SkyscrapersGame,
|
|
68
|
+
"nqueens": NQueensGame,
|
|
69
|
+
"numberlink": NumberlinkGame,
|
|
70
|
+
"graph_coloring": GraphColoringGame,
|
|
71
|
+
"cryptarithmetic": CryptarithmeticGame,
|
|
72
|
+
"rush_hour": RushHourGame,
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
# Registry of game command handlers (games that have moved command handling out of server)
|
|
63
76
|
GAME_COMMAND_HANDLERS = {
|
|
64
77
|
"sudoku": SudokuCommandHandler,
|
|
78
|
+
"graph_coloring": GraphColoringCommandHandler,
|
|
79
|
+
"cryptarithmetic": CryptarithmeticCommandHandler,
|
|
80
|
+
"rush_hour": RushHourCommandHandler,
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
__all__ = [
|
|
@@ -89,6 +105,12 @@ __all__ = [
|
|
|
89
105
|
"NurikabeGame",
|
|
90
106
|
"EinsteinGame",
|
|
91
107
|
"MinesweeperGame",
|
|
108
|
+
"SkyscrapersGame",
|
|
109
|
+
"NQueensGame",
|
|
110
|
+
"NumberlinkGame",
|
|
111
|
+
"GraphColoringGame",
|
|
112
|
+
"CryptarithmeticGame",
|
|
113
|
+
"RushHourGame",
|
|
92
114
|
"AVAILABLE_GAMES",
|
|
93
115
|
"GAME_COMMAND_HANDLERS",
|
|
94
116
|
]
|
|
@@ -358,6 +358,8 @@ class BinaryPuzzleGame(PuzzleGame):
|
|
|
358
358
|
Returns:
|
|
359
359
|
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
360
360
|
"""
|
|
361
|
+
if not self.can_use_hint():
|
|
362
|
+
return None
|
|
361
363
|
empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == -1]
|
|
362
364
|
if not empty_cells:
|
|
363
365
|
return None
|
|
@@ -415,6 +415,8 @@ class BridgesGame(PuzzleGame):
|
|
|
415
415
|
|
|
416
416
|
async def get_hint(self) -> tuple[Any, str] | None:
|
|
417
417
|
"""Get a hint for the next move."""
|
|
418
|
+
if not self.can_use_hint():
|
|
419
|
+
return None
|
|
418
420
|
# Find a bridge in the solution that's not yet placed correctly
|
|
419
421
|
for bridge_key, solution_count in self.solution.items():
|
|
420
422
|
current_count = self.bridges.get(bridge_key, 0)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Cryptarithmetic puzzle game."""
|
|
2
|
+
|
|
3
|
+
from .commands import CryptarithmeticCommandHandler
|
|
4
|
+
from .config import CryptarithmeticConfig
|
|
5
|
+
from .game import CryptarithmeticGame
|
|
6
|
+
|
|
7
|
+
__all__ = ["CryptarithmeticGame", "CryptarithmeticConfig", "CryptarithmeticCommandHandler"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Command handler for Cryptarithmetic 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 CryptarithmeticGame
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CryptarithmeticCommandHandler(GameCommandHandler):
|
|
13
|
+
"""Handles commands for Cryptarithmetic game."""
|
|
14
|
+
|
|
15
|
+
game: "CryptarithmeticGame"
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def supported_commands(self) -> set[GameCommand]:
|
|
19
|
+
"""Return the set of GameCommand enums this handler supports."""
|
|
20
|
+
return {GameCommand.ASSIGN, GameCommand.UNASSIGN}
|
|
21
|
+
|
|
22
|
+
async def handle_command(self, cmd: GameCommand, args: list[str]) -> CommandResult:
|
|
23
|
+
"""Handle a Cryptarithmetic 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.ASSIGN:
|
|
33
|
+
return await self._handle_assign(args)
|
|
34
|
+
elif cmd == GameCommand.UNASSIGN:
|
|
35
|
+
return await self._handle_unassign(args)
|
|
36
|
+
else:
|
|
37
|
+
return self.error_result(f"Unknown command: {cmd}")
|
|
38
|
+
|
|
39
|
+
async def _handle_assign(self, args: list[str]) -> CommandResult:
|
|
40
|
+
"""Handle the ASSIGN command: assign <letter> <digit>."""
|
|
41
|
+
if len(args) != 2:
|
|
42
|
+
return CommandResult(
|
|
43
|
+
result=MoveResult(success=False, message="Usage: assign <letter> <digit>"),
|
|
44
|
+
should_display=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
letter = args[0].upper()
|
|
48
|
+
digit = self.parse_int(args[1], "digit")
|
|
49
|
+
|
|
50
|
+
if digit is None:
|
|
51
|
+
return self.error_result("Digit must be an integer (0-9).")
|
|
52
|
+
|
|
53
|
+
result = await self.game.validate_move(letter, digit)
|
|
54
|
+
|
|
55
|
+
return CommandResult(
|
|
56
|
+
result=result,
|
|
57
|
+
should_display=result.success,
|
|
58
|
+
is_game_over=result.success and self.game.is_complete(),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _handle_unassign(self, args: list[str]) -> CommandResult:
|
|
62
|
+
"""Handle the UNASSIGN command: unassign <letter>."""
|
|
63
|
+
if len(args) != 1:
|
|
64
|
+
return CommandResult(
|
|
65
|
+
result=MoveResult(success=False, message="Usage: unassign <letter>"),
|
|
66
|
+
should_display=False,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
letter = args[0].upper()
|
|
70
|
+
result = await self.game.validate_move(letter, -1)
|
|
71
|
+
|
|
72
|
+
return CommandResult(
|
|
73
|
+
result=result,
|
|
74
|
+
should_display=result.success,
|
|
75
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Configuration for Cryptarithmetic puzzle game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CryptarithmeticConfig(BaseModel):
|
|
9
|
+
"""Configuration for a Cryptarithmetic puzzle."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY)
|
|
12
|
+
max_word_length: int = Field(ge=2, le=6, description="Maximum word length")
|
|
13
|
+
pre_assigned: int = Field(ge=0, description="Number of pre-assigned letter-digit pairs")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "CryptarithmeticConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"max_word_length": 3, "pre_assigned": 3},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"max_word_length": 4, "pre_assigned": 2},
|
|
21
|
+
DifficultyLevel.HARD: {"max_word_length": 5, "pre_assigned": 0},
|
|
22
|
+
}
|
|
23
|
+
return cls(difficulty=difficulty, **config_map[difficulty])
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Cryptarithmetic puzzle game implementation."""
|
|
2
|
+
|
|
3
|
+
import string
|
|
4
|
+
from itertools import permutations
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ...models import DifficultyLevel, DifficultyProfile, MoveResult
|
|
8
|
+
from .._base import PuzzleGame
|
|
9
|
+
from .config import CryptarithmeticConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CryptarithmeticGame(PuzzleGame):
|
|
13
|
+
"""Cryptarithmetic puzzle - map letters to digits to satisfy an addition equation.
|
|
14
|
+
|
|
15
|
+
Rules:
|
|
16
|
+
- Each letter represents a unique digit (0-9)
|
|
17
|
+
- The equation must be a valid addition (e.g., SEND + MORE = MONEY)
|
|
18
|
+
- No number may have a leading zero
|
|
19
|
+
- Each digit is used by at most one letter
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
23
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
24
|
+
self.config = CryptarithmeticConfig.from_difficulty(self.difficulty)
|
|
25
|
+
self.operands: list[str] = []
|
|
26
|
+
self.result_word: str = ""
|
|
27
|
+
self.equation: str = ""
|
|
28
|
+
self.letters: list[str] = []
|
|
29
|
+
self.leading_letters: set[str] = set()
|
|
30
|
+
self.letter_mapping: dict[str, int] = {} # Solution
|
|
31
|
+
self.player_mapping: dict[str, int | None] = {} # Player's assignments
|
|
32
|
+
self.initial_mapping: dict[str, int] = {} # Pre-assigned
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
return "Cryptarithmetic"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def description(self) -> str:
|
|
40
|
+
return "Map letters to digits to make the addition equation work"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def constraint_types(self) -> list[str]:
|
|
44
|
+
return ["arithmetic", "all_different", "carry_propagation", "bijective_mapping"]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def business_analogies(self) -> list[str]:
|
|
48
|
+
return ["code_breaking", "financial_reconciliation", "auditing", "data_mapping"]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
52
|
+
return {
|
|
53
|
+
"reasoning_type": "deductive",
|
|
54
|
+
"search_space": "large",
|
|
55
|
+
"constraint_density": "dense",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def complexity_metrics(self) -> dict[str, int | float]:
|
|
60
|
+
unassigned = sum(1 for v in self.player_mapping.values() if v is None)
|
|
61
|
+
return {
|
|
62
|
+
"variable_count": len(self.letters),
|
|
63
|
+
"constraint_count": len(self.letters) + len(self.leading_letters) + 1,
|
|
64
|
+
"domain_size": 10,
|
|
65
|
+
"branching_factor": 10 - len(self.letters) / 2.0,
|
|
66
|
+
"empty_cells": unassigned,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def difficulty_profile(self) -> DifficultyProfile:
|
|
71
|
+
profiles = {
|
|
72
|
+
DifficultyLevel.EASY: DifficultyProfile(
|
|
73
|
+
logic_depth=3, branching_factor=4.0, state_observability=1.0, constraint_density=0.7
|
|
74
|
+
),
|
|
75
|
+
DifficultyLevel.MEDIUM: DifficultyProfile(
|
|
76
|
+
logic_depth=5, branching_factor=6.0, state_observability=1.0, constraint_density=0.6
|
|
77
|
+
),
|
|
78
|
+
DifficultyLevel.HARD: DifficultyProfile(
|
|
79
|
+
logic_depth=7, branching_factor=8.0, state_observability=1.0, constraint_density=0.5
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
return profiles[self.difficulty]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def optimal_steps(self) -> int | None:
|
|
86
|
+
return len(self.letters) - len(self.initial_mapping)
|
|
87
|
+
|
|
88
|
+
def _word_to_number(self, word: str, mapping: dict[str, int]) -> int | None:
|
|
89
|
+
"""Convert a word to a number using the letter-digit mapping."""
|
|
90
|
+
digits = []
|
|
91
|
+
for ch in word:
|
|
92
|
+
if ch not in mapping or mapping[ch] is None:
|
|
93
|
+
return None
|
|
94
|
+
digits.append(str(mapping[ch]))
|
|
95
|
+
return int("".join(digits))
|
|
96
|
+
|
|
97
|
+
def _verify_unique_solution(
|
|
98
|
+
self, letters: list[str], leading: set[str], operands: list[str], result_word: str
|
|
99
|
+
) -> dict[str, int] | None:
|
|
100
|
+
"""Brute-force verify the puzzle has a unique solution.
|
|
101
|
+
|
|
102
|
+
Returns the solution mapping if unique, None otherwise.
|
|
103
|
+
"""
|
|
104
|
+
n = len(letters)
|
|
105
|
+
if n > 10:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
solutions = []
|
|
109
|
+
for perm in permutations(range(10), n):
|
|
110
|
+
mapping = dict(zip(letters, perm, strict=True))
|
|
111
|
+
# Check leading zeros
|
|
112
|
+
if any(mapping[ch] == 0 for ch in leading):
|
|
113
|
+
continue
|
|
114
|
+
# Check equation
|
|
115
|
+
operand_values = []
|
|
116
|
+
for word in operands:
|
|
117
|
+
val = 0
|
|
118
|
+
for ch in word:
|
|
119
|
+
val = val * 10 + mapping[ch]
|
|
120
|
+
operand_values.append(val)
|
|
121
|
+
|
|
122
|
+
result_val = 0
|
|
123
|
+
for ch in result_word:
|
|
124
|
+
result_val = result_val * 10 + mapping[ch]
|
|
125
|
+
|
|
126
|
+
if sum(operand_values) == result_val:
|
|
127
|
+
solutions.append(mapping.copy())
|
|
128
|
+
if len(solutions) > 1:
|
|
129
|
+
return None # Not unique
|
|
130
|
+
|
|
131
|
+
return solutions[0] if len(solutions) == 1 else None
|
|
132
|
+
|
|
133
|
+
async def generate_puzzle(self) -> None:
|
|
134
|
+
"""Generate a cryptarithmetic puzzle."""
|
|
135
|
+
max_len = self.config.max_word_length
|
|
136
|
+
max_attempts = 200
|
|
137
|
+
|
|
138
|
+
for _ in range(max_attempts):
|
|
139
|
+
# Generate random numbers
|
|
140
|
+
min_val = 10 ** (max_len - 1)
|
|
141
|
+
max_val = 10**max_len - 1
|
|
142
|
+
|
|
143
|
+
num1 = self._rng.randint(min_val, max_val)
|
|
144
|
+
num2 = self._rng.randint(min_val, max_val)
|
|
145
|
+
total = num1 + num2
|
|
146
|
+
|
|
147
|
+
# Collect all digits used
|
|
148
|
+
all_digits_str = str(num1) + str(num2) + str(total)
|
|
149
|
+
unique_digits = sorted({int(d) for d in all_digits_str})
|
|
150
|
+
|
|
151
|
+
if len(unique_digits) < 4 or len(unique_digits) > 10:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Create letter mapping: digit -> letter
|
|
155
|
+
available_letters = list(string.ascii_uppercase)
|
|
156
|
+
self._rng.shuffle(available_letters)
|
|
157
|
+
digit_to_letter = {}
|
|
158
|
+
for i, digit in enumerate(unique_digits):
|
|
159
|
+
digit_to_letter[digit] = available_letters[i]
|
|
160
|
+
|
|
161
|
+
# Convert numbers to words
|
|
162
|
+
word1 = "".join(digit_to_letter[int(d)] for d in str(num1))
|
|
163
|
+
word2 = "".join(digit_to_letter[int(d)] for d in str(num2))
|
|
164
|
+
result = "".join(digit_to_letter[int(d)] for d in str(total))
|
|
165
|
+
|
|
166
|
+
# Build letter mapping (letter -> digit)
|
|
167
|
+
letter_mapping = {v: k for k, v in digit_to_letter.items()}
|
|
168
|
+
letters = sorted(letter_mapping.keys())
|
|
169
|
+
leading = {word1[0], word2[0], result[0]}
|
|
170
|
+
|
|
171
|
+
# Verify uniqueness
|
|
172
|
+
solution = self._verify_unique_solution(letters, leading, [word1, word2], result)
|
|
173
|
+
if solution is None:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Found a valid puzzle
|
|
177
|
+
self.operands = [word1, word2]
|
|
178
|
+
self.result_word = result
|
|
179
|
+
self.equation = f"{word1} + {word2} = {result}"
|
|
180
|
+
self.letters = letters
|
|
181
|
+
self.leading_letters = leading
|
|
182
|
+
self.letter_mapping = solution
|
|
183
|
+
|
|
184
|
+
# Initialize player mapping
|
|
185
|
+
self.player_mapping = dict.fromkeys(letters)
|
|
186
|
+
|
|
187
|
+
# Pre-assign some letters based on difficulty
|
|
188
|
+
pre_count = min(self.config.pre_assigned, len(letters))
|
|
189
|
+
pre_letters = letters[:]
|
|
190
|
+
self._rng.shuffle(pre_letters)
|
|
191
|
+
self.initial_mapping = {}
|
|
192
|
+
for ch in pre_letters[:pre_count]:
|
|
193
|
+
self.initial_mapping[ch] = solution[ch]
|
|
194
|
+
self.player_mapping[ch] = solution[ch]
|
|
195
|
+
|
|
196
|
+
self.game_started = True
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Fallback: hardcoded puzzle
|
|
200
|
+
self._generate_fallback()
|
|
201
|
+
|
|
202
|
+
def _generate_fallback(self) -> None:
|
|
203
|
+
"""Generate a simple fallback puzzle."""
|
|
204
|
+
# AB + CD = EF where 12 + 34 = 46 -> not unique
|
|
205
|
+
# Use: TO + GO = OUT (89 + 78 = 167) ... not quite.
|
|
206
|
+
# Simple: 21 + 34 = 55 is not all-different.
|
|
207
|
+
# Use manually verified: IF + IT = AT -> not valid
|
|
208
|
+
# Simplest: use small known puzzles
|
|
209
|
+
self.operands = ["AB", "CD"]
|
|
210
|
+
self.result_word = "EFG"
|
|
211
|
+
# 98 + 76 = 174 -> A=9,B=8,C=7,D=6,E=1,F=7 -> F and C both 7, not unique letters
|
|
212
|
+
# Try: 57 + 48 = 105 -> A=5,B=7,C=4,D=8,E=1,F=0,G=5 -> A and G both 5
|
|
213
|
+
# Just use a known-good puzzle: 34 + 56 = 90 -> nope, only 5 unique
|
|
214
|
+
# Go with: AB + CB = DEA: 23 + 43 = 66 -> nope
|
|
215
|
+
# Simpler fallback strategy: just pick numbers and accept
|
|
216
|
+
self.operands = ["AB", "BA"]
|
|
217
|
+
self.result_word = "CDC"
|
|
218
|
+
# 12 + 21 = 33 -> nope (D=C)
|
|
219
|
+
# 13 + 31 = 44 -> nope
|
|
220
|
+
# 27 + 72 = 99 -> nope
|
|
221
|
+
# 19 + 91 = 110 -> A=1,B=9,C=1 -> conflict
|
|
222
|
+
# Just hardcode SEND+MORE=MONEY equivalent at small scale
|
|
223
|
+
# Use: 23 + 45 = 68 -> A=2,B=3,C=4,D=5,E=6,F=8 -> all unique, 6 letters
|
|
224
|
+
self.operands = ["AB", "CD"]
|
|
225
|
+
self.result_word = "EF"
|
|
226
|
+
self.letter_mapping = {"A": 2, "B": 3, "C": 4, "D": 5, "E": 6, "F": 8}
|
|
227
|
+
# Verify: 23 + 45 = 68 ✓
|
|
228
|
+
self.equation = "AB + CD = EF"
|
|
229
|
+
self.letters = sorted(self.letter_mapping.keys())
|
|
230
|
+
self.leading_letters = {"A", "C", "E"}
|
|
231
|
+
self.player_mapping = dict.fromkeys(self.letters)
|
|
232
|
+
self.initial_mapping = {}
|
|
233
|
+
self.game_started = True
|
|
234
|
+
|
|
235
|
+
async def validate_move(self, letter: str, digit: int) -> MoveResult:
|
|
236
|
+
"""Validate assigning a digit to a letter.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
letter: Uppercase letter
|
|
240
|
+
digit: 0-9 to assign, -1 to unassign
|
|
241
|
+
"""
|
|
242
|
+
letter = letter.upper()
|
|
243
|
+
|
|
244
|
+
if letter not in self.letters:
|
|
245
|
+
self.record_move((letter,), False)
|
|
246
|
+
return MoveResult(
|
|
247
|
+
success=False,
|
|
248
|
+
message=f"Letter '{letter}' is not in this puzzle. Available: {', '.join(self.letters)}",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if letter in self.initial_mapping:
|
|
252
|
+
self.record_move((letter,), False)
|
|
253
|
+
return MoveResult(success=False, message=f"Letter '{letter}' is pre-assigned and cannot be changed.")
|
|
254
|
+
|
|
255
|
+
# Unassign
|
|
256
|
+
if digit == -1:
|
|
257
|
+
if self.player_mapping[letter] is None:
|
|
258
|
+
self.record_move((letter,), False)
|
|
259
|
+
return MoveResult(success=False, message=f"Letter '{letter}' is not assigned.")
|
|
260
|
+
self.player_mapping[letter] = None
|
|
261
|
+
self.record_move((letter,), True)
|
|
262
|
+
return MoveResult(success=True, message=f"Unassigned letter '{letter}'.", state_changed=True)
|
|
263
|
+
|
|
264
|
+
if not (0 <= digit <= 9):
|
|
265
|
+
self.record_move((letter,), False)
|
|
266
|
+
return MoveResult(success=False, message="Digit must be between 0 and 9.")
|
|
267
|
+
|
|
268
|
+
# Check leading zero constraint
|
|
269
|
+
if digit == 0 and letter in self.leading_letters:
|
|
270
|
+
self.record_move((letter,), False)
|
|
271
|
+
return MoveResult(
|
|
272
|
+
success=False,
|
|
273
|
+
message=f"Letter '{letter}' starts a word and cannot be 0 (no leading zeros).",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Check if digit is already used by another letter
|
|
277
|
+
for other_letter, other_digit in self.player_mapping.items():
|
|
278
|
+
if other_digit == digit and other_letter != letter:
|
|
279
|
+
self.record_move((letter,), False)
|
|
280
|
+
return MoveResult(
|
|
281
|
+
success=False,
|
|
282
|
+
message=f"Digit {digit} is already assigned to letter '{other_letter}'.",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
self.player_mapping[letter] = digit
|
|
286
|
+
self.record_move((letter,), True)
|
|
287
|
+
return MoveResult(success=True, message=f"Assigned {letter} = {digit}.", state_changed=True)
|
|
288
|
+
|
|
289
|
+
def is_complete(self) -> bool:
|
|
290
|
+
"""Check if all letters are assigned and the equation holds."""
|
|
291
|
+
# Check all assigned
|
|
292
|
+
if any(v is None for v in self.player_mapping.values()):
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
# Check equation
|
|
296
|
+
operand_values = []
|
|
297
|
+
for word in self.operands:
|
|
298
|
+
val = self._word_to_number(word, self.player_mapping)
|
|
299
|
+
if val is None:
|
|
300
|
+
return False
|
|
301
|
+
operand_values.append(val)
|
|
302
|
+
|
|
303
|
+
result_val = self._word_to_number(self.result_word, self.player_mapping)
|
|
304
|
+
if result_val is None:
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
return sum(operand_values) == result_val
|
|
308
|
+
|
|
309
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
310
|
+
"""Suggest a letter-digit assignment."""
|
|
311
|
+
if not self.can_use_hint():
|
|
312
|
+
return None
|
|
313
|
+
for letter in self.letters:
|
|
314
|
+
if self.player_mapping[letter] is None:
|
|
315
|
+
digit = self.letter_mapping[letter]
|
|
316
|
+
return (
|
|
317
|
+
(letter, digit),
|
|
318
|
+
f"Try assigning {letter} = {digit}.",
|
|
319
|
+
)
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
def render_grid(self) -> str:
|
|
323
|
+
"""Render the equation and current assignments."""
|
|
324
|
+
lines = []
|
|
325
|
+
lines.append(f"Equation: {self.equation}")
|
|
326
|
+
lines.append("")
|
|
327
|
+
|
|
328
|
+
# Show the equation with current assignments
|
|
329
|
+
def render_word(word: str) -> str:
|
|
330
|
+
chars = []
|
|
331
|
+
for ch in word:
|
|
332
|
+
val = self.player_mapping.get(ch)
|
|
333
|
+
if val is not None:
|
|
334
|
+
chars.append(str(val))
|
|
335
|
+
else:
|
|
336
|
+
chars.append(ch)
|
|
337
|
+
return "".join(chars)
|
|
338
|
+
|
|
339
|
+
rendered_operands = [render_word(w) for w in self.operands]
|
|
340
|
+
rendered_result = render_word(self.result_word)
|
|
341
|
+
lines.append(f" {' + '.join(rendered_operands)} = {rendered_result}")
|
|
342
|
+
lines.append("")
|
|
343
|
+
|
|
344
|
+
# Letter assignments table
|
|
345
|
+
lines.append("Assignments:")
|
|
346
|
+
for letter in self.letters:
|
|
347
|
+
val = self.player_mapping[letter]
|
|
348
|
+
prefix = "*" if letter in self.initial_mapping else " "
|
|
349
|
+
leading = " (leading)" if letter in self.leading_letters else ""
|
|
350
|
+
if val is not None:
|
|
351
|
+
lines.append(f" {prefix}{letter} = {val}{leading}")
|
|
352
|
+
else:
|
|
353
|
+
lines.append(f" {letter} = ?{leading}")
|
|
354
|
+
|
|
355
|
+
# Available digits
|
|
356
|
+
used_digits = {v for v in self.player_mapping.values() if v is not None}
|
|
357
|
+
available = [str(d) for d in range(10) if d not in used_digits]
|
|
358
|
+
lines.append(f"\nAvailable digits: {', '.join(available)}")
|
|
359
|
+
|
|
360
|
+
assigned = sum(1 for v in self.player_mapping.values() if v is not None)
|
|
361
|
+
lines.append(f"Assigned: {assigned}/{len(self.letters)}")
|
|
362
|
+
|
|
363
|
+
return "\n".join(lines)
|
|
364
|
+
|
|
365
|
+
def get_stats(self) -> str:
|
|
366
|
+
"""Get current game statistics."""
|
|
367
|
+
assigned = sum(1 for v in self.player_mapping.values() if v is not None)
|
|
368
|
+
return f"Moves: {self.moves_made} | Assigned: {assigned}/{len(self.letters)} | Seed: {self.seed}"
|
|
369
|
+
|
|
370
|
+
def get_rules(self) -> str:
|
|
371
|
+
return (
|
|
372
|
+
"CRYPTARITHMETIC\n"
|
|
373
|
+
"Each letter represents a unique digit (0-9).\n"
|
|
374
|
+
"Find the digit for each letter so the addition equation is correct.\n"
|
|
375
|
+
"No number may have a leading zero.\n"
|
|
376
|
+
"Each digit is used by at most one letter."
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def get_commands(self) -> str:
|
|
380
|
+
return (
|
|
381
|
+
"Commands:\n"
|
|
382
|
+
" assign <letter> <digit> - Assign a digit to a letter\n"
|
|
383
|
+
" unassign <letter> - Remove assignment\n"
|
|
384
|
+
" hint - Get a hint\n"
|
|
385
|
+
" check - Check if solved\n"
|
|
386
|
+
" show - Show current state\n"
|
|
387
|
+
" menu - Return to menu"
|
|
388
|
+
)
|
|
@@ -281,6 +281,8 @@ class EinsteinGame(PuzzleGame):
|
|
|
281
281
|
Returns:
|
|
282
282
|
Tuple of (hint_data, hint_message) or None
|
|
283
283
|
"""
|
|
284
|
+
if not self.can_use_hint():
|
|
285
|
+
return None
|
|
284
286
|
# Find first unassigned attribute in solution
|
|
285
287
|
for i in range(self.num_houses):
|
|
286
288
|
for attr in ATTRIBUTES:
|
|
@@ -435,6 +435,8 @@ class FillominoGame(PuzzleGame):
|
|
|
435
435
|
Returns:
|
|
436
436
|
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
437
437
|
"""
|
|
438
|
+
if not self.can_use_hint():
|
|
439
|
+
return None
|
|
438
440
|
# Find an empty cell
|
|
439
441
|
for r in range(self.size):
|
|
440
442
|
for c in range(self.size):
|
|
@@ -288,6 +288,8 @@ class FutoshikiGame(PuzzleGame):
|
|
|
288
288
|
Returns:
|
|
289
289
|
Tuple of (hint_data, hint_message) or None if puzzle is complete
|
|
290
290
|
"""
|
|
291
|
+
if not self.can_use_hint():
|
|
292
|
+
return None
|
|
291
293
|
empty_cells = [(r, c) for r in range(self.size) for c in range(self.size) if self.grid[r][c] == 0]
|
|
292
294
|
if not empty_cells:
|
|
293
295
|
return None
|