Spaces:
Running
Running
Update backend/agents/puzzle_agent.py
Browse files- backend/agents/puzzle_agent.py +332 -275
backend/agents/puzzle_agent.py
CHANGED
|
@@ -19,7 +19,6 @@ THEME_PALETTES = {
|
|
| 19 |
"default": {"wall_color": "#8E24AA", "path_color": "#020817", "accent": "#FF477E"},
|
| 20 |
}
|
| 21 |
|
| 22 |
-
# Maps required_step keywords β tile IDs
|
| 23 |
STEP_TILE_MAP = {
|
| 24 |
"meet_manager": 8,
|
| 25 |
"meet_scientist": 8,
|
|
@@ -33,81 +32,73 @@ STEP_TILE_MAP = {
|
|
| 33 |
"verify_pin": 5,
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
"force_item": True,
|
| 49 |
-
},
|
| 50 |
-
{
|
| 51 |
-
"id": "shortest_path",
|
| 52 |
-
"title": "BFS: Find the Shortest Path",
|
| 53 |
-
"description": "Multiple routes exist. Explore carefully β some paths are dead ends. The shortest route wins. This teaches: BFS, graph traversal.",
|
| 54 |
-
"maze_type": "imperfect", # extra walls removed = multiple routes
|
| 55 |
-
"min_dim": 8,
|
| 56 |
-
"max_dim": 12,
|
| 57 |
-
"force_npc": False,
|
| 58 |
-
"force_item": True, # at least collect an item on the way
|
| 59 |
-
},
|
| 60 |
-
{
|
| 61 |
-
"id": "dfs_backtrack",
|
| 62 |
-
"title": "DFS: Explore and Backtrack",
|
| 63 |
-
"description": "The maze has many dead ends. Systematically explore each branch. When stuck, backtrack and try another. This teaches: DFS and recursion.",
|
| 64 |
-
"maze_type": "perfect", # perfect = single path but MASSIVE dead ends
|
| 65 |
-
"min_dim": 10,
|
| 66 |
-
"max_dim": 15,
|
| 67 |
-
"force_npc": True,
|
| 68 |
-
"force_item": True,
|
| 69 |
-
},
|
| 70 |
-
{
|
| 71 |
-
"id": "sequence",
|
| 72 |
-
"title": "Sequencing: Visit in Correct Order",
|
| 73 |
-
"description": "Visit the Sentinel (π€΅) FIRST, then collect the Key (π), THEN reach the Exit. Order matters! This teaches: sequential execution and ordered steps.",
|
| 74 |
-
"maze_type": "imperfect",
|
| 75 |
-
"min_dim": 7,
|
| 76 |
-
"max_dim": 11,
|
| 77 |
-
"force_npc": True,
|
| 78 |
-
"force_item": True,
|
| 79 |
-
},
|
| 80 |
-
]
|
| 81 |
def _get_best_flash_model() -> str:
|
| 82 |
-
"""Dynamically finds the best available Gemini Flash model."""
|
| 83 |
try:
|
| 84 |
available_models = []
|
| 85 |
for m in genai.list_models():
|
| 86 |
-
if 'generateContent' in m.supported_generation_methods:
|
| 87 |
-
|
| 88 |
-
available_models.append(m.name)
|
| 89 |
-
|
| 90 |
if available_models:
|
| 91 |
-
# Sort descending to prefer newer versions (e.g. gemini-2.0-flash over gemini-1.5-flash)
|
| 92 |
available_models.sort(reverse=True)
|
| 93 |
return available_models[0].replace('models/', '')
|
| 94 |
-
|
| 95 |
-
# Fallback to any gemini model
|
| 96 |
for m in genai.list_models():
|
| 97 |
if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower():
|
| 98 |
return m.name.replace('models/', '')
|
| 99 |
-
|
| 100 |
except Exception as e:
|
| 101 |
print(f"Warning: Could not list models ({e}). Using hardcoded default.")
|
| 102 |
-
|
| 103 |
return "gemini-2.0-flash"
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
class PuzzleAgent:
|
| 107 |
"""
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
and a specific CS/DAA learning concept per level.
|
| 111 |
"""
|
| 112 |
|
| 113 |
def __init__(self, api_key: Optional[str] = None):
|
|
@@ -121,36 +112,35 @@ class PuzzleAgent:
|
|
| 121 |
print(f"[PuzzleAgent] Selected Model: {best_model}")
|
| 122 |
self.model = genai.GenerativeModel(best_model)
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 127 |
-
path = os.path.join(base_dir, "data", "maze_scenarios_500.json")
|
| 128 |
self.scenarios: List[Dict] = []
|
| 129 |
try:
|
| 130 |
-
|
| 131 |
-
|
| 132 |
print(f"[PuzzleAgent] Loaded {len(self.scenarios)} scenarios.")
|
| 133 |
except Exception as e:
|
| 134 |
-
print(f"[PuzzleAgent] Could not load
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
-
#
|
| 138 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
def _create_perfect_maze(self, dim: int) -> List[List[int]]:
|
| 140 |
-
"""
|
| 141 |
-
Generates a perfect maze (one path between any two cells).
|
| 142 |
-
Identical algorithm to the Kaggle maze dataset.
|
| 143 |
-
dim = number of 'rooms' β grid size = 2*dim+1
|
| 144 |
-
"""
|
| 145 |
size = dim * 2 + 1
|
| 146 |
maze = [[1] * size for _ in range(size)]
|
| 147 |
-
|
| 148 |
x, y = 0, 0
|
| 149 |
maze[2 * x + 1][2 * y + 1] = 0
|
| 150 |
-
|
| 151 |
stack = [(x, y)]
|
| 152 |
sys.setrecursionlimit(50000)
|
| 153 |
-
|
| 154 |
while stack:
|
| 155 |
x, y = stack[-1]
|
| 156 |
directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
|
|
@@ -166,17 +156,9 @@ class PuzzleAgent:
|
|
| 166 |
break
|
| 167 |
if not moved:
|
| 168 |
stack.pop()
|
| 169 |
-
|
| 170 |
return maze
|
| 171 |
|
| 172 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
-
# IMPERFECT MAZE (perfect + extra wall removals = loops)
|
| 174 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 175 |
def _create_imperfect_maze(self, dim: int, extra_wall_removals: float = 0.20) -> List[List[int]]:
|
| 176 |
-
"""
|
| 177 |
-
Imperfect maze: removes ~20% of qualifying walls to create multiple routes.
|
| 178 |
-
Forces the user to evaluate multiple branching paths.
|
| 179 |
-
"""
|
| 180 |
maze = self._create_perfect_maze(dim)
|
| 181 |
size = len(maze)
|
| 182 |
wall_candidates = []
|
|
@@ -187,16 +169,11 @@ class PuzzleAgent:
|
|
| 187 |
wall_candidates.append((i, j))
|
| 188 |
elif maze[i][j - 1] == 0 and maze[i][j + 1] == 0:
|
| 189 |
wall_candidates.append((i, j))
|
| 190 |
-
|
| 191 |
num_to_remove = int(len(wall_candidates) * extra_wall_removals)
|
| 192 |
for i, j in random.sample(wall_candidates, min(num_to_remove, len(wall_candidates))):
|
| 193 |
maze[i][j] = 0
|
| 194 |
-
|
| 195 |
return maze
|
| 196 |
|
| 197 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
-
# BFS PATH FINDER
|
| 199 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
def _bfs_path(self, grid: List[List[int]], start: Tuple, goal: Tuple) -> List[Tuple]:
|
| 201 |
rows, cols = len(grid), len(grid[0])
|
| 202 |
visited = {start}
|
|
@@ -205,152 +182,260 @@ class PuzzleAgent:
|
|
| 205 |
(r, c), path = queue.popleft()
|
| 206 |
if (r, c) == goal:
|
| 207 |
return path
|
| 208 |
-
for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
|
| 209 |
nr, nc = r + dr, c + dc
|
| 210 |
if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited and grid[nr][nc] != 1:
|
| 211 |
visited.add((nr, nc))
|
| 212 |
queue.append(((nr, nc), path + [(nr, nc)]))
|
| 213 |
return []
|
| 214 |
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
"""
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
if maze_type == "imperfect":
|
| 227 |
grid = self._create_imperfect_maze(dim)
|
| 228 |
else:
|
| 229 |
grid = self._create_perfect_maze(dim)
|
| 230 |
|
| 231 |
size = len(grid)
|
| 232 |
-
|
| 233 |
-
# Start top-left corridor, Goal bottom-right corridor
|
| 234 |
sr, sc = 1, 1
|
| 235 |
gr, gc = size - 2, size - 2
|
| 236 |
-
|
| 237 |
grid[sr][sc] = 0
|
| 238 |
grid[gr][gc] = 0
|
| 239 |
|
| 240 |
-
# Get BFS path
|
| 241 |
path = self._bfs_path(grid, (sr, sc), (gr, gc))
|
| 242 |
if not path or len(path) < 5:
|
| 243 |
-
# Safety: rebuild with a simpler grid
|
| 244 |
grid = self._create_perfect_maze(max(dim, 7))
|
| 245 |
size = len(grid)
|
| 246 |
sr, sc = 1, 1
|
| 247 |
gr, gc = size - 2, size - 2
|
| 248 |
-
grid[sr][sc] = 0
|
|
|
|
| 249 |
path = self._bfs_path(grid, (sr, sc), (gr, gc))
|
| 250 |
|
| 251 |
-
path_middle = path[1:-1]
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
ordered = [] # [(step_label, tile_id), ...]
|
| 255 |
-
|
| 256 |
-
# Use scenario steps first
|
| 257 |
-
used_npc = False
|
| 258 |
-
used_item = False
|
| 259 |
for step in step_names:
|
| 260 |
tid = STEP_TILE_MAP.get(step)
|
| 261 |
if tid == 8 and not used_npc:
|
| 262 |
-
ordered.append((step, 8))
|
|
|
|
| 263 |
elif tid == 5 and not used_item:
|
| 264 |
-
ordered.append((step, 5))
|
|
|
|
| 265 |
|
| 266 |
-
# FORCE NPC and Item if not already present
|
| 267 |
if force_npc and not used_npc:
|
| 268 |
ordered.insert(0, ("meet_expert", 8))
|
| 269 |
if force_item and not used_item:
|
| 270 |
ordered.append(("collect_data", 5))
|
| 271 |
|
| 272 |
-
# Place ordered tiles evenly on the middle path
|
| 273 |
-
n = len(ordered)
|
| 274 |
placed = []
|
|
|
|
| 275 |
for i, (step_label, tile_id) in enumerate(ordered):
|
| 276 |
if not path_middle:
|
| 277 |
break
|
| 278 |
idx = (i + 1) * len(path_middle) // (n + 1)
|
| 279 |
idx = max(0, min(idx, len(path_middle) - 1))
|
| 280 |
r, c = path_middle[idx]
|
| 281 |
-
# Avoid placing on same cell
|
| 282 |
while (r, c) in [(pr, pc) for _, (pr, pc) in placed] and idx + 1 < len(path_middle):
|
| 283 |
idx += 1
|
| 284 |
r, c = path_middle[idx]
|
| 285 |
grid[r][c] = tile_id
|
| 286 |
placed.append((step_label, (r, c)))
|
| 287 |
|
| 288 |
-
# Place Start and Goal
|
| 289 |
grid[sr][sc] = 2
|
| 290 |
grid[gr][gc] = 3
|
| 291 |
|
| 292 |
-
|
| 293 |
-
palette = THEME_PALETTES.get(theme, THEME_PALETTES["default"])
|
| 294 |
|
| 295 |
return {
|
| 296 |
"grid": grid,
|
| 297 |
"rows": size,
|
| 298 |
"cols": size,
|
| 299 |
-
"ordered_steps":
|
| 300 |
-
"
|
|
|
|
| 301 |
}
|
| 302 |
|
| 303 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 304 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 306 |
-
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
if not self.model:
|
| 309 |
raise Exception("No AI model")
|
| 310 |
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
progression_str = ""
|
| 313 |
-
for i, (scen, cg) in enumerate(zip(scenarios, compiled_grids)):
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
prompt = f"""
|
| 321 |
-
You are the Mission Architect for CodeCracker
|
| 322 |
-
Create a 5-level story arc. Each level uses a completely different theme and mission steps.
|
| 323 |
|
| 324 |
-
|
| 325 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
-
|
| 328 |
-
|
| 329 |
|
| 330 |
-
|
| 331 |
-
1.
|
| 332 |
-
2.
|
| 333 |
-
3.
|
| 334 |
-
4.
|
| 335 |
-
5.
|
|
|
|
| 336 |
|
| 337 |
OUTPUT only valid JSON (no markdown):
|
| 338 |
{{
|
| 339 |
-
"story_arc_title": "
|
| 340 |
-
"
|
| 341 |
-
"daa_description": "{concept['description']}",
|
| 342 |
"levels": [
|
| 343 |
{{
|
| 344 |
"type": "maze",
|
| 345 |
-
"level_id": "
|
| 346 |
-
"title": "
|
| 347 |
-
"story": "
|
| 348 |
-
"goal_description": "Step 1
|
| 349 |
-
"
|
| 350 |
-
"
|
| 351 |
-
"
|
| 352 |
-
"
|
| 353 |
-
"
|
| 354 |
}}
|
| 355 |
]
|
| 356 |
}}
|
|
@@ -363,140 +448,112 @@ OUTPUT only valid JSON (no markdown):
|
|
| 363 |
text = text.split("```")[1].split("```")[0]
|
| 364 |
return json.loads(text.strip())
|
| 365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 367 |
# PUBLIC ENTRY POINT
|
| 368 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 369 |
def generate_level(self, difficulty: str, topic: Optional[str] = None) -> Dict[str, Any]:
|
| 370 |
-
"""
|
| 371 |
-
Main pipeline:
|
| 372 |
-
1. Pick a scenario from the dataset
|
| 373 |
-
2. Pick a DAA concept matching difficulty
|
| 374 |
-
3. Build 5 progressively larger mazes with FORCED NPC+Item checkpoints
|
| 375 |
-
4. Enrich with AI story
|
| 376 |
-
5. Return complete arc
|
| 377 |
-
"""
|
| 378 |
-
# Normalise: app sends "Beginner"/"Intermediate"/"Expert"
|
| 379 |
-
# dataset uses "easy"/"medium"/"hard"
|
| 380 |
_diff_norm = {
|
| 381 |
-
"beginner":
|
| 382 |
-
"
|
| 383 |
-
"
|
| 384 |
-
"medium": "medium",
|
| 385 |
-
"expert": "hard",
|
| 386 |
-
"hard": "hard",
|
| 387 |
}
|
| 388 |
diff_key = _diff_norm.get(difficulty.lower(), "easy")
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
arc_scenarios =
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
arc_scenarios.append(random.choice(filtered) if filtered else {})
|
| 400 |
-
|
| 401 |
-
# Ensure each level gets a distinct visual theme color palette
|
| 402 |
-
palette_keys = list(THEME_PALETTES.keys())
|
| 403 |
-
random.shuffle(palette_keys)
|
| 404 |
-
|
| 405 |
-
# Step 2: Pick DAA concept β harder = more complex maze type + larger dim
|
| 406 |
-
concept_map = {
|
| 407 |
-
"easy": [DAA_CONCEPTS[0], DAA_CONCEPTS[3]], # collect+reach, sequence
|
| 408 |
-
"medium": [DAA_CONCEPTS[1], DAA_CONCEPTS[0]], # shortest_path, collect+reach
|
| 409 |
-
"hard": [DAA_CONCEPTS[2], DAA_CONCEPTS[1]], # dfs_backtrack, shortest_path
|
| 410 |
-
}
|
| 411 |
-
concept_pool = concept_map.get(diff_key, DAA_CONCEPTS)
|
| 412 |
-
concept = random.choice(concept_pool)
|
| 413 |
|
| 414 |
-
# Step 3: Build 5 mazes, growing in complexity, each with its own scenario
|
| 415 |
compiled_grids = []
|
| 416 |
-
for i in
|
| 417 |
-
dim = random.randint(
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
lvl_steps = lvl_scenario.get("required_steps", [])
|
| 421 |
-
|
| 422 |
-
grid_data = self._build_level_maze(
|
| 423 |
-
dim=dim,
|
| 424 |
-
maze_type=concept["maze_type"],
|
| 425 |
-
theme=lvl_theme,
|
| 426 |
-
force_npc=concept["force_npc"],
|
| 427 |
-
force_item=concept["force_item"],
|
| 428 |
-
step_names=lvl_steps,
|
| 429 |
)
|
| 430 |
-
|
|
|
|
| 431 |
|
| 432 |
-
# Step 4: AI enrichment
|
| 433 |
try:
|
| 434 |
-
|
| 435 |
except Exception as e:
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
arc = self._build_fallback_arc(arc_scenarios, concept, compiled_grids, error_msg)
|
| 439 |
|
| 440 |
-
|
| 441 |
-
levels = arc.get("levels", [])
|
| 442 |
for i, level in enumerate(levels):
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
level["
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
base_palette["item_emoji"] = level.get("item_emoji", "π")
|
| 456 |
-
base_palette["goal_emoji"] = level.get("goal_emoji", "
|
| 457 |
-
|
| 458 |
-
level["theme_palette"] = base_palette
|
| 459 |
-
level["daa_concept"] = concept["title"]
|
| 460 |
-
level["daa_description"]= concept["description"]
|
| 461 |
-
level["type"] = "maze"
|
| 462 |
-
|
| 463 |
-
arc["levels"] = levels
|
| 464 |
-
# Remove top-level theme_palette so we don't override the distinct ones later
|
| 465 |
-
return arc
|
| 466 |
-
|
| 467 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 468 |
-
# FALLBACK (no AI or API failure)
|
| 469 |
-
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 470 |
-
def _build_fallback_arc(self, scenarios: List[Dict], concept: Dict,
|
| 471 |
-
compiled_grids: List[Dict], error_msg: str = "Unknown") -> Dict:
|
| 472 |
-
levels = []
|
| 473 |
-
for i, cg in enumerate(compiled_grids):
|
| 474 |
-
theme = scenarios[i].get("theme", "mission").capitalize()
|
| 475 |
-
steps = cg["ordered_steps"]
|
| 476 |
-
step_str = " β ".join(steps) or "Reach the Exit"
|
| 477 |
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
"story": (
|
| 483 |
-
f"SERVER ERROR: {error_msg}\n\n"
|
| 484 |
-
f"Mission {i+1}: Apply {concept['title']} to navigate the {theme.lower()} labyrinth. "
|
| 485 |
-
f"{concept['description']}"
|
| 486 |
-
),
|
| 487 |
-
"goal_description": step_str + " β Exit",
|
| 488 |
-
"hint": "Try planning your path by starting from the end and tracing backwards.",
|
| 489 |
-
"npc_emoji": "π€΅",
|
| 490 |
-
"item_emoji": "π",
|
| 491 |
-
"goal_emoji": "π",
|
| 492 |
-
"daa_concept": concept["title"],
|
| 493 |
-
"daa_description": concept["description"],
|
| 494 |
-
"allowed_blocks": ["move_forward", "turn_left", "turn_right"],
|
| 495 |
-
})
|
| 496 |
-
return {
|
| 497 |
-
"story_arc_title": f"Grand {concept['title']}",
|
| 498 |
-
"levels": levels,
|
| 499 |
-
}
|
| 500 |
|
| 501 |
def learn_from_feedback(self, level_data: Dict[str, Any], rating: int,
|
| 502 |
developer_feedback: str = None):
|
|
|
|
| 19 |
"default": {"wall_color": "#8E24AA", "path_color": "#020817", "accent": "#FF477E"},
|
| 20 |
}
|
| 21 |
|
|
|
|
| 22 |
STEP_TILE_MAP = {
|
| 23 |
"meet_manager": 8,
|
| 24 |
"meet_scientist": 8,
|
|
|
|
| 32 |
"verify_pin": 5,
|
| 33 |
}
|
| 34 |
|
| 35 |
+
MAZE_TOOLBOX_XML = (
|
| 36 |
+
'<category name="Movement" colour="230">'
|
| 37 |
+
'<block type="move_up"></block><block type="move_down"></block>'
|
| 38 |
+
'<block type="move_left"></block><block type="move_right"></block>'
|
| 39 |
+
'</category>'
|
| 40 |
+
'<category name="Loops" colour="120">'
|
| 41 |
+
'<block type="controls_repeat_ext">'
|
| 42 |
+
'<value name="TIMES"><block type="math_number"><field name="NUM">3</field></block></value>'
|
| 43 |
+
'</block></category>'
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def _get_best_flash_model() -> str:
|
|
|
|
| 48 |
try:
|
| 49 |
available_models = []
|
| 50 |
for m in genai.list_models():
|
| 51 |
+
if 'generateContent' in m.supported_generation_methods and 'flash' in m.name.lower():
|
| 52 |
+
available_models.append(m.name)
|
|
|
|
|
|
|
| 53 |
if available_models:
|
|
|
|
| 54 |
available_models.sort(reverse=True)
|
| 55 |
return available_models[0].replace('models/', '')
|
|
|
|
|
|
|
| 56 |
for m in genai.list_models():
|
| 57 |
if 'generateContent' in m.supported_generation_methods and 'gemini' in m.name.lower():
|
| 58 |
return m.name.replace('models/', '')
|
|
|
|
| 59 |
except Exception as e:
|
| 60 |
print(f"Warning: Could not list models ({e}). Using hardcoded default.")
|
|
|
|
| 61 |
return "gemini-2.0-flash"
|
| 62 |
|
| 63 |
|
| 64 |
+
def _load_json_data(base_dir: str, filename: str) -> Any:
|
| 65 |
+
"""Load JSON from backend/data with fallbacks for local dev and HF Docker."""
|
| 66 |
+
candidates = [
|
| 67 |
+
os.path.join(base_dir, "data", filename),
|
| 68 |
+
os.path.join(base_dir, "..", filename),
|
| 69 |
+
os.path.join(os.path.dirname(base_dir), filename),
|
| 70 |
+
]
|
| 71 |
+
for path in candidates:
|
| 72 |
+
if os.path.isfile(path):
|
| 73 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 74 |
+
return json.load(f)
|
| 75 |
+
raise FileNotFoundError(f"Could not find {filename} in {candidates}")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _is_prime(n: int) -> bool:
|
| 79 |
+
if n < 2:
|
| 80 |
+
return False
|
| 81 |
+
if n == 2:
|
| 82 |
+
return True
|
| 83 |
+
if n % 2 == 0:
|
| 84 |
+
return False
|
| 85 |
+
d = 3
|
| 86 |
+
while d * d <= n:
|
| 87 |
+
if n % d == 0:
|
| 88 |
+
return False
|
| 89 |
+
d += 2
|
| 90 |
+
return True
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _is_palindrome(n: int) -> bool:
|
| 94 |
+
s = str(n)
|
| 95 |
+
return s == s[::-1]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
class PuzzleAgent:
|
| 99 |
"""
|
| 100 |
+
Curriculum-driven maze puzzle generator for CodeCracker Puzzle mode.
|
| 101 |
+
Each 5-level arc teaches one concept per level with Cody & Zara continuity.
|
|
|
|
| 102 |
"""
|
| 103 |
|
| 104 |
def __init__(self, api_key: Optional[str] = None):
|
|
|
|
| 112 |
print(f"[PuzzleAgent] Selected Model: {best_model}")
|
| 113 |
self.model = genai.GenerativeModel(best_model)
|
| 114 |
|
| 115 |
+
self.base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 116 |
+
|
|
|
|
|
|
|
| 117 |
self.scenarios: List[Dict] = []
|
| 118 |
try:
|
| 119 |
+
data = _load_json_data(self.base_dir, "maze_scenarios_500.json")
|
| 120 |
+
self.scenarios = data.get("scenarios", [])
|
| 121 |
print(f"[PuzzleAgent] Loaded {len(self.scenarios)} scenarios.")
|
| 122 |
except Exception as e:
|
| 123 |
+
print(f"[PuzzleAgent] Could not load scenarios: {e}")
|
| 124 |
+
|
| 125 |
+
self.catalog: Dict = {"arcs": [], "concepts": {}}
|
| 126 |
+
self.characters: Dict = {}
|
| 127 |
+
try:
|
| 128 |
+
self.catalog = _load_json_data(self.base_dir, "maze_concept_catalog.json")
|
| 129 |
+
self.characters = _load_json_data(self.base_dir, "character_bible.json")
|
| 130 |
+
print(f"[PuzzleAgent] Loaded catalog with {len(self.catalog.get('arcs', []))} arcs.")
|
| 131 |
+
except Exception as e:
|
| 132 |
+
print(f"[PuzzleAgent] Could not load catalog/bible: {e}")
|
| 133 |
|
| 134 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
# MAZE GENERATION
|
| 136 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
def _create_perfect_maze(self, dim: int) -> List[List[int]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
size = dim * 2 + 1
|
| 139 |
maze = [[1] * size for _ in range(size)]
|
|
|
|
| 140 |
x, y = 0, 0
|
| 141 |
maze[2 * x + 1][2 * y + 1] = 0
|
|
|
|
| 142 |
stack = [(x, y)]
|
| 143 |
sys.setrecursionlimit(50000)
|
|
|
|
| 144 |
while stack:
|
| 145 |
x, y = stack[-1]
|
| 146 |
directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
|
|
|
|
| 156 |
break
|
| 157 |
if not moved:
|
| 158 |
stack.pop()
|
|
|
|
| 159 |
return maze
|
| 160 |
|
|
|
|
|
|
|
|
|
|
| 161 |
def _create_imperfect_maze(self, dim: int, extra_wall_removals: float = 0.20) -> List[List[int]]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
maze = self._create_perfect_maze(dim)
|
| 163 |
size = len(maze)
|
| 164 |
wall_candidates = []
|
|
|
|
| 169 |
wall_candidates.append((i, j))
|
| 170 |
elif maze[i][j - 1] == 0 and maze[i][j + 1] == 0:
|
| 171 |
wall_candidates.append((i, j))
|
|
|
|
| 172 |
num_to_remove = int(len(wall_candidates) * extra_wall_removals)
|
| 173 |
for i, j in random.sample(wall_candidates, min(num_to_remove, len(wall_candidates))):
|
| 174 |
maze[i][j] = 0
|
|
|
|
| 175 |
return maze
|
| 176 |
|
|
|
|
|
|
|
|
|
|
| 177 |
def _bfs_path(self, grid: List[List[int]], start: Tuple, goal: Tuple) -> List[Tuple]:
|
| 178 |
rows, cols = len(grid), len(grid[0])
|
| 179 |
visited = {start}
|
|
|
|
| 182 |
(r, c), path = queue.popleft()
|
| 183 |
if (r, c) == goal:
|
| 184 |
return path
|
| 185 |
+
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
| 186 |
nr, nc = r + dr, c + dc
|
| 187 |
if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited and grid[nr][nc] != 1:
|
| 188 |
visited.add((nr, nc))
|
| 189 |
queue.append(((nr, nc), path + [(nr, nc)]))
|
| 190 |
return []
|
| 191 |
|
| 192 |
+
def _generate_cell_values(self, concept: Dict, path_middle: List[Tuple]) -> Tuple[Dict[str, int], Dict]:
|
| 193 |
+
"""Place numbered floor tiles on the solution path for puzzle rules."""
|
| 194 |
+
rule = dict(concept.get("maze_rule") or {"type": "none"})
|
| 195 |
+
rule_type = rule.get("type", "none")
|
| 196 |
+
cell_values: Dict[str, int] = {}
|
| 197 |
+
|
| 198 |
+
if not path_middle or rule_type == "none" or rule_type == "ordered_checkpoints":
|
| 199 |
+
return cell_values, rule
|
| 200 |
+
|
| 201 |
+
slots = path_middle[::max(1, len(path_middle) // 4)][:5]
|
| 202 |
+
if not slots:
|
| 203 |
+
slots = path_middle[:3]
|
| 204 |
+
|
| 205 |
+
if rule_type == "parity":
|
| 206 |
+
pass_if = rule.get("pass_if", "even")
|
| 207 |
+
for idx, (r, c) in enumerate(slots):
|
| 208 |
+
val = random.choice([2, 4, 6, 8, 10, 12]) if pass_if == "even" else random.choice([3, 5, 7, 9, 11])
|
| 209 |
+
if idx % 2 == 1:
|
| 210 |
+
val = random.choice([3, 5, 7, 9, 11]) if pass_if == "even" else random.choice([2, 4, 6, 8, 10])
|
| 211 |
+
cell_values[f"{r},{c}"] = val
|
| 212 |
+
return cell_values, rule
|
| 213 |
+
|
| 214 |
+
if rule_type == "prime_only":
|
| 215 |
+
pool = [4, 6, 8, 9, 10, 12, 14, 15]
|
| 216 |
+
primes = [3, 5, 7, 11, 13]
|
| 217 |
+
for i, (r, c) in enumerate(slots):
|
| 218 |
+
cell_values[f"{r},{c}"] = primes[i % len(primes)] if i % 2 == 0 else pool[i % len(pool)]
|
| 219 |
+
return cell_values, rule
|
| 220 |
+
|
| 221 |
+
if rule_type == "palindrome_only":
|
| 222 |
+
pals = [3, 5, 7, 11, 121]
|
| 223 |
+
non_pals = [12, 14, 18, 19]
|
| 224 |
+
for i, (r, c) in enumerate(slots):
|
| 225 |
+
cell_values[f"{r},{c}"] = pals[i % len(pals)] if i % 2 == 0 else non_pals[i % len(non_pals)]
|
| 226 |
+
return cell_values, rule
|
| 227 |
+
|
| 228 |
+
if rule_type == "max_label_only":
|
| 229 |
+
if len(slots) >= 2:
|
| 230 |
+
low, high = 8, 17
|
| 231 |
+
cell_values[f"{slots[0][0]},{slots[0][1]}"] = low
|
| 232 |
+
cell_values[f"{slots[1][0]},{slots[1][1]}"] = high
|
| 233 |
+
rule = {"type": "labeled_safe", "safe_values": [high]}
|
| 234 |
+
return cell_values, rule
|
| 235 |
+
|
| 236 |
+
return cell_values, rule
|
| 237 |
+
|
| 238 |
+
def _build_level_maze(
|
| 239 |
+
self,
|
| 240 |
+
dim: int,
|
| 241 |
+
concept: Dict,
|
| 242 |
+
step_names: List[str],
|
| 243 |
+
) -> Dict:
|
| 244 |
+
maze_type = concept.get("maze_type", "imperfect")
|
| 245 |
+
force_npc = concept.get("force_npc", False)
|
| 246 |
+
force_item = concept.get("force_item", False)
|
| 247 |
+
|
| 248 |
if maze_type == "imperfect":
|
| 249 |
grid = self._create_imperfect_maze(dim)
|
| 250 |
else:
|
| 251 |
grid = self._create_perfect_maze(dim)
|
| 252 |
|
| 253 |
size = len(grid)
|
|
|
|
|
|
|
| 254 |
sr, sc = 1, 1
|
| 255 |
gr, gc = size - 2, size - 2
|
|
|
|
| 256 |
grid[sr][sc] = 0
|
| 257 |
grid[gr][gc] = 0
|
| 258 |
|
|
|
|
| 259 |
path = self._bfs_path(grid, (sr, sc), (gr, gc))
|
| 260 |
if not path or len(path) < 5:
|
|
|
|
| 261 |
grid = self._create_perfect_maze(max(dim, 7))
|
| 262 |
size = len(grid)
|
| 263 |
sr, sc = 1, 1
|
| 264 |
gr, gc = size - 2, size - 2
|
| 265 |
+
grid[sr][sc] = 0
|
| 266 |
+
grid[gr][gc] = 0
|
| 267 |
path = self._bfs_path(grid, (sr, sc), (gr, gc))
|
| 268 |
|
| 269 |
+
path_middle = path[1:-1]
|
| 270 |
+
ordered = []
|
| 271 |
+
used_npc = used_item = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
for step in step_names:
|
| 273 |
tid = STEP_TILE_MAP.get(step)
|
| 274 |
if tid == 8 and not used_npc:
|
| 275 |
+
ordered.append((step, 8))
|
| 276 |
+
used_npc = True
|
| 277 |
elif tid == 5 and not used_item:
|
| 278 |
+
ordered.append((step, 5))
|
| 279 |
+
used_item = True
|
| 280 |
|
|
|
|
| 281 |
if force_npc and not used_npc:
|
| 282 |
ordered.insert(0, ("meet_expert", 8))
|
| 283 |
if force_item and not used_item:
|
| 284 |
ordered.append(("collect_data", 5))
|
| 285 |
|
|
|
|
|
|
|
| 286 |
placed = []
|
| 287 |
+
n = len(ordered)
|
| 288 |
for i, (step_label, tile_id) in enumerate(ordered):
|
| 289 |
if not path_middle:
|
| 290 |
break
|
| 291 |
idx = (i + 1) * len(path_middle) // (n + 1)
|
| 292 |
idx = max(0, min(idx, len(path_middle) - 1))
|
| 293 |
r, c = path_middle[idx]
|
|
|
|
| 294 |
while (r, c) in [(pr, pc) for _, (pr, pc) in placed] and idx + 1 < len(path_middle):
|
| 295 |
idx += 1
|
| 296 |
r, c = path_middle[idx]
|
| 297 |
grid[r][c] = tile_id
|
| 298 |
placed.append((step_label, (r, c)))
|
| 299 |
|
|
|
|
| 300 |
grid[sr][sc] = 2
|
| 301 |
grid[gr][gc] = 3
|
| 302 |
|
| 303 |
+
cell_values, maze_rule = self._generate_cell_values(concept, path_middle)
|
|
|
|
| 304 |
|
| 305 |
return {
|
| 306 |
"grid": grid,
|
| 307 |
"rows": size,
|
| 308 |
"cols": size,
|
| 309 |
+
"ordered_steps": [s for s, _ in ordered],
|
| 310 |
+
"cell_values": cell_values,
|
| 311 |
+
"maze_rule": maze_rule,
|
| 312 |
}
|
| 313 |
|
| 314 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 315 |
+
# ARC & SCENARIO SELECTION
|
| 316 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 317 |
+
def _pick_arc(self, diff_key: str, topic: Optional[str]) -> Dict:
|
| 318 |
+
arcs = self.catalog.get("arcs", [])
|
| 319 |
+
if topic:
|
| 320 |
+
t = topic.lower()
|
| 321 |
+
matched = [a for a in arcs if a.get("theme", "").lower() == t or t in a.get("title", "").lower()]
|
| 322 |
+
if matched:
|
| 323 |
+
arcs = matched
|
| 324 |
+
matched_diff = [a for a in arcs if a.get("difficulty", "easy") == diff_key]
|
| 325 |
+
if matched_diff:
|
| 326 |
+
return random.choice(matched_diff)
|
| 327 |
+
if arcs:
|
| 328 |
+
return random.choice(arcs)
|
| 329 |
+
return {
|
| 330 |
+
"arc_id": "fallback_arc",
|
| 331 |
+
"title": "Training Protocol",
|
| 332 |
+
"theme": "space",
|
| 333 |
+
"difficulty": diff_key,
|
| 334 |
+
"concept_ids": ["sequence", "even_odd", "collect_and_reach", "shortest_path", "loops_path"],
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
def _get_concept(self, concept_id: str) -> Dict:
|
| 338 |
+
concepts = self.catalog.get("concepts", {})
|
| 339 |
+
if concept_id in concepts:
|
| 340 |
+
return concepts[concept_id]
|
| 341 |
+
return {
|
| 342 |
+
"id": concept_id,
|
| 343 |
+
"title": "Maze Navigation",
|
| 344 |
+
"learning_outcome": "Navigate checkpoints in order.",
|
| 345 |
+
"description": "Reach the exit by following the mission steps.",
|
| 346 |
+
"maze_type": "imperfect",
|
| 347 |
+
"min_dim": 7,
|
| 348 |
+
"max_dim": 10,
|
| 349 |
+
"force_npc": True,
|
| 350 |
+
"force_item": True,
|
| 351 |
+
"maze_rule": {"type": "ordered_checkpoints"},
|
| 352 |
+
"hint_template": "Follow the checklist step by step.",
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
def _pick_scenarios_for_arc(self, arc: Dict, diff_key: str) -> List[Dict]:
|
| 356 |
+
theme = arc.get("theme", "space").lower()
|
| 357 |
+
filtered = [
|
| 358 |
+
s for s in self.scenarios
|
| 359 |
+
if s.get("difficulty", "").lower() == diff_key and s.get("theme", "").lower() == theme
|
| 360 |
+
]
|
| 361 |
+
if len(filtered) < 5:
|
| 362 |
+
filtered = [s for s in self.scenarios if s.get("difficulty", "").lower() == diff_key]
|
| 363 |
+
if len(filtered) < 5:
|
| 364 |
+
filtered = self.scenarios
|
| 365 |
+
if len(filtered) >= 5:
|
| 366 |
+
return random.sample(filtered, 5)
|
| 367 |
+
pool = filtered if filtered else [{}]
|
| 368 |
+
return [random.choice(pool) for _ in range(5)]
|
| 369 |
+
|
| 370 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 371 |
+
# AI STORY (Cody & Zara, one arc)
|
| 372 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 373 |
+
def _enrich_with_ai(
|
| 374 |
+
self,
|
| 375 |
+
arc: Dict,
|
| 376 |
+
level_concepts: List[Dict],
|
| 377 |
+
scenarios: List[Dict],
|
| 378 |
+
compiled_grids: List[Dict],
|
| 379 |
+
) -> Dict:
|
| 380 |
if not self.model:
|
| 381 |
raise Exception("No AI model")
|
| 382 |
|
| 383 |
+
cast = self.characters.get("cast", [])
|
| 384 |
+
cody = next((c for c in cast if c.get("id") == "cody"), {"name": "Captain Cody", "emoji": "π§βπ"})
|
| 385 |
+
zara = next((c for c in cast if c.get("id") == "zara"), {"name": "Engineer Zara", "emoji": "π©βπ¬"})
|
| 386 |
+
setting = self.characters.get("setting", "Planet Antaria")
|
| 387 |
+
|
| 388 |
progression_str = ""
|
| 389 |
+
for i, (concept, scen, cg) in enumerate(zip(level_concepts, scenarios, compiled_grids)):
|
| 390 |
+
steps = " β ".join(cg["ordered_steps"]) or "reach the exit"
|
| 391 |
+
nums = ", ".join(str(v) for v in cg.get("cell_values", {}).values())
|
| 392 |
+
progression_str += (
|
| 393 |
+
f"Level {i + 1} β Teach: {concept['title']}\n"
|
| 394 |
+
f" Learning: {concept['learning_outcome']}\n"
|
| 395 |
+
f" Puzzle rule: {concept['description']}\n"
|
| 396 |
+
f" Numbered tiles on path: {nums or 'none'}\n"
|
| 397 |
+
f" Mission steps: {steps}\n"
|
| 398 |
+
f" Setting hint: {scen.get('title', 'Mission')}\n\n"
|
| 399 |
+
)
|
| 400 |
|
| 401 |
prompt = f"""
|
| 402 |
+
You are the Mission Architect for CodeCracker Puzzle mode (maze-only teaching, ages 10-16).
|
|
|
|
| 403 |
|
| 404 |
+
CHARACTERS (use in EVERY level):
|
| 405 |
+
- {cody['name']} {cody['emoji']} β navigator
|
| 406 |
+
- {zara['name']} {zara['emoji']} β explains logic rules
|
| 407 |
+
|
| 408 |
+
SETTING: {setting}
|
| 409 |
+
STORY ARC: "{arc.get('title', 'Mission')}" (arc_id: {arc.get('arc_id')})
|
| 410 |
+
CRITICAL: All 5 levels are ONE continuous story on the SAME planet/ship crisis. Do NOT switch to unrelated themes like random bank/hospital.
|
| 411 |
|
| 412 |
+
LEVEL PROGRESSION (each level teaches ONE new programming idea via the maze):
|
| 413 |
+
{progression_str}
|
| 414 |
|
| 415 |
+
RULES:
|
| 416 |
+
1. `story` must star Cody and Zara and explain THAT level's concept in plain language (even/odd, primes, sequence, BFS, loops, etc.).
|
| 417 |
+
2. `goal_description` must list the exact mission steps for that level.
|
| 418 |
+
3. `concept_tutorial` = one short line shown in the app banner (use the learning outcome).
|
| 419 |
+
4. `hint` must be unique per level; use the puzzle logic, not generic advice.
|
| 420 |
+
5. `npc_emoji` should be {zara['emoji']} when meeting the expert; `item_emoji` π; `goal_emoji` π.
|
| 421 |
+
6. Exactly 5 levels in order.
|
| 422 |
|
| 423 |
OUTPUT only valid JSON (no markdown):
|
| 424 |
{{
|
| 425 |
+
"story_arc_title": "{arc.get('title', 'Antaria Mission')}",
|
| 426 |
+
"arc_id": "{arc.get('arc_id', 'arc')}",
|
|
|
|
| 427 |
"levels": [
|
| 428 |
{{
|
| 429 |
"type": "maze",
|
| 430 |
+
"level_id": "arc_lvl_1",
|
| 431 |
+
"title": "Episode title",
|
| 432 |
+
"story": "Cody and Zara ...",
|
| 433 |
+
"goal_description": "Step 1 β Step 2 β Exit",
|
| 434 |
+
"concept_tutorial": "Short learning line",
|
| 435 |
+
"hint": "Level-specific hint",
|
| 436 |
+
"npc_emoji": "{zara['emoji']}",
|
| 437 |
+
"item_emoji": "π",
|
| 438 |
+
"goal_emoji": "π"
|
| 439 |
}}
|
| 440 |
]
|
| 441 |
}}
|
|
|
|
| 448 |
text = text.split("```")[1].split("```")[0]
|
| 449 |
return json.loads(text.strip())
|
| 450 |
|
| 451 |
+
def _build_fallback_arc(
|
| 452 |
+
self,
|
| 453 |
+
arc: Dict,
|
| 454 |
+
level_concepts: List[Dict],
|
| 455 |
+
scenarios: List[Dict],
|
| 456 |
+
compiled_grids: List[Dict],
|
| 457 |
+
error_msg: str = "Unknown",
|
| 458 |
+
) -> Dict:
|
| 459 |
+
cast = self.characters.get("cast", [])
|
| 460 |
+
cody = next((c for c in cast if c.get("id") == "cody"), {"name": "Captain Cody", "emoji": "π§βπ"})
|
| 461 |
+
zara = next((c for c in cast if c.get("id") == "zara"), {"name": "Engineer Zara", "emoji": "π©βπ¬"})
|
| 462 |
+
|
| 463 |
+
levels = []
|
| 464 |
+
for i, (concept, cg) in enumerate(zip(level_concepts, compiled_grids)):
|
| 465 |
+
steps = cg["ordered_steps"]
|
| 466 |
+
step_str = " β ".join(steps) + " β Exit" if steps else "Reach the Exit"
|
| 467 |
+
levels.append({
|
| 468 |
+
"type": "maze",
|
| 469 |
+
"level_id": f"{arc.get('arc_id', 'arc')}_{i + 1}",
|
| 470 |
+
"title": f"{concept['title']} β Part {i + 1}",
|
| 471 |
+
"story": (
|
| 472 |
+
f"{cody['emoji']} {cody['name']} and {zara['emoji']} {zara['name']} continue "
|
| 473 |
+
f'"{arc.get("title", "the mission")}".\n\n'
|
| 474 |
+
f"Level {i + 1}: {concept['description']}\n\n"
|
| 475 |
+
f"(Story AI offline: {error_msg})"
|
| 476 |
+
),
|
| 477 |
+
"goal_description": step_str,
|
| 478 |
+
"concept_tutorial": concept.get("learning_outcome", concept["title"]),
|
| 479 |
+
"hint": concept.get("hint_template", "Plan your route before moving."),
|
| 480 |
+
"npc_emoji": zara.get("emoji", "π©βπ¬"),
|
| 481 |
+
"item_emoji": "π",
|
| 482 |
+
"goal_emoji": "π",
|
| 483 |
+
})
|
| 484 |
+
return {
|
| 485 |
+
"story_arc_title": arc.get("title", "Training Arc"),
|
| 486 |
+
"arc_id": arc.get("arc_id"),
|
| 487 |
+
"levels": levels,
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 491 |
# PUBLIC ENTRY POINT
|
| 492 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 493 |
def generate_level(self, difficulty: str, topic: Optional[str] = None) -> Dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
_diff_norm = {
|
| 495 |
+
"beginner": "easy", "easy": "easy",
|
| 496 |
+
"intermediate": "medium", "medium": "medium",
|
| 497 |
+
"expert": "hard", "hard": "hard",
|
|
|
|
|
|
|
|
|
|
| 498 |
}
|
| 499 |
diff_key = _diff_norm.get(difficulty.lower(), "easy")
|
| 500 |
|
| 501 |
+
arc = self._pick_arc(diff_key, topic)
|
| 502 |
+
concept_ids = arc.get("concept_ids", [])[:5]
|
| 503 |
+
while len(concept_ids) < 5:
|
| 504 |
+
concept_ids.append("sequence")
|
| 505 |
+
|
| 506 |
+
level_concepts = [self._get_concept(cid) for cid in concept_ids]
|
| 507 |
+
arc_scenarios = self._pick_scenarios_for_arc(arc, diff_key)
|
| 508 |
+
theme = arc.get("theme", "space").lower()
|
| 509 |
+
palette = THEME_PALETTES.get(theme, THEME_PALETTES["default"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
|
|
|
|
| 511 |
compiled_grids = []
|
| 512 |
+
for i, concept in enumerate(level_concepts):
|
| 513 |
+
dim = random.randint(
|
| 514 |
+
concept.get("min_dim", 7) + i // 2,
|
| 515 |
+
concept.get("max_dim", 10) + i // 2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
)
|
| 517 |
+
steps = arc_scenarios[i].get("required_steps", []) if i < len(arc_scenarios) else []
|
| 518 |
+
compiled_grids.append(self._build_level_maze(dim, concept, steps))
|
| 519 |
|
|
|
|
| 520 |
try:
|
| 521 |
+
arc_json = self._enrich_with_ai(arc, level_concepts, arc_scenarios, compiled_grids)
|
| 522 |
except Exception as e:
|
| 523 |
+
print(f"[PuzzleAgent] AI enrichment failed: {e}. Using fallback.")
|
| 524 |
+
arc_json = self._build_fallback_arc(arc, level_concepts, arc_scenarios, compiled_grids, str(e))
|
|
|
|
| 525 |
|
| 526 |
+
levels = arc_json.get("levels", [])
|
|
|
|
| 527 |
for i, level in enumerate(levels):
|
| 528 |
+
if i >= len(compiled_grids):
|
| 529 |
+
break
|
| 530 |
+
cg = compiled_grids[i]
|
| 531 |
+
concept = level_concepts[i]
|
| 532 |
+
level["grid_layout"] = cg["grid"]
|
| 533 |
+
level["maze_rows"] = cg["rows"]
|
| 534 |
+
level["maze_cols"] = cg["cols"]
|
| 535 |
+
level["ordered_steps"] = cg["ordered_steps"]
|
| 536 |
+
level["cell_values"] = cg["cell_values"]
|
| 537 |
+
level["maze_rule"] = cg["maze_rule"]
|
| 538 |
+
level["concept_id"] = concept["id"]
|
| 539 |
+
level["concept_title"] = concept["title"]
|
| 540 |
+
level["track"] = concept.get("track", "PF")
|
| 541 |
+
level["learning_outcome"] = concept.get("learning_outcome", concept["title"])
|
| 542 |
+
level["concept_tutorial"] = level.get("concept_tutorial") or concept.get("learning_outcome", "")
|
| 543 |
+
level["type"] = "maze"
|
| 544 |
+
level["toolbox_xml"] = MAZE_TOOLBOX_XML
|
| 545 |
+
level["allowed_blocks"] = ["move_up", "move_down", "move_left", "move_right", "controls_repeat_ext"]
|
| 546 |
+
|
| 547 |
+
base_palette = palette.copy()
|
| 548 |
+
base_palette["npc_emoji"] = level.get("npc_emoji", "π©βπ¬")
|
| 549 |
base_palette["item_emoji"] = level.get("item_emoji", "π")
|
| 550 |
+
base_palette["goal_emoji"] = level.get("goal_emoji", "π")
|
| 551 |
+
level["theme_palette"] = base_palette
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
|
| 553 |
+
arc_json["levels"] = levels[:5]
|
| 554 |
+
arc_json["arc_id"] = arc.get("arc_id")
|
| 555 |
+
arc_json["theme"] = theme
|
| 556 |
+
return arc_json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
|
| 558 |
def learn_from_feedback(self, level_data: Dict[str, Any], rating: int,
|
| 559 |
developer_feedback: str = None):
|