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:
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue