import { DraftState, Position, ObstacleType, WarpPair, PuzzleData, PlaceableTileType } from '../core/types.js';
import { validatePuzzleData, validatePosition } from '../core/validator.js';
class DraftStore {
private currentDraft: DraftState | null = null;
private savedDrafts: Map<string, DraftState> = new Map();
private warpCounter = 1;
private undoStack: DraftState[] = [];
private redoStack: DraftState[] = [];
private lastSolvableSnapshot: DraftState | null = null;
private historySuspended = false;
private clonePosition(position: Position): Position {
return { x: position.x, y: position.y };
}
private cloneDraft(draft: DraftState): DraftState {
return {
...draft,
previewId: draft.previewId ?? null,
startPosition: this.clonePosition(draft.startPosition),
goalPosition: this.clonePosition(draft.goalPosition),
obstacles: draft.obstacles.map((obstacle) => ({ ...obstacle })),
warpPairs: draft.warpPairs.map((pair) => ({
id: pair.id,
positions: pair.positions.map((position) => this.clonePosition(position)),
})),
thinIceTiles: draft.thinIceTiles.map((position) => this.clonePosition(position)),
pushableRocks: draft.pushableRocks.map((position) => this.clonePosition(position)),
pressurePlate: draft.pressurePlate ? this.clonePosition(draft.pressurePlate) : null,
barrier: draft.barrier ? this.clonePosition(draft.barrier) : null,
manualSolveMechanicsBaseline: draft.manualSolveMechanicsBaseline
? [...draft.manualSolveMechanicsBaseline]
: null,
manualSolveFingerprint: draft.manualSolveFingerprint ?? null,
lastSolverResult: draft.lastSolverResult
? {
...draft.lastSolverResult,
solution: draft.lastSolverResult.solution ? [...draft.lastSolverResult.solution] : null,
}
: null,
};
}
private positionKey(position: Position): string {
return `${position.x},${position.y}`;
}
private dedupePositions(positions: Position[]): Position[] {
const seen = new Set<string>();
const deduped: Position[] = [];
for (const position of positions) {
const key = this.positionKey(position);
if (seen.has(key)) continue;
seen.add(key);
deduped.push(this.clonePosition(position));
}
return deduped;
}
private isSamePosition(a: Position, b: Position): boolean {
return a.x === b.x && a.y === b.y;
}
private clampGridSize(value: number): number {
if (!Number.isFinite(value)) {
return 10;
}
return Math.max(5, Math.min(25, Math.floor(value)));
}
private pushHistorySnapshot(): void {
if (!this.currentDraft || this.historySuspended) return;
this.undoStack.push(this.cloneDraft(this.currentDraft));
this.redoStack = [];
}
private runWithHistorySuspended<T>(operation: () => T): T {
const prior = this.historySuspended;
this.historySuspended = true;
try {
return operation();
} finally {
this.historySuspended = prior;
}
}
createDraft(name: string, width: number = 10, height: number = 10): DraftState {
this.warpCounter = 1;
this.undoStack = [];
this.redoStack = [];
this.lastSolvableSnapshot = null;
const draft: DraftState = {
id: null,
previewId: null,
name,
description: '',
gridWidth: width,
gridHeight: height,
par: 0,
startPosition: { x: 1, y: height - 2 }, // bottom-left
goalPosition: { x: width - 2, y: 1 }, // top-right
obstacles: [],
warpPairs: [],
thinIceTiles: [],
pushableRocks: [],
pressurePlate: null,
barrier: null,
manualSolveMechanicsBaseline: null,
manualSolveFingerprint: null,
isDirty: false,
lastSolverResult: null,
};
this.currentDraft = draft;
return draft;
}
getCurrentDraft(): DraftState | null {
return this.currentDraft;
}
updateDraft(updates: Partial<DraftState>, options: { trackHistory?: boolean } = {}): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft to update');
}
if (options.trackHistory !== false) {
this.pushHistorySnapshot();
}
this.currentDraft = {
...this.currentDraft,
...updates,
isDirty: 'isDirty' in updates ? updates.isDirty! : true,
};
return this.currentDraft;
}
saveDraft(): string {
if (!this.currentDraft) {
throw new Error('No current draft to save');
}
const id = this.currentDraft.id || this.generateId();
const draftToSave = this.cloneDraft({
...this.currentDraft,
id,
isDirty: false,
});
this.savedDrafts.set(id, draftToSave);
this.currentDraft = this.cloneDraft(draftToSave);
return id;
}
loadDraft(id: string): DraftState | null {
const draft = this.savedDrafts.get(id);
if (!draft) {
return null;
}
this.currentDraft = this.cloneDraft(draft);
this.undoStack = [];
this.redoStack = [];
this.lastSolvableSnapshot = this.currentDraft.lastSolverResult?.solvable ? this.cloneDraft(this.currentDraft) : null;
return this.currentDraft;
}
listDrafts(): Array<{ id: string; name: string; description: string; updatedAt: number }> {
return Array.from(this.savedDrafts.values()).map(draft => ({
id: draft.id!,
name: draft.name,
description: draft.description,
updatedAt: Date.now(), // Could be enhanced with actual timestamps
}));
}
deleteDraft(id: string): boolean {
return this.savedDrafts.delete(id);
}
clearCurrentDraft(): void {
this.currentDraft = null;
this.undoStack = [];
this.redoStack = [];
this.lastSolvableSnapshot = null;
}
resetAll(): void {
this.currentDraft = null;
this.savedDrafts = new Map();
this.warpCounter = 1;
this.undoStack = [];
this.redoStack = [];
this.lastSolvableSnapshot = null;
}
exportPuzzleData(): PuzzleData | null {
if (!this.currentDraft) {
return null;
}
const draft = this.currentDraft;
// Convert warp pairs to WarpZones
const warps = draft.warpPairs.flatMap(pair =>
pair.positions.map(pos => ({
x: pos.x,
y: pos.y,
id: pair.id,
}))
);
// Barrier: prefer draft.barrier (Position), fall back to first barrier obstacle
const barrierObstacles = draft.obstacles.filter(obs => obs.type === 'barrier');
const exportedBarrier = draft.barrier || (barrierObstacles.length > 0
? { x: barrierObstacles[0].x, y: barrierObstacles[0].y }
: null);
// Regular obstacles: exclude barrier (separate field)
const regularObstacles = draft.obstacles.filter(
obs =>
obs.type !== 'barrier' &&
obs.type !== 'thin_ice' &&
obs.type !== 'pushable_rock'
);
// Normalize legacy encodings where thin ice / pushable rocks were stored as obstacles.
const normalizedThinIce = [...draft.thinIceTiles];
for (const obs of draft.obstacles) {
if (obs.type === 'thin_ice' && !normalizedThinIce.some((tile) => tile.x === obs.x && tile.y === obs.y)) {
normalizedThinIce.push({ x: obs.x, y: obs.y });
}
}
const normalizedPushableRocks = [...draft.pushableRocks];
for (const obs of draft.obstacles) {
if (obs.type === 'pushable_rock' && !normalizedPushableRocks.some((rock) => rock.x === obs.x && rock.y === obs.y)) {
normalizedPushableRocks.push({ x: obs.x, y: obs.y });
}
}
const puzzleData: PuzzleData = {
id: draft.id || this.generateId(),
name: draft.name,
theme: 'ice',
width: draft.gridWidth,
height: draft.gridHeight,
par: draft.par,
start: draft.startPosition,
goal: draft.goalPosition,
obstacles: regularObstacles,
};
if (warps.length > 0) {
puzzleData.warps = warps;
}
if (normalizedThinIce.length > 0) {
puzzleData.thinIceTiles = normalizedThinIce;
}
if (normalizedPushableRocks.length > 0) {
puzzleData.pushableRocks = normalizedPushableRocks;
}
if (draft.pressurePlate) {
puzzleData.pressurePlate = draft.pressurePlate;
}
if (exportedBarrier) {
puzzleData.barrier = exportedBarrier;
}
return puzzleData;
}
importPuzzleData(puzzle: PuzzleData): DraftState {
// Validate incoming data
const validation = validatePuzzleData(puzzle);
if (!validation.valid) {
throw new Error(`Invalid puzzle data: ${validation.error}`);
}
// Additional validations beyond basic schema
// Start and goal must be different
if (puzzle.start.x === puzzle.goal.x && puzzle.start.y === puzzle.goal.y) {
throw new Error('Invalid puzzle data: start and goal positions cannot be the same');
}
// Validate obstacle positions are within playable area
for (const obs of puzzle.obstacles) {
if (obs.x <= 0 || obs.x >= puzzle.width - 1 || obs.y <= 0 || obs.y >= puzzle.height - 1) {
throw new Error(`Invalid puzzle data: obstacle at (${obs.x},${obs.y}) is outside playable area`);
}
}
// Check for duplicate obstacle positions
const obsPositions = new Set<string>();
for (const obs of puzzle.obstacles) {
const key = `${obs.x},${obs.y}`;
if (obsPositions.has(key)) {
throw new Error(`Invalid puzzle data: duplicate obstacle at (${obs.x},${obs.y})`);
}
obsPositions.add(key);
}
// Validate warp pairs have exactly 2 positions per ID
if (puzzle.warps) {
const warpCounts = new Map<string, number>();
for (const warp of puzzle.warps) {
warpCounts.set(warp.id, (warpCounts.get(warp.id) || 0) + 1);
// Validate warp positions within playable area
if (warp.x <= 0 || warp.x >= puzzle.width - 1 || warp.y <= 0 || warp.y >= puzzle.height - 1) {
throw new Error(`Invalid puzzle data: warp at (${warp.x},${warp.y}) is outside playable area`);
}
}
for (const [id, count] of warpCounts) {
if (count !== 2) {
throw new Error(`Invalid puzzle data: warp '${id}' has ${count} positions (must be exactly 2)`);
}
}
}
// Validate thin ice positions
if (puzzle.thinIceTiles) {
for (const tile of puzzle.thinIceTiles) {
if (tile.x <= 0 || tile.x >= puzzle.width - 1 || tile.y <= 0 || tile.y >= puzzle.height - 1) {
throw new Error(`Invalid puzzle data: thin ice at (${tile.x},${tile.y}) is outside playable area`);
}
}
}
// Validate pushable rock positions
if (puzzle.pushableRocks) {
for (const rock of puzzle.pushableRocks) {
if (rock.x <= 0 || rock.x >= puzzle.width - 1 || rock.y <= 0 || rock.y >= puzzle.height - 1) {
throw new Error(`Invalid puzzle data: pushable rock at (${rock.x},${rock.y}) is outside playable area`);
}
}
}
// Convert WarpZones to WarpPairs
const warpPairs: WarpPair[] = [];
const warpMap = new Map<string, Position[]>();
if (puzzle.warps) {
for (const warp of puzzle.warps) {
const positions = warpMap.get(warp.id) || [];
positions.push({ x: warp.x, y: warp.y });
warpMap.set(warp.id, positions);
}
warpMap.forEach((positions, id) => {
warpPairs.push({ id, positions });
});
}
// Handle barrier and legacy thin-ice/pushable encodings in obstacles.
const cleanObstacles = puzzle.obstacles.filter(
obs => obs.type !== 'barrier' && obs.type !== 'thin_ice' && obs.type !== 'pushable_rock'
);
const legacyThinIce = puzzle.obstacles
.filter(obs => obs.type === 'thin_ice')
.map(obs => ({ x: obs.x, y: obs.y }));
const legacyPushable = puzzle.obstacles
.filter(obs => obs.type === 'pushable_rock')
.map(obs => ({ x: obs.x, y: obs.y }));
const mergedThinIceTiles = [...(puzzle.thinIceTiles || [])];
for (const tile of legacyThinIce) {
if (!mergedThinIceTiles.some((existing) => existing.x === tile.x && existing.y === tile.y)) {
mergedThinIceTiles.push(tile);
}
}
const mergedPushableRocks = [...(puzzle.pushableRocks || [])];
for (const rock of legacyPushable) {
if (!mergedPushableRocks.some((existing) => existing.x === rock.x && existing.y === rock.y)) {
mergedPushableRocks.push(rock);
}
}
const draft: DraftState = {
id: puzzle.id,
name: puzzle.name,
description: '',
gridWidth: puzzle.width,
gridHeight: puzzle.height,
par: Number.isFinite(puzzle.par) ? Math.max(0, Math.floor(puzzle.par)) : 0,
startPosition: puzzle.start,
goalPosition: puzzle.goal,
obstacles: cleanObstacles,
warpPairs,
thinIceTiles: mergedThinIceTiles,
pushableRocks: mergedPushableRocks,
pressurePlate: puzzle.pressurePlate || null,
barrier: puzzle.barrier || null,
previewId: null,
manualSolveMechanicsBaseline: null,
manualSolveFingerprint: null,
isDirty: false,
lastSolverResult: null,
};
this.currentDraft = this.cloneDraft(draft);
this.undoStack = [];
this.redoStack = [];
this.lastSolvableSnapshot = null;
return draft;
}
renameCurrentDraft(name: string): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
const nextName = String(name || '').trim();
if (!nextName) {
throw new Error('Draft name cannot be empty');
}
return this.updateDraft({ name: nextName });
}
markCurrentAsLastSolvable(): void {
if (!this.currentDraft) return;
this.lastSolvableSnapshot = this.cloneDraft(this.currentDraft);
}
getHistoryStatus(): { canUndo: boolean; canRedo: boolean; undoDepth: number; redoDepth: number } {
return {
canUndo: this.undoStack.length > 0,
canRedo: this.redoStack.length > 0,
undoDepth: this.undoStack.length,
redoDepth: this.redoStack.length,
};
}
undo(): DraftState | null {
if (!this.currentDraft || this.undoStack.length === 0) {
return null;
}
const previous = this.undoStack.pop()!;
this.redoStack.push(this.cloneDraft(this.currentDraft));
this.currentDraft = this.cloneDraft(previous);
return this.currentDraft;
}
redo(): DraftState | null {
if (!this.currentDraft || this.redoStack.length === 0) {
return null;
}
const next = this.redoStack.pop()!;
this.undoStack.push(this.cloneDraft(this.currentDraft));
this.currentDraft = this.cloneDraft(next);
return this.currentDraft;
}
revertToLastSolvable(): DraftState | null {
if (!this.currentDraft || !this.lastSolvableSnapshot) {
return null;
}
this.undoStack.push(this.cloneDraft(this.currentDraft));
this.redoStack = [];
this.currentDraft = this.cloneDraft(this.lastSolvableSnapshot);
return this.currentDraft;
}
withSingleHistoryEntry<T>(operation: () => T): T {
if (!this.currentDraft) {
throw new Error('No current draft');
}
const priorSuspended = this.historySuspended;
if (!priorSuspended) {
this.pushHistorySnapshot();
this.historySuspended = true;
}
try {
return operation();
} finally {
if (!priorSuspended) {
this.historySuspended = false;
}
}
}
placeElement(x: number, y: number, type: ObstacleType): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
// Ensure each tile uses a single canonical encoding by clearing any
// existing special element at this coordinate before placing an obstacle.
this.clearPosition(x, y);
const draft = this.currentDraft!;
// Remove any existing element at this position
const obstacles = draft.obstacles.filter(
obs => !(obs.x === x && obs.y === y)
);
// Add new element
obstacles.push({ x, y, type });
return this.updateDraft({ obstacles }, { trackHistory: false });
});
}
removeElement(x: number, y: number): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
const obstacles = this.currentDraft.obstacles.filter(
obs => !(obs.x === x && obs.y === y)
);
return this.updateDraft({ obstacles }, { trackHistory: false });
}
setStartPosition(x: number, y: number): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
if (this.currentDraft.goalPosition.x === x && this.currentDraft.goalPosition.y === y) {
throw new Error('Start and goal positions cannot be the same');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
this.clearPosition(x, y);
return this.updateDraft({ startPosition: { x, y } }, { trackHistory: false });
});
}
setGoalPosition(x: number, y: number): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
if (this.currentDraft.startPosition.x === x && this.currentDraft.startPosition.y === y) {
throw new Error('Start and goal positions cannot be the same');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
this.clearPosition(x, y);
return this.updateDraft({ goalPosition: { x, y } }, { trackHistory: false });
});
}
clearPosition(x: number, y: number, options: { trackHistory?: boolean } = {}): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
if (options.trackHistory === true) {
this.pushHistorySnapshot();
}
const obstacles = this.currentDraft.obstacles.filter(
(obs) => !(obs.x === x && obs.y === y)
);
const thinIceTiles = this.currentDraft.thinIceTiles.filter(
(tile) => !(tile.x === x && tile.y === y)
);
const pushableRocks = this.currentDraft.pushableRocks.filter(
(rock) => !(rock.x === x && rock.y === y)
);
const warpPairs = this.currentDraft.warpPairs
.map((pair) => ({
...pair,
positions: pair.positions.filter((position) => !(position.x === x && position.y === y)),
}))
.filter((pair) => pair.positions.length === 2);
const pressurePlate = this.currentDraft.pressurePlate
&& this.currentDraft.pressurePlate.x === x
&& this.currentDraft.pressurePlate.y === y
? null
: this.currentDraft.pressurePlate;
const barrier = this.currentDraft.barrier
&& this.currentDraft.barrier.x === x
&& this.currentDraft.barrier.y === y
? null
: this.currentDraft.barrier;
return this.updateDraft({
obstacles,
thinIceTiles,
pushableRocks,
warpPairs,
pressurePlate,
barrier,
}, { trackHistory: false });
}
addWarpPair(x1: number, y1: number, x2: number, y2: number): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
this.clearPosition(x1, y1);
this.clearPosition(x2, y2);
const id = `warp_${this.warpCounter++}`;
const warpPair: WarpPair = {
id,
positions: [
{ x: x1, y: y1 },
{ x: x2, y: y2 },
],
};
const warpPairs = [...this.currentDraft!.warpPairs, warpPair];
return this.updateDraft({ warpPairs }, { trackHistory: false });
});
}
removeWarp(warpId: string): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
const warpPairs = this.currentDraft.warpPairs.filter(pair => pair.id !== warpId);
return this.updateDraft({ warpPairs }, { trackHistory: false });
}
addThinIce(positions: Position[]): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
const incoming = this.dedupePositions(positions);
for (const position of incoming) {
this.clearPosition(position.x, position.y);
}
const thinIceTiles = this.dedupePositions([...this.currentDraft!.thinIceTiles, ...incoming]);
return this.updateDraft({ thinIceTiles }, { trackHistory: false });
});
}
removeThinIce(positions: Position[]): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
const thinIceTiles = this.currentDraft.thinIceTiles.filter(
tile => !positions.some(pos => pos.x === tile.x && pos.y === tile.y)
);
return this.updateDraft({ thinIceTiles }, { trackHistory: false });
}
addPushableRock(positions: Position[]): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
const incoming = this.dedupePositions(positions);
for (const position of incoming) {
this.clearPosition(position.x, position.y);
}
const pushableRocks = this.dedupePositions([...this.currentDraft!.pushableRocks, ...incoming]);
return this.updateDraft({ pushableRocks }, { trackHistory: false });
});
}
removePushableRock(positions: Position[]): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
const pushableRocks = this.currentDraft.pushableRocks.filter(
rock => !positions.some(pos => pos.x === rock.x && pos.y === rock.y)
);
return this.updateDraft({ pushableRocks }, { trackHistory: false });
}
setPressurePlate(x: number, y: number): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
this.clearPosition(x, y);
return this.updateDraft({ pressurePlate: { x, y } }, { trackHistory: false });
});
}
removePressurePlate(): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.updateDraft({ pressurePlate: null }, { trackHistory: false });
}
setBarrier(x: number, y: number): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
this.clearPosition(x, y);
return this.updateDraft({ barrier: { x, y } }, { trackHistory: false });
});
}
removeBarrier(): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
return this.updateDraft({ barrier: null }, { trackHistory: false });
}
resizeGrid(width: number, height: number): DraftState {
if (!this.currentDraft) {
throw new Error('No current draft');
}
this.pushHistorySnapshot();
const draft = this.currentDraft;
const w = this.clampGridSize(width);
const h = this.clampGridSize(height);
let start: Position = {
x: Math.max(1, Math.min(w - 2, draft.startPosition.x)),
y: Math.max(1, Math.min(h - 2, draft.startPosition.y)),
};
let goal: Position = {
x: Math.max(0, Math.min(w - 1, draft.goalPosition.x)),
y: Math.max(0, Math.min(h - 1, draft.goalPosition.y)),
};
const isCorner = (position: Position): boolean =>
(position.x === 0 || position.x === w - 1) && (position.y === 0 || position.y === h - 1);
if (isCorner(goal)) {
goal = {
x: goal.x === 0 ? 1 : w - 2,
y: goal.y === 0 ? 1 : h - 2,
};
}
if (this.isSamePosition(start, goal)) {
if (goal.x < w - 2) {
goal = { ...goal, x: goal.x + 1 };
} else {
goal = { ...goal, x: goal.x - 1 };
}
}
const isStartOrGoal = (position: Position): boolean =>
this.isSamePosition(position, start) || this.isSamePosition(position, goal);
const obstacles = draft.obstacles.filter((o) => (
o.x > 0
&& o.x < w - 1
&& o.y > 0
&& o.y < h - 1
&& !isStartOrGoal(o)
));
const thinIceTiles = draft.thinIceTiles.filter((t) => (
t.x > 0
&& t.x < w - 1
&& t.y > 0
&& t.y < h - 1
&& !isStartOrGoal(t)
));
const pushableRocks = draft.pushableRocks.filter((r) => (
r.x > 0
&& r.x < w - 1
&& r.y > 0
&& r.y < h - 1
&& !isStartOrGoal(r)
));
const warpPairs = draft.warpPairs
.map((wp) => ({
...wp,
positions: wp.positions.filter((p) => (
p.x > 0
&& p.x < w - 1
&& p.y > 0
&& p.y < h - 1
&& !isStartOrGoal(p)
)),
}))
.filter((wp) => wp.positions.length === 2);
let pressurePlate = draft.pressurePlate;
if (
pressurePlate
&& (
pressurePlate.x <= 0
|| pressurePlate.x >= w - 1
|| pressurePlate.y <= 0
|| pressurePlate.y >= h - 1
|| isStartOrGoal(pressurePlate)
)
) {
pressurePlate = null;
}
let barrier = draft.barrier;
if (
barrier
&& (
barrier.x <= 0
|| barrier.x >= w - 1
|| barrier.y <= 0
|| barrier.y >= h - 1
|| isStartOrGoal(barrier)
)
) {
barrier = null;
}
return this.updateDraft({
gridWidth: w,
gridHeight: h,
obstacles,
thinIceTiles,
pushableRocks,
warpPairs,
startPosition: start,
goalPosition: goal,
pressurePlate,
barrier,
isDirty: true,
}, { trackHistory: false });
}
moveElement(from: Position, to: Position): { draft: DraftState; movedType: string } {
if (!this.currentDraft) {
throw new Error('No current draft');
}
if (this.isSamePosition(from, to)) {
throw new Error('Source and destination are the same');
}
if (this.isSamePosition(to, this.currentDraft.startPosition) || this.isSamePosition(to, this.currentDraft.goalPosition)) {
throw new Error('Cannot move a tile onto start/goal');
}
const sourceObstacle = this.currentDraft.obstacles.find((obs) => obs.x === from.x && obs.y === from.y);
const sourceThinIce = this.currentDraft.thinIceTiles.some((tile) => tile.x === from.x && tile.y === from.y);
const sourcePushable = this.currentDraft.pushableRocks.some((rock) => rock.x === from.x && rock.y === from.y);
const sourcePressurePlate = this.currentDraft.pressurePlate
&& this.currentDraft.pressurePlate.x === from.x
&& this.currentDraft.pressurePlate.y === from.y;
const sourceBarrier = this.currentDraft.barrier
&& this.currentDraft.barrier.x === from.x
&& this.currentDraft.barrier.y === from.y;
const sourceWarp = this.currentDraft.warpPairs.find((pair) =>
pair.positions.some((position) => position.x === from.x && position.y === from.y)
);
if (!sourceObstacle && !sourceThinIce && !sourcePushable && !sourcePressurePlate && !sourceBarrier && !sourceWarp) {
throw new Error(`No movable tile found at (${from.x}, ${from.y})`);
}
this.pushHistorySnapshot();
return this.runWithHistorySuspended(() => {
this.clearPosition(to.x, to.y);
let movedType = 'tile';
if (sourceObstacle) {
const obstacles = this.currentDraft!.obstacles.filter((obs) => !(obs.x === from.x && obs.y === from.y));
obstacles.push({ x: to.x, y: to.y, type: sourceObstacle.type });
this.updateDraft({ obstacles }, { trackHistory: false });
movedType = sourceObstacle.type;
} else if (sourceThinIce) {
const thinIceTiles = this.currentDraft!.thinIceTiles.filter((tile) => !(tile.x === from.x && tile.y === from.y));
thinIceTiles.push({ x: to.x, y: to.y });
this.updateDraft({ thinIceTiles: this.dedupePositions(thinIceTiles) }, { trackHistory: false });
movedType = 'thin_ice';
} else if (sourcePushable) {
const pushableRocks = this.currentDraft!.pushableRocks.filter((rock) => !(rock.x === from.x && rock.y === from.y));
pushableRocks.push({ x: to.x, y: to.y });
this.updateDraft({ pushableRocks: this.dedupePositions(pushableRocks) }, { trackHistory: false });
movedType = 'pushable_rock';
} else if (sourcePressurePlate) {
this.updateDraft({ pressurePlate: { x: to.x, y: to.y } }, { trackHistory: false });
movedType = 'pressure_plate';
} else if (sourceBarrier) {
this.updateDraft({ barrier: { x: to.x, y: to.y } }, { trackHistory: false });
movedType = 'barrier';
} else if (sourceWarp) {
const warpPairs = this.currentDraft!.warpPairs.map((pair) => {
if (pair.id !== sourceWarp.id) return pair;
return {
...pair,
positions: pair.positions.map((position) => (
(position.x === from.x && position.y === from.y) ? { x: to.x, y: to.y } : position
)),
};
});
this.updateDraft({ warpPairs }, { trackHistory: false });
movedType = `warp (${sourceWarp.id})`;
}
return { draft: this.currentDraft!, movedType };
});
}
private generateId(): string {
const timestamp = Date.now().toString(36);
const random = Array.from({ length: 16 }, () => Math.random().toString(36).charAt(2)).join('');
return `draft_${timestamp}_${random}`;
}
}
export const draftStore = new DraftStore();