diff --git a/gameblocks/modules/behavior/GridPathPlanner.js b/gameblocks/modules/behavior/GridPathPlanner.js index c352caf..d8a0198 100644 --- a/gameblocks/modules/behavior/GridPathPlanner.js +++ b/gameblocks/modules/behavior/GridPathPlanner.js @@ -27,6 +27,20 @@ function wrapDelta(delta, size) { return Math.min(Math.abs(delta), size - Math.abs(delta)); } +function wrapCoord(value, size) { + if (!Number.isFinite(size) || size <= 0) return 0; + let wrapped = value % size; + if (wrapped < 0) wrapped += size; + return wrapped; +} + +function wrapCell(cell, board) { + return { + right: wrapCoord(cell.right, board.columns), + forward: wrapCoord(cell.forward, board.rows), + }; +} + function priorityInsert(open, entry) { let index = open.length; while (index > 0 && open[index - 1].f > entry.f) { @@ -124,8 +138,8 @@ export class GridPathPlanner { ) { if (!start || !goal) return null; - const startCell = cloneCell(start); - const goalCell = cloneCell(goal); + const startCell = wrap ? wrapCell(cloneCell(start), this) : cloneCell(start); + const goalCell = wrap ? wrapCell(cloneCell(goal), this) : cloneCell(goal); const startKey = gridCellKey(startCell); const goalKey = gridCellKey(goalCell); const blockedKeys = normalizeBlockedCells(blocked); @@ -198,7 +212,7 @@ export class GridPathPlanner { return { count: 0, cells: [] }; } - const startCell = cloneCell(start); + const startCell = wrap ? wrapCell(cloneCell(start), this) : cloneCell(start); const startKey = gridCellKey(startCell); const blockedKeys = normalizeBlockedCells(blocked); if (allowStartOccupied) blockedKeys.delete(startKey); diff --git a/gameblocks/modules/behavior/GridPathPlanner.wrap-start-goal.test.js b/gameblocks/modules/behavior/GridPathPlanner.wrap-start-goal.test.js new file mode 100644 index 0000000..9a7e8f9 --- /dev/null +++ b/gameblocks/modules/behavior/GridPathPlanner.wrap-start-goal.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { GridPathPlanner, gridCellKey } from './GridPathPlanner.js'; + +const navigation = { + vectors: { + north: { right: 0, forward: 1 }, + east: { right: 1, forward: 0 }, + south: { right: 0, forward: -1 }, + west: { right: -1, forward: 0 }, + }, + neighborOrder: ['north', 'east', 'south', 'west'], +}; + +test('wrap findPath maps off-board start and goal onto the torus', () => { + const planner = new GridPathPlanner({ navigation, columns: 10, rows: 10, wrap: true }); + const path = planner.findPath({ right: -5, forward: 0 }, { right: 0, forward: 0 }); + assert.ok(path); + assert.deepEqual(path[0], { right: 5, forward: 0 }); + assert.deepEqual(path.at(-1), { right: 0, forward: 0 }); + for (const cell of path) { + assert.ok(cell.right >= 0 && cell.right < 10); + assert.ok(cell.forward >= 0 && cell.forward < 10); + } + assert.equal(gridCellKey(path[0]), '5:0'); +}); + +test('wrap floodFill maps off-board start onto the torus', () => { + const planner = new GridPathPlanner({ navigation, columns: 8, rows: 8, wrap: true }); + const fill = planner.floodFill({ right: -1, forward: 0 }, [], true, true, 1); + assert.equal(fill.count, 1); + assert.deepEqual(fill.cells[0], { right: 7, forward: 0 }); +}); + +test('bounded findPath keeps off-board start off-board and fails', () => { + const planner = new GridPathPlanner({ navigation, columns: 10, rows: 10, wrap: false }); + const path = planner.findPath({ right: -5, forward: 0 }, { right: 0, forward: 0 }); + assert.equal(path, null); +});