mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 12:02:04 +00:00
3c196cb7e7
Resolves #4401 NOTE: While this PR is an improvment, the Indian subcontinent crash IS NOT caused by >253 water components, as the map only has ~15 water components. ## Description: Fixes a critical browser tab crash ("Aw, Snap! Something went wrong") when loading the game on the new Indian Subcontinent map (or any map with >= 253 water components) in Solo Mode. ### Technical Cause: 1. When a map contains >= 253 disconnected water components, the array mapping tiles to component IDs is dynamically promoted from a Uint8Array to a Uint16Array. 2. This promotion upgrades the land sentinel LAND_MARKER from 0xff (255) to LAND_MARKER_WIDE (0xffff / 65535). 3. The BFS local search filter in AStarWaterHierarchical had a hardcoded sentinel check: (t: TileRef) => this.graph.getComponentId(t) !== LAND_MARKER (evaluating against 255). 4. On promoted maps, land tiles (65535) matched this check as water. The local BFS then traversed the entire landmass of the map, resulting in CPU exhaustion and memory/stack overflows that crashed the rendering process. ### Solution: Changed the hardcoded sentinel check to query the map's terrain directly via this.map.isWater(t). This makes the check immune to any component ID promotions or sentinel representation upgrades. Verified that the existing water pathfinding test suite passes successfully. ## Please complete the following: - [ ] I have added screenshots for all UI updates (N/A) - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file (N/A) - [x] I have added relevant tests to the test directory (Existing tests in tests/core/pathfinding/PathFinding.Water.test.ts cover water pathfinding behavior and run successfully) ## Please put your Discord username so you can be contacted if a bug or regression is found: blontd6
299 lines
8.3 KiB
TypeScript
299 lines
8.3 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { Game } from "../../../src/core/game/Game";
|
|
import { TileRef } from "../../../src/core/game/GameMap";
|
|
import { PathFinding } from "../../../src/core/pathfinding/PathFinder";
|
|
import {
|
|
PathStatus,
|
|
SteppingPathFinder,
|
|
} from "../../../src/core/pathfinding/types";
|
|
import { setup } from "../../util/Setup";
|
|
import { createGame, L, W } from "./_fixtures";
|
|
|
|
describe("PathFinding.Water", () => {
|
|
let game: Game;
|
|
let worldGame: Game;
|
|
let giantWorldGame: Game;
|
|
|
|
function createPathFinder(g: Game = game): SteppingPathFinder<TileRef> {
|
|
return PathFinding.Water(g);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
game = await setup("ocean_and_land");
|
|
worldGame = await setup("world", { disableNavMesh: false });
|
|
giantWorldGame = await setup("giantworldmap", { disableNavMesh: false });
|
|
});
|
|
|
|
describe("findPath", () => {
|
|
it("finds path between adjacent water tiles", () => {
|
|
const pathFinder = createPathFinder();
|
|
const map = game.map();
|
|
|
|
const from = map.ref(8, 0);
|
|
const to = map.ref(9, 0);
|
|
|
|
expect(map.isWater(from)).toBe(true);
|
|
expect(map.isWater(to)).toBe(true);
|
|
|
|
const path = pathFinder.findPath(from, to);
|
|
|
|
expect(path).not.toBeNull();
|
|
expect(path!.length).toBe(2);
|
|
expect(path![0]).toBe(from);
|
|
expect(path![1]).toBe(to);
|
|
});
|
|
|
|
it("returns null for land tiles", () => {
|
|
const pathFinder = createPathFinder();
|
|
const map = game.map();
|
|
|
|
const landTile = map.ref(0, 0);
|
|
const waterTile = map.ref(8, 0);
|
|
|
|
expect(map.isLand(landTile)).toBe(true);
|
|
expect(map.isShore(landTile)).toBe(false);
|
|
expect(map.isWater(waterTile)).toBe(true);
|
|
|
|
const path = pathFinder.findPath(landTile, waterTile);
|
|
|
|
expect(path).toBeNull();
|
|
});
|
|
|
|
it("returns single-tile path when from equals to", () => {
|
|
const pathFinder = createPathFinder();
|
|
const map = game.map();
|
|
|
|
const waterTile = map.ref(8, 0);
|
|
expect(map.isWater(waterTile)).toBe(true);
|
|
|
|
const path = pathFinder.findPath(waterTile, waterTile);
|
|
|
|
expect(path).not.toBeNull();
|
|
expect(path!.length).toBe(1);
|
|
expect(path![0]).toBe(waterTile);
|
|
});
|
|
|
|
it("supports multiple start tiles", () => {
|
|
const pathFinder = createPathFinder();
|
|
const map = game.map();
|
|
|
|
const dest = map.ref(8, 0);
|
|
const source1 = map.ref(9, 0);
|
|
const source2 = map.ref(8, 1);
|
|
|
|
expect(map.isWater(dest)).toBe(true);
|
|
expect(map.isWater(source1)).toBe(true);
|
|
expect(map.isWater(source2)).toBe(true);
|
|
|
|
const from = [source1, source2];
|
|
const path = pathFinder.findPath(from, dest);
|
|
|
|
expect(path).not.toBeNull();
|
|
expect(path!.length).toBe(2);
|
|
expect(from).toContain(path![0]);
|
|
expect(path![1]).toBe(dest);
|
|
});
|
|
});
|
|
|
|
describe("path validity", () => {
|
|
it("all consecutive tiles in path are connected", () => {
|
|
const pathFinder = createPathFinder();
|
|
const map = game.map();
|
|
|
|
// Distant water tiles: (8,0) → (15,4), distance = 11
|
|
const from = map.ref(8, 0);
|
|
const to = map.ref(15, 4);
|
|
|
|
expect(map.isWater(from)).toBe(true);
|
|
expect(map.isWater(to)).toBe(true);
|
|
expect(map.manhattanDist(from, to)).toBe(11);
|
|
|
|
const path = pathFinder.findPath(from, to);
|
|
|
|
expect(path).not.toBeNull();
|
|
|
|
for (let i = 1; i < path!.length; i++) {
|
|
const dist = map.manhattanDist(path![i - 1], path![i]);
|
|
expect(dist).toEqual(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("shore handling", () => {
|
|
it("path from shore to shore starts and ends on shore", () => {
|
|
const pathFinder = createPathFinder();
|
|
const map = game.map();
|
|
|
|
// Shore tiles at (7,0) and (7,6), distance = 6
|
|
// Both have water neighbors at (8,0) and (8,6)
|
|
const from = map.ref(7, 0);
|
|
const to = map.ref(7, 6);
|
|
|
|
expect(map.isShore(from)).toBe(true);
|
|
expect(map.isShore(to)).toBe(true);
|
|
expect(map.manhattanDist(from, to)).toBe(6);
|
|
|
|
const path = pathFinder.findPath(from, to);
|
|
|
|
expect(path).not.toBeNull();
|
|
expect(path![0]).toBe(from);
|
|
expect(path![path!.length - 1]).toBe(to);
|
|
});
|
|
});
|
|
|
|
describe("determinism", () => {
|
|
it("same inputs produce identical paths", () => {
|
|
const pathFinder1 = createPathFinder();
|
|
const pathFinder2 = createPathFinder();
|
|
const map = game.map();
|
|
|
|
// Distant water tiles: (8,0) → (15,4)
|
|
const from = map.ref(8, 0);
|
|
const to = map.ref(15, 4);
|
|
|
|
const path1 = pathFinder1.findPath(from, to);
|
|
const path2 = pathFinder2.findPath(from, to);
|
|
|
|
expect(path1).not.toBeNull();
|
|
expect(path2).not.toBeNull();
|
|
expect(path1).toEqual(path2);
|
|
});
|
|
});
|
|
|
|
describe("World map routes", () => {
|
|
it("Spain to France (Mediterranean)", () => {
|
|
const pathFinder = createPathFinder(worldGame);
|
|
const path = pathFinder.findPath(
|
|
worldGame.ref(926, 283),
|
|
worldGame.ref(950, 257),
|
|
);
|
|
expect(path).not.toBeNull();
|
|
});
|
|
|
|
it("Miami to Rio (Atlantic)", () => {
|
|
const pathFinder = createPathFinder(worldGame);
|
|
const path = pathFinder.findPath(
|
|
worldGame.ref(488, 355),
|
|
worldGame.ref(680, 658),
|
|
);
|
|
expect(path).not.toBeNull();
|
|
});
|
|
|
|
it("France to Poland (around Europe)", () => {
|
|
const pathFinder = createPathFinder(worldGame);
|
|
const path = pathFinder.findPath(
|
|
worldGame.ref(950, 257),
|
|
worldGame.ref(1033, 175),
|
|
);
|
|
expect(path).not.toBeNull();
|
|
});
|
|
|
|
it("Miami to Spain (transatlantic)", () => {
|
|
const pathFinder = createPathFinder(worldGame);
|
|
const path = pathFinder.findPath(
|
|
worldGame.ref(488, 355),
|
|
worldGame.ref(926, 283),
|
|
);
|
|
expect(path).not.toBeNull();
|
|
});
|
|
|
|
it("Rio to Poland (South Atlantic to Baltic)", () => {
|
|
const pathFinder = createPathFinder(worldGame);
|
|
const path = pathFinder.findPath(
|
|
worldGame.ref(680, 658),
|
|
worldGame.ref(1033, 175),
|
|
);
|
|
expect(path).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Error handling", () => {
|
|
it("returns NOT_FOUND for null source", () => {
|
|
const pathFinder = createPathFinder();
|
|
|
|
const consoleSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
|
|
const result = pathFinder.next(
|
|
null as unknown as TileRef,
|
|
game.ref(8, 0),
|
|
);
|
|
|
|
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it("returns NOT_FOUND for null destination", () => {
|
|
const pathFinder = createPathFinder();
|
|
|
|
const consoleSpy = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => {});
|
|
|
|
const result = pathFinder.next(
|
|
game.ref(8, 0),
|
|
null as unknown as TileRef,
|
|
);
|
|
|
|
expect(result.status).toBe(PathStatus.NOT_FOUND);
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("Known bugs", () => {
|
|
it("path can cross 1-tile land barrier", () => {
|
|
const syntheticGame = createGame({
|
|
width: 10,
|
|
height: 1,
|
|
grid: [W, L, L, W, L, W, W, L, L, W],
|
|
});
|
|
|
|
const pathFinder = createPathFinder(syntheticGame);
|
|
const path = pathFinder.findPath(
|
|
syntheticGame.ref(0, 0),
|
|
syntheticGame.ref(9, 0),
|
|
);
|
|
|
|
expect(path).not.toBeNull();
|
|
});
|
|
|
|
it("path can cross diagonal land barrier", () => {
|
|
const syntheticGame = createGame({
|
|
width: 2,
|
|
height: 2,
|
|
grid: [W, L, L, W],
|
|
});
|
|
|
|
const pathFinder = createPathFinder(syntheticGame);
|
|
const path = pathFinder.findPath(
|
|
syntheticGame.ref(0, 0),
|
|
syntheticGame.ref(1, 1),
|
|
);
|
|
|
|
expect(path).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Giant World Map routes", () => {
|
|
it("routes correctly in promoted components", () => {
|
|
const pathFinder = createPathFinder(giantWorldGame);
|
|
const map = giantWorldGame.map();
|
|
|
|
// Coordinates inside Component 459 in map4x (promoted)
|
|
const from = map.ref(2616, 452);
|
|
const to = map.ref(2676, 474);
|
|
|
|
expect(map.isWater(from)).toBe(true);
|
|
expect(map.isWater(to)).toBe(true);
|
|
|
|
const path = pathFinder.findPath(from, to);
|
|
expect(path).not.toBeNull();
|
|
expect(path![0]).toBe(from);
|
|
expect(path![path!.length - 1]).toBe(to);
|
|
});
|
|
});
|
|
});
|