diff --git a/src/game/level-links.ts b/src/game/level-links.ts index 9ddd762..b7b8b63 100644 --- a/src/game/level-links.ts +++ b/src/game/level-links.ts @@ -394,7 +394,8 @@ export function seamArrivalSpot( const step = inwardStep(side) const targetX = x + step.x * (SEAM_TRIGGER_SUBTILES + 3) const targetY = y + step.y * (SEAM_TRIGGER_SUBTILES + 3) - for (let radius = 0; radius <= 32; radius += 1) { + const maxRadius = Math.max(grid.gridWidth, grid.gridHeight, 64) + for (let radius = 0; radius <= maxRadius; radius += 1) { for (let dy = -radius; dy <= radius; dy += 1) { for (let dx = -radius; dx <= radius; dx += 1) { if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue @@ -406,7 +407,14 @@ export function seamArrivalSpot( } } } - return nearestWalkable(grid, targetX, targetY, 32, region) ?? { x, y } + // Never place the player directly back on or inside the trigger box around (x, y), + // which would trigger an immediate bounce back in an infinite ping-pong loop. + const clampedX = Math.max(0, Math.min(grid.gridWidth - 1, targetX)) + const clampedY = Math.max(0, Math.min(grid.gridHeight - 1, targetY)) + if (Math.abs(clampedX - x) <= SEAM_TRIGGER_SUBTILES && Math.abs(clampedY - y) <= SEAM_TRIGGER_SUBTILES) { + return { x: targetX, y: targetY } + } + return { x: clampedX, y: clampedY } } /** Where an edge of a level opens, and how convincing the opening is. */ diff --git a/tests/world-graph.test.ts b/tests/world-graph.test.ts index 55ce4f9..efe14ac 100644 --- a/tests/world-graph.test.ts +++ b/tests/world-graph.test.ts @@ -16,6 +16,11 @@ import { DROPPED_VIS_EDGES, } from '../src/game/world-graph.ts' import type { Side } from '../src/game/world-graph.ts' +import { + seamArrivalSpot, + SEAM_TRIGGER_SUBTILES, + type LinkGrid, +} from '../src/game/level-links.ts' /** * Build a `Levels.txt`-shaped table from sparse overrides. @@ -376,3 +381,66 @@ describe('parseWarpGeometry', () => { expect(findWarpGeometry(parseWarpGeometry(lvlwarp), 71)?.id).toBe(71) }) }) + +describe('seamArrivalSpot', () => { + function makeGrid(width: number, height: number, blockedIndices: Set = new Set()): LinkGrid { + const blocked = new Uint8Array(width * height) + for (const idx of blockedIndices) { + blocked[idx] = 1 + } + return { + cellsX: Math.ceil(width / 5), + cellsY: Math.ceil(height / 5), + gridWidth: width, + gridHeight: height, + blocked, + } + } + + it('places arrival strictly outside trigger box on all 4 sides', () => { + const grid = makeGrid(50, 50) + const sides: Side[] = ['north', 'south', 'east', 'west'] + const x = 25 + const y = 25 + + for (const side of sides) { + const spot = seamArrivalSpot(grid, x, y, side) + const insideTriggerBox = + Math.abs(spot.x - x) <= SEAM_TRIGGER_SUBTILES && + Math.abs(spot.y - y) <= SEAM_TRIGGER_SUBTILES + expect(insideTriggerBox).toBe(false) + expect(spot).not.toEqual({ x, y }) + } + }) + + it('never bounces back to trigger tile (x, y) even when completely blocked', () => { + // A grid where every single tile is impassable + const blockedSet = new Set() + for (let i = 0; i < 50 * 50; i++) blockedSet.add(i) + const grid = makeGrid(50, 50, blockedSet) + + const spot = seamArrivalSpot(grid, 20, 0, 'north') + // Must never fall back to trigger tile (20, 0) + expect(spot).not.toEqual({ x: 20, y: 0 }) + const insideTriggerBox = + Math.abs(spot.x - 20) <= SEAM_TRIGGER_SUBTILES && + Math.abs(spot.y - 0) <= SEAM_TRIGGER_SUBTILES + expect(insideTriggerBox).toBe(false) + }) + + it('ignores walkable tiles inside trigger radius and finds the nearest valid tile outside', () => { + // Only the trigger tile itself and its immediate neighbors (radius 2) are walkable + const blockedSet = new Set() + for (let i = 0; i < 50 * 50; i++) blockedSet.add(i) + // Clear trigger tile and one tile far inward + blockedSet.delete(0 * 50 + 20) // trigger (20, 0) + blockedSet.delete(1 * 50 + 20) // (20, 1) inside trigger box + blockedSet.delete(10 * 50 + 20) // (20, 10) valid outside trigger box + + const grid = makeGrid(50, 50, blockedSet) + const spot = seamArrivalSpot(grid, 20, 0, 'north') + + expect(spot).toEqual({ x: 20, y: 10 }) + expect(Math.abs(spot.y - 0)).toBeGreaterThan(SEAM_TRIGGER_SUBTILES) + }) +})