import { ALL_DIRECTIONS, DIRECTION_VECTORS, PLAYER_MAX_HEALTH, HOT_COALS_DAMAGE } from './constants.js';
import { slideWithHazards, solve } from './solver.js';
import type { Direction, Position, PuzzleData, SolverResult } from './types.js';
const MAX_TIED_PATH_STATES = 150000;
interface PathCountingState {
position: Position;
brokenIce: Position[];
pushableRocks: Position[];
health: number;
pressurePlateActivated: boolean;
}
export interface HotCoalsShortcutDiagnostics {
hotCoalsTileCount: number;
hotCoalsHitsOnShortest: number;
shortestIfHotCoalsWereRocks: number | null;
shortestPathUsesHotCoalsShortcut: boolean;
}
export interface QualityGateReport {
solvable: boolean;
shortest: number | null;
par: number;
parIsSet: boolean;
parEqualsShortest: boolean;
shorterThanParExists: boolean;
longerThanParConfigured: boolean;
tiedOptimalPathsCount: number | null;
timeoutRuleMatchesRuntime: boolean;
warpParityPass: boolean;
hotCoalsDiagnostics: HotCoalsShortcutDiagnostics;
failures: string[];
warnings: string[];
pass: boolean;
}
export interface QualityGateOptions {
requirePar?: boolean;
}
function normalizePar(par: number): number {
if (!Number.isFinite(par)) {
return 0;
}
return Math.max(0, Math.floor(par));
}
function isHotCoalsType(type: string): boolean {
return type === 'hot_coals' || type === 'spike';
}
function positionsEqual(a: Position, b: Position): boolean {
return a.x === b.x && a.y === b.y;
}
function positionKey(position: Position): string {
return `${position.x},${position.y}`;
}
function encodePathCountingState(state: PathCountingState): string {
const brokenKey = state.brokenIce.map(positionKey).sort().join('|');
const rocksKey = state.pushableRocks.map(positionKey).sort().join('|');
const plateKey = state.pressurePlateActivated ? 'P' : '';
return `${state.position.x},${state.position.y}:${brokenKey}:${rocksKey}:${state.health}:${plateKey}`;
}
function markBrokenThinIceAlongSlide(
puzzle: PuzzleData,
from: Position,
direction: Direction,
result: ReturnType<typeof slideWithHazards>,
brokenIce: Position[]
): void {
if (!puzzle.thinIceTiles || puzzle.thinIceTiles.length === 0) {
return;
}
const delta = DIRECTION_VECTORS[direction];
const target = result.warpEntrance || result.position;
let x = from.x;
let y = from.y;
for (let safety = 0; safety < 50 && (x !== target.x || y !== target.y); safety++) {
x += delta.x;
y += delta.y;
const isThinIce = puzzle.thinIceTiles.some((tile) => tile.x === x && tile.y === y);
if (isThinIce && !brokenIce.some((tile) => tile.x === x && tile.y === y)) {
brokenIce.push({ x, y });
}
}
if (!result.warpDestination) {
return;
}
x = result.warpDestination.x;
y = result.warpDestination.y;
for (let safety = 0; safety < 50 && (x !== result.position.x || y !== result.position.y); safety++) {
x += delta.x;
y += delta.y;
const isThinIce = puzzle.thinIceTiles.some((tile) => tile.x === x && tile.y === y);
if (isThinIce && !brokenIce.some((tile) => tile.x === x && tile.y === y)) {
brokenIce.push({ x, y });
}
}
}
function updatePushableRocks(
puzzle: PuzzleData,
currentPushableRocks: Position[],
result: ReturnType<typeof slideWithHazards>
): Position[] {
if (!result.pushedRock) {
return currentPushableRocks;
}
const updated = currentPushableRocks.filter(
(rock) => rock.x !== result.pushedRock!.from.x || rock.y !== result.pushedRock!.from.y
);
const pushedIntoLava = puzzle.obstacles.some(
(obstacle) =>
obstacle.x === result.pushedRock!.to.x
&& obstacle.y === result.pushedRock!.to.y
&& obstacle.type === 'lava'
);
if (!pushedIntoLava) {
updated.push(result.pushedRock.to);
}
return updated;
}
function transitionState(
puzzle: PuzzleData,
currentState: PathCountingState,
direction: Direction
): PathCountingState | null {
const result = slideWithHazards(
currentState.position,
direction,
puzzle,
currentState.brokenIce,
currentState.pushableRocks,
currentState.pressurePlateActivated
);
if (result.hitLava || result.fellInHole || result.hitBarrier) {
return null;
}
let newHealth = currentState.health;
if (result.hitHotCoals) {
newHealth -= HOT_COALS_DAMAGE;
}
if (newHealth <= 0) {
return null;
}
if (
!result.pushedRock
&& result.position.x === currentState.position.x
&& result.position.y === currentState.position.y
) {
return null;
}
const nextBrokenIce = currentState.brokenIce.map((tile) => ({ ...tile }));
markBrokenThinIceAlongSlide(puzzle, currentState.position, direction, result, nextBrokenIce);
const nextPushableRocks = updatePushableRocks(
puzzle,
currentState.pushableRocks.map((rock) => ({ ...rock })),
result
);
return {
position: { ...result.position },
brokenIce: nextBrokenIce,
pushableRocks: nextPushableRocks,
health: newHealth,
pressurePlateActivated: currentState.pressurePlateActivated || result.crossedPressurePlate,
};
}
function countTiedOptimalPaths(puzzle: PuzzleData, shortest: number): number | null {
if (shortest < 0) {
return null;
}
if (shortest === 0) {
return positionsEqual(puzzle.start, puzzle.goal) ? 1 : 0;
}
const initialState: PathCountingState = {
position: { ...puzzle.start },
brokenIce: [],
pushableRocks: puzzle.pushableRocks ? puzzle.pushableRocks.map((rock) => ({ ...rock })) : [],
health: PLAYER_MAX_HEALTH,
pressurePlateActivated: false,
};
let currentLayer = new Map<string, { state: PathCountingState; count: number }>();
currentLayer.set(encodePathCountingState(initialState), { state: initialState, count: 1 });
let goalCount = 0;
for (let depth = 0; depth < shortest; depth++) {
const nextLayer = new Map<string, { state: PathCountingState; count: number }>();
for (const node of currentLayer.values()) {
for (const direction of ALL_DIRECTIONS) {
const nextState = transitionState(puzzle, node.state, direction);
if (!nextState) {
continue;
}
const nextDepth = depth + 1;
const reachedGoal = positionsEqual(nextState.position, puzzle.goal);
if (reachedGoal) {
if (nextDepth === shortest) {
goalCount += node.count;
}
continue;
}
if (nextDepth >= shortest) {
continue;
}
const key = encodePathCountingState(nextState);
const existing = nextLayer.get(key);
if (existing) {
existing.count += node.count;
} else {
nextLayer.set(key, { state: nextState, count: node.count });
}
}
}
if (nextLayer.size > MAX_TIED_PATH_STATES) {
return null;
}
currentLayer = nextLayer;
if (currentLayer.size === 0 && depth < shortest - 1) {
break;
}
}
return goalCount;
}
function countHotCoalsHitsOnPath(puzzle: PuzzleData, solution: Direction[] | null): number {
if (!solution || solution.length === 0) {
return 0;
}
let hotCoalsHits = 0;
let position = { ...puzzle.start };
const brokenIce: Position[] = [];
let pushableRocks = puzzle.pushableRocks ? [...puzzle.pushableRocks] : [];
let pressurePlateActivated = false;
for (const direction of solution) {
const result = slideWithHazards(position, direction, puzzle, brokenIce, pushableRocks, pressurePlateActivated);
if (result.hitHotCoals) {
hotCoalsHits++;
}
if (result.crossedPressurePlate) {
pressurePlateActivated = true;
}
markBrokenThinIceAlongSlide(puzzle, position, direction, result, brokenIce);
pushableRocks = updatePushableRocks(puzzle, pushableRocks, result);
position = result.position;
if (result.hitLava || result.fellInHole || result.hitBarrier) {
break;
}
}
return hotCoalsHits;
}
function getShortestIfHotCoalsWereRocks(puzzle: PuzzleData): number | null {
if (!puzzle.obstacles.some((obstacle) => isHotCoalsType(obstacle.type))) {
return null;
}
const hotCoalsAsRocksPuzzle: PuzzleData = {
...puzzle,
obstacles: puzzle.obstacles.map((obstacle) => (
isHotCoalsType(obstacle.type) ? { ...obstacle, type: 'rock' as const } : obstacle
)),
};
const result = solve(hotCoalsAsRocksPuzzle);
return result.solvable ? result.moves : null;
}
function checkTimeoutRuleProbe(): boolean {
const par = 8;
const shouldTimeout = (moveCount: number, reachedGoal: boolean): boolean => moveCount >= par && !reachedGoal;
return (
shouldTimeout(par - 1, false) === false
&& shouldTimeout(par, false) === true
&& shouldTimeout(par + 1, false) === true
&& shouldTimeout(par, true) === false
);
}
function checkWarpStopParityProbe(): boolean {
const probePuzzle: PuzzleData = {
id: 'warp-stop-probe',
name: 'warp-stop-probe',
theme: 'ice',
width: 8,
height: 6,
par: 2,
start: { x: 1, y: 1 },
goal: { x: 6, y: 4 },
obstacles: [],
warps: [
{ x: 3, y: 1, id: 'probe' },
{ x: 3, y: 4, id: 'probe' },
],
};
const result = slideWithHazards(probePuzzle.start, 'right', probePuzzle);
return Boolean(
result.warpDestination
&& result.position.x === result.warpDestination.x
&& result.position.y === result.warpDestination.y
);
}
export function evaluateQualityGate(
puzzle: PuzzleData,
solverResult?: SolverResult,
options: QualityGateOptions = {}
): QualityGateReport {
const requirePar = options.requirePar === true;
const resolvedSolver = solverResult ?? solve(puzzle);
const shortest = resolvedSolver.solvable ? resolvedSolver.moves : null;
const par = normalizePar(puzzle.par);
const parIsSet = par > 0;
const failures: string[] = [];
const warnings: string[] = [];
if (!resolvedSolver.solvable || shortest === null) {
failures.push('Level is not solvable.');
}
if (requirePar && !parIsSet) {
failures.push('par is not set. Set an explicit par before publishing.');
}
const shorterThanParExists = shortest !== null && parIsSet && shortest < par;
const longerThanParConfigured = shortest !== null && parIsSet && shortest > par;
const parEqualsShortest = shortest !== null && parIsSet && shortest === par;
if (shorterThanParExists) {
failures.push(`Shortest optimal path (${shortest}) is below par (${par}).`);
}
if (longerThanParConfigured) {
failures.push(`par (${par}) is below shortest optimal path (${shortest}).`);
}
if (!parIsSet) {
warnings.push('par is not set; shortest/par correctness cannot be fully enforced.');
}
const legacyObstacleEncodings = puzzle.obstacles.filter(
(obstacle) => obstacle.type === 'thin_ice' || obstacle.type === 'pushable_rock'
);
if (legacyObstacleEncodings.length > 0) {
failures.push(
'Legacy obstacle encodings detected (thin_ice/pushable_rock). Use puzzleData.thinIceTiles and puzzleData.pushableRocks fields.'
);
}
const shortestPathCount = shortest !== null
? countTiedOptimalPaths(puzzle, shortest)
: null;
if (shortest !== null && shortestPathCount === null) {
warnings.push('Tied optimal path count could not be computed (state space too large).');
}
const tiedOptimalPathsCount = shortestPathCount === null
? null
: Math.max(0, shortestPathCount - 1);
const hotCoalsTileCount = puzzle.obstacles.filter((obstacle) => isHotCoalsType(obstacle.type)).length;
const hotCoalsHitsOnShortest = shortest !== null
? countHotCoalsHitsOnPath(puzzle, resolvedSolver.solution)
: 0;
const shortestIfHotCoalsWereRocks = shortest !== null ? getShortestIfHotCoalsWereRocks(puzzle) : null;
const shortestPathUsesHotCoalsShortcut = Boolean(
shortest !== null
&& hotCoalsHitsOnShortest > 0
&& (shortestIfHotCoalsWereRocks === null || shortestIfHotCoalsWereRocks > shortest)
);
if (shortestPathUsesHotCoalsShortcut && !shorterThanParExists) {
warnings.push(
'Optimal route currently relies on hot coals landings; add explicit anti-shortcut failure routes around hot coals.'
);
}
return {
solvable: resolvedSolver.solvable,
shortest,
par,
parIsSet,
parEqualsShortest,
shorterThanParExists,
longerThanParConfigured,
tiedOptimalPathsCount,
timeoutRuleMatchesRuntime: checkTimeoutRuleProbe(),
warpParityPass: checkWarpStopParityProbe(),
hotCoalsDiagnostics: {
hotCoalsTileCount,
hotCoalsHitsOnShortest,
shortestIfHotCoalsWereRocks,
shortestPathUsesHotCoalsShortcut,
},
failures,
warnings,
pass: failures.length === 0,
};
}
export function formatQualityGateReport(report: QualityGateReport): string {
const tiedPathsText = report.tiedOptimalPathsCount === null
? 'not computed'
: `${report.tiedOptimalPathsCount}`;
const lines = [
'=== Quality Gate Checklist ===',
'',
`Solvable: ${report.solvable ? 'Yes' : 'No'}`,
`Shortest optimal length found: ${report.shortest ?? 'N/A'}`,
`par value: ${report.parIsSet ? report.par : 'not set'}`,
`par == shortest: ${report.parEqualsShortest ? 'Yes' : 'No'}`,
`Tied optimal paths present: count ${tiedPathsText}`,
`Any path shorter than par: ${report.shorterThanParExists ? 'Yes' : 'No'} (must be No)`,
`Timeout death when reaching par without goal: ${report.timeoutRuleMatchesRuntime ? 'Yes (moves >= par)' : 'No'}`,
`Warp parity (solver/runtime): ${report.warpParityPass ? 'pass' : 'fail'}`,
'',
'Hot Coals shortcut diagnostics:',
`- Hot Coals tiles: ${report.hotCoalsDiagnostics.hotCoalsTileCount}`,
`- Hot Coals landings on shortest path: ${report.hotCoalsDiagnostics.hotCoalsHitsOnShortest}`,
`- Shortest if hot coals were rocks: ${report.hotCoalsDiagnostics.shortestIfHotCoalsWereRocks ?? 'N/A'}`,
`- Shortest currently uses hot coals shortcut: ${report.hotCoalsDiagnostics.shortestPathUsesHotCoalsShortcut ? 'Yes' : 'No'}`,
'',
`Quality gate result: ${report.pass ? 'PASS' : 'FAIL'}`,
];
if (report.failures.length > 0) {
lines.push('');
lines.push('Failures:');
for (const failure of report.failures) {
lines.push(`- ${failure}`);
}
}
if (report.warnings.length > 0) {
lines.push('');
lines.push('Warnings:');
for (const warning of report.warnings) {
lines.push(`- ${warning}`);
}
}
return lines.join('\n');
}