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,431 @@
|
|
|
1
|
+
"""Task Scheduler optimization 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 SchedulerConfig
|
|
8
|
+
from .constants import TASK_NAMES
|
|
9
|
+
from .enums import SchedulerAction
|
|
10
|
+
from .models import Task
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SchedulerGame(PuzzleGame):
|
|
14
|
+
"""Task Scheduler optimization puzzle game.
|
|
15
|
+
|
|
16
|
+
Schedule tasks with dependencies and resource constraints
|
|
17
|
+
to minimize total completion time (makespan).
|
|
18
|
+
Demonstrates temporal reasoning and optimization.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, difficulty: str = "easy", seed: int | None = None, **kwargs):
|
|
22
|
+
"""Initialize a new Scheduler game.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
difficulty: Game difficulty level (easy/medium/hard)
|
|
26
|
+
"""
|
|
27
|
+
super().__init__(difficulty, seed, **kwargs)
|
|
28
|
+
|
|
29
|
+
# Configuration using Pydantic model
|
|
30
|
+
self.config = SchedulerConfig.from_difficulty(self.difficulty)
|
|
31
|
+
self.num_tasks: int = self.config.num_tasks
|
|
32
|
+
self.num_workers: int = self.config.num_workers
|
|
33
|
+
|
|
34
|
+
# Task properties - using Task model
|
|
35
|
+
self.tasks: list[Task] = []
|
|
36
|
+
self.dependencies: list[tuple[int, int]] = [] # (task_a, task_b) means A must finish before B starts
|
|
37
|
+
|
|
38
|
+
# Player's schedule: task_id -> (worker_id, start_time)
|
|
39
|
+
self.schedule: dict[int, tuple[int, int]] = {}
|
|
40
|
+
|
|
41
|
+
# Optimal solution
|
|
42
|
+
self.optimal_makespan = 0
|
|
43
|
+
self.optimal_schedule: dict[int, tuple[int, int]] = {}
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def name(self) -> str:
|
|
47
|
+
"""The display name of this puzzle type."""
|
|
48
|
+
return "Task Scheduler"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def description(self) -> str:
|
|
52
|
+
"""A one-line description of this puzzle type."""
|
|
53
|
+
return "Schedule tasks with dependencies to minimize completion time"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def constraint_types(self) -> list[str]:
|
|
57
|
+
"""Constraint types demonstrated by this puzzle."""
|
|
58
|
+
return ["optimization", "precedence", "resource_allocation", "makespan_minimization"]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def business_analogies(self) -> list[str]:
|
|
62
|
+
"""Business problems this puzzle models."""
|
|
63
|
+
return ["project_scheduling", "sprint_planning", "team_allocation", "workflow_optimization"]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def complexity_profile(self) -> dict[str, str]:
|
|
67
|
+
"""Complexity profile of this puzzle."""
|
|
68
|
+
return {"reasoning_type": "optimization", "search_space": "exponential", "constraint_density": "moderate"}
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def optimal_steps(self) -> int | None:
|
|
72
|
+
"""Minimum steps = tasks to schedule."""
|
|
73
|
+
return len(self.tasks) if hasattr(self, "tasks") else None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def difficulty_profile(self) -> "DifficultyProfile":
|
|
77
|
+
"""Difficulty characteristics for Task Scheduler."""
|
|
78
|
+
from ...models import DifficultyLevel
|
|
79
|
+
|
|
80
|
+
logic_depth = {
|
|
81
|
+
DifficultyLevel.EASY.value: 2,
|
|
82
|
+
DifficultyLevel.MEDIUM.value: 4,
|
|
83
|
+
DifficultyLevel.HARD.value: 5,
|
|
84
|
+
}.get(self.difficulty.value, 3)
|
|
85
|
+
n_workers = self.num_workers if hasattr(self, "num_workers") else 3
|
|
86
|
+
return DifficultyProfile(
|
|
87
|
+
logic_depth=logic_depth,
|
|
88
|
+
branching_factor=float(n_workers),
|
|
89
|
+
state_observability=1.0,
|
|
90
|
+
constraint_density=0.5,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def generate_puzzle(self) -> None:
|
|
94
|
+
"""Generate a new Scheduler puzzle."""
|
|
95
|
+
# Generate tasks with random durations using constants
|
|
96
|
+
self.tasks = []
|
|
97
|
+
for i in range(self.num_tasks):
|
|
98
|
+
name = TASK_NAMES[i] if i < len(TASK_NAMES) else f"Task {chr(65 + i)}"
|
|
99
|
+
duration = self._rng.randint(2, 8)
|
|
100
|
+
dependencies: list[int] = []
|
|
101
|
+
task = Task(id=i, name=name, duration=duration, dependencies=dependencies)
|
|
102
|
+
self.tasks.append(task)
|
|
103
|
+
|
|
104
|
+
# Generate dependencies (DAG - no cycles)
|
|
105
|
+
self.dependencies = []
|
|
106
|
+
for i in range(self.num_tasks):
|
|
107
|
+
for j in range(i + 1, self.num_tasks):
|
|
108
|
+
if self._rng.random() < self.config.dependency_prob:
|
|
109
|
+
# Task i must complete before task j can start
|
|
110
|
+
self.dependencies.append((i, j))
|
|
111
|
+
|
|
112
|
+
# Calculate optimal solution
|
|
113
|
+
self._solve_optimal()
|
|
114
|
+
|
|
115
|
+
# Initialize empty schedule
|
|
116
|
+
self.schedule = {}
|
|
117
|
+
self.moves_made = 0
|
|
118
|
+
self.game_started = True
|
|
119
|
+
|
|
120
|
+
def _solve_optimal(self) -> None:
|
|
121
|
+
"""Solve the scheduling problem optimally using greedy approach with topological sort."""
|
|
122
|
+
# Build dependency graph
|
|
123
|
+
in_degree = [0] * self.num_tasks
|
|
124
|
+
adj_list: list[list[int]] = [[] for _ in range(self.num_tasks)]
|
|
125
|
+
|
|
126
|
+
for task_a, task_b in self.dependencies:
|
|
127
|
+
adj_list[task_a].append(task_b)
|
|
128
|
+
in_degree[task_b] += 1
|
|
129
|
+
|
|
130
|
+
# Topological sort with earliest start times
|
|
131
|
+
earliest_start = [0] * self.num_tasks
|
|
132
|
+
queue = [i for i in range(self.num_tasks) if in_degree[i] == 0]
|
|
133
|
+
|
|
134
|
+
# Calculate earliest start times
|
|
135
|
+
while queue:
|
|
136
|
+
task = queue.pop(0)
|
|
137
|
+
for dependent in adj_list[task]:
|
|
138
|
+
# Dependent can't start until current task finishes
|
|
139
|
+
earliest_start[dependent] = max(
|
|
140
|
+
earliest_start[dependent], earliest_start[task] + self.tasks[task].duration
|
|
141
|
+
)
|
|
142
|
+
in_degree[dependent] -= 1
|
|
143
|
+
if in_degree[dependent] == 0:
|
|
144
|
+
queue.append(dependent)
|
|
145
|
+
|
|
146
|
+
# Schedule tasks greedily with proper dependency handling
|
|
147
|
+
worker_available = [0] * self.num_workers
|
|
148
|
+
self.optimal_schedule = {}
|
|
149
|
+
|
|
150
|
+
# Sort tasks by earliest start time
|
|
151
|
+
sorted_tasks = sorted(range(self.num_tasks), key=lambda t: earliest_start[t])
|
|
152
|
+
|
|
153
|
+
for task_id in sorted_tasks:
|
|
154
|
+
# Calculate actual earliest start based on scheduled dependencies
|
|
155
|
+
actual_earliest_start = 0
|
|
156
|
+
for dep_task, dependent in self.dependencies:
|
|
157
|
+
if dependent == task_id and dep_task in self.optimal_schedule:
|
|
158
|
+
dep_worker, dep_start = self.optimal_schedule[dep_task]
|
|
159
|
+
dep_end = dep_start + self.tasks[dep_task].duration
|
|
160
|
+
actual_earliest_start = max(actual_earliest_start, dep_end)
|
|
161
|
+
|
|
162
|
+
# Find the worker that can start this task earliest
|
|
163
|
+
# considering both worker availability and actual dependency finish times
|
|
164
|
+
best_worker = 0
|
|
165
|
+
best_start_time = max(worker_available[0], actual_earliest_start)
|
|
166
|
+
|
|
167
|
+
for worker_id in range(1, self.num_workers):
|
|
168
|
+
# This task can't start before its dependencies finish
|
|
169
|
+
# and can't start before the worker is available
|
|
170
|
+
candidate_start = max(worker_available[worker_id], actual_earliest_start)
|
|
171
|
+
|
|
172
|
+
if candidate_start < best_start_time:
|
|
173
|
+
best_start_time = candidate_start
|
|
174
|
+
best_worker = worker_id
|
|
175
|
+
|
|
176
|
+
# Store 1-indexed worker_id for consistency
|
|
177
|
+
self.optimal_schedule[task_id] = (best_worker + 1, best_start_time)
|
|
178
|
+
worker_available[best_worker] = best_start_time + self.tasks[task_id].duration
|
|
179
|
+
|
|
180
|
+
# Calculate makespan
|
|
181
|
+
self.optimal_makespan = max(worker_available) if worker_available else 0
|
|
182
|
+
|
|
183
|
+
async def validate_move(self, task_id: int | str, worker_id: int, start_time: int) -> MoveResult:
|
|
184
|
+
"""Assign a task to a worker at a specific start time, or unassign a task.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
task_id: Task number (1-indexed) or "unassign" action
|
|
188
|
+
worker_id: Worker number (1-indexed) when assigning, or task number when unassigning
|
|
189
|
+
start_time: Start time (0 or positive integer) when assigning, unused when unassigning
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
MoveResult with success status and message
|
|
193
|
+
"""
|
|
194
|
+
# Handle unassign action
|
|
195
|
+
if isinstance(task_id, str) and task_id.lower() == SchedulerAction.UNASSIGN.value:
|
|
196
|
+
return await self._unassign_task(worker_id)
|
|
197
|
+
|
|
198
|
+
# Convert to 0-indexed
|
|
199
|
+
task_id = int(task_id) - 1
|
|
200
|
+
worker_id -= 1
|
|
201
|
+
|
|
202
|
+
# Validate inputs
|
|
203
|
+
if not (0 <= task_id < self.num_tasks):
|
|
204
|
+
return MoveResult(success=False, message=f"Invalid task number. Use 1-{self.num_tasks}.")
|
|
205
|
+
|
|
206
|
+
if not (0 <= worker_id < self.num_workers):
|
|
207
|
+
return MoveResult(success=False, message=f"Invalid worker number. Use 1-{self.num_workers}.")
|
|
208
|
+
|
|
209
|
+
if start_time < 0:
|
|
210
|
+
return MoveResult(success=False, message="Start time must be non-negative.")
|
|
211
|
+
|
|
212
|
+
# Check if task is already scheduled - allow reassignment
|
|
213
|
+
is_reassignment = task_id in self.schedule
|
|
214
|
+
|
|
215
|
+
# Check dependencies
|
|
216
|
+
for dep_task, dependent in self.dependencies:
|
|
217
|
+
if dependent == task_id:
|
|
218
|
+
# This task depends on dep_task
|
|
219
|
+
if dep_task not in self.schedule:
|
|
220
|
+
return MoveResult(
|
|
221
|
+
success=False,
|
|
222
|
+
message=f"Cannot schedule - {self.tasks[dep_task].name} must be scheduled first.",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
dep_worker, dep_start = self.schedule[dep_task]
|
|
226
|
+
dep_end = dep_start + self.tasks[dep_task].duration
|
|
227
|
+
|
|
228
|
+
if start_time < dep_end:
|
|
229
|
+
return MoveResult(
|
|
230
|
+
success=False,
|
|
231
|
+
message=f"Cannot start at {start_time} - {self.tasks[dep_task].name} finishes at {dep_end}.",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Check worker availability (no overlap)
|
|
235
|
+
task_duration = self.tasks[task_id].duration
|
|
236
|
+
task_end = start_time + task_duration
|
|
237
|
+
|
|
238
|
+
for other_task_id, (other_worker, other_start) in self.schedule.items():
|
|
239
|
+
if other_worker == worker_id + 1: # other_worker is 1-indexed in schedule
|
|
240
|
+
other_end = other_start + self.tasks[other_task_id].duration
|
|
241
|
+
# Check for overlap
|
|
242
|
+
if not (task_end <= other_start or start_time >= other_end):
|
|
243
|
+
return MoveResult(
|
|
244
|
+
success=False,
|
|
245
|
+
message=f"Worker {worker_id + 1} is busy with {self.tasks[other_task_id].name} from {other_start} to {other_end}.",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Schedule the task (store 1-indexed worker_id for consistency with user input)
|
|
249
|
+
self.schedule[task_id] = (worker_id + 1, start_time)
|
|
250
|
+
self.moves_made += 1
|
|
251
|
+
|
|
252
|
+
task_name = self.tasks[task_id].name
|
|
253
|
+
if is_reassignment:
|
|
254
|
+
return MoveResult(
|
|
255
|
+
success=True,
|
|
256
|
+
message=f"Reassigned {task_name} to Worker {worker_id + 1} at time {start_time}",
|
|
257
|
+
state_changed=True,
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
return MoveResult(
|
|
261
|
+
success=True,
|
|
262
|
+
message=f"Scheduled {task_name} on Worker {worker_id + 1} at time {start_time}",
|
|
263
|
+
state_changed=True,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def _unassign_task(self, task_id: int) -> MoveResult:
|
|
267
|
+
"""Unassign a task from the schedule.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
task_id: Task number (1-indexed)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
MoveResult with success status and message
|
|
274
|
+
"""
|
|
275
|
+
task_id -= 1
|
|
276
|
+
|
|
277
|
+
if task_id not in self.schedule:
|
|
278
|
+
return MoveResult(success=False, message=f"{self.tasks[task_id].name} is not currently assigned.")
|
|
279
|
+
|
|
280
|
+
# Check if any scheduled task depends on this one
|
|
281
|
+
for dep_task, dependent in self.dependencies:
|
|
282
|
+
if dep_task == task_id and dependent in self.schedule:
|
|
283
|
+
return MoveResult(
|
|
284
|
+
success=False,
|
|
285
|
+
message=f"Cannot unassign - {self.tasks[dependent].name} depends on {self.tasks[task_id].name}.",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
del self.schedule[task_id]
|
|
289
|
+
self.moves_made += 1
|
|
290
|
+
return MoveResult(success=True, message=f"Unassigned {self.tasks[task_id].name}", state_changed=True)
|
|
291
|
+
|
|
292
|
+
def _get_makespan(self) -> int:
|
|
293
|
+
"""Calculate the makespan (total completion time) of current schedule."""
|
|
294
|
+
if not self.schedule:
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
max_end = 0
|
|
298
|
+
for task_id, (_worker, start_time) in self.schedule.items():
|
|
299
|
+
end_time = start_time + self.tasks[task_id].duration
|
|
300
|
+
max_end = max(max_end, end_time)
|
|
301
|
+
|
|
302
|
+
return max_end
|
|
303
|
+
|
|
304
|
+
def is_complete(self) -> bool:
|
|
305
|
+
"""Check if all tasks are scheduled optimally."""
|
|
306
|
+
# All tasks must be scheduled
|
|
307
|
+
if len(self.schedule) != self.num_tasks:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
# Check if makespan is optimal
|
|
311
|
+
return self._get_makespan() == self.optimal_makespan
|
|
312
|
+
|
|
313
|
+
async def get_hint(self) -> tuple[Any, str] | None:
|
|
314
|
+
"""Get a hint for the next move.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Tuple of (hint_data, hint_message) or None
|
|
318
|
+
"""
|
|
319
|
+
# Find an unscheduled task that's in the optimal solution
|
|
320
|
+
for task_id in range(self.num_tasks):
|
|
321
|
+
if task_id not in self.schedule and task_id in self.optimal_schedule:
|
|
322
|
+
worker, start_time = self.optimal_schedule[task_id]
|
|
323
|
+
# worker is already 1-indexed in optimal_schedule
|
|
324
|
+
hint_data = (task_id + 1, worker, start_time)
|
|
325
|
+
hint_message = f"Try scheduling {self.tasks[task_id].name} on Worker {worker} at time {start_time}"
|
|
326
|
+
return hint_data, hint_message
|
|
327
|
+
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
def render_grid(self) -> str:
|
|
331
|
+
"""Render the current schedule as ASCII art.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
String representation of the schedule
|
|
335
|
+
"""
|
|
336
|
+
lines = []
|
|
337
|
+
|
|
338
|
+
lines.append("Task Scheduler")
|
|
339
|
+
lines.append(f"Workers: {self.num_workers} | Tasks: {self.num_tasks}")
|
|
340
|
+
lines.append(f"Current Makespan: {self._get_makespan()}")
|
|
341
|
+
lines.append(f"Optimal Makespan: {self.optimal_makespan}")
|
|
342
|
+
lines.append("")
|
|
343
|
+
|
|
344
|
+
# Tasks table
|
|
345
|
+
lines.append("Tasks:")
|
|
346
|
+
lines.append(" # | Name | Duration | Status")
|
|
347
|
+
lines.append(" --+--------+----------+----------------------------------")
|
|
348
|
+
|
|
349
|
+
for task in self.tasks:
|
|
350
|
+
task_id = task.id
|
|
351
|
+
status = "Not scheduled"
|
|
352
|
+
|
|
353
|
+
if task_id in self.schedule:
|
|
354
|
+
worker, start_time = self.schedule[task_id]
|
|
355
|
+
end_time = start_time + task.duration
|
|
356
|
+
status = f"Worker {worker}, time {start_time}-{end_time}"
|
|
357
|
+
|
|
358
|
+
lines.append(f" {task_id + 1:2d} | {task.name:<6s} | {task.duration:4d}hrs | {status}")
|
|
359
|
+
|
|
360
|
+
# Dependencies
|
|
361
|
+
if self.dependencies:
|
|
362
|
+
lines.append("")
|
|
363
|
+
lines.append("Dependencies:")
|
|
364
|
+
for task_a, task_b in self.dependencies:
|
|
365
|
+
lines.append(f" {self.tasks[task_a].name} → {self.tasks[task_b].name}")
|
|
366
|
+
|
|
367
|
+
# Timeline visualization
|
|
368
|
+
if self.schedule:
|
|
369
|
+
lines.append("")
|
|
370
|
+
lines.append("Timeline:")
|
|
371
|
+
makespan = self._get_makespan()
|
|
372
|
+
|
|
373
|
+
for worker_id in range(self.num_workers):
|
|
374
|
+
timeline = ["."] * (makespan + 1)
|
|
375
|
+
|
|
376
|
+
for task_id, (w, start) in self.schedule.items():
|
|
377
|
+
if w == worker_id + 1:
|
|
378
|
+
duration = self.tasks[task_id].duration
|
|
379
|
+
task_letter = chr(65 + task_id) # A, B, C...
|
|
380
|
+
|
|
381
|
+
for t in range(start, start + duration):
|
|
382
|
+
if t <= makespan:
|
|
383
|
+
timeline[t] = task_letter
|
|
384
|
+
|
|
385
|
+
timeline_str = "".join(timeline)
|
|
386
|
+
lines.append(f" Worker {worker_id + 1}: {timeline_str}")
|
|
387
|
+
|
|
388
|
+
return "\n".join(lines)
|
|
389
|
+
|
|
390
|
+
def get_rules(self) -> str:
|
|
391
|
+
"""Get the rules description for Scheduler.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Multi-line string describing the puzzle rules
|
|
395
|
+
"""
|
|
396
|
+
return f"""TASK SCHEDULER RULES:
|
|
397
|
+
- Schedule all {self.num_tasks} tasks across {self.num_workers} workers
|
|
398
|
+
- Each task has a duration in hours
|
|
399
|
+
- Tasks with dependencies must wait for predecessors
|
|
400
|
+
- Workers can only do one task at a time
|
|
401
|
+
- Goal: Minimize makespan (total completion time)
|
|
402
|
+
- Optimal makespan: {self.optimal_makespan} hours
|
|
403
|
+
- This is an OPTIMIZATION problem!"""
|
|
404
|
+
|
|
405
|
+
def get_commands(self) -> str:
|
|
406
|
+
"""Get the available commands for Scheduler.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
Multi-line string describing available commands
|
|
410
|
+
"""
|
|
411
|
+
return """TASK SCHEDULER COMMANDS:
|
|
412
|
+
assign <task> <worker> <time> - Schedule a task (e.g., 'assign 1 2 5')
|
|
413
|
+
unassign <task> - Remove task from schedule
|
|
414
|
+
show - Display current schedule
|
|
415
|
+
hint - Get scheduling hint
|
|
416
|
+
check - Check if schedule is optimal
|
|
417
|
+
solve - Show optimal schedule (ends game)
|
|
418
|
+
menu - Return to game selection
|
|
419
|
+
quit - Exit the server"""
|
|
420
|
+
|
|
421
|
+
def get_stats(self) -> str:
|
|
422
|
+
"""Get current game statistics.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
String with game stats
|
|
426
|
+
"""
|
|
427
|
+
scheduled = len(self.schedule)
|
|
428
|
+
makespan = self._get_makespan()
|
|
429
|
+
optimality = (self.optimal_makespan / makespan * 100) if makespan > 0 else 0
|
|
430
|
+
|
|
431
|
+
return f"Moves: {self.moves_made} | Scheduled: {scheduled}/{self.num_tasks} | Makespan: {makespan}/{self.optimal_makespan}hrs ({optimality:.0f}%) | Seed: {self.seed}"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Scheduler game models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Task(BaseModel):
|
|
7
|
+
"""A task in the Scheduler game."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(frozen=False) # Allow mutation for game state
|
|
10
|
+
|
|
11
|
+
id: int = Field(ge=0, description="Task ID")
|
|
12
|
+
name: str = Field(min_length=1, description="Task name")
|
|
13
|
+
duration: int = Field(gt=0, description="Task duration in time units")
|
|
14
|
+
dependencies: list[int] = Field(default_factory=list, description="List of task IDs this task depends on")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Configuration for Shikaku game."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from ...models.enums import DifficultyLevel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ShikakuConfig(BaseModel):
|
|
9
|
+
"""Configuration for Shikaku game."""
|
|
10
|
+
|
|
11
|
+
difficulty: DifficultyLevel = Field(default=DifficultyLevel.EASY, description="Game difficulty level")
|
|
12
|
+
size: int = Field(ge=5, le=10, description="Grid size (NxN)")
|
|
13
|
+
num_clues: int = Field(ge=3, description="Number of clue numbers")
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_difficulty(cls, difficulty: DifficultyLevel) -> "ShikakuConfig":
|
|
17
|
+
"""Create config from difficulty level."""
|
|
18
|
+
config_map = {
|
|
19
|
+
DifficultyLevel.EASY: {"size": 5, "num_clues": 5},
|
|
20
|
+
DifficultyLevel.MEDIUM: {"size": 7, "num_clues": 7},
|
|
21
|
+
DifficultyLevel.HARD: {"size": 9, "num_clues": 10},
|
|
22
|
+
}
|
|
23
|
+
params = config_map[difficulty]
|
|
24
|
+
return cls(difficulty=difficulty, **params)
|