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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-02-15 02:36:46 -05:00
commit 4c809bdd0f
2 changed files with 63 additions and 11 deletions

View file

@ -137,16 +137,37 @@ class Level:
for f in room_plan: for f in room_plan:
fcoord = None fcoord = None
used_coords = [c[1] for c in feature_coords]
for _ in range(100): for _ in range(100):
fc = self.room_coord(leaf) fc = self.room_coord(leaf)
if not self.grid.at(fc).walkable: if not self.grid.at(fc).walkable:
continue continue
if fc in [c[1] for c in feature_coords]: if fc in used_coords:
continue continue
fcoord = fc fcoord = fc
break break
if fcoord is None: 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)) feature_coords.append((f, fcoord))
# 7. Solvability check # 7. Solvability check
@ -154,6 +175,7 @@ class Level:
boulder_positions = [] boulder_positions = []
button_positions = [] button_positions = []
exit_pos = None exit_pos = None
obstacle_positions = []
for f, pos in feature_coords: for f, pos in feature_coords:
if f == "spawn": if f == "spawn":
spawn_pos = pos spawn_pos = pos
@ -163,11 +185,22 @@ class Level:
button_positions.append(pos) button_positions.append(pos)
elif f == "exit": elif f == "exit":
exit_pos = pos exit_pos = pos
elif f == "treasure":
obstacle_positions.append(pos)
if spawn_pos and boulder_positions and button_positions and exit_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 from cos_solver import is_solvable
if is_solvable(self.grid, spawn_pos, boulder_positions, if is_solvable(self.grid, spawn_pos, boulder_positions,
button_positions, exit_pos): button_positions, exit_pos,
obstacles=obstacle_positions):
break break
print(f"Level attempt {attempt + 1}: unsolvable, retrying...") print(f"Level attempt {attempt + 1}: unsolvable, retrying...")
else: else:

View file

@ -2,8 +2,8 @@
from collections import deque from collections import deque
def can_reach(grid, start, goal, boulders, w, h): def can_reach(grid, start, goal, boulders, w, h, obstacles=frozenset()):
"""BFS check if start can reach goal, treating boulders as walls.""" """BFS check if start can reach goal, treating boulders and obstacles as walls."""
if start == goal: if start == goal:
return True return True
visited = {start} visited = {start}
@ -20,6 +20,8 @@ def can_reach(grid, start, goal, boulders, w, h):
continue continue
if (nx, ny) in boulders: if (nx, ny) in boulders:
continue continue
if (nx, ny) in obstacles:
continue
if (nx, ny) not in visited: if (nx, ny) not in visited:
visited.add((nx, ny)) visited.add((nx, ny))
queue.append((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, 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. """BFS through Sokoban state space. Returns True if solvable.
Args: 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 button_positions: list of (x, y) tuples for buttons
exit_pos: (x, y) tuple for exit exit_pos: (x, y) tuple for exit
max_states: maximum states to explore before giving up max_states: maximum states to explore before giving up
obstacles: list of (x, y) tuples for immovable obstacles (treasures, etc.)
Returns: Returns:
True if the puzzle is solvable, False otherwise True if the puzzle is solvable, False otherwise
""" """
w, h = int(grid.grid_size.x), int(grid.grid_size.y) 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: if x < 0 or x >= w or y < 0 or y >= h:
return False 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 return False
if (x, y) in boulders: if (x, y) in boulders:
return False return False
return True 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)) initial = (player_pos, frozenset(boulder_positions))
target_buttons = frozenset(button_positions) target_buttons = frozenset(button_positions)
visited = {initial} visited = {initial}
@ -73,15 +92,15 @@ def is_solvable(grid, player_pos, boulder_positions, button_positions, exit_pos,
nx, ny = px + dx, py + dy nx, ny = px + dx, py + dy
if (nx, ny) in boulders: if (nx, ny) in boulders:
# Push boulder # Push boulder - boulder landing site must be obstacle-free
bx, by = nx + dx, ny + dy 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)}) new_boulders = frozenset(boulders - {(nx, ny)} | {(bx, by)})
state = ((nx, ny), new_boulders) state = ((nx, ny), new_boulders)
if state not in visited: if state not in visited:
visited.add(state) visited.add(state)
queue.append(state) queue.append(state)
elif is_walkable(nx, ny, boulders): elif player_can_walk(nx, ny, boulders):
state = ((nx, ny), boulders) state = ((nx, ny), boulders)
if state not in visited: if state not in visited:
visited.add(state) visited.add(state)