From 27e0db38c8d271a71e673706a232f6e5982beb10 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 17 Sep 2026 13:27:27 +0000 Subject: [PATCH] fix(level-links): prevent infinite teleport bounce at seam arrival Eliminate fallback to trigger tile (x, y) when no walkable tile is found within initial radius. Expand search to full grid extent and guarantee that the arrival destination is strictly offset inward away from the seam trigger box. TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1 --- src/game/level-links.ts | 12 +++++-- tests/world-graph.test.ts | 68 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) 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) + }) +})