import type { Position } from './types.js';
export type SeedDifficulty = 'easy' | 'medium' | 'hard';
export type SeedPattern = 'columns_with_gaps' | 'switchback_lanes';
export interface SeedLayoutPlan {
pattern: SeedPattern;
difficulty: SeedDifficulty;
rocks: Position[];
notes: string[];
estimatedMoveBand: [number, number];
}
function clampInterior(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function uniquePositions(positions: Position[]): Position[] {
const seen = new Set<string>();
const deduped: Position[] = [];
for (const position of positions) {
const key = `${position.x},${position.y}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(position);
}
return deduped;
}
function isInterior(position: Position, width: number, height: number): boolean {
return position.x > 0 && position.x < width - 1 && position.y > 0 && position.y < height - 1;
}
function getColumnCount(width: number, difficulty: SeedDifficulty): number {
const desired = difficulty === 'easy' ? 2 : difficulty === 'medium' ? 3 : 4;
const maxPossible = Math.max(1, Math.floor((width - 4) / 2));
return Math.max(1, Math.min(desired, maxPossible));
}
function getSwitchbackBandCount(height: number, difficulty: SeedDifficulty): number {
const desired = difficulty === 'easy' ? 3 : difficulty === 'medium' ? 4 : 5;
const maxPossible = Math.max(2, Math.floor((height - 4) / 2));
return Math.max(2, Math.min(desired, maxPossible));
}
function buildColumnsWithGaps(width: number, height: number, difficulty: SeedDifficulty): SeedLayoutPlan {
const columnCount = getColumnCount(width, difficulty);
const interiorMinX = 2;
const interiorMaxX = width - 3;
const spacing = Math.max(2, Math.floor((interiorMaxX - interiorMinX + 1) / (columnCount + 1)));
const columns: number[] = [];
for (let i = 1; i <= columnCount; i++) {
const rawX = interiorMinX + i * spacing - 1;
const x = clampInterior(rawX, interiorMinX, interiorMaxX);
if (!columns.includes(x)) {
columns.push(x);
}
}
const rocks: Position[] = [];
for (let index = 0; index < columns.length; index++) {
const x = columns[index];
const baseGap = index % 2 === 0
? Math.floor(height * 0.34)
: Math.floor(height * 0.66);
const primaryGap = clampInterior(baseGap, 2, height - 3);
const secondaryGap = difficulty === 'easy'
? clampInterior(index % 2 === 0 ? Math.floor(height * 0.7) : Math.floor(height * 0.3), 2, height - 3)
: null;
for (let y = 1; y < height - 1; y++) {
if (y === primaryGap || (secondaryGap !== null && y === secondaryGap)) {
continue;
}
rocks.push({ x, y });
}
}
// Add lane anchors between columns so slides have intentional stop points.
for (let i = 0; i < columns.length - 1; i++) {
const midX = Math.floor((columns[i] + columns[i + 1]) / 2);
const midY = clampInterior(Math.floor(height / 2) + (i % 2 === 0 ? -1 : 1), 2, height - 3);
rocks.push({ x: midX, y: midY });
if (difficulty === 'hard') {
rocks.push({ x: midX, y: clampInterior(midY + (i % 2 === 0 ? 2 : -2), 2, height - 3) });
}
}
const filtered = uniquePositions(rocks).filter((position) => isInterior(position, width, height));
return {
pattern: 'columns_with_gaps',
difficulty,
rocks: filtered,
notes: [
`Generated ${columns.length} staggered vertical columns with gap doors.`,
'Use one extra rock to close a gap and stretch route length.',
'Add plate/barrier or warp after solving this rock skeleton.',
],
estimatedMoveBand: difficulty === 'easy'
? [8, 12]
: difficulty === 'medium'
? [12, 18]
: [18, 26],
};
}
function buildSwitchbackLanes(width: number, height: number, difficulty: SeedDifficulty): SeedLayoutPlan {
const bandCount = getSwitchbackBandCount(height, difficulty);
const top = 2;
const bottom = height - 3;
const usableRows = bottom - top + 1;
const step = Math.max(1, Math.floor(usableRows / bandCount));
const laneRows: number[] = [];
for (let i = 0; i < bandCount; i++) {
const y = clampInterior(top + i * step, 2, height - 3);
if (!laneRows.includes(y)) {
laneRows.push(y);
}
}
const rocks: Position[] = [];
for (let i = 0; i < laneRows.length; i++) {
const y = laneRows[i];
const openingX = i % 2 === 0 ? 2 : width - 3;
for (let x = 1; x < width - 1; x++) {
if (x === openingX) continue;
rocks.push({ x, y });
}
// Side blockers nudge the player into longer switchbacks.
if (difficulty !== 'easy') {
const blockerX = i % 2 === 0 ? width - 3 : 2;
const blockerY = clampInterior(y + (i % 2 === 0 ? 1 : -1), 2, height - 3);
rocks.push({ x: blockerX, y: blockerY });
}
}
const filtered = uniquePositions(rocks).filter((position) => isInterior(position, width, height));
return {
pattern: 'switchback_lanes',
difficulty,
rocks: filtered,
notes: [
`Generated ${laneRows.length} alternating switchback bands.`,
'Works well for longer deterministic routes before adding hazards.',
'Try placing start/goal on opposite side openings to force full traversal.',
],
estimatedMoveBand: difficulty === 'easy'
? [7, 11]
: difficulty === 'medium'
? [11, 17]
: [16, 24],
};
}
export function generateSeedLayout(
pattern: SeedPattern,
width: number,
height: number,
difficulty: SeedDifficulty = 'medium',
): SeedLayoutPlan {
if (pattern === 'switchback_lanes') {
return buildSwitchbackLanes(width, height, difficulty);
}
return buildColumnsWithGaps(width, height, difficulty);
}
export function suggestSkeletonLayouts(
width: number,
height: number,
difficulty: SeedDifficulty = 'medium',
): SeedLayoutPlan[] {
return [
generateSeedLayout('columns_with_gaps', width, height, difficulty),
generateSeedLayout('switchback_lanes', width, height, difficulty),
];
}