From 4c809bdd0f46345ed2a8ff904975edd58487af5b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sun, 15 Feb 2026 02:36:46 -0500 Subject: [PATCH] Fix Sokoban puzzle: prevent treasure/button overlap and model obstacles in solver Three bugs that could produce unsolvable puzzles: 1. Feature placement fallback used leaf_center without duplicate checking, allowing treasures to spawn on the same cell as buttons in dense rooms (level 2 puts 8 features in one room). Fixed with exhaustive cell scan fallback. 2. Solvability checker ignored treasure entities entirely. Boulders cannot be pushed through treasures (TreasureEntity.bump returns False for non-player entities), so the solver now models them as boulder-blocking obstacles while allowing player movement through them. 3. Added explicit button-blocked-by-obstacle check before running the full BFS solver, catching the most common failure mode early. Co-Authored-By: Claude Opus 4.6 --- src/scripts/cos_level.py | 39 ++++++++++++++++++++++++++++++++++++--- src/scripts/cos_solver.py | 35 +++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/scripts/cos_level.py b/src/scripts/cos_level.py index 512940d..3a4bbac 100644 --- a/src/scripts/cos_level.py +++ b/src/scripts/cos_level.py @@ -137,16 +137,37 @@ class Level: for f in room_plan: fcoord = None + used_coords = [c[1] for c in feature_coords] for _ in range(100): fc = self.room_coord(leaf) if not self.grid.at(fc).walkable: continue - if fc in [c[1] for c in feature_coords]: + if fc in used_coords: continue fcoord = fc break if fcoord is None: - fcoord = self.leaf_center(leaf) + # Fallback: leaf center, but only if not already used + fc = self.leaf_center(leaf) + if fc not in used_coords: + fcoord = fc + else: + # Last resort: scan all walkable cells in the room + lx, ly = int(leaf.pos[0]), int(leaf.pos[1]) + lw, lh = int(leaf.size[0]), int(leaf.size[1]) + for cx in range(lx + 1, lx + lw - 1): + for cy in range(ly + 1, ly + lh - 1): + if (cx, cy) not in used_coords and \ + 0 <= cx < self.width and \ + 0 <= cy < self.height and \ + self.grid.at((cx, cy)).walkable: + fcoord = (cx, cy) + break + if fcoord is not None: + break + if fcoord is None: + print(f"WARNING: Could not place '{f}' in room {room_num} - no free cells!") + fcoord = self.leaf_center(leaf) # absolute last resort feature_coords.append((f, fcoord)) # 7. Solvability check @@ -154,6 +175,7 @@ class Level: boulder_positions = [] button_positions = [] exit_pos = None + obstacle_positions = [] for f, pos in feature_coords: if f == "spawn": spawn_pos = pos @@ -163,11 +185,22 @@ class Level: button_positions.append(pos) elif f == "exit": exit_pos = pos + elif f == "treasure": + obstacle_positions.append(pos) if spawn_pos and boulder_positions and button_positions and exit_pos: + # Check that no obstacle sits on a button + buttons_blocked = any( + bp in obstacle_positions for bp in button_positions + ) + if buttons_blocked: + print(f"Level attempt {attempt + 1}: button blocked by obstacle, retrying...") + continue + from cos_solver import is_solvable if is_solvable(self.grid, spawn_pos, boulder_positions, - button_positions, exit_pos): + button_positions, exit_pos, + obstacles=obstacle_positions): break print(f"Level attempt {attempt + 1}: unsolvable, retrying...") else: diff --git a/src/scripts/cos_solver.py b/src/scripts/cos_solver.py index ff38292..4349667 100644 --- a/src/scripts/cos_solver.py +++ b/src/scripts/cos_solver.py @@ -2,8 +2,8 @@ from collections import deque -def can_reach(grid, start, goal, boulders, w, h): - """BFS check if start can reach goal, treating boulders as walls.""" +def can_reach(grid, start, goal, boulders, w, h, obstacles=frozenset()): + """BFS check if start can reach goal, treating boulders and obstacles as walls.""" if start == goal: return True visited = {start} @@ -20,6 +20,8 @@ def can_reach(grid, start, goal, boulders, w, h): continue if (nx, ny) in boulders: continue + if (nx, ny) in obstacles: + continue if (nx, ny) not in visited: visited.add((nx, ny)) queue.append((nx, ny)) @@ -27,7 +29,7 @@ def can_reach(grid, start, goal, boulders, w, h): def is_solvable(grid, player_pos, boulder_positions, button_positions, exit_pos, - max_states=500000): + max_states=500000, obstacles=None): """BFS through Sokoban state space. Returns True if solvable. Args: @@ -37,21 +39,38 @@ def is_solvable(grid, player_pos, boulder_positions, button_positions, exit_pos, button_positions: list of (x, y) tuples for buttons exit_pos: (x, y) tuple for exit max_states: maximum states to explore before giving up + obstacles: list of (x, y) tuples for immovable obstacles (treasures, etc.) Returns: True if the puzzle is solvable, False otherwise """ w, h = int(grid.grid_size.x), int(grid.grid_size.y) + obstacle_set = frozenset(obstacles) if obstacles else frozenset() - def is_walkable(x, y, boulders): + def is_cell_open(x, y): + """Check if a cell is in-bounds and walkable.""" if x < 0 or x >= w or y < 0 or y >= h: return False - if not grid.at((x, y)).walkable: + return grid.at((x, y)).walkable + + def player_can_walk(x, y, boulders): + """Check if player can walk to (x,y). Players can pass through obstacles (chests).""" + if not is_cell_open(x, y): return False if (x, y) in boulders: return False return True + def boulder_can_land(x, y, boulders): + """Check if a boulder can be pushed to (x,y). Boulders cannot pass through obstacles.""" + if not is_cell_open(x, y): + return False + if (x, y) in boulders: + return False + if (x, y) in obstacle_set: + return False + return True + initial = (player_pos, frozenset(boulder_positions)) target_buttons = frozenset(button_positions) visited = {initial} @@ -73,15 +92,15 @@ def is_solvable(grid, player_pos, boulder_positions, button_positions, exit_pos, nx, ny = px + dx, py + dy if (nx, ny) in boulders: - # Push boulder + # Push boulder - boulder landing site must be obstacle-free bx, by = nx + dx, ny + dy - if is_walkable(bx, by, boulders): + if boulder_can_land(bx, by, boulders): new_boulders = frozenset(boulders - {(nx, ny)} | {(bx, by)}) state = ((nx, ny), new_boulders) if state not in visited: visited.add(state) queue.append(state) - elif is_walkable(nx, ny, boulders): + elif player_can_walk(nx, ny, boulders): state = ((nx, ny), boulders) if state not in visited: visited.add(state)