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:
parent
99f439930d
commit
4c809bdd0f
2 changed files with 63 additions and 11 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue