// Synced from ice_puzzle/utils/puzzleSolver.ts - keep in sync manually
import { Position, Direction, PuzzleData, WarpZone, SlideResult, SolverResult } from './types.js';
import {
DIRECTION_VECTORS,
ALL_DIRECTIONS,
MAX_SOLVER_ITERATIONS,
PLAYER_MAX_HEALTH,
HOT_COALS_DAMAGE,
} from './constants.js';
interface SolverState {
position: Position;
moves: Direction[];
brokenIce: string[];
pushedRocks: string[];
health: number;
pressurePlateActivated: boolean;
}
function isHotCoalsType(type: string): boolean {
return type === 'hot_coals' || type === 'spike';
}
/**
* Simulates sliding in a direction until hitting an obstacle or wall.
* Basic version for backward compatibility.
*/
export function slide(
pos: Position,
dir: Direction,
puzzle: PuzzleData
): Position {
const delta = DIRECTION_VECTORS[dir];
let newX = pos.x;
let newY = pos.y;
while (true) {
const nextX = newX + delta.x;
const nextY = newY + delta.y;
// Check if goal is on the edge (replaces a wall)
if (nextX === puzzle.goal.x && nextY === puzzle.goal.y) {
newX = nextX;
newY = nextY;
break;
}
if (nextX <= 0 || nextX >= puzzle.width - 1 || nextY <= 0 || nextY >= puzzle.height - 1) {
break;
}
const hitObstacle = puzzle.obstacles.some(
(o) => o.x === nextX && o.y === nextY && (o.type === 'rock' || isHotCoalsType(o.type))
);
if (hitObstacle) {
break;
}
newX = nextX;
newY = nextY;
if (newX === puzzle.goal.x && newY === puzzle.goal.y) {
break;
}
}
return { x: newX, y: newY };
}
/**
* Enhanced slide with hazard detection and pushable rock support.
* Returns position and hazard information.
*/
export function slideWithHazards(
pos: Position,
dir: Direction,
puzzle: PuzzleData,
brokenThinIce: Position[] = [],
currentPushableRocks: Position[] = [],
pressurePlateAlreadyActivated: boolean = false
): SlideResult {
const delta = DIRECTION_VECTORS[dir];
let newX = pos.x;
let newY = pos.y;
let hitHotCoals = false;
let hitLava = false;
let fellInHole = false;
let hitBarrier = false;
let crossedPressurePlate = false;
let warpDestination: Position | null = null;
let warpEntrance: Position | null = null;
let pushedRock: { from: Position; to: Position } | null = null;
const pushableRocks = currentPushableRocks.length > 0
? currentPushableRocks
: (puzzle.pushableRocks || []);
while (true) {
const nextX = newX + delta.x;
const nextY = newY + delta.y;
const isGoalOnEdge = puzzle.goal.x === nextX && puzzle.goal.y === nextY;
if (!isGoalOnEdge && (nextX <= 0 || nextX >= puzzle.width - 1 || nextY <= 0 || nextY >= puzzle.height - 1)) {
break;
}
const isHole = brokenThinIce.some(p => p.x === nextX && p.y === nextY);
if (isHole) {
newX = nextX;
newY = nextY;
fellInHole = true;
break;
}
const pushableRockIndex = pushableRocks.findIndex(r => r.x === nextX && r.y === nextY);
if (pushableRockIndex !== -1) {
const rockNewX = nextX + delta.x;
const rockNewY = nextY + delta.y;
const rockBlockedByWall = rockNewX <= 0 || rockNewX >= puzzle.width - 1 ||
rockNewY <= 0 || rockNewY >= puzzle.height - 1;
const rockBlockedByRock = puzzle.obstacles.some(o => o.x === rockNewX && o.y === rockNewY && o.type === 'rock');
const rockBlockedByPushable = pushableRocks.some((r, i) => i !== pushableRockIndex && r.x === rockNewX && r.y === rockNewY);
if (rockBlockedByWall || rockBlockedByRock || rockBlockedByPushable) {
break;
}
pushedRock = {
from: { x: nextX, y: nextY },
to: { x: rockNewX, y: rockNewY }
};
break;
}
if (puzzle.barrier && puzzle.barrier.x === nextX && puzzle.barrier.y === nextY) {
const plateActivated = pressurePlateAlreadyActivated || crossedPressurePlate;
if (!plateActivated) {
newX = nextX;
newY = nextY;
hitBarrier = true;
break;
}
}
const obstacle = puzzle.obstacles.find(o => o.x === nextX && o.y === nextY);
if (obstacle) {
if (obstacle.type === 'rock' || obstacle.type === 'wall') {
break;
} else if (obstacle.type === 'barrier') {
const plateActivated = pressurePlateAlreadyActivated || crossedPressurePlate;
if (!plateActivated) {
newX = nextX;
newY = nextY;
hitBarrier = true;
break;
}
} else if (isHotCoalsType(obstacle.type)) {
newX = nextX;
newY = nextY;
hitHotCoals = true;
break;
} else if (obstacle.type === 'lava') {
newX = nextX;
newY = nextY;
hitLava = true;
break;
}
}
newX = nextX;
newY = nextY;
if (puzzle.pressurePlate && puzzle.pressurePlate.x === newX && puzzle.pressurePlate.y === newY) {
crossedPressurePlate = true;
}
if (puzzle.warps) {
const warp = puzzle.warps.find(w => w.x === newX && w.y === newY);
if (warp) {
const pairedWarp = puzzle.warps.find(w => w.id === warp.id && (w.x !== warp.x || w.y !== warp.y));
if (pairedWarp) {
warpEntrance = { x: newX, y: newY };
warpDestination = { x: pairedWarp.x, y: pairedWarp.y };
newX = pairedWarp.x;
newY = pairedWarp.y;
// Warp-stop semantics: teleport and end movement immediately.
if (puzzle.pressurePlate && puzzle.pressurePlate.x === newX && puzzle.pressurePlate.y === newY) {
crossedPressurePlate = true;
}
break;
}
}
}
if (newX === puzzle.goal.x && newY === puzzle.goal.y) {
break;
}
}
return {
position: { x: newX, y: newY },
hitHotCoals,
hitLava,
fellInHole,
hitBarrier,
crossedPressurePlate,
warpDestination,
warpEntrance,
pushedRock,
};
}
/**
* BFS solver to find optimal solution.
* Returns array of moves if solvable, null if not.
* Accounts for hazards (lava/hot coals/thin ice/pushable rocks).
*/
export function solve(puzzle: PuzzleData): SolverResult {
const initialPushedRocks = puzzle.pushableRocks
? puzzle.pushableRocks.map(r => `${r.x},${r.y}`)
: [];
const initialState: SolverState = {
position: { ...puzzle.start },
moves: [],
brokenIce: [],
pushedRocks: initialPushedRocks,
health: PLAYER_MAX_HEALTH,
pressurePlateActivated: false,
};
const queue: SolverState[] = [initialState];
const visited = new Set<string>();
let iterations = 0;
while (queue.length > 0 && iterations < MAX_SOLVER_ITERATIONS) {
iterations++;
const state = queue.shift()!;
const brokenIceKey = state.brokenIce.sort().join('|');
const pushedRocksKey = state.pushedRocks.sort().join('|');
const plateKey = state.pressurePlateActivated ? 'P' : '';
const key = `${state.position.x},${state.position.y}:${brokenIceKey}:${pushedRocksKey}:${state.health}:${plateKey}`;
if (visited.has(key)) continue;
visited.add(key);
if (
state.position.x === puzzle.goal.x &&
state.position.y === puzzle.goal.y
) {
return { solution: state.moves, solvable: true, moves: state.moves.length, iterations, visitedCount: visited.size };
}
for (const dir of ALL_DIRECTIONS) {
const brokenIcePositions = state.brokenIce.map(s => {
const [x, y] = s.split(',').map(Number);
return { x, y };
});
const currentRockPositions = state.pushedRocks.map(s => {
const [x, y] = s.split(',').map(Number);
return { x, y };
});
const result = slideWithHazards(state.position, dir, puzzle, brokenIcePositions, currentRockPositions, state.pressurePlateActivated);
if (result.hitLava || result.fellInHole || result.hitBarrier) continue;
let newHealth = state.health;
if (result.hitHotCoals) {
newHealth -= HOT_COALS_DAMAGE;
}
if (newHealth <= 0) continue;
if (result.position.x !== state.position.x || result.position.y !== state.position.y || result.pushedRock) {
const newBrokenIce = [...state.brokenIce];
if (puzzle.thinIceTiles) {
const delta = DIRECTION_VECTORS[dir];
const targetPos = result.warpEntrance || result.position;
let checkX = state.position.x;
let checkY = state.position.y;
for (let safety = 0; safety < 50 && (checkX !== targetPos.x || checkY !== targetPos.y); safety++) {
checkX += delta.x;
checkY += delta.y;
for (const tile of puzzle.thinIceTiles) {
const tileKey = `${tile.x},${tile.y}`;
if (tile.x === checkX && tile.y === checkY) {
if (!newBrokenIce.includes(tileKey)) {
newBrokenIce.push(tileKey);
}
}
}
}
if (result.warpDestination) {
checkX = result.warpDestination.x;
checkY = result.warpDestination.y;
for (let safety = 0; safety < 50 && (checkX !== result.position.x || checkY !== result.position.y); safety++) {
checkX += delta.x;
checkY += delta.y;
for (const tile of puzzle.thinIceTiles) {
const tileKey = `${tile.x},${tile.y}`;
if (tile.x === checkX && tile.y === checkY) {
if (!newBrokenIce.includes(tileKey)) {
newBrokenIce.push(tileKey);
}
}
}
}
}
}
let newPushedRocks = [...state.pushedRocks];
if (result.pushedRock) {
const oldKey = `${result.pushedRock.from.x},${result.pushedRock.from.y}`;
const newKey = `${result.pushedRock.to.x},${result.pushedRock.to.y}`;
newPushedRocks = newPushedRocks.filter(k => k !== oldKey);
const pushedIntoLava = puzzle.obstacles.some(
o => o.x === result.pushedRock!.to.x && o.y === result.pushedRock!.to.y && o.type === 'lava'
);
if (!pushedIntoLava) {
newPushedRocks.push(newKey);
}
}
const newPressurePlateActivated = state.pressurePlateActivated || result.crossedPressurePlate;
const newBrokenKey = newBrokenIce.sort().join('|');
const newPushedKey = newPushedRocks.sort().join('|');
const newPlateKey = newPressurePlateActivated ? 'P' : '';
const newStateKey = `${result.position.x},${result.position.y}:${newBrokenKey}:${newPushedKey}:${newHealth}:${newPlateKey}`;
if (!visited.has(newStateKey)) {
queue.push({
position: result.position,
moves: [...state.moves, dir],
brokenIce: newBrokenIce,
pushedRocks: newPushedRocks,
health: newHealth,
pressurePlateActivated: newPressurePlateActivated,
});
}
}
}
}
const exhausted = iterations >= MAX_SOLVER_ITERATIONS;
return { solution: null, solvable: false, moves: null, iterations, visitedCount: visited.size, exhausted };
}
/**
* Check if a puzzle is solvable.
*/
export function isSolvable(puzzle: PuzzleData): boolean {
return solve(puzzle).solvable;
}
/**
* Get the optimal move count (par) for a puzzle.
*/
export function getOptimalMoveCount(puzzle: PuzzleData): number | null {
const result = solve(puzzle);
return result.moves;
}