HPA* Pathfinding (#2815)

## Pathfinding with HPA*

Hi! The primary objective of this PR is to replace per-tile A* with
hierarchical pathfinding - HPA*. In practice, this means we create an
abstract graph on top of the actual map with far fewer points and use it
to decide on general path structure. Only then we go back to tile-level
and build path between selected waypoints. This speeds up long distance
pathfinding by over 1000x in some cases. To make the review easier, it
comes with a benchmark and visual playground.

## PREPROCESSING

H part of HPA* means "hierarchical" and requires preprocessing.

This PR includes pre-processing as part inside `new Game()` constructor.
It takes about 135ms for `giantworldmap` on my machine, which increases
the effective initialization from ~95ms to ~230ms. This time could be
reduced in different ways, which are **out of scope** for this PR.

After confirming the initialization time is bearable on low-end devices,
I argue merging this PR as-is is acceptable tradeoff. It creates small
lag at the beginning of a round but pays for itself in the first minute
of the match.

## Nerdy details

**Architecture**
- HPA*-style hierarchical pathfinding
- 32×32 sectors on minimap with gateway nodes on borders
- Gateway graph built via BFS during preprocessing
- Water component optimization skips unreachable gateway pairs
- A* on gateway graph → local A* within sectors → Bresenham path
smoothing
- Minimap upscaling identical to currently used in MiniAStar

**Key Optimizations**
- Typed arrays instead of high-level primitives
- Stamp-based visited tracking (no need to recreate buffers, O(1)
clearing)
- Optional - enabled by default - caching of tile paths between gateways
- Line of sight smoothing for the final path

## Review Focus

Play with included tools, benchmark and visualization. Pathfinding
should be safe to merge as a black box - you do not need to understand
the details. Outcomes can be tested empirically in-game. Visualize (and
share!) edge cases with included playground. Confirm the 100x speedup is
real with benchmark.

If you plan to dive into the code, I suggest the following order:
- Pathfinding abstraction in `src/core/pathfinding/`
- Pathfinding tests in `tests/core/pathfinding/`
- NavMesh in `src/core/pathfinding/navmesh/` + integration with
`Game.ts`
- Benchmark in `tests/pathfinding/benchmark/`

Do not look at playground's code, it has been created with a clanker.
The design is 100% mine and I spent way too long polishing it, but I
haven't even once edited the code manually. There is probably no
abstraction whatsoever, just do not look at the code, let it play.

## Core Changes

#### Pathfinding (`src/core/pathfinding/navmesh/`)
- HPA* + refinement -> three phased pathfinding: A* over the graph ->
naive path -> refinement
- comes with A* and BFS optimized for for specific needs

#### Pre-Processing (`src/core/pathfinding/navmesh/`)
- identify water bodies to avoid pathfinding between disconnected nodes
- create high-level graph of gateways on top of tile map

#### Abstraction (`src/core/pathfinding/`)

- common `PathFinder` interface that can return full path and also act
as state machine (`.next()`)
- adapters for both new and legacy algorithm with fallback to legacy if
navigation mesh not available

#### Benchmark (`tests/pathfinding/benchmark/`)

- `npx tsx tests/pathfinding/benchmark/run.ts` - no guesswork, numbers
- `npx tsx tests/pathfinding/benchmark/run.ts --synthetic` - 1000s of
synthetic paths
- `npx tsc tests/pathfinding/benchmark/generate.ts` - generate more as
needed, test new maps
- includes ONE synthetic scenario to avoid PR bloat, generate more
locally / later

#### Playground (`tests/pathfinding/playground/`)

- `npx tsx tests/pathfinding/playground/server.ts` - visualize paths
with both new and legacy algorithm

## Benchmarks

### Compared with legacy in default - hand picked - scenario:
```
Initialization: 95.95ms -> 227.29ms
Pathfinding: 3038.43ms -> 6.45ms
Distance: 26972 -> 26810 tiles
```

### 42,000 synthetic routes across all maps
```
Running 42 synthetic scenarios with hpa.cached adapter...

 synthetic/achiran                   | Init:    93.42ms | Path:    139.07ms | Dist: 1481630 tiles | Routes: 1000/1000
 synthetic/africa                    | Init:    87.14ms | Path:    155.08ms | Dist: 1829414 tiles | Routes: 1000/1000
 synthetic/asia                      | Init:    57.60ms | Path:    112.55ms | Dist: 1204082 tiles | Routes: 1000/1000
 synthetic/australia                 | Init:    78.18ms | Path:     77.12ms | Dist:  978375 tiles | Routes: 1000/1000
 synthetic/baikal                    | Init:    78.26ms | Path:    152.14ms | Dist: 1600016 tiles | Routes: 1000/1000
 synthetic/baikalnukewars            | Init:    81.44ms | Path:    165.90ms | Dist: 1699283 tiles | Routes: 1000/1000
 synthetic/betweentwoseas            | Init:    29.29ms | Path:    114.99ms | Dist: 1338075 tiles | Routes: 1000/1000
 synthetic/blacksea                  | Init:    30.66ms | Path:     93.14ms | Dist:  949217 tiles | Routes: 1000/1000
 synthetic/britannia                 | Init:    74.12ms | Path:     85.62ms | Dist:  866752 tiles | Routes: 1000/1000
 synthetic/deglaciatedantarctica     | Init:   105.49ms | Path:    192.93ms | Dist: 1574684 tiles | Routes: 1000/1000
 synthetic/didier                    | Init:    81.51ms | Path:    153.70ms | Dist: 1734876 tiles | Routes: 1000/1000
 synthetic/eastasia                  | Init:    49.29ms | Path:    128.63ms | Dist: 1410270 tiles | Routes: 1000/1000
 synthetic/europe                    | Init:    92.55ms | Path:    178.35ms | Dist: 1525216 tiles | Routes: 1000/1000
 synthetic/europeclassic             | Init:    33.50ms | Path:    104.40ms | Dist: 1209759 tiles | Routes: 1000/1000
 synthetic/falklandislands           | Init:    63.00ms | Path:    107.41ms | Dist: 1080251 tiles | Routes: 1000/1000
 synthetic/faroeislands              | Init:    71.91ms | Path:     49.52ms | Dist:  604613 tiles | Routes: 1000/1000
 synthetic/fourislands               | Init:    45.75ms | Path:     78.91ms | Dist:  937439 tiles | Routes: 1000/1000
 synthetic/gatewaytotheatlantic      | Init:    81.00ms | Path:    257.06ms | Dist: 2555551 tiles | Routes: 1000/1000
 synthetic/giantworldmap             | Init:   214.25ms | Path:    220.42ms | Dist: 1976693 tiles | Routes: 1000/1000
 synthetic/gulfofstlawrence          | Init:    45.16ms | Path:     96.05ms | Dist: 1014604 tiles | Routes: 1000/1000
 synthetic/halkidiki                 | Init:    74.68ms | Path:    149.39ms | Dist: 1546781 tiles | Routes: 1000/1000
 synthetic/iceland                   | Init:    58.72ms | Path:     78.16ms | Dist: 1001554 tiles | Routes: 1000/1000
 synthetic/italia                    | Init:    29.78ms | Path:    139.93ms | Dist: 1412024 tiles | Routes: 1000/1000
 synthetic/japan                     | Init:   161.07ms | Path:    118.65ms | Dist: 1154393 tiles | Routes: 1000/1000
 synthetic/lemnos                    | Init:    52.59ms | Path:    136.69ms | Dist: 1481101 tiles | Routes: 1000/1000
 synthetic/lisbon                    | Init:    49.27ms | Path:     86.53ms | Dist: 1032011 tiles | Routes: 1000/1000
 synthetic/manicouagan               | Init:    53.74ms | Path:    110.52ms | Dist: 1307630 tiles | Routes: 1000/1000
 synthetic/mars                      | Init:    29.39ms | Path:     80.55ms | Dist: 1091702 tiles | Routes: 1000/1000
 synthetic/mena                      | Init:    26.37ms | Path:    120.09ms | Dist: 1272751 tiles | Routes: 1000/1000
 synthetic/montreal                  | Init:    26.08ms | Path:    106.77ms | Dist: 1187736 tiles | Routes: 1000/1000
 synthetic/newyorkcity               | Init:    56.60ms | Path:    181.19ms | Dist: 1753875 tiles | Routes: 1000/1000
 synthetic/northamerica              | Init:    96.29ms | Path:    123.02ms | Dist: 1217221 tiles | Routes: 1000/1000
 synthetic/oceania                   | Init:    52.81ms | Path:     51.96ms | Dist:  482373 tiles | Routes: 1000/1000
 synthetic/pangaea                   | Init:    21.29ms | Path:     56.58ms | Dist:  716189 tiles | Routes: 1000/1000
 synthetic/pluto                     | Init:    53.89ms | Path:    141.62ms | Dist: 1304362 tiles | Routes: 1000/1000
 synthetic/southamerica              | Init:    85.19ms | Path:    123.03ms | Dist: 1301403 tiles | Routes: 1000/1000
 synthetic/straitofgibraltar         | Init:    76.68ms | Path:    108.30ms | Dist: 1304592 tiles | Routes: 1000/1000
 synthetic/straitofhormuz            | Init:    38.97ms | Path:     67.78ms | Dist:  754920 tiles | Routes: 1000/1000
 synthetic/surrounded                | Init:    95.35ms | Path:     90.18ms | Dist: 1017142 tiles | Routes: 1000/1000
 synthetic/svalmel                   | Init:    60.58ms | Path:    104.75ms | Dist: 1235501 tiles | Routes: 1000/1000
 synthetic/twolakes                  | Init:    62.05ms | Path:     94.54ms | Dist: 1140807 tiles | Routes: 1000/1000
 synthetic/world                     | Init:    41.43ms | Path:     93.42ms | Dist:  873406 tiles | Routes: 1000/1000

Completed 42 scenarios
Total Initialization Time: 2796.32ms
Total Pathfinding Time: 5026.64ms
Total Distance: 53160274 tiles
```

## Playground

**That's the fun part**. Watch NavMesh running circles around legacy
`PathFinder.Mini` in real time. Debug inner workings, test edge cases,
share URLs for debugging.


https://github.com/user-attachments/assets/34e2e3f5-fbc1-4b1f-917d-820766e98d5d

## Discord Tag
`moleole`
This commit is contained in:
Arkadiusz Sygulski
2026-01-08 22:34:18 +01:00
committed by GitHub
parent 9512e480d2
commit b090f2f624
45 changed files with 9032 additions and 61 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ const gitignorePath = path.resolve(__dirname, ".gitignore");
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(gitignorePath),
{ ignores: ["src/server/gatekeeper/**"] },
{ ignores: ["src/server/gatekeeper/**", "tests/pathfinding/playground/**"] },
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

@@ -0,0 +1,310 @@
{
"name": "World",
"nations": [
{
"coordinates": [375, 272],
"flag": "us",
"name": "United States"
},
{
"coordinates": [372, 136],
"flag": "ca",
"name": "Canada"
},
{
"coordinates": [375, 374],
"flag": "mx",
"name": "Mexico"
},
{
"coordinates": [500, 378],
"flag": "cu",
"name": "Cuba"
},
{
"coordinates": [524, 474],
"flag": "co",
"name": "Colombia"
},
{
"coordinates": [593, 473],
"flag": "ve",
"name": "Venezuela"
},
{
"coordinates": [596, 705],
"flag": "ar",
"name": "Argentina"
},
{
"coordinates": [637, 567],
"flag": "br",
"name": "Brazil"
},
{
"coordinates": [1280, 975],
"flag": "aq",
"name": "Antarctica"
},
{
"coordinates": [709, 57],
"flag": "gl",
"name": "Greenland"
},
{
"coordinates": [831, 112],
"flag": "is",
"name": "Iceland"
},
{
"coordinates": [925, 186],
"flag": "gb",
"name": "United Kingdom"
},
{
"coordinates": [887, 183],
"flag": "ie",
"name": "Ireland"
},
{
"coordinates": [908, 264],
"flag": "es",
"name": "Spain"
},
{
"coordinates": [1004, 250],
"flag": "it",
"name": "Italy"
},
{
"coordinates": [958, 220],
"flag": "fr",
"name": "France"
},
{
"coordinates": [997, 205],
"flag": "de",
"name": "Germany"
},
{
"coordinates": [1064, 101],
"flag": "se",
"name": "Sweden"
},
{
"coordinates": [1046, 193],
"flag": "pl",
"name": "Poland"
},
{
"coordinates": [1061, 188],
"flag": "by",
"name": "Belarus"
},
{
"coordinates": [1073, 243],
"flag": "ro",
"name": "Romania"
},
{
"coordinates": [1161, 274],
"flag": "tr",
"name": "Turkey"
},
{
"coordinates": [969, 133],
"flag": "no",
"name": "Norway"
},
{
"coordinates": [1062, 133],
"flag": "fi",
"name": "Finland"
},
{
"coordinates": [1099, 211],
"flag": "ua",
"name": "Ukraine"
},
{
"coordinates": [1344, 136],
"flag": "ru",
"name": "Russia"
},
{
"coordinates": [1537, 186],
"flag": "mn",
"name": "Mongolia"
},
{
"coordinates": [1524, 328],
"flag": "cn",
"name": "China"
},
{
"coordinates": [1368, 373],
"flag": "in",
"name": "India"
},
{
"coordinates": [1276, 239],
"flag": "kz",
"name": "Kazakhstan"
},
{
"coordinates": [1238, 309],
"flag": "ir",
"name": "Islamic Republic Of Iran"
},
{
"coordinates": [1178, 351],
"flag": "sa",
"name": "Saudi Arabia"
},
{
"coordinates": [1679, 657],
"flag": "au",
"name": "Australia"
},
{
"coordinates": [1890, 775],
"flag": "nz",
"name": "New Zealand"
},
{
"coordinates": [918, 342],
"flag": "dz",
"name": "Algeria"
},
{
"coordinates": [1030, 332],
"flag": "ly",
"name": "Libyan Arab Jamahiriya"
},
{
"coordinates": [1092, 335],
"flag": "eg",
"name": "Egypt"
},
{
"coordinates": [963, 410],
"flag": "ne",
"name": "Niger"
},
{
"coordinates": [1112, 406],
"flag": "sd",
"name": "Sudan"
},
{
"coordinates": [1074, 508],
"flag": "cd",
"name": "DR Congo"
},
{
"coordinates": [1154, 443],
"flag": "et",
"name": "Ethiopia"
},
{
"coordinates": [1075, 707],
"flag": "za",
"name": "South Africa"
},
{
"coordinates": [1194, 627],
"flag": "mg",
"name": "Madagascar"
},
{
"coordinates": [1052, 420],
"flag": "td",
"name": "Chad"
},
{
"coordinates": [1030, 665],
"flag": "na",
"name": "Namibia"
},
{
"coordinates": [1632, 465],
"flag": "ph",
"name": "Philippines"
},
{
"coordinates": [1537, 426],
"flag": "th",
"name": "Thailand"
},
{
"coordinates": [1610, 364],
"flag": "tw",
"name": "Taiwan"
},
{
"coordinates": [1710, 290],
"flag": "jp",
"name": "Japan"
},
{
"coordinates": [1869, 119],
"flag": "ru",
"name": "Siberia"
},
{
"coordinates": [74, 117],
"flag": "polar_bears",
"name": "Polar Bears"
},
{
"coordinates": [419, 975],
"flag": "aq",
"name": "West Antarctica"
},
{
"coordinates": [542, 603],
"flag": "pe",
"name": "Peru"
},
{
"coordinates": [1075, 615],
"flag": "zm",
"name": "Zambia"
},
{
"coordinates": [1099, 165],
"flag": "lv",
"name": "Latvia"
},
{
"coordinates": [1427, 336],
"flag": "bt",
"name": "Bhutan"
},
{
"coordinates": [1511, 524],
"flag": "id",
"name": "Indonesia"
},
{
"coordinates": [1809, 977],
"flag": "aq",
"name": "East Antarctica"
},
{
"coordinates": [1255, 382],
"flag": "om",
"name": "Oman"
},
{
"coordinates": [853, 373],
"flag": "ma",
"name": "Morocco"
},
{
"coordinates": [656, 678],
"flag": "uy",
"name": "Uruguay"
}
]
}
+1
View File
@@ -70,6 +70,7 @@ var maps = []struct {
{Name: "ocean_and_land", IsTest: true},
{Name: "plains", IsTest: true},
{Name: "giantworldmap", IsTest: true},
{Name: "world", IsTest: true},
}
// outputMapDir returns the absolute path to the directory where generated map files should be written.
+1
View File
@@ -182,6 +182,7 @@ export const GameConfigSchema = z.object({
infiniteGold: z.boolean(),
infiniteTroops: z.boolean(),
instantBuild: z.boolean(),
disableNavMesh: z.boolean().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(),
+1
View File
@@ -83,6 +83,7 @@ export interface Config {
infiniteTroops(): boolean;
donateTroops(): boolean;
instantBuild(): boolean;
disableNavMesh(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
+3
View File
@@ -333,6 +333,9 @@ export class DefaultConfig implements Config {
instantBuild(): boolean {
return this._gameConfig.instantBuild;
}
disableNavMesh(): boolean {
return this._gameConfig.disableNavMesh ?? false;
}
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
+8 -9
View File
@@ -8,8 +8,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
import { distSortUnit } from "../Util";
export class TradeShipExecution implements Execution {
@@ -28,7 +27,7 @@ export class TradeShipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 2500);
this.pathFinder = PathFinders.Water(mg);
}
tick(ticks: number): void {
@@ -102,14 +101,14 @@ export class TradeShipExecution implements Execution {
return;
}
const result = this.pathFinder.nextTile(curTile, this._dstPort.tile());
const result = this.pathFinder.next(curTile, this._dstPort.tile());
switch (result.type) {
case PathFindResultType.Pending:
switch (result.status) {
case PathStatus.PENDING:
// Fire unit event to rerender.
this.tradeShip.move(curTile);
break;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
// Update safeFromPirates status
if (this.mg.isWater(result.node) && this.mg.isShoreline(result.node)) {
this.tradeShip.setSafeFromPirates();
@@ -117,10 +116,10 @@ export class TradeShipExecution implements Execution {
this.tradeShip.move(result.node);
this.tilesTraveled++;
break;
case PathFindResultType.Completed:
case PathStatus.COMPLETE:
this.complete();
break;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
console.warn("captured trade ship cannot find route");
if (this.tradeShip.isActive()) {
this.tradeShip.delete(false);
+8 -9
View File
@@ -11,8 +11,7 @@ import {
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { targetTransportTile } from "../game/TransportShipUtils";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
@@ -70,7 +69,7 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.pathFinder = PathFinder.Mini(mg, 10_000, true, 100);
this.pathFinder = PathFinders.Water(mg);
if (
this.attacker.unitCount(UnitType.TransportShip) >=
@@ -224,9 +223,9 @@ export class TransportShipExecution implements Execution {
}
}
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
switch (result.type) {
case PathFindResultType.Completed:
const result = this.pathFinder.next(this.boat.tile(), this.dst);
switch (result.status) {
case PathStatus.COMPLETE:
if (this.mg.owner(this.dst) === this.attacker) {
const deaths = this.boat.troops() * (malusForRetreat / 100);
const survivors = this.boat.troops() - deaths;
@@ -269,12 +268,12 @@ export class TransportShipExecution implements Execution {
.stats()
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
return;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
this.boat.move(result.node);
break;
case PathFindResultType.Pending:
case PathStatus.PENDING:
break;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
// TODO: add to poisoned port list
console.warn(`path not found to dst`);
this.attacker.addTroops(this.boat.troops());
+14 -15
View File
@@ -8,8 +8,7 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PathFindResultType } from "../pathfinding/AStar";
import { PathFinder } from "../pathfinding/PathFinding";
import { PathFinder, PathFinders, PathStatus } from "../pathfinding/PathFinder";
import { PseudoRandom } from "../PseudoRandom";
import { ShellExecution } from "./ShellExecution";
@@ -27,7 +26,7 @@ export class WarshipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
this.pathfinder = PathFinder.Mini(mg, 10_000, true, 100);
this.pathfinder = PathFinders.Water(mg);
this.random = new PseudoRandom(mg.ticks());
if (isUnit(this.input)) {
this.warship = this.input;
@@ -177,24 +176,24 @@ export class WarshipExecution implements Execution {
private huntDownTradeShip() {
for (let i = 0; i < 2; i++) {
// target is trade ship so capture it.
const result = this.pathfinder.nextTile(
const result = this.pathfinder.next(
this.warship.tile(),
this.warship.targetUnit()!.tile(),
5,
);
switch (result.type) {
case PathFindResultType.Completed:
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.owner().captureUnit(this.warship.targetUnit()!);
this.warship.setTargetUnit(undefined);
this.warship.move(this.warship.tile());
return;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
case PathStatus.PENDING:
this.warship.touch();
break;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
console.log(`path not found to target`);
break;
}
@@ -209,22 +208,22 @@ export class WarshipExecution implements Execution {
}
}
const result = this.pathfinder.nextTile(
const result = this.pathfinder.next(
this.warship.tile(),
this.warship.targetTile()!,
);
switch (result.type) {
case PathFindResultType.Completed:
switch (result.status) {
case PathStatus.COMPLETE:
this.warship.setTargetTile(undefined);
this.warship.move(result.node);
break;
case PathFindResultType.NextTile:
case PathStatus.NEXT:
this.warship.move(result.node);
break;
case PathFindResultType.Pending:
case PathStatus.PENDING:
this.warship.touch();
return;
case PathFindResultType.PathNotFound:
case PathStatus.NOT_FOUND:
console.warn(`path not found to target tile`);
this.warship.setTargetTile(undefined);
break;
+2
View File
@@ -1,4 +1,5 @@
import { Config } from "../configuration/Config";
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
import { AllPlayersStats, ClientID } from "../Schemas";
import { getClanTag } from "../Util";
import { GameMap, TileRef } from "./GameMap";
@@ -795,6 +796,7 @@ export interface Game extends GameMap {
addUpdate(update: GameUpdate): void;
railNetwork(): RailNetwork;
conquerPlayer(conqueror: Player, conquered: Player): void;
navMesh(): NavMesh | null;
}
export interface PlayerActions {
+10
View File
@@ -1,5 +1,6 @@
import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import { NavMesh } from "../pathfinding/navmesh/NavMesh";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { simpleHash } from "../Util";
import { AllianceImpl } from "./AllianceImpl";
@@ -86,6 +87,7 @@ export class GameImpl implements Game {
private nextAllianceID: number = 0;
private _isPaused: boolean = false;
private _navMesh: NavMesh | null = null;
constructor(
private _humans: PlayerInfo[],
@@ -104,6 +106,11 @@ export class GameImpl implements Game {
this.populateTeams();
}
this.addPlayers();
if (!_config.disableNavMesh()) {
this._navMesh = new NavMesh(this, { cachePaths: true });
this._navMesh.initialize();
}
}
private populateTeams() {
@@ -957,6 +964,9 @@ export class GameImpl implements Game {
railNetwork(): RailNetwork {
return this._railNetwork;
}
navMesh(): NavMesh | null {
return this._navMesh;
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
const ships = conquered
+43
View File
@@ -0,0 +1,43 @@
import { Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { MiniAStarAdapter } from "./adapters/MiniAStarAdapter";
import { NavMeshAdapter } from "./adapters/NavMeshAdapter";
export enum PathStatus {
NEXT,
PENDING,
COMPLETE,
NOT_FOUND,
}
export type PathResult =
| { status: PathStatus.PENDING }
| { status: PathStatus.NEXT; node: TileRef }
| { status: PathStatus.COMPLETE; node: TileRef }
| { status: PathStatus.NOT_FOUND };
export interface PathFinder {
next(from: TileRef, to: TileRef, dist?: number): PathResult;
findPath(from: TileRef, to: TileRef): TileRef[] | null;
}
export interface MiniAStarOptions {
waterPath?: boolean;
iterations?: number;
maxTries?: number;
}
export class PathFinders {
static Water(game: Game): PathFinder {
if (!game.navMesh()) {
// Fall back to old water pathfinder if navmesh is not available
return PathFinders.WaterLegacy(game);
}
return new NavMeshAdapter(game);
}
static WaterLegacy(game: Game, options?: MiniAStarOptions): PathFinder {
return new MiniAStarAdapter(game, options);
}
}
+16 -21
View File
@@ -114,7 +114,7 @@ export class AirPathFinder {
}
}
export class PathFinder {
export class MiniPathFinder {
private curr: TileRef | null = null;
private dst: TileRef | null = null;
private path: TileRef[] | null = null;
@@ -122,28 +122,23 @@ export class PathFinder {
private aStar: AStar<TileRef>;
private computeFinished = true;
private constructor(
constructor(
private game: Game,
private newAStar: (curr: TileRef, dst: TileRef) => AStar<TileRef>,
private iterations: number,
private waterPath: boolean,
private maxTries: number,
) {}
public static Mini(
game: Game,
iterations: number,
waterPath: boolean = true,
maxTries: number = 20,
) {
return new PathFinder(game, (curr: TileRef, dst: TileRef) => {
return new MiniAStar(
game.map(),
game.miniMap(),
curr,
dst,
iterations,
maxTries,
waterPath,
);
});
private createAStar(curr: TileRef, dst: TileRef): AStar<TileRef> {
return new MiniAStar(
this.game.map(),
this.game.miniMap(),
curr,
dst,
this.iterations,
this.maxTries,
this.waterPath,
);
}
nextTile(
@@ -171,7 +166,7 @@ export class PathFinder {
this.dst = dst;
this.path = null;
this.path_idx = 0;
this.aStar = this.newAStar(curr, dst);
this.aStar = this.createAStar(curr, dst);
this.computeFinished = false;
return this.nextTile(curr, dst);
} else {
@@ -0,0 +1,66 @@
import { Game } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { PathFindResultType } from "../AStar";
import {
MiniAStarOptions,
PathFinder,
PathResult,
PathStatus,
} from "../PathFinder";
import { MiniPathFinder } from "../PathFinding";
const DEFAULT_ITERATIONS = 10_000;
const DEFAULT_MAX_TRIES = 100;
export class MiniAStarAdapter implements PathFinder {
private miniPathFinder: MiniPathFinder;
constructor(game: Game, options?: MiniAStarOptions) {
this.miniPathFinder = new MiniPathFinder(
game,
options?.iterations ?? DEFAULT_ITERATIONS,
options?.waterPath ?? true,
options?.maxTries ?? DEFAULT_MAX_TRIES,
);
}
next(from: TileRef, to: TileRef, dist?: number): PathResult {
const result = this.miniPathFinder.nextTile(from, to, dist);
switch (result.type) {
case PathFindResultType.Pending:
return { status: PathStatus.PENDING };
case PathFindResultType.NextTile:
return { status: PathStatus.NEXT, node: result.node };
case PathFindResultType.Completed:
return { status: PathStatus.COMPLETE, node: result.node };
case PathFindResultType.PathNotFound:
return { status: PathStatus.NOT_FOUND };
}
}
findPath(from: TileRef, to: TileRef): TileRef[] | null {
const path: TileRef[] = [from];
let current = from;
const maxSteps = 100_000;
for (let i = 0; i < maxSteps; i++) {
const result = this.next(current, to);
if (result.status === PathStatus.COMPLETE) {
return path;
}
if (result.status === PathStatus.NOT_FOUND) {
return null;
}
if (result.status === PathStatus.NEXT) {
current = result.node;
path.push(current);
}
}
return null;
}
}
@@ -0,0 +1,99 @@
import { Game } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { NavMesh } from "../navmesh/NavMesh";
import { PathFinder, PathResult, PathStatus } from "../PathFinder";
export class NavMeshAdapter implements PathFinder {
private navMesh: NavMesh;
private pathIndex = 0;
private path: TileRef[] | null = null;
private lastTo: TileRef | null = null;
constructor(private game: Game) {
const navMesh = game.navMesh();
if (!navMesh) {
throw new Error("NavMeshAdapter requires game.navMesh() to be available");
}
this.navMesh = navMesh;
}
next(from: TileRef, to: TileRef, dist?: number): PathResult {
if (typeof from !== "number" || typeof to !== "number") {
return { status: PathStatus.NOT_FOUND };
}
if (!this.game.isValidRef(from) || !this.game.isValidRef(to)) {
return { status: PathStatus.NOT_FOUND };
}
if (from === to) {
return { status: PathStatus.COMPLETE, node: to };
}
if (dist !== undefined && dist > 0) {
const distance = this.game.manhattanDist(from, to);
if (distance <= dist) {
return { status: PathStatus.COMPLETE, node: from };
}
}
if (this.lastTo !== to) {
this.path = null;
this.pathIndex = 0;
this.lastTo = to;
}
if (this.path === null) {
this.cachePath(from, to);
if (this.path === null) {
return { status: PathStatus.NOT_FOUND };
}
}
// Recompute if deviated from planned path
const expectedPos = this.path[this.pathIndex - 1];
if (this.pathIndex > 0 && from !== expectedPos) {
this.cachePath(from, to);
if (this.path === null) {
return { status: PathStatus.NOT_FOUND };
}
}
if (this.pathIndex >= this.path.length) {
return { status: PathStatus.COMPLETE, node: to };
}
const nextNode = this.path[this.pathIndex];
this.pathIndex++;
return { status: PathStatus.NEXT, node: nextNode };
}
findPath(from: TileRef, to: TileRef): TileRef[] | null {
return this.navMesh.findPath(from, to);
}
private cachePath(from: TileRef, to: TileRef): boolean {
try {
this.path = this.navMesh.findPath(from, to);
} catch {
return false;
}
if (this.path === null) {
return false;
}
this.pathIndex = 0;
// Path starts with 'from', skip to next tile
if (this.path.length > 0 && this.path[0] === from) {
this.pathIndex = 1;
}
return true;
}
}
+202
View File
@@ -0,0 +1,202 @@
// A* optimized for performance for small to medium graphs.
// Works with node IDs represented as integers (0 to numNodes-1)
export interface FastAStarAdapter {
getNeighbors(node: number): number[];
getCost(from: number, to: number): number;
heuristic(node: number, goal: number): number;
}
// Simple binary min-heap for open set using typed arrays
class MinHeap {
private heap: Int32Array;
private scores: Float32Array;
private size = 0;
constructor(capacity: number, scores: Float32Array) {
this.heap = new Int32Array(capacity);
this.scores = scores;
}
push(node: number): void {
let i = this.size++;
this.heap[i] = node;
// Bubble up
while (i > 0) {
const parent = (i - 1) >> 1;
if (this.scores[this.heap[parent]] <= this.scores[this.heap[i]]) {
break;
}
// Swap
const tmp = this.heap[parent];
this.heap[parent] = this.heap[i];
this.heap[i] = tmp;
i = parent;
}
}
pop(): number {
const result = this.heap[0];
this.heap[0] = this.heap[--this.size];
// Bubble down
let i = 0;
while (true) {
const left = (i << 1) + 1;
const right = left + 1;
let smallest = i;
if (
left < this.size &&
this.scores[this.heap[left]] < this.scores[this.heap[smallest]]
) {
smallest = left;
}
if (
right < this.size &&
this.scores[this.heap[right]] < this.scores[this.heap[smallest]]
) {
smallest = right;
}
if (smallest === i) {
break;
}
// Swap
const tmp = this.heap[smallest];
this.heap[smallest] = this.heap[i];
this.heap[i] = tmp;
i = smallest;
}
return result;
}
isEmpty(): boolean {
return this.size === 0;
}
clear(): void {
this.size = 0;
}
}
export class FastAStar {
private stamp = 1;
private readonly closedStamp: Uint32Array; // Tracks fully processed nodes
private readonly gScoreStamp: Uint32Array; // Tracks valid gScores
private readonly gScore: Float32Array;
private readonly fScore: Float32Array;
private readonly cameFrom: Int32Array;
private readonly openHeap: MinHeap;
constructor(numNodes: number) {
this.closedStamp = new Uint32Array(numNodes);
this.gScoreStamp = new Uint32Array(numNodes);
this.gScore = new Float32Array(numNodes);
this.fScore = new Float32Array(numNodes);
this.cameFrom = new Int32Array(numNodes);
this.openHeap = new MinHeap(numNodes, this.fScore);
}
private nextStamp(): number {
const stamp = this.stamp++;
if (this.stamp === 0) {
// Overflow - reset (extremely rare)
this.closedStamp.fill(0);
this.gScoreStamp.fill(0);
this.stamp = 1;
}
return stamp;
}
search(
start: number,
goal: number,
adapter: FastAStarAdapter,
maxIterations: number = 100000,
): number[] | null {
const stamp = this.nextStamp();
this.openHeap.clear();
this.gScore[start] = 0;
this.gScoreStamp[start] = stamp;
this.fScore[start] = adapter.heuristic(start, goal);
this.cameFrom[start] = -1;
this.openHeap.push(start);
let iterations = 0;
while (!this.openHeap.isEmpty() && iterations < maxIterations) {
iterations++;
const current = this.openHeap.pop();
// Skip if already processed (duplicate from heap)
if (this.closedStamp[current] === stamp) {
continue;
}
// Mark as processed
this.closedStamp[current] = stamp;
// Found goal
if (current === goal) {
return this.reconstructPath(start, goal);
}
const neighbors = adapter.getNeighbors(current);
const currentGScore = this.gScore[current];
for (const neighbor of neighbors) {
// Skip already processed neighbors
if (this.closedStamp[neighbor] === stamp) {
continue;
}
const tentativeGScore =
currentGScore + adapter.getCost(current, neighbor);
// If we haven't visited this neighbor yet, or found a better path
const hasValidGScore = this.gScoreStamp[neighbor] === stamp;
if (!hasValidGScore || tentativeGScore < this.gScore[neighbor]) {
this.cameFrom[neighbor] = current;
this.gScore[neighbor] = tentativeGScore;
this.gScoreStamp[neighbor] = stamp;
this.fScore[neighbor] =
tentativeGScore + adapter.heuristic(neighbor, goal);
// Add to heap (allow duplicates for better paths)
this.openHeap.push(neighbor);
}
}
}
return null;
}
private reconstructPath(start: number, goal: number): number[] {
const path: number[] = [];
let current = goal;
while (current !== start) {
path.push(current);
current = this.cameFrom[current];
// Safety check
if (current === -1) {
return [];
}
}
path.push(start);
path.reverse();
return path;
}
}
@@ -0,0 +1,120 @@
import { GameMap, TileRef } from "../../game/GameMap";
import { FastAStarAdapter } from "./FastAStar";
import { GatewayGraph } from "./GatewayGraph";
export class GatewayGraphAdapter implements FastAStarAdapter {
constructor(private graph: GatewayGraph) {}
getNeighbors(node: number): number[] {
const edges = this.graph.getEdges(node);
return edges.map((edge) => edge.to);
}
getCost(from: number, to: number): number {
const edges = this.graph.getEdges(from);
const edge = edges.find((edge) => edge.to === to);
return edge?.cost ?? 1;
}
heuristic(node: number, goal: number): number {
const nodeGw = this.graph.getGateway(node);
const goalGw = this.graph.getGateway(goal);
if (!nodeGw || !goalGw) {
throw new Error(
`Invalid gateway ID in heuristic: node=${node} (${nodeGw ? "exists" : "missing"}), goal=${goal} (${goalGw ? "exists" : "missing"})`,
);
}
// Manhattan distance heuristic
const dx = Math.abs(nodeGw.x - goalGw.x);
const dy = Math.abs(nodeGw.y - goalGw.y);
return dx + dy;
}
}
export class BoundedGameMapAdapter implements FastAStarAdapter {
private readonly minX: number;
private readonly minY: number;
private readonly width: number;
private readonly height: number;
private readonly startTile: TileRef;
private readonly goalTile: TileRef;
readonly numNodes: number;
constructor(
private map: GameMap,
startTile: TileRef,
goalTile: TileRef,
bounds: { minX: number; maxX: number; minY: number; maxY: number },
) {
this.startTile = startTile;
this.goalTile = goalTile;
this.minX = bounds.minX;
this.minY = bounds.minY;
this.width = bounds.maxX - bounds.minX + 1;
this.height = bounds.maxY - bounds.minY + 1;
this.numNodes = this.width * this.height;
}
// Convert global TileRef to local node ID
tileToNode(tile: TileRef): number {
const x = this.map.x(tile) - this.minX;
const y = this.map.y(tile) - this.minY;
// Allow start and goal tiles to be outside bounds (matching graph building behavior)
const isOutsideBounds =
x < 0 || x >= this.width || y < 0 || y >= this.height;
const isStartOrGoal = tile === this.startTile || tile === this.goalTile;
if (isOutsideBounds && !isStartOrGoal) {
return -1; // Outside bounds
}
// Clamp coordinates for start/goal tiles that are outside bounds
const clampedX = Math.max(0, Math.min(this.width - 1, x));
const clampedY = Math.max(0, Math.min(this.height - 1, y));
return clampedY * this.width + clampedX;
}
// Convert local node ID to global TileRef
nodeToTile(node: number): TileRef {
const localX = node % this.width;
const localY = Math.floor(node / this.width);
return this.map.ref(localX + this.minX, localY + this.minY);
}
getNeighbors(node: number): number[] {
const tile = this.nodeToTile(node);
const neighbors = this.map.neighbors(tile);
const result: number[] = [];
for (const neighborTile of neighbors) {
if (!this.map.isWater(neighborTile)) continue;
const neighborNode = this.tileToNode(neighborTile);
if (neighborNode !== -1) {
result.push(neighborNode);
}
}
return result;
}
getCost(_from: number, _to: number): number {
return 1; // Uniform cost for water tiles
}
heuristic(node: number, goal: number): number {
const nodeTile = this.nodeToTile(node);
const goalTile = this.nodeToTile(goal);
const dx = Math.abs(this.map.x(nodeTile) - this.map.x(goalTile));
const dy = Math.abs(this.map.y(nodeTile) - this.map.y(goalTile));
return dx + dy; // Manhattan distance
}
}
+118
View File
@@ -0,0 +1,118 @@
export interface FastBFSAdapter<T> {
visitor(node: number, dist: number): T | null | undefined;
isValidNode(node: number): boolean;
}
// Optimized BFS using stamp-based visited tracking and typed array queue
export class FastBFS {
private stamp = 1;
private readonly visitedStamp: Uint32Array;
private readonly queue: Int32Array;
private readonly dist: Uint16Array;
constructor(numNodes: number) {
this.visitedStamp = new Uint32Array(numNodes);
this.queue = new Int32Array(numNodes);
this.dist = new Uint16Array(numNodes);
}
search<T>(
width: number,
height: number,
start: number,
maxDistance: number,
isValidNode: FastBFSAdapter<T>["isValidNode"],
visitor: FastBFSAdapter<T>["visitor"],
): T | null {
const stamp = this.nextStamp();
const lastRowStart = (height - 1) * width;
let head = 0;
let tail = 0;
this.visitedStamp[start] = stamp;
this.dist[start] = 0;
this.queue[tail++] = start;
while (head < tail) {
const node = this.queue[head++];
const currentDist = this.dist[node];
if (currentDist > maxDistance) {
continue;
}
// Call visitor:
// - Returns T: Found target, return immediately
// - Returns null: Reject tile, don't explore neighbors
// - Returns undefined: Valid tile, explore neighbors
const result = visitor(node, currentDist);
if (result !== null && result !== undefined) {
return result;
}
// If visitor returned null, reject this tile and don't explore neighbors
if (result === null) {
continue;
}
const nextDist = currentDist + 1;
const x = node % width;
// North
if (node >= width) {
const n = node - width;
if (this.visitedStamp[n] !== stamp && isValidNode(n)) {
this.visitedStamp[n] = stamp;
this.dist[n] = nextDist;
this.queue[tail++] = n;
}
}
// South
if (node < lastRowStart) {
const s = node + width;
if (this.visitedStamp[s] !== stamp && isValidNode(s)) {
this.visitedStamp[s] = stamp;
this.dist[s] = nextDist;
this.queue[tail++] = s;
}
}
// West
if (x !== 0) {
const wv = node - 1;
if (this.visitedStamp[wv] !== stamp && isValidNode(wv)) {
this.visitedStamp[wv] = stamp;
this.dist[wv] = nextDist;
this.queue[tail++] = wv;
}
}
// East
if (x !== width - 1) {
const ev = node + 1;
if (this.visitedStamp[ev] !== stamp && isValidNode(ev)) {
this.visitedStamp[ev] = stamp;
this.dist[ev] = nextDist;
this.queue[tail++] = ev;
}
}
}
return null;
}
private nextStamp(): number {
const stamp = this.stamp++;
if (this.stamp === 0) {
// Overflow - reset (extremely rare)
this.visitedStamp.fill(0);
this.stamp = 1;
}
return stamp;
}
}
@@ -0,0 +1,587 @@
import { Game } from "../../game/Game";
import { GameMap, TileRef } from "../../game/GameMap";
import { FastBFS } from "./FastBFS";
import { WaterComponents } from "./WaterComponents";
export interface Gateway {
id: number;
x: number;
y: number;
tile: TileRef;
componentId: number;
}
export interface Edge {
from: number;
to: number;
cost: number;
path?: TileRef[];
sectorX: number;
sectorY: number;
}
export interface Sector {
x: number;
y: number;
gateways: Gateway[];
edges: Edge[];
}
export type BuildDebugInfo = {
sectors: number | null;
gateways: number | null;
edges: number | null;
actualBFSCalls: number | null;
potentialBFSCalls: number | null;
skippedByComponentFilter: number | null;
timings: { [key: string]: number };
};
export class GatewayGraph {
constructor(
readonly sectors: ReadonlyMap<number, Sector>,
readonly gateways: ReadonlyMap<number, Gateway>,
readonly edges: ReadonlyMap<number, Edge[]>,
readonly sectorSize: number,
readonly sectorsX: number,
) {}
getSectorKey(sectorX: number, sectorY: number): number {
return sectorY * this.sectorsX + sectorX;
}
getSector(sectorX: number, sectorY: number): Sector | undefined {
return this.sectors.get(this.getSectorKey(sectorX, sectorY));
}
getGateway(id: number): Gateway | undefined {
return this.gateways.get(id);
}
getEdges(gatewayId: number): Edge[] {
return this.edges.get(gatewayId) ?? [];
}
getNearbySectorGateways(sectorX: number, sectorY: number): Gateway[] {
const nearby: Gateway[] = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const sector = this.getSector(sectorX + dx, sectorY + dy);
if (sector) {
nearby.push(...sector.gateways);
}
}
}
return nearby;
}
getAllGateways(): Gateway[] {
return Array.from(this.gateways.values());
}
}
export class GatewayGraphBuilder {
static readonly SECTOR_SIZE = 32;
// Derived immutable state
private readonly miniMap: GameMap;
private readonly width: number;
private readonly height: number;
private readonly sectorsX: number;
private readonly sectorsY: number;
private readonly fastBFS: FastBFS;
private readonly waterComponents: WaterComponents;
// Mutable build state
private sectors = new Map<number, Sector>();
private gateways = new Map<number, Gateway>();
private tileToGateway = new Map<TileRef, Gateway>();
private edges = new Map<number, Edge[]>();
private nextGatewayId = 0;
// Programatically accessible debug info
public debugInfo: BuildDebugInfo | null = null;
constructor(
private readonly game: Game,
private readonly sectorSize: number,
) {
this.miniMap = game.miniMap();
this.width = this.miniMap.width();
this.height = this.miniMap.height();
this.sectorsX = Math.ceil(this.width / sectorSize);
this.sectorsY = Math.ceil(this.height / sectorSize);
this.fastBFS = new FastBFS(this.width * this.height);
this.waterComponents = new WaterComponents(this.miniMap);
}
build(debug: boolean): GatewayGraph {
performance.mark("navsat:build:start");
if (debug) {
console.log(
`[DEBUG] Building gateway graph with sector size ${this.sectorSize} (${this.sectorsX}x${this.sectorsY} sectors)`,
);
this.debugInfo = {
sectors: null,
gateways: null,
edges: null,
actualBFSCalls: null,
potentialBFSCalls: null,
skippedByComponentFilter: null,
timings: {},
};
}
// Initialize water components before building gateway graph
performance.mark("navsat:build:water-component:start");
this.waterComponents.initialize();
performance.mark("navsat:build:water-component:end");
const measure = performance.measure(
"navsat:build:water-component",
"navsat:build:water-component:start",
"navsat:build:water-component:end",
);
if (debug) {
console.log(
`[DEBUG] Water Component Identification: ${measure.duration.toFixed(2)}ms`,
);
}
performance.mark("navsat:build:gateways:start");
for (let sy = 0; sy < this.sectorsY; sy++) {
for (let sx = 0; sx < this.sectorsX; sx++) {
this.processSector(sx, sy);
}
}
performance.mark("navsat:build:gateways:end");
const gatewaysMeasure = performance.measure(
"navsat:build:gateways",
"navsat:build:gateways:start",
"navsat:build:gateways:end",
);
if (debug) {
console.log(
`[DEBUG] Gateway identification: ${gatewaysMeasure.duration.toFixed(2)}ms`,
);
this.debugInfo!.edges = 0;
this.debugInfo!.potentialBFSCalls = 0;
this.debugInfo!.skippedByComponentFilter = 0;
}
performance.mark("navsat:build:edges:start");
for (const sector of this.sectors.values()) {
const gws = sector.gateways;
const numGateways = gws.length;
if (debug) {
this.debugInfo!.potentialBFSCalls! +=
(numGateways * (numGateways - 1)) / 2;
for (let i = 0; i < gws.length; i++) {
for (let j = i + 1; j < gws.length; j++) {
if (gws[i].componentId !== gws[j].componentId) {
this.debugInfo!.skippedByComponentFilter!++;
}
}
}
}
this.buildSectorConnections(sector);
if (debug) {
// Divide by 2 because bidirectional
this.debugInfo!.edges! += sector.edges.length / 2;
}
}
if (debug) {
this.debugInfo!.actualBFSCalls =
this.debugInfo!.potentialBFSCalls! -
this.debugInfo!.skippedByComponentFilter!;
}
performance.mark("navsat:build:edges:end");
const edgesMeasure = performance.measure(
"navsat:build:edges",
"navsat:build:edges:start",
"navsat:build:edges:end",
);
if (debug) {
console.log(
`[DEBUG] Edges Identification: ${edgesMeasure.duration.toFixed(2)}ms`,
);
console.log(
`[DEBUG] Potential BFS calls: ${this.debugInfo!.potentialBFSCalls}`,
);
console.log(
`[DEBUG] Skipped by component filter: ${this.debugInfo!.skippedByComponentFilter} (${((this.debugInfo!.skippedByComponentFilter! / this.debugInfo!.potentialBFSCalls!) * 100).toFixed(1)}%)`,
);
console.log(
`[DEBUG] Actual BFS calls: ${this.debugInfo!.actualBFSCalls}`,
);
console.log(
`[DEBUG] Edges Found: ${this.debugInfo!.edges} (${((this.debugInfo!.edges! / this.debugInfo!.actualBFSCalls!) * 100).toFixed(1)}% success rate)`,
);
}
performance.mark("navsat:build:end");
const totalMeasure = performance.measure(
"navsat:build:total",
"navsat:build:start",
"navsat:build:end",
);
if (debug) {
console.log(
`[DEBUG] Gateway graph built in ${totalMeasure.duration.toFixed(2)}ms`,
);
console.log(`[DEBUG] Gateways: ${this.gateways.size}`);
console.log(`[DEBUG] Sectors: ${this.sectors.size}`);
}
return new GatewayGraph(
this.sectors,
this.gateways,
this.edges,
this.sectorSize,
this.sectorsX,
);
}
private getSectorKey(sectorX: number, sectorY: number): number {
return sectorY * this.sectorsX + sectorX;
}
private getOrCreateGateway(x: number, y: number): Gateway {
const tile = this.miniMap.ref(x, y);
// O(1) lookup using tile reference
const existing = this.tileToGateway.get(tile);
if (existing) {
return existing;
}
const gateway: Gateway = {
id: this.nextGatewayId++,
x: x,
y: y,
tile: tile,
componentId: this.waterComponents.getComponentId(tile),
};
this.gateways.set(gateway.id, gateway);
this.tileToGateway.set(tile, gateway);
return gateway;
}
private addGatewayToSector(sector: Sector, gateway: Gateway): void {
// Check for duplicates: a gateway at a sector corner can be
// detected by both horizontal and vertical edge scans
for (const existingGw of sector.gateways) {
if (existingGw.x === gateway.x && existingGw.y === gateway.y) {
return;
}
}
// Gateway doesn't exist in sector yet, add it
sector.gateways.push(gateway);
}
private processSector(sx: number, sy: number): void {
const sectorKey = this.getSectorKey(sx, sy);
let sector = this.sectors.get(sectorKey);
if (!sector) {
sector = { x: sx, y: sy, gateways: [], edges: [] };
this.sectors.set(sectorKey, sector);
}
const baseX = sx * this.sectorSize;
const baseY = sy * this.sectorSize;
if (sx < this.sectorsX - 1) {
const edgeX = Math.min(baseX + this.sectorSize - 1, this.width - 1);
const newGateways = this.findGatewaysOnVerticalEdge(edgeX, baseY);
for (const gateway of newGateways) {
this.addGatewayToSector(sector, gateway);
const rightSectorKey = this.getSectorKey(sx + 1, sy);
let rightSector = this.sectors.get(rightSectorKey);
if (!rightSector) {
rightSector = { x: sx + 1, y: sy, gateways: [], edges: [] };
this.sectors.set(rightSectorKey, rightSector);
}
this.addGatewayToSector(rightSector, gateway);
}
}
if (sy < this.sectorsY - 1) {
const edgeY = Math.min(baseY + this.sectorSize - 1, this.height - 1);
const newGateways = this.findGatewaysOnHorizontalEdge(edgeY, baseX);
for (const gateway of newGateways) {
this.addGatewayToSector(sector, gateway);
const bottomSectorKey = this.getSectorKey(sx, sy + 1);
let bottomSector = this.sectors.get(bottomSectorKey);
if (!bottomSector) {
bottomSector = { x: sx, y: sy + 1, gateways: [], edges: [] };
this.sectors.set(bottomSectorKey, bottomSector);
}
this.addGatewayToSector(bottomSector, gateway);
}
}
}
private findGatewaysOnVerticalEdge(x: number, baseY: number): Gateway[] {
const gateways: Gateway[] = [];
const maxY = Math.min(baseY + this.sectorSize, this.height);
let gatewayStart = -1;
const tryAddGateway = (y: number) => {
if (gatewayStart === -1) return;
const gatewayLength = y - gatewayStart;
const midY = gatewayStart + Math.floor(gatewayLength / 2);
gatewayStart = -1;
const gateway = this.getOrCreateGateway(x, midY);
gateways.push(gateway);
};
for (let y = baseY; y < maxY; y++) {
const tile = this.miniMap.ref(x, y);
const nextTile =
x + 1 < this.miniMap.width() ? this.miniMap.ref(x + 1, y) : -1;
const isGateway =
this.miniMap.isWater(tile) &&
nextTile !== -1 &&
this.miniMap.isWater(nextTile);
if (isGateway) {
if (gatewayStart === -1) {
gatewayStart = y;
}
} else {
tryAddGateway(y);
}
}
tryAddGateway(maxY);
return gateways;
}
private findGatewaysOnHorizontalEdge(y: number, baseX: number): Gateway[] {
const gateways: Gateway[] = [];
const maxX = Math.min(baseX + this.sectorSize, this.width);
let gatewayStart = -1;
const tryAddGateway = (x: number) => {
if (gatewayStart === -1) return;
const gatewayLength = x - gatewayStart;
const midX = gatewayStart + Math.floor(gatewayLength / 2);
gatewayStart = -1;
const gateway = this.getOrCreateGateway(midX, y);
gateways.push(gateway);
};
for (let x = baseX; x < maxX; x++) {
const tile = this.miniMap.ref(x, y);
const nextTile =
y + 1 < this.miniMap.height() ? this.miniMap.ref(x, y + 1) : -1;
const isGateway =
this.miniMap.isWater(tile) &&
nextTile !== -1 &&
this.miniMap.isWater(nextTile);
if (isGateway) {
if (gatewayStart === -1) {
gatewayStart = x;
}
} else {
tryAddGateway(x);
}
}
tryAddGateway(maxX);
return gateways;
}
private buildSectorConnections(sector: Sector): void {
const gateways = sector.gateways;
// Calculate bounding box once for this sector
const sectorMinX = sector.x * this.sectorSize;
const sectorMinY = sector.y * this.sectorSize;
const sectorMaxX = Math.min(
this.width - 1,
sectorMinX + this.sectorSize - 1,
);
const sectorMaxY = Math.min(
this.height - 1,
sectorMinY + this.sectorSize - 1,
);
for (let i = 0; i < gateways.length; i++) {
const fromGateway = gateways[i];
// Build list of target gateways (only those we haven't processed yet)
const targetGateways: Gateway[] = [];
for (let j = i + 1; j < gateways.length; j++) {
// Skip if gateways are in different water components
if (gateways[i].componentId !== gateways[j].componentId) {
continue;
}
targetGateways.push(gateways[j]);
}
if (targetGateways.length === 0) {
continue;
}
// Single BFS to find all reachable target gateways
const reachableGateways = this.findAllReachableGatewaysInBounds(
fromGateway.tile,
targetGateways,
sectorMinX,
sectorMaxX,
sectorMinY,
sectorMaxY,
);
// Create edges for all reachable gateways
for (const [targetId, cost] of reachableGateways.entries()) {
if (!this.edges.has(fromGateway.id)) {
this.edges.set(fromGateway.id, []);
}
if (!this.edges.has(targetId)) {
this.edges.set(targetId, []);
}
// Check for existing edges - gateways may live in 2 sectors, keep only cheaper connection
const existingEdgeFromI = this.edges
.get(fromGateway.id)!
.find((e) => e.to === targetId);
const existingEdgeFromJ = this.edges
.get(targetId)!
.find((e) => e.to === fromGateway.id);
// If edge doesn't exist or new cost is cheaper, update it
if (!existingEdgeFromI || cost < existingEdgeFromI.cost) {
const edge1: Edge = {
from: fromGateway.id,
to: targetId,
cost: cost,
sectorX: sector.x,
sectorY: sector.y,
};
const edge2: Edge = {
from: targetId,
to: fromGateway.id,
cost: cost,
sectorX: sector.x,
sectorY: sector.y,
};
// Add to sector edges for tracking
sector.edges.push(edge1, edge2);
if (existingEdgeFromI) {
const idx1 = this.edges
.get(fromGateway.id)!
.indexOf(existingEdgeFromI);
this.edges.get(fromGateway.id)![idx1] = edge1;
const idx2 = this.edges.get(targetId)!.indexOf(existingEdgeFromJ!);
this.edges.get(targetId)![idx2] = edge2;
} else {
this.edges.get(fromGateway.id)!.push(edge1);
this.edges.get(targetId)!.push(edge2);
}
}
}
}
}
private findAllReachableGatewaysInBounds(
from: TileRef,
targetGateways: Gateway[],
minX: number,
maxX: number,
minY: number,
maxY: number,
): Map<number, number> {
const fromX = this.miniMap.x(from);
const fromY = this.miniMap.y(from);
// Create a map of tile positions to gateway IDs for fast lookup
const tileToGateway = new Map<TileRef, number>();
let maxManhattanDist = 0;
for (const gateway of targetGateways) {
tileToGateway.set(gateway.tile, gateway.id);
const dx = Math.abs(gateway.x - fromX);
const dy = Math.abs(gateway.y - fromY);
maxManhattanDist = Math.max(maxManhattanDist, dx + dy);
}
const maxDistance = maxManhattanDist * 4; // Allow path deviation
const reachable = new Map<number, number>();
let foundCount = 0;
this.fastBFS.search(
this.miniMap.width(),
this.miniMap.height(),
from,
maxDistance,
(tile: number) => this.miniMap.isWater(tile),
(tile: number, dist: number) => {
const x = this.miniMap.x(tile);
const y = this.miniMap.y(tile);
// Reject if outside of bounding box
const isStartOrEnd = tile === from || tileToGateway.has(tile);
if (!isStartOrEnd && (x < minX || x > maxX || y < minY || y > maxY)) {
return null;
}
// Check if this tile is one of our target gateways
const gatewayId = tileToGateway.get(tile);
if (gatewayId !== undefined) {
reachable.set(gatewayId, dist);
foundCount++;
// Early exit if we've found all target gateways
if (foundCount === targetGateways.length) {
return dist; // Return to stop BFS
}
}
},
);
return reachable;
}
}
+819
View File
@@ -0,0 +1,819 @@
import { Game } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { FastAStar } from "./FastAStar";
import { BoundedGameMapAdapter, GatewayGraphAdapter } from "./FastAStarAdapter";
import { FastBFS } from "./FastBFS";
import { Gateway, GatewayGraph, GatewayGraphBuilder } from "./GatewayGraph";
type PathDebugInfo = {
gatewayPath: TileRef[] | null;
initialPath: TileRef[] | null;
smoothPath: TileRef[] | null;
graph: {
sectorSize: number;
gateways: Array<{ id: number; tile: TileRef }>;
edges: Array<{
fromId: number;
toId: number;
from: TileRef;
to: TileRef;
cost: number;
path: TileRef[] | null;
}>;
};
timings: { [key: string]: number };
};
export class NavMesh {
private graph!: GatewayGraph;
private initialized = false;
private fastBFS!: FastBFS;
private gatewayAStar!: FastAStar;
private localAStar!: FastAStar;
private localAStarMultiSector!: FastAStar;
public debugInfo: PathDebugInfo | null = null;
constructor(
private game: Game,
private options: {
cachePaths?: boolean;
} = {},
) {}
initialize(debug: boolean = false) {
const gatewayGraphBuilder = new GatewayGraphBuilder(
this.game,
GatewayGraphBuilder.SECTOR_SIZE,
);
this.graph = gatewayGraphBuilder.build(debug);
const miniMap = this.game.miniMap();
this.fastBFS = new FastBFS(miniMap.width() * miniMap.height());
const gatewayCount = this.graph.getAllGateways().length;
this.gatewayAStar = new FastAStar(gatewayCount);
// Fixed-size FastAStar for sector-bounded local pathfinding
// Single sector: 32×32 = 1,024 nodes
const sectorSize = GatewayGraphBuilder.SECTOR_SIZE;
const maxLocalNodes = sectorSize * sectorSize; // 1,024 nodes
this.localAStar = new FastAStar(maxLocalNodes);
// Multi-sector FastAStar for cross-sector pathfinding (same gateway, different sectors)
// 3×3 sectors: 96×96 = 9,216 nodes
const multiSectorSize = sectorSize * 3;
const maxMultiSectorNodes = multiSectorSize * multiSectorSize;
this.localAStarMultiSector = new FastAStar(maxMultiSectorNodes);
this.initialized = true;
}
findPath(
from: TileRef,
to: TileRef,
debug: boolean = false,
): TileRef[] | null {
if (!this.initialized) {
throw new Error(
"NavMesh not initialized. Call initialize() before using findPath().",
);
}
if (debug) {
// Collect all edges with their paths for visualization
const allEdges: Array<{
fromId: number;
toId: number;
from: TileRef;
to: TileRef;
cost: number;
path: TileRef[] | null;
}> = [];
for (const [fromId, edges] of this.graph.edges.entries()) {
const fromGw = this.graph.getGateway(fromId);
if (!fromGw) continue;
for (const edge of edges) {
const toGw = this.graph.getGateway(edge.to);
if (!toGw) continue;
// Only add each edge once (not both directions)
// Include self-loops (fromId === edge.to) for debugging
if (fromId <= edge.to) {
allEdges.push({
fromId: fromId,
toId: edge.to,
from: fromGw.tile,
to: toGw.tile,
cost: edge.cost,
path: edge.path ?? null,
});
}
}
}
this.debugInfo = {
gatewayPath: null,
initialPath: null,
smoothPath: null,
graph: {
sectorSize: this.graph.sectorSize,
gateways: this.graph
.getAllGateways()
.map((gw) => ({ id: gw.id, tile: gw.tile })),
edges: allEdges,
},
timings: {
total: 0,
},
};
}
const dist = this.game.manhattanDist(from, to);
// Early exit for very short distances that fit within multi-sector range
if (dist <= this.graph.sectorSize) {
performance.mark("navsat:findPath:earlyExitLocalPath:start");
const map = this.game.map();
const startMiniX = Math.floor(map.x(from) / 2);
const startMiniY = Math.floor(map.y(from) / 2);
const sectorX = Math.floor(startMiniX / this.graph.sectorSize);
const sectorY = Math.floor(startMiniY / this.graph.sectorSize);
const localPath = this.findLocalPath(
from,
to,
sectorX,
sectorY,
2000,
true,
);
performance.mark("navsat:findPath:earlyExitLocalPath:end");
const measure = performance.measure(
"navsat:findPath:earlyExitLocalPath",
"navsat:findPath:earlyExitLocalPath:start",
"navsat:findPath:earlyExitLocalPath:end",
);
if (debug) {
this.debugInfo!.timings.earlyExitLocalPath = measure.duration;
this.debugInfo!.timings.total += measure.duration;
}
if (localPath) {
if (debug) {
console.log(
`[DEBUG] Direct local path found for dist=${dist}, length=${localPath.length}`,
);
}
return localPath;
}
if (debug) {
console.log(
`[DEBUG] Direct path failed for dist=${dist}, falling back to gateway graph`,
);
}
}
performance.mark("navsat:findPath:findGateways:start");
const startGateway = this.findNearestGateway(from);
const endGateway = this.findNearestGateway(to);
performance.mark("navsat:findPath:findGateways:end");
const findGatewaysMeasure = performance.measure(
"navsat:findPath:findGateways",
"navsat:findPath:findGateways:start",
"navsat:findPath:findGateways:end",
);
if (debug) {
this.debugInfo!.timings.findGateways = findGatewaysMeasure.duration;
this.debugInfo!.timings.total += findGatewaysMeasure.duration;
}
if (!startGateway) {
if (debug) {
console.log(
`[DEBUG] Cannot find start gateway for (${this.game.x(from)}, ${this.game.y(from)})`,
);
}
return null;
}
if (!endGateway) {
if (debug) {
console.log(
`[DEBUG] Cannot find end gateway for (${this.game.x(to)}, ${this.game.y(to)})`,
);
}
return null;
}
if (startGateway.id === endGateway.id) {
if (debug) {
console.log(
`[DEBUG] Start and end gateways are the same (ID=${startGateway.id}), finding local path with multi-sector search`,
);
}
performance.mark("navsat:findPath:sameGatewayLocalPath:start");
const sectorX = Math.floor(startGateway.x / this.graph.sectorSize);
const sectorY = Math.floor(startGateway.y / this.graph.sectorSize);
const path = this.findLocalPath(from, to, sectorX, sectorY, 10000, true);
performance.mark("navsat:findPath:sameGatewayLocalPath:end");
const sameGatewayMeasure = performance.measure(
"navsat:findPath:sameGatewayLocalPath",
"navsat:findPath:sameGatewayLocalPath:start",
"navsat:findPath:sameGatewayLocalPath:end",
);
if (debug) {
this.debugInfo!.timings.sameGatewayLocalPath =
sameGatewayMeasure.duration;
this.debugInfo!.timings.total += sameGatewayMeasure.duration;
}
return path;
}
performance.mark("navsat:findPath:findGatewayPath:start");
const gatewayPath = this.findGatewayPath(startGateway.id, endGateway.id);
performance.mark("navsat:findPath:findGatewayPath:end");
const findGatewayPathMeasure = performance.measure(
"navsat:findPath:findGatewayPath",
"navsat:findPath:findGatewayPath:start",
"navsat:findPath:findGatewayPath:end",
);
if (debug) {
this.debugInfo!.timings.findGatewayPath = findGatewayPathMeasure.duration;
this.debugInfo!.timings.total += findGatewayPathMeasure.duration;
this.debugInfo!.gatewayPath = gatewayPath
? gatewayPath
.map((gwId) => {
const gw = this.graph.getGateway(gwId);
return gw ? gw.tile : -1;
})
.filter((tile) => tile !== -1)
: null;
}
if (!gatewayPath) {
if (debug) {
console.log(
`[DEBUG] No gateway path between gateways ${startGateway.id} and ${endGateway.id}`,
);
}
return null;
}
if (debug) {
console.log(
`[DEBUG] Gateway path found: ${gatewayPath.length} waypoints`,
);
}
const initialPath: TileRef[] = [];
const map = this.game.map();
const miniMap = this.game.miniMap();
performance.mark("navsat:findPath:buildInitialPath:start");
// 1. Find path from start to first gateway
const firstGateway = this.graph.getGateway(gatewayPath[0])!;
const firstGatewayTile = map.ref(
miniMap.x(firstGateway.tile) * 2,
miniMap.y(firstGateway.tile) * 2,
);
// Use start position's sector with multi-sector search (gateway may be on border)
const startMiniX = Math.floor(map.x(from) / 2);
const startMiniY = Math.floor(map.y(from) / 2);
const startSectorX = Math.floor(startMiniX / this.graph.sectorSize);
const startSectorY = Math.floor(startMiniY / this.graph.sectorSize);
const startSegment = this.findLocalPath(
from,
firstGatewayTile,
startSectorX,
startSectorY,
);
if (!startSegment) {
return null;
}
initialPath.push(...startSegment);
// 2. Build path through gateways
for (let i = 0; i < gatewayPath.length - 1; i++) {
const fromGwId = gatewayPath[i];
const toGwId = gatewayPath[i + 1];
const edges = this.graph.getEdges(fromGwId);
const edge = edges.find((edge) => edge.to === toGwId);
if (!edge) {
return null;
}
if (edge.path) {
// Use cached path if available
initialPath.push(...edge.path.slice(1));
continue;
}
const fromGw = this.graph.getGateway(fromGwId)!;
const toGw = this.graph.getGateway(toGwId)!;
const fromTile = map.ref(
miniMap.x(fromGw.tile) * 2,
miniMap.y(fromGw.tile) * 2,
);
const toTile = map.ref(
miniMap.x(toGw.tile) * 2,
miniMap.y(toGw.tile) * 2,
);
const segmentPath = this.findLocalPath(
fromTile,
toTile,
edge.sectorX,
edge.sectorY,
);
if (!segmentPath) {
return null;
}
// Skip first tile to avoid duplication
initialPath.push(...segmentPath.slice(1));
if (this.options.cachePaths) {
// Cache the path for future reuse on both directional edges
edge.path = segmentPath;
// Also cache the reversed path on the opposite direction edge
const reverseEdges = this.graph.getEdges(toGwId);
const reverseEdge = reverseEdges.find((e) => e.to === fromGwId);
if (reverseEdge) {
reverseEdge.path = segmentPath.slice().reverse();
}
}
}
// 3. Find path from last gateway to end
const lastGateway = this.graph.getGateway(
gatewayPath[gatewayPath.length - 1],
)!;
const lastGatewayTile = map.ref(
miniMap.x(lastGateway.tile) * 2,
miniMap.y(lastGateway.tile) * 2,
);
// Use end position's sector with multi-sector search (gateway may be on border)
const endMiniX = Math.floor(map.x(to) / 2);
const endMiniY = Math.floor(map.y(to) / 2);
const endSectorX = Math.floor(endMiniX / this.graph.sectorSize);
const endSectorY = Math.floor(endMiniY / this.graph.sectorSize);
const endSegment = this.findLocalPath(
lastGatewayTile,
to,
endSectorX,
endSectorY,
);
if (!endSegment) {
return null;
}
// Skip first tile to avoid duplication
initialPath.push(...endSegment.slice(1));
performance.mark("navsat:findPath:buildInitialPath:end");
const buildInitialPathMeasure = performance.measure(
"navsat:findPath:buildInitialPath",
"navsat:findPath:buildInitialPath:start",
"navsat:findPath:buildInitialPath:end",
);
if (debug) {
this.debugInfo!.timings.buildInitialPath =
buildInitialPathMeasure.duration;
this.debugInfo!.timings.total += buildInitialPathMeasure.duration;
this.debugInfo!.initialPath = initialPath;
console.log(`[DEBUG] Initial path: ${initialPath.length} tiles`);
}
performance.mark("navsat:findPath:smoothPath:start");
const smoothedPath = this.smoothPath(initialPath);
performance.mark("navsat:findPath:smoothPath:end");
const smoothPathMeasure = performance.measure(
"navsat:findPath:smoothPath",
"navsat:findPath:smoothPath:start",
"navsat:findPath:smoothPath:end",
);
if (debug) {
this.debugInfo!.timings.buildSmoothPath = smoothPathMeasure.duration;
this.debugInfo!.timings.total += smoothPathMeasure.duration;
this.debugInfo!.smoothPath = smoothedPath;
console.log(
`[DEBUG] Smoothed path: ${initialPath.length}${smoothedPath.length} tiles`,
);
}
return smoothedPath;
}
private findNearestGateway(tile: TileRef): Gateway | null {
const map = this.game.map();
const x = map.x(tile);
const y = map.y(tile);
// Convert to miniMap coordinates
const miniMap = this.game.miniMap();
const miniX = Math.floor(x / 2);
const miniY = Math.floor(y / 2);
const miniFrom = miniMap.ref(miniX, miniY);
// Check gateways in the tile's own sector (using miniMap coordinates)
const sectorX = Math.floor(miniX / this.graph.sectorSize);
const sectorY = Math.floor(miniY / this.graph.sectorSize);
// Calculate single sector bounds
const sectorSize = this.graph.sectorSize;
const minX = sectorX * sectorSize;
const minY = sectorY * sectorSize;
const maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
const maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
// Get gateways from the tile's own sector only (includes border gateways)
const sector = this.graph.getSector(sectorX, sectorY);
if (!sector) {
return null;
}
const candidateGateways = sector.gateways;
if (candidateGateways.length === 0) {
return null;
}
// Use BFS to find the nearest reachable gateway (by water path distance)
// Search space is bounded by sector bounds, so maxDistance can be large
const maxDistance = sectorSize * sectorSize;
return this.fastBFS.search(
miniMap.width(),
miniMap.height(),
miniFrom,
maxDistance,
(tile: TileRef) => miniMap.isWater(tile),
(tile: TileRef, _dist: number) => {
const tileX = miniMap.x(tile);
const tileY = miniMap.y(tile);
// Check if any candidate gateway is at this position first
for (const gateway of candidateGateways) {
if (gateway.x === tileX && gateway.y === tileY) {
return gateway;
}
}
// Reject non-gateway tiles outside the sector bounds
if (tileX < minX || tileX > maxX || tileY < minY || tileY > maxY) {
return null;
}
},
);
}
private findGatewayPath(
fromGatewayId: number,
toGatewayId: number,
): number[] | null {
const adapter = new GatewayGraphAdapter(this.graph);
return this.gatewayAStar.search(
fromGatewayId,
toGatewayId,
adapter,
100000,
);
}
private findLocalPath(
from: TileRef,
to: TileRef,
sectorX: number,
sectorY: number,
maxIterations: number = 10000,
multiSector: boolean = false,
): TileRef[] | null {
const map = this.game.map();
const miniMap = this.game.miniMap();
// Convert full map coordinates to miniMap coordinates
const miniFrom = miniMap.ref(
Math.floor(map.x(from) / 2),
Math.floor(map.y(from) / 2),
);
const miniTo = miniMap.ref(
Math.floor(map.x(to) / 2),
Math.floor(map.y(to) / 2),
);
// Calculate sector bounds
const sectorSize = this.graph.sectorSize;
let minX: number;
let minY: number;
let maxX: number;
let maxY: number;
if (multiSector) {
// 3×3 sectors centered on the starting sector
minX = Math.max(0, (sectorX - 1) * sectorSize);
minY = Math.max(0, (sectorY - 1) * sectorSize);
maxX = Math.min(miniMap.width() - 1, (sectorX + 2) * sectorSize - 1);
maxY = Math.min(miniMap.height() - 1, (sectorY + 2) * sectorSize - 1);
} else {
// Single sector
minX = sectorX * sectorSize;
minY = sectorY * sectorSize;
maxX = Math.min(miniMap.width() - 1, minX + sectorSize - 1);
maxY = Math.min(miniMap.height() - 1, minY + sectorSize - 1);
}
const adapter = new BoundedGameMapAdapter(miniMap, miniFrom, miniTo, {
minX,
maxX,
minY,
maxY,
});
// Convert to local node IDs
const startNode = adapter.tileToNode(miniFrom);
const goalNode = adapter.tileToNode(miniTo);
if (startNode === -1 || goalNode === -1) {
return null; // Start or goal outside bounds
}
// Choose the appropriate FastAStar buffer based on search area
const selectedAStar = multiSector
? this.localAStarMultiSector
: this.localAStar;
// Run FastAStar on bounded region
const path = selectedAStar.search(
startNode,
goalNode,
adapter,
maxIterations,
);
if (!path) {
return null;
}
// Convert path from local node IDs back to miniMap TileRefs
const miniPath = path.map((node: number) => adapter.nodeToTile(node));
// Upscale from miniMap to full map (same logic as MiniAStar)
const result = this.upscalePathToFullMap(miniPath, from, to);
return result;
}
private upscalePathToFullMap(
miniPath: TileRef[],
from: TileRef,
to: TileRef,
): TileRef[] {
const map = this.game.map();
const miniMap = this.game.miniMap();
// Convert miniMap path to cells
const miniCells = miniPath.map((tile) => ({
x: miniMap.x(tile),
y: miniMap.y(tile),
}));
// FIRST: Scale all points (2x)
const scaledPath = miniCells.map((point) => ({
x: point.x * 2,
y: point.y * 2,
}));
// SECOND: Interpolate between scaled points
const smoothPath: Array<{ x: number; y: number }> = [];
for (let i = 0; i < scaledPath.length - 1; i++) {
const current = scaledPath[i];
const next = scaledPath[i + 1];
// Add the current point
smoothPath.push(current);
// Calculate dx/dy from SCALED coordinates
const dx = next.x - current.x;
const dy = next.y - current.y;
const distance = Math.max(Math.abs(dx), Math.abs(dy));
const steps = distance;
// Add intermediate points
for (let step = 1; step < steps; step++) {
smoothPath.push({
x: Math.round(current.x + (dx * step) / steps),
y: Math.round(current.y + (dy * step) / steps),
});
}
}
// Add last point
if (scaledPath.length > 0) {
smoothPath.push(scaledPath[scaledPath.length - 1]);
}
const scaledCells = smoothPath;
// Fix extremes to ensure exact start/end
const fromCell = { x: map.x(from), y: map.y(from) };
const toCell = { x: map.x(to), y: map.y(to) };
// Ensure start is correct
const startIdx = scaledCells.findIndex(
(c) => c.x === fromCell.x && c.y === fromCell.y,
);
if (startIdx === -1) {
scaledCells.unshift(fromCell);
} else if (startIdx !== 0) {
scaledCells.splice(0, startIdx);
}
// Ensure end is correct
const endIdx = scaledCells.findIndex(
(c) => c.x === toCell.x && c.y === toCell.y,
);
if (endIdx === -1) {
scaledCells.push(toCell);
} else if (endIdx !== scaledCells.length - 1) {
scaledCells.splice(endIdx + 1);
}
// Convert back to TileRefs
return scaledCells.map((cell) => map.ref(cell.x, cell.y));
}
private tracePath(from: TileRef, to: TileRef): TileRef[] | null {
const x0 = this.game.x(from);
const y0 = this.game.y(from);
const x1 = this.game.x(to);
const y1 = this.game.y(to);
const tiles: TileRef[] = [];
// Bresenham's line algorithm - trace and collect all tiles
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
let x = x0;
let y = y0;
// Safety limit to prevent excessive memory allocation
const maxTiles = 100000;
let iterations = 0;
while (true) {
if (iterations++ > maxTiles) {
return null; // Path too long
}
const tile = this.game.ref(x, y);
if (!this.game.isWater(tile)) {
return null; // Path blocked
}
tiles.push(tile);
if (x === x1 && y === y1) {
break;
}
const e2 = 2 * err;
const shouldMoveX = e2 > -dy;
const shouldMoveY = e2 < dx;
if (shouldMoveX && shouldMoveY) {
// Diagonal move - need to expand into two 4-directional moves
// Try moving X first, then Y
x += sx;
err -= dy;
const intermediateTile = this.game.ref(x, y);
if (!this.game.isWater(intermediateTile)) {
// X first doesn't work, try Y first instead
x -= sx; // undo
err += dy; // undo
y += sy;
err += dx;
const altTile = this.game.ref(x, y);
if (!this.game.isWater(altTile)) {
return null; // Neither direction works
}
tiles.push(altTile);
// Now move X
x += sx;
err -= dy;
} else {
tiles.push(intermediateTile);
// Now move Y
y += sy;
err += dx;
}
} else {
// Single-axis move
if (shouldMoveX) {
x += sx;
err -= dy;
}
if (shouldMoveY) {
y += sy;
err += dx;
}
}
}
return tiles;
}
private smoothPath(path: TileRef[]): TileRef[] {
if (path.length <= 2) {
return path;
}
const smoothed: TileRef[] = [];
let current = 0;
while (current < path.length - 1) {
// Look as far ahead as possible while maintaining line of sight
let farthest = current + 1;
let bestTrace: TileRef[] | null = null;
for (
let i = current + 2;
i < path.length;
i += Math.max(1, Math.floor(path.length / 20))
) {
const trace = this.tracePath(path[current], path[i]);
if (trace !== null) {
farthest = i;
bestTrace = trace;
} else {
break;
}
}
// Also try the final tile if we haven't already
if (
farthest < path.length - 1 &&
(path.length - 1 - current) % 10 !== 0
) {
const trace = this.tracePath(path[current], path[path.length - 1]);
if (trace !== null) {
farthest = path.length - 1;
bestTrace = trace;
}
}
// Add the traced path (or just current tile if no improvement)
if (bestTrace !== null && farthest > current + 1) {
// Add all tiles from the trace except the last one (to avoid duplication)
smoothed.push(...bestTrace.slice(0, -1));
} else {
// No LOS improvement, just add current tile
smoothed.push(path[current]);
}
current = farthest;
}
// Add the final tile
smoothed.push(path[path.length - 1]);
return smoothed;
}
}
@@ -0,0 +1,200 @@
import { GameMap, TileRef } from "../../game/GameMap";
const LAND_MARKER = 0xff; // Must fit in Uint8Array
/**
* Manages water component identification using flood-fill.
* Pre-allocates buffers and provides explicit initialization.
*/
export class WaterComponents {
private readonly width: number;
private readonly height: number;
private readonly numTiles: number;
private readonly lastRowStart: number;
private readonly queue: Int32Array;
private componentIds: Uint8Array | Uint16Array | null = null;
constructor(
private readonly map: GameMap,
private readonly accessTerrainDirectly: boolean = true,
) {
this.width = map.width();
this.height = map.height();
this.numTiles = this.width * this.height;
this.lastRowStart = (this.height - 1) * this.width;
this.queue = new Int32Array(this.numTiles);
}
initialize(): void {
let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
let nextId = 0;
// Scan all tiles and flood-fill each unvisited water component
for (let start = 0; start < this.numTiles; start++) {
const value = ids[start];
// Skip if already visited (land=0xFF or water component >0)
if (value === LAND_MARKER || value > 0) {
continue;
}
nextId++;
// Dynamically upgrade to Uint16Array when we hit component 254
if (nextId === 254 && ids instanceof Uint8Array) {
ids = this.upgradeToUint16Array(ids);
}
this.floodFillComponent(ids, start, nextId);
}
this.componentIds = ids;
}
/**
* Create and prefill a Uint8Array with land markers.
* Uses direct terrain access for performance.
*/
private createPrefilledIds(): Uint8Array {
const ids = new Uint8Array(this.numTiles);
if (this.accessTerrainDirectly) {
this.premarkLandTilesDirect(ids);
} else {
this.premarkLandTiles(ids);
}
return ids;
}
/**
* Pre-mark all land tiles in the ids array.
* Land tiles are marked with 0xFF, water tiles remain 0.
*/
private premarkLandTiles(ids: Uint8Array): void {
for (let i = 0; i < this.numTiles; i++) {
ids[i] = this.map.isWater(i) ? 0 : LAND_MARKER;
}
}
/**
* Pre-mark all land tiles in the ids array.
* Land tiles are marked with 0xFF, water tiles remain 0.
*
* This implementation accesses the terrain data **directly** without GameMap abstraction.
* In tests it is 30% to 50% faster than using isWater() method calls.
* As of 2026-01-05 it reduces avg. time for GWM from 15ms to 10ms.
*/
private premarkLandTilesDirect(ids: Uint8Array): void {
const terrain = (this.map as any).terrain as Uint8Array;
// Write 4 bytes at once using Uint32Array view for better performance
const numChunks = Math.floor(this.numTiles / 4);
const terrain32 = new Uint32Array(
terrain.buffer,
terrain.byteOffset,
numChunks,
);
const ids32 = new Uint32Array(ids.buffer, ids.byteOffset, numChunks);
for (let i = 0; i < numChunks; i++) {
const chunk = terrain32[i];
// Extract bit 7 from each byte, negate, and combine into single 32-bit write
// bit 7 = 0 (water) → -(0) = 0x00
// bit 7 = 1 (land) → -(1) = 0xFF (truncated to 8 bits)
const b0 = -((chunk >> 7) & 1) & 0xff;
const b1 = -((chunk >> 15) & 1) & 0xff;
const b2 = -((chunk >> 23) & 1) & 0xff;
const b3 = -((chunk >> 31) & 1); // Upper byte, no mask needed
ids32[i] = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
}
// Handle remaining tiles (when numTiles not divisible by 4)
for (let i = numChunks * 4; i < this.numTiles; i++) {
ids[i] = -(terrain[i] >> 7);
}
}
/**
* Upgrade from Uint8Array to Uint16Array when we exceed 254 components.
* Direct copy works because both use 0xFF for land marker.
*/
private upgradeToUint16Array(ids: Uint8Array): Uint16Array {
const newIds = new Uint16Array(this.numTiles);
for (let i = 0; i < this.numTiles; i++) {
newIds[i] = ids[i];
}
return newIds;
}
/**
* Flood-fill a single connected water component using scan-line algorithm.
* Processes horizontal spans of tiles for better memory locality and cache performance.
*
* Note: Land tiles are pre-marked, so ids[x] === 0 guarantees water tile.
*/
private floodFillComponent(
ids: Uint8Array | Uint16Array,
start: number,
componentId: number,
): void {
let head = 0;
let tail = 0;
this.queue[tail++] = start;
while (head < tail) {
const seed = this.queue[head++]!;
// Skip if already processed
if (ids[seed] !== 0) continue;
// Scan left to find the start of this horizontal water span
// No isWaterFast check needed - ids[x] === 0 guarantees water
let left = seed;
const rowStart = seed - (seed % this.width);
while (left > rowStart && ids[left - 1] === 0) {
left--;
}
// Scan right to find the end of this horizontal water span
let right = seed;
const rowEnd = rowStart + this.width - 1;
while (right < rowEnd && ids[right + 1] === 0) {
right++;
}
// Fill the entire horizontal span and check above/below for new spans
for (let x = left; x <= right; x++) {
ids[x] = componentId;
// Check tile above (if not in first row)
if (x >= this.width) {
const above = x - this.width;
if (ids[above] === 0) {
this.queue[tail++] = above;
}
}
// Check tile below (if not in last row)
if (x < this.lastRowStart) {
const below = x + this.width;
if (ids[below] === 0) {
this.queue[tail++] = below;
}
}
}
}
}
/**
* Get the component ID for a tile.
* Returns 0 for land tiles or if not initialized.
*/
getComponentId(tile: TileRef): number {
if (!this.componentIds) return 0;
return this.componentIds[tile] ?? 0;
}
}
@@ -1,5 +1,6 @@
import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution";
import { Game, Player, Unit } from "../../../src/core/game/Game";
import { PathStatus } from "../../../src/core/pathfinding/PathFinder";
import { setup } from "../../util/Setup";
describe("TradeShipExecution", () => {
@@ -83,7 +84,7 @@ describe("TradeShipExecution", () => {
tradeShipExecution = new TradeShipExecution(origOwner, srcPort, dstPort);
tradeShipExecution.init(game, 0);
tradeShipExecution["pathFinder"] = {
nextTile: vi.fn(() => ({ type: 0, node: 2001 })),
next: vi.fn(() => ({ status: PathStatus.NEXT, node: 2001 })),
} as any;
tradeShipExecution["tradeShip"] = tradeShip;
});
@@ -114,7 +115,7 @@ describe("TradeShipExecution", () => {
it("should complete trade and award gold", () => {
tradeShipExecution["pathFinder"] = {
nextTile: vi.fn(() => ({ type: 2, node: 2001 })),
next: vi.fn(() => ({ status: PathStatus.COMPLETE, node: 2001 })),
} as any;
tradeShipExecution.tick(1);
expect(tradeShip.delete).toHaveBeenCalledWith(false);
+333
View File
@@ -0,0 +1,333 @@
import { beforeAll, describe, expect, test, vi } from "vitest";
import { Game } from "../../../src/core/game/Game";
import { TileRef } from "../../../src/core/game/GameMap";
import { MiniAStarAdapter } from "../../../src/core/pathfinding/adapters/MiniAStarAdapter";
import { NavMeshAdapter } from "../../../src/core/pathfinding/adapters/NavMeshAdapter";
import {
PathFinder,
PathStatus,
} from "../../../src/core/pathfinding/PathFinder";
import { setup } from "../../util/Setup";
import { gameFromString } from "./utils";
type AdapterFactory = {
name: string;
create: (game: Game) => PathFinder;
};
const adapters: AdapterFactory[] = [
{
name: "MiniAStarAdapter",
create: (game) => new MiniAStarAdapter(game, { waterPath: true }),
},
{
name: "NavMeshAdapter",
create: (game) => new NavMeshAdapter(game),
},
];
// Shared world game instance
let worldGame: Game;
beforeAll(async () => {
worldGame = await setup("world", { disableNavMesh: false });
});
describe.each(adapters)("$name", ({ create }) => {
describe("findPath()", () => {
test("finds path between adjacent tiles", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(1, 0);
const path = adapter.findPath(src, dst);
expect(path).not.toBeNull();
expect(path![0]).toBe(src);
expect(path![path!.length - 1]).toBe(dst);
});
test("finds path across multiple tiles", async () => {
const game = await gameFromString(["WWWWWW", "WWWWWW", "WWWWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(5, 2);
const path = adapter.findPath(src, dst);
expect(path).not.toBeNull();
expect(path![0]).toBe(src);
expect(path![path!.length - 1]).toBe(dst);
});
test("returns single-element path for same tile", async () => {
// Old quirk of MiniAStar, we return dst tile twice
// Should probably be fixed to return [] instead
const game = await gameFromString(["WW"]);
const adapter = create(game);
const tile = game.ref(0, 0);
const path = adapter.findPath(tile, tile);
expect(path).not.toBeNull();
expect(path!.length).toBe(1);
expect(path![0]).toBe(tile);
});
test("returns null for blocked path", async () => {
const game = await gameFromString(["WWLLWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(5, 0);
const path = adapter.findPath(src, dst);
expect(path).toBeNull();
});
test("returns null for water to land", () => {
const adapter = create(worldGame);
const src = worldGame.ref(926, 283); // water
const dst = worldGame.ref(950, 230); // land
const path = adapter.findPath(src, dst);
expect(path).toBeNull();
});
test("traverses 3-tile path in 3 tiles", async () => {
// Expected: [1, 2, 3]
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(3, 0);
const path = adapter.findPath(src, dst);
expect(path).not.toBeNull();
expect(path).toEqual([
game.ref(0, 0),
game.ref(1, 0),
game.ref(2, 0),
game.ref(3, 0),
]);
});
});
describe("next() state machine", () => {
test("returns NEXT on first call", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(3, 0);
const result = adapter.next(src, dst);
expect(result.status).toBe(PathStatus.NEXT);
});
test("returns COMPLETE when at destination", async () => {
const game = await gameFromString(["WW"]);
const adapter = create(game);
const tile = game.ref(0, 0);
const result = adapter.next(tile, tile);
expect(result.status).toBe(PathStatus.COMPLETE);
});
test("returns NOT_FOUND for blocked path", async () => {
const game = await gameFromString(["WWLLWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(5, 0);
const result = adapter.next(src, dst);
expect(result.status).toBe(PathStatus.NOT_FOUND);
});
test("traverses 3-tile path in 4 calls", async () => {
// Expected: NEXT(1) -> NEXT(2) -> NEXT(3) -> COMPLETE(4)
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const dst = game.ref(3, 0);
let current = src;
const steps: string[] = [];
// 3 NEXT calls to reach destination
for (let i = 1; i <= 4; i++) {
const result = adapter.next(current, dst);
expect([PathStatus.NEXT, PathStatus.COMPLETE]).toContain(result.status);
current = (result as { node: TileRef }).node;
steps.push(`${PathStatus[result.status]}(${current})`);
}
expect(steps).toEqual(["NEXT(1)", "NEXT(2)", "NEXT(3)", "COMPLETE(3)"]);
});
});
describe("Destination changes", () => {
test("reaches new destination when dest changes", async () => {
const game = await gameFromString(["WWWWWWWW"]); // 8 wide
const adapter = create(game);
const src = game.ref(0, 0);
const dst1 = game.ref(4, 0);
const dst2 = game.ref(7, 0);
// First path exists
expect(adapter.findPath(src, dst1)).not.toBeNull();
// Can still find path to new destination
expect(adapter.findPath(dst1, dst2)).not.toBeNull();
});
test("recomputes when destination changes mid-path", async () => {
const game = await gameFromString(["WWWWWWWWWWWWWWWWWWWW"]); // 20 wide
const adapter = create(game);
const src = game.ref(0, 0);
const dst1 = game.ref(10, 0);
const dst2 = game.ref(19, 0);
// Start pathing to dst1, take one step
const result1 = adapter.next(src, dst1);
expect(result1.status).toBe(PathStatus.NEXT);
// Change destination mid-path, continue from current position
let current = (result1 as { node: TileRef }).node;
let result = adapter.next(current, dst2);
for (let i = 0; i < 100 && result.status === PathStatus.NEXT; i++) {
current = (result as { node: TileRef }).node;
result = adapter.next(current, dst2);
}
expect(result.status).toBe(PathStatus.COMPLETE);
expect(current).toBe(dst2);
});
});
describe("Error handling", () => {
// MiniAStar logs console error when nulls passed, muted in test
test("returns NOT_FOUND for null source", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const dst = game.ref(0, 0);
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = adapter.next(null as unknown as TileRef, dst);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
test("returns NOT_FOUND for null destination", async () => {
const game = await gameFromString(["WWWW"]);
const adapter = create(game);
const src = game.ref(0, 0);
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = adapter.next(src, null as unknown as TileRef);
expect(result.status).toBe(PathStatus.NOT_FOUND);
consoleSpy.mockRestore();
});
});
describe("dist parameter", () => {
test("returns COMPLETE when within dist", () => {
const adapter = create(worldGame);
const src = worldGame.ref(926, 283);
const dst = worldGame.ref(928, 283); // 2 tiles away
const result = adapter.next(src, dst, 5);
expect(result.status).toBe(PathStatus.COMPLETE);
});
test("returns NEXT when beyond dist", () => {
const adapter = create(worldGame);
const src = worldGame.ref(926, 283);
const dst = worldGame.ref(950, 257);
// Adapter may need a few ticks to compute path
let result = adapter.next(src, dst, 5);
for (let i = 0; i < 100 && result.status === PathStatus.PENDING; i++) {
result = adapter.next(src, dst, 5);
}
expect(result.status).toBe(PathStatus.NEXT);
});
});
describe("World map routes", () => {
test("Spain to France (Mediterranean)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(926, 283),
worldGame.ref(950, 257),
);
expect(path).not.toBeNull();
});
test("Miami to Rio (Atlantic)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(488, 355),
worldGame.ref(680, 658),
);
expect(path).not.toBeNull();
expect(path!.length).toBeGreaterThan(100);
});
test("France to Poland (around Europe)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(950, 257),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
test("Miami to Spain (transatlantic)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(488, 355),
worldGame.ref(926, 283),
);
expect(path).not.toBeNull();
});
test("Rio to Poland (South Atlantic to Baltic)", () => {
const adapter = create(worldGame);
const path = adapter.findPath(
worldGame.ref(680, 658),
worldGame.ref(1033, 175),
);
expect(path).not.toBeNull();
});
});
describe("Known bugs", () => {
test("path can cross 1-tile land barrier", async () => {
const game = await gameFromString(["WLLWLWWLLW"]);
const adapter = create(game);
const path = adapter.findPath(game.ref(0, 0), game.ref(9, 0));
expect(path).not.toBeNull();
});
test("path can cross diagonal land barrier", async () => {
const game = await gameFromString(["WL", "LW"]);
const adapter = create(game);
const path = adapter.findPath(game.ref(0, 0), game.ref(1, 1));
expect(path).not.toBeNull();
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import {
Difficulty,
Game,
GameMapSize,
GameMapType,
GameMode,
GameType,
} from "../../../src/core/game/Game";
import { createGame } from "../../../src/core/game/GameImpl";
import { GameMapImpl } from "../../../src/core/game/GameMap";
import { UserSettings } from "../../../src/core/game/UserSettings";
import { TestConfig } from "../../util/TestConfig";
import { TestServerConfig } from "../../util/TestServerConfig";
const LAND_BIT = 7;
const OCEAN_BIT = 5;
/**
* Creates a Game from inline map strings.
* Each char = 1 tile: W=water (ocean), L=land
* miniMap automatically generated (2x2→1, water if ANY tile water)
*
* Example:
* const game = await gameFromString([
* 'WWWWW',
* 'WLLLW',
* 'WWWWW'
* ]);
*/
export async function gameFromString(mapRows: string[]): Promise<Game> {
const height = mapRows.length;
const width = mapRows[0].length;
for (const row of mapRows) {
if (row.length !== width) {
throw new Error(
`All rows must have same width. Expected ${width}, got ${row.length}`,
);
}
}
const terrainData = new Uint8Array(width * height);
let numLandTiles = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
const char = mapRows[y][x];
if (char === "L") {
terrainData[idx] = 1 << LAND_BIT; // Set land bit
numLandTiles++;
} else if (char === "W") {
terrainData[idx] = 1 << OCEAN_BIT; // Set ocean bit (water)
} else {
throw new Error(
`Unknown char '${char}' at (${x},${y}). Use W=water, L=land`,
);
}
}
}
const gameMap = new GameMapImpl(width, height, terrainData, numLandTiles);
// Create miniMap (2x2→1, water if ANY tile water)
const miniWidth = Math.ceil(width / 2);
const miniHeight = Math.ceil(height / 2);
const miniTerrainData = new Uint8Array(miniWidth * miniHeight);
let miniNumLandTiles = 0;
for (let miniY = 0; miniY < miniHeight; miniY++) {
for (let miniX = 0; miniX < miniWidth; miniX++) {
const miniIdx = miniY * miniWidth + miniX;
// Check 2x2 chunk: if ANY tile is water, miniMap tile is water
let water = false;
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 2; dx++) {
const x = miniX * 2 + dx;
const y = miniY * 2 + dy;
if (x < width && y < height) {
const idx = y * width + x;
if (!(terrainData[idx] & (1 << LAND_BIT))) {
water = true;
}
}
}
}
// Water if ANY tile is water
if (water) {
miniTerrainData[miniIdx] = 1 << OCEAN_BIT; // ocean
} else {
miniTerrainData[miniIdx] = 1 << LAND_BIT; // land
miniNumLandTiles++;
}
}
}
const miniGameMap = new GameMapImpl(
miniWidth,
miniHeight,
miniTerrainData,
miniNumLandTiles,
);
// Create game config
const serverConfig = new TestServerConfig();
const gameConfig = {
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
disableNations: false,
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
disableNavMesh: false,
randomSpawn: false,
};
const config = new TestConfig(
serverConfig,
gameConfig,
new UserSettings(),
false,
);
return createGame([], [], gameMap, miniGameMap, config);
}
+152
View File
@@ -0,0 +1,152 @@
# Pathfinding Tests
This directory contains benchmarking tools, scenario generators, and an interactive playground for testing and optimizing pathfinding algorithms in OpenFrontIO.
## TLDR
```bash
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
npx tsx tests/pathfinding/playground/server.ts
```
## Directory Structure
```
tests/pathfinding/
├── benchmark.ts # Benchmarking tool
├── scenarios/ # Scenarios for benchmarks
│ ├── default.ts # Hand-picked scenario
│ └── synthetic/ # Auto-generated synthetic scenarios
└── playground/ # Interactive web-based visualization
```
## Available algorithms
- **NavSat** - **future** implementation - NavigationSatellite (HPA\*)
- **PF.Mini** - **current** implementation - PathFinder.Mini (A\*)
## Benchmarking
### Running a Single Scenario
```bash
# Run default scenario with default adapter (NavSat)
npx tsx tests/pathfinding/benchmark/run.ts
# Run specific scenario
npx tsx tests/pathfinding/benchmark/run.ts default
# Run with specific adapter
npx tsx tests/pathfinding/benchmark/run.ts default legacy
```
### Running Synthetic Scenarios
Synthetic scenarios are auto-generated from maps with random port selections and routes.
```bash
# Run single synthetic scenario
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland
# Run single synthetic scenario with specific adapter
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
# Run ALL synthetic scenarios (comprehensive benchmark)
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
# Run all with specific adapter
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all legacy
```
### Benchmark Metrics
The benchmark measures three key metrics:
1. **Initialization Time** - How long it takes to preprocess the map
2. **Path Distance** - Total distance across all routes (quality metric)
3. **Pathfinding Time** - How long it takes to compute paths (performance metric)
### Example Output
```
================================================================================
METRIC 1: INITIALIZATION TIME
================================================================================
Initialization time: 45.32ms
================================================================================
METRIC 2: PATH DISTANCE
================================================================================
Route Path Length
Miami → Boston 346 tiles
Miami → Houston 212 tiles
...
Total distance: 52432 tiles
Routes completed: 22 / 22
================================================================================
METRIC 3: PATHFINDING TIME
================================================================================
Route Time
Miami → Boston 2.45ms
Miami → Houston 1.82ms
...
Total time: 156.34ms
Average time: 7.11ms
Routes benchmarked: 22 / 22
================================================================================
SUMMARY
================================================================================
Adapter: default
Scenario: default
Scores:
Initialization: 45.32ms
Pathfinding: 156.34ms
Distance: 52432 tiles
```
## Generating Scenarios
### Generate Synthetic Scenarios
Synthetic scenarios are generated by:
1. Finding all water shoreline tiles on a map
2. Randomly selecting 200 ports
3. Creating 1000 routes connecting nearby ports
```bash
# Generate scenario for a single map
npx tsx tests/pathfinding/benchmark/generate.ts iceland
# Generate scenarios for all maps
npx tsx tests/pathfinding/benchmark/generate.ts --all
# Force overwrite existing scenarios
npx tsx tests/pathfinding/benchmark/generate.ts iceland --force
npx tsx tests/pathfinding/benchmark/generate.ts --all --force
```
## Interactive Playground
The playground provides a web-based UI for visualizing pathfinding results, comparing algorithms, and debugging.
### Starting the Playground
```bash
# Start with path caching enabled (default)
npx tsx tests/pathfinding/playground/server.ts
# Start without path caching (to measure uncached performance)
npx tsx tests/pathfinding/playground/server.ts --no-cache
```
Then open http://localhost:5555 in your browser.
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env node
/**
* Generate synthetic benchmark scenarios for pathfinding tests
*
* Usage:
* npx tsx tests/pathfinding/benchmark/generate.ts <map-name> [--force]
* npx tsx tests/pathfinding/benchmark/generate.ts --all [--force]
*
* Examples:
* npx tsx tests/pathfinding/benchmark/generate.ts iceland
* npx tsx tests/pathfinding/benchmark/generate.ts giantworldmap --force
* npx tsx tests/pathfinding/benchmark/generate.ts --all
*/
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { setupFromPath } from "../utils";
const currentFile = fileURLToPath(import.meta.url);
const pathfindingDir = dirname(currentFile);
const projectRoot = join(pathfindingDir, "../../..");
const mapsDirectory = join(projectRoot, "resources/maps");
const scenariosDir = join(pathfindingDir, "scenarios", "synthetic");
const NUM_PORTS = 200;
const NUM_ROUTES = 1000;
const ROUTES_PER_PORT = 5;
interface GenerationOptions {
force: boolean;
silent: boolean;
}
async function generateScenarioForMap(
mapName: string,
options: GenerationOptions,
): Promise<"created" | "skipped" | "error"> {
const outputPath = join(scenariosDir, `${mapName}.ts`);
// Check if file exists and --force not provided
if (existsSync(outputPath) && !options.force) {
if (!options.silent) {
console.log(
`⚠️ ${mapName}: File already exists (use --force to overwrite)`,
);
}
return "skipped";
}
try {
const game = await setupFromPath(mapsDirectory, mapName);
const map = game.map();
// Find all water shoreline tiles
const shorelinePorts: Array<[number, number]> = [];
map.forEachTile((tile) => {
if (map.isOcean(tile) && map.isShoreline(tile)) {
shorelinePorts.push([map.x(tile), map.y(tile)]);
}
});
if (shorelinePorts.length < 10) {
console.log(
`${mapName}: Not enough water shoreline tiles (minimum 10 required)`,
);
return "error";
}
// Select random ports
const numPortsToSelect = Math.min(NUM_PORTS, shorelinePorts.length);
const selectedPorts: Array<[number, number]> = [];
const shuffled = shorelinePorts.sort(() => Math.random() - 0.5);
for (let i = 0; i < numPortsToSelect; i++) {
selectedPorts.push(shuffled[i]);
}
// Build ports array
const ports: Port[] = selectedPorts.map((coord, index) => ({
name: `Port${String(index + 1).padStart(3, "0")}`,
coords: coord,
}));
// Build routes array
const routes: Route[] = [];
// Generate routes: each port connects to next N ports
for (let i = 0; i < selectedPorts.length; i++) {
for (
let j = 1;
j <= ROUTES_PER_PORT && i + j < selectedPorts.length;
j++
) {
routes.push({
from: `Port${String(i + 1).padStart(3, "0")}`,
to: `Port${String(i + j + 1).padStart(3, "0")}`,
});
}
}
// Add extra routes to reach target (or as many as possible)
const targetRoutes = Math.min(NUM_ROUTES, routes.length + 200);
const additionalRoutesNeeded = targetRoutes - routes.length;
if (additionalRoutesNeeded > 0) {
let added = 0;
for (
let i = 0;
i < selectedPorts.length && added < additionalRoutesNeeded;
i++
) {
for (
let j = ROUTES_PER_PORT + 1;
j <= ROUTES_PER_PORT + 3 &&
i + j < selectedPorts.length &&
added < additionalRoutesNeeded;
j++
) {
routes.push({
from: `Port${String(i + 1).padStart(3, "0")}`,
to: `Port${String(i + j + 1).padStart(3, "0")}`,
});
added++;
}
}
}
// Generate content from template
const content = generateScenarioContent({
mapName,
ports,
routes,
});
const routeCount = routes.length;
// Ensure directory exists
mkdirSync(scenariosDir, { recursive: true });
// Write to file
writeFileSync(outputPath, content);
console.log(
`${mapName} generated with ${numPortsToSelect} ports and ${routeCount} routes`,
);
return "created";
} catch (error) {
console.error(`${mapName}:`, error);
return "error";
}
}
function printUsage() {
console.log(`
Usage:
npx tsx tests/pathfinding/benchmark/generate.ts <map-name> [--force]
npx tsx tests/pathfinding/benchmark/generate.ts --all [--force]
Arguments:
<map-name> Name of the map to generate scenario for (e.g., iceland, giantworldmap)
--all Generate scenarios for all available maps
--force Overwrite existing scenario files
Examples:
npx tsx tests/pathfinding/benchmark/generate.ts iceland
npx tsx tests/pathfinding/benchmark/generate.ts giantworldmap --force
npx tsx tests/pathfinding/benchmark/generate.ts --all
Available maps:
Run 'ls resources/maps' to see all available maps
`);
}
// Parse command-line arguments
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printUsage();
process.exit(0);
}
const options: GenerationOptions = {
force: args.includes("--force"),
silent: args.includes("--all"),
};
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
if (args.includes("--all")) {
// Generate for all maps
const maps = readdirSync(mapsDirectory, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
.sort();
console.log(`Generating synthetic scenarios for ${maps.length} maps...`);
console.log(`Config: ${NUM_PORTS} ports, ${NUM_ROUTES} routes`);
console.log(`Force overwrite: ${options.force}`);
console.log(``);
let createdCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const mapName of maps) {
const result = await generateScenarioForMap(mapName, options);
if (result === "created") {
createdCount++;
} else if (result === "skipped") {
skippedCount++;
} else if (result === "error") {
errorCount++;
}
}
if (createdCount + errorCount > 0) {
console.log(``);
}
console.log(
`Created: ${createdCount}, Skipped: ${skippedCount}, Errors: ${errorCount}`,
);
} else if (nonFlagArgs.length === 1) {
// Generate for single map
const mapName = nonFlagArgs[0];
const mapPath = join(mapsDirectory, mapName);
if (!existsSync(mapPath)) {
console.error(`Map not found: ${mapName}`);
process.exit(1);
}
console.log(`Generating synthetic scenario for ${mapName}...`);
console.log(`Config: ${NUM_PORTS} ports, ${NUM_ROUTES} routes`);
console.log(`Force overwrite: ${options.force}`);
console.log(``);
const result = await generateScenarioForMap(mapName, options);
if (result === "created") {
console.log(``);
console.log(`Scenario generated successfully!`);
console.log(
`You can now run: npx tsx tests/pathfinding/benchmark/run.ts --synthetic ${mapName}`,
);
} else {
process.exit(1);
}
} else {
console.error(`Invalid arguments.`);
printUsage();
process.exit(1);
}
}
// Run main function
main().catch((error) => {
console.error(`Fatal error:`, error);
process.exit(1);
});
/**
* Template for generating synthetic benchmark scenarios
*/
interface Port {
name: string;
coords: [number, number];
}
interface Route {
from: string;
to: string;
}
interface TemplateParams {
mapName: string;
ports: Port[];
routes: Route[];
}
function generateScenarioContent(params: TemplateParams): string {
const { mapName, ports, routes } = params;
let content = ``;
// Simplified format - just data, no setup function
content += `export const MAP_NAME = "${mapName}";\n\n`;
// Generate PORTS object
content += `export const PORTS: { [k: string]: [number, number] } = {\n`;
ports.forEach((port) => {
content += ` ${port.name}: [${port.coords[0]}, ${port.coords[1]}],\n`;
});
content += `};\n\n`;
// Generate ROUTES array
content += `export const ROUTES: Array<[keyof typeof PORTS, keyof typeof PORTS]> = [\n`;
routes.forEach((route) => {
content += ` ["${route.from}", "${route.to}"],\n`;
});
content += `];\n`;
return content;
}
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env node
/**
* Benchmark pathfinding adapters on various scenarios
*
* Usage:
* npx tsx tests/pathfinding/benchmark/run.ts [<scenario> [<adapter>]]
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic <map-name> [<adapter>]
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all [<adapter>]
*
* Examples:
* npx tsx tests/pathfinding/benchmark/run.ts
* npx tsx tests/pathfinding/benchmark/run.ts default legacy
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
* npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
*/
import { readdirSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import {
type BenchmarkResult,
calculateStats,
getAdapter,
getScenario,
measureExecutionTime,
measurePathLength,
printHeader,
printRow,
} from "../utils";
const currentFile = fileURLToPath(import.meta.url);
const pathfindingDir = dirname(currentFile);
const syntheticScenariosDir = join(pathfindingDir, "scenarios", "synthetic");
interface RunOptions {
silent?: boolean;
iterations?: number;
}
const DEFAULT_ADAPTER = "hpa";
const DEFAULT_SCENARIO = "default";
const DEFAULT_ITERATIONS = 10;
async function runScenario(
adapterName: string,
scenarioName: string,
options: RunOptions = {},
) {
const { game, routes, initTime } = await getScenario(
scenarioName,
adapterName,
);
const adapter = getAdapter(game, adapterName);
const { silent = false } = options;
if (!silent) {
console.log(`Date: ${new Date().toISOString()}`);
console.log(`Benchmarking: ${adapterName}`);
console.log(`Scenario: ${scenarioName}`);
console.log(`Routes: ${routes.length}`);
console.log(``);
}
// =============================================================================
if (!silent) {
printHeader("METRIC 1: INITIALIZATION TIME");
}
const initializationTime = initTime;
if (!silent) {
console.log(`Initialization time: ${initializationTime.toFixed(2)}ms`);
console.log(``);
}
// =============================================================================
if (!silent) {
printHeader("METRIC 2: PATH DISTANCE");
printRow(["Route", "Path Length"], [40, 12]);
}
const results: BenchmarkResult[] = [];
for (const route of routes) {
const pathLength = measurePathLength(adapter, route);
results.push({ route: route.name, pathLength, executionTime: null });
if (!silent) {
printRow(
[route.name, pathLength !== null ? `${pathLength} tiles` : "FAILED"],
[40, 12],
);
}
}
const { totalDistance, successfulRoutes, totalRoutes } =
calculateStats(results);
if (!silent) {
console.log(``);
console.log(`Total distance: ${totalDistance} tiles`);
console.log(`Routes completed: ${successfulRoutes} / ${totalRoutes}`);
console.log(``);
}
// =============================================================================
if (!silent) {
printHeader("METRIC 3: PATHFINDING TIME");
printRow(["Route", "Time"], [40, 12]);
}
for (const route of routes) {
const result = results.find((r) => r.route === route.name);
if (result && result.pathLength !== null) {
const execTime = measureExecutionTime(
adapter,
route,
options.iterations ?? DEFAULT_ITERATIONS,
);
result.executionTime = execTime;
if (!silent) {
printRow([route.name, `${execTime!.toFixed(2)}ms`], [40, 12]);
}
} else {
if (!silent) {
printRow([route.name, "FAILED"], [40, 12]);
}
}
}
const stats = calculateStats(results);
if (!silent) {
console.log(``);
console.log(`Total time: ${stats.totalTime.toFixed(2)}ms`);
console.log(`Average time: ${stats.avgTime.toFixed(2)}ms`);
console.log(
`Routes benchmarked: ${stats.timedRoutes} / ${stats.totalRoutes}`,
);
console.log(``);
// =============================================================================
printHeader("SUMMARY");
console.log(`Adapter: ${adapterName}`);
console.log(`Scenario: ${scenarioName}`);
console.log(``);
if (stats.successfulRoutes < stats.totalRoutes) {
console.log(
`Warning: Only ${stats.successfulRoutes} out of ${stats.totalRoutes} routes were completed successfully!`,
);
console.log(``);
}
console.log("Scores:");
console.log(` Initialization: ${initializationTime.toFixed(2)}ms`);
console.log(` Pathfinding: ${stats.totalTime.toFixed(2)}ms`);
console.log(` Distance: ${totalDistance} tiles`);
console.log(``);
} else {
// Silent mode - just print a summary line
const status = stats.successfulRoutes < stats.totalRoutes ? "⚠️ " : "✅";
console.log(
`${status} ${scenarioName.padEnd(35)} | Init: ${initializationTime.toFixed(2).padStart(8)}ms | Path: ${stats.totalTime.toFixed(2).padStart(9)}ms | Dist: ${totalDistance.toString().padStart(7)} tiles | Routes: ${stats.successfulRoutes}/${stats.totalRoutes}`,
);
}
return {
initializationTime,
totalTime: stats.totalTime,
totalDistance: totalDistance,
};
}
function printUsage() {
console.log(`
Usage:
npx tsx tests/pathfinding/benchmark/run.ts [<scenario> [<adapter>]]
npx tsx tests/pathfinding/benchmark/run.ts --synthetic <map-name> [<adapter>]
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all [<adapter>]
Arguments:
<scenario> Name of the scenario to benchmark (default: "default" -> giantworldmap with handpicked ports)
<adapter> Pathfinding adapter: "hpa" (default), "hpa.cached", "legacy"
--silent Minimize output, only print summary lines
--synthetic Run synthetic scenarios
--all Run all synthetic scenarios (requires --synthetic)
Examples:
npx tsx tests/pathfinding/benchmark/run.ts
npx tsx tests/pathfinding/benchmark/run.ts default legacy
npx tsx tests/pathfinding/benchmark/run.ts --synthetic --all
npx tsx tests/pathfinding/benchmark/run.ts --synthetic iceland legacy
Available synthetic scenarios:
Run 'ls tests/pathfinding/benchmark/scenarios/synthetic' to see all available scenarios
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.includes("-h")) {
printUsage();
process.exit(0);
}
const isSynthetic = args.includes("--synthetic");
const isAll = args.includes("--all");
const isSilent = args.includes("--silent");
const nonFlagArgs = args.filter((arg) => !arg.startsWith("--"));
if (isSynthetic) {
if (isAll) {
// Run all synthetic scenarios
const adapterName = nonFlagArgs[0] || DEFAULT_ADAPTER;
// Find all synthetic scenario files
const scenarioFiles = readdirSync(syntheticScenariosDir)
.filter((file) => file.endsWith(".ts"))
.map((file) => file.replace(".ts", ""))
.sort();
console.log(
`Running ${scenarioFiles.length} synthetic scenarios with ${adapterName} adapter...`,
);
console.log(``);
const results: {
initializationTime: number;
totalTime: number;
totalDistance: number;
}[] = [];
for (let i = 0; i < scenarioFiles.length; i++) {
const mapName = scenarioFiles[i];
const scenarioName = `synthetic/${mapName}`;
const result = await runScenario(adapterName, scenarioName, {
silent: true,
iterations: 1,
});
results.push(result);
}
console.log(``);
console.log(`Completed ${scenarioFiles.length} scenarios`);
console.log(
`Total Initialization Time: ${results.reduce((sum, r) => sum + r.initializationTime, 0).toFixed(2)}ms`,
);
console.log(
`Total Pathfinding Time: ${results.reduce((sum, r) => sum + r.totalTime, 0).toFixed(2)}ms`,
);
console.log(
`Total Distance: ${results.reduce((sum, r) => sum + r.totalDistance, 0)} tiles`,
);
} else if (nonFlagArgs.length >= 1) {
// Run single synthetic scenario
const mapName = nonFlagArgs[0];
const adapterName = nonFlagArgs[1] || DEFAULT_ADAPTER;
const scenarioName = `synthetic/${mapName}`;
await runScenario(adapterName, scenarioName, { silent: isSilent });
} else {
console.error("Error: --synthetic requires a map name or --all flag");
printUsage();
process.exit(1);
}
} else {
// Standard mode with positional arguments
const scenarioName = nonFlagArgs[0] || DEFAULT_SCENARIO;
const adapterName = nonFlagArgs[1] || DEFAULT_ADAPTER;
await runScenario(adapterName, scenarioName, { silent: isSilent });
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
@@ -0,0 +1,55 @@
export const MAP_NAME = "giantworldmap";
export const PORTS: { [k: string]: [number, number] } = {
Miami: [1014, 694],
Boston: [1120, 498],
Houston: [844, 657],
Chicago: [928, 508],
Cleveland: [995, 514],
Barcelona: [1943, 518],
"Sao Paulo": [1396, 1288],
"San Francisco": [520, 547],
"Falkland Islands": [1259, 1616],
"San Salvador": [901, 842],
Luxor: [2302, 700],
Venice: [2068, 468],
"Kongo River": [2142, 965],
Shanghai: [3317, 639],
Tokyo: [3525, 583],
"Abu Zabi": [2554, 711],
Gdansk: [2143, 362],
Neapol: [2081, 516],
"San Felipe": [617, 637],
Anchorage: [217, 281],
Honolulu: [156, 768],
"Ghana River": [1877, 869],
"Guinea River": [1824, 868],
"Peru River": [1097, 1162],
"China Desert River": [2965, 599],
"Australia River": [3663, 1343],
};
export const ROUTES: Array<[keyof typeof PORTS, keyof typeof PORTS]> = [
["Miami", "Boston"],
["Miami", "Houston"],
["Miami", "Chicago"],
["Miami", "Cleveland"],
["Miami", "Barcelona"],
["Miami", "Sao Paulo"],
["Miami", "San Francisco"],
["Miami", "Falkland Islands"],
["Miami", "San Salvador"],
["Luxor", "Venice"],
["Luxor", "Kongo River"],
["Shanghai", "Tokyo"],
["Shanghai", "San Francisco"],
["Abu Zabi", "Gdansk"],
["Chicago", "Cleveland"],
["Barcelona", "Neapol"],
["San Francisco", "San Felipe"],
["Anchorage", "Honolulu"],
["Kongo River", "Ghana River"],
["Kongo River", "Guinea River"],
["Peru River", "China Desert River"],
["China Desert River", "Australia River"],
];
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
# Pathfinding Playground
Interactive web-based visualization tool for exploring and comparing pathfinding algorithms.
## Usage
Start the server from the project root:
```bash
# With path caching (default)
npx tsx tests/pathfinding/playground/server.ts
# Without path caching
npx tsx tests/pathfinding/playground/server.ts --no-cache
```
Then open http://localhost:5555 in your browser.
## Options
- `--no-cache` - Disable path caching in NavMesh to measure uncached performance
## Clanker Disclosure
The source code of both the UI and the backend is mostly generated by a robot and therefore probably unmaintainable by humans. Since this is meant to be one-off playground project the author has not put additional thought into the inner workings. The UI however is very highly opinionated, if you believe a clanker could do this first pass, you live in the (possibly not far off) future.
+224
View File
@@ -0,0 +1,224 @@
import { readdirSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { Game } from "../../../../src/core/game/Game.js";
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { NavMesh } from "../../../../src/core/pathfinding/navmesh/NavMesh.js";
import { setupFromPath } from "../../utils.js";
export interface MapInfo {
name: string;
displayName: string;
}
export interface MapCache {
game: Game;
navMesh: NavMesh;
}
const cache = new Map<string, MapCache>();
/**
* Global configuration for map loading
*/
let config = {
cachePaths: true,
};
/**
* Set configuration options
*/
export function setConfig(options: { cachePaths?: boolean }) {
config = { ...config, ...options };
}
/**
* Get the resources/maps directory path
*/
function getMapsDirectory(): string {
return join(
dirname(fileURLToPath(import.meta.url)),
"../../../../resources/maps",
);
}
/**
* Format map name to title case with proper spacing
* Handles: underscores, camelCase, existing spaces, and parentheses
*/
function formatMapName(name: string): string {
return (
name
// Replace underscores with spaces
.replace(/_/g, " ")
// Add space before capital letters (for camelCase)
.replace(/([a-z])([A-Z])/g, "$1 $2")
// Convert to lowercase first
.toLowerCase()
// Capitalize first letter of string
.replace(/^\w/, (char) => char.toUpperCase())
// Capitalize after spaces and opening parentheses
.replace(/(\s+|[(])\w/g, (match) => match.toUpperCase())
);
}
/**
* Get list of available maps by reading the resources/maps directory
*/
export function listMaps(): MapInfo[] {
const mapsDir = getMapsDirectory();
const maps: MapInfo[] = [];
try {
const entries = readdirSync(mapsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const name = entry.name;
let displayName = formatMapName(name);
// Try to read displayName from manifest.json
try {
const manifestPath = join(mapsDir, name, "manifest.json");
const manifestData = JSON.parse(readFileSync(manifestPath, "utf-8"));
if (manifestData.name) {
displayName = formatMapName(manifestData.name);
}
} catch (e) {
// If manifest doesn't exist or doesn't have name, use formatted folder name
console.warn(
`Could not read manifest for ${name}:`,
e instanceof Error ? e.message : e,
);
}
maps.push({ name, displayName });
}
}
} catch (e) {
console.error("Failed to read maps directory:", e);
}
return maps.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
/**
* Load a map from cache or disk
*/
export async function loadMap(mapName: string): Promise<MapCache> {
// Check cache first
if (cache.has(mapName)) {
return cache.get(mapName)!;
}
const mapsDir = getMapsDirectory();
// Use the existing setupFromPath utility to load the map
const game = await setupFromPath(mapsDir, mapName);
// Initialize NavMesh
const navMesh = new NavMesh(game, { cachePaths: config.cachePaths });
navMesh.initialize();
const cacheEntry: MapCache = { game, navMesh };
// Store in cache
cache.set(mapName, cacheEntry);
return cacheEntry;
}
/**
* Get map metadata for client
*/
export async function getMapMetadata(mapName: string) {
const { game, navMesh } = await loadMap(mapName);
// Extract map data
const mapData: number[] = [];
for (let y = 0; y < game.height(); y++) {
for (let x = 0; x < game.width(); x++) {
const tile = game.ref(x, y);
mapData.push(game.isWater(tile) ? 1 : 0);
}
}
// Extract static graph data from NavMesh
const miniMap = game.miniMap();
const navMeshGraph = (navMesh as any).graph;
// Convert gateways from Map to array
const gatewaysArray = Array.from(navMeshGraph.gateways.values());
const allGateways = gatewaysArray.map((gw: any) => ({
id: gw.id,
x: miniMap.x(gw.tile),
y: miniMap.y(gw.tile),
}));
// Create a lookup map from gateway ID to gateway for edge conversion
const gatewayById = new Map(gatewaysArray.map((gw: any) => [gw.id, gw]));
// Convert edges from Map<gatewayId, Edge[]> to flat array
// The edges Map has gateway IDs as keys, and arrays of edges as values
const allEdges: any[] = [];
for (const edgeArray of navMeshGraph.edges.values()) {
allEdges.push(...edgeArray);
}
// Deduplicate edges (they're bidirectional, so each edge appears twice)
const seenEdges = new Set<string>();
const edges = allEdges
.filter((edge: any) => {
const edgeKey =
edge.from < edge.to
? `${edge.from}-${edge.to}`
: `${edge.to}-${edge.from}`;
if (seenEdges.has(edgeKey)) return false;
seenEdges.add(edgeKey);
return true;
})
.map((edge: any) => {
const fromGateway = gatewayById.get(edge.from);
const toGateway = gatewayById.get(edge.to);
return {
fromId: edge.from,
toId: edge.to,
from: fromGateway
? [miniMap.x(fromGateway.tile) * 2, miniMap.y(fromGateway.tile) * 2]
: [0, 0],
to: toGateway
? [miniMap.x(toGateway.tile) * 2, miniMap.y(toGateway.tile) * 2]
: [0, 0],
cost: edge.cost,
path: edge.path
? edge.path.map((tile: TileRef) => [game.x(tile), game.y(tile)])
: null,
};
});
console.log(
`Map ${mapName}: ${allGateways.length} gateways, ${edges.length} edges`,
);
const sectorSize = navMeshGraph.sectorSize;
return {
name: mapName,
width: game.width(),
height: game.height(),
mapData,
graphDebug: {
allGateways,
edges,
sectorSize,
},
};
}
/**
* Clear map cache
*/
export function clearCache() {
cache.clear();
}
@@ -0,0 +1,157 @@
import { TileRef } from "../../../../src/core/game/GameMap.js";
import { MiniAStarAdapter } from "../../../../src/core/pathfinding/adapters/MiniAStarAdapter.js";
import { loadMap } from "./maps.js";
interface PathfindingOptions {
includePfMini?: boolean;
includeNavMesh?: boolean;
}
interface NavMeshResult {
path: Array<[number, number]> | null;
initialPath: Array<[number, number]> | null;
gateways: Array<[number, number]> | null;
timings: any;
length: number;
time: number;
}
interface PfMiniResult {
path: Array<[number, number]> | null;
length: number;
time: number;
}
// Cache pathfinding adapters per map
const pfMiniCache = new Map<string, MiniAStarAdapter>();
/**
* Get or create MiniAStar adapter for a map
*/
function getPfMiniAdapter(mapName: string, game: any): MiniAStarAdapter {
if (!pfMiniCache.has(mapName)) {
const adapter = new MiniAStarAdapter(game, { waterPath: true });
pfMiniCache.set(mapName, adapter);
}
return pfMiniCache.get(mapName)!;
}
/**
* Convert TileRef array to coordinate array
*/
function pathToCoords(
path: TileRef[] | null,
game: any,
): Array<[number, number]> | null {
if (!path) return null;
return path.map((tile) => [game.x(tile), game.y(tile)]);
}
/**
* Compute pathfinding between two points
*/
export async function computePath(
mapName: string,
from: [number, number],
to: [number, number],
options: PathfindingOptions = {},
): Promise<NavMeshResult> {
const { game, navMesh: navMeshAdapter } = await loadMap(mapName);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute NavMesh path
const navMeshPath = navMeshAdapter.findPath(fromRef, toRef, true);
const path = pathToCoords(navMeshPath, game);
const miniMap = game.miniMap();
// Extract debug info
let gateways: Array<[number, number]> | null = null;
let initialPath: Array<[number, number]> | null = null;
let timings: any = {};
if (navMeshAdapter.debugInfo) {
// Convert gatewayPath (TileRefs on miniMap) to full map coordinates
if (navMeshAdapter.debugInfo.gatewayPath) {
gateways = navMeshAdapter.debugInfo.gatewayPath.map((tile: TileRef) => {
const x = miniMap.x(tile) * 2;
const y = miniMap.y(tile) * 2;
return [x, y] as [number, number];
});
}
// Convert initial path
if (navMeshAdapter.debugInfo.initialPath) {
initialPath = navMeshAdapter.debugInfo.initialPath.map(
(tile: TileRef) => [game.x(tile), game.y(tile)] as [number, number],
);
}
timings = navMeshAdapter.debugInfo.timings || {};
}
return {
path,
initialPath,
gateways,
timings,
length: path ? path.length : 0,
time: timings.total ?? 0,
};
}
/**
* Compute only PathFinder.Mini path
*/
export async function computePfMiniPath(
mapName: string,
from: [number, number],
to: [number, number],
): Promise<PfMiniResult> {
const { game } = await loadMap(mapName);
// Convert coordinates to TileRefs
const fromRef = game.ref(from[0], from[1]);
const toRef = game.ref(to[0], to[1]);
// Validate that both points are water tiles
if (!game.isWater(fromRef)) {
throw new Error(`Start point (${from[0]}, ${from[1]}) is not water`);
}
if (!game.isWater(toRef)) {
throw new Error(`End point (${to[0]}, ${to[1]}) is not water`);
}
// Compute PathFinder.Mini path
const pfMiniAdapter = getPfMiniAdapter(mapName, game);
const pfMiniStart = performance.now();
const pfMiniPath = pfMiniAdapter.findPath(fromRef, toRef);
const pfMiniEnd = performance.now();
const path = pathToCoords(pfMiniPath, game);
const time = pfMiniEnd - pfMiniStart;
return {
path,
length: path ? path.length : 0,
time,
};
}
/**
* Clear pathfinding adapter caches
*/
export function clearAdapterCaches() {
pfMiniCache.clear();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pathfinding Playground - Interactive Visualization</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!-- Fullscreen map container -->
<div class="canvas-container">
<div class="canvas-wrapper" id="canvasWrapper">
<canvas id="mapCanvas"></canvas>
<canvas id="overlayCanvas"></canvas>
</div>
<!-- Interactive canvas added dynamically outside wrapper -->
</div>
<!-- Welcome screen -->
<div class="welcome-screen" id="welcomeScreen">
<div class="welcome-content">
<h1>Pathfinding Playground</h1>
<p>Interactive visualization for naval pathfinding algorithms</p>
<!-- Map grid -->
<div class="map-grid" id="mapGrid">
<div class="map-card" data-map-index="0">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 1</div>
</div>
<div class="map-card" data-map-index="1">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 2</div>
</div>
<div class="map-card" data-map-index="2">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 3</div>
</div>
<div class="map-card" data-map-index="3">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 4</div>
</div>
<div class="map-card" data-map-index="4">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 5</div>
</div>
<div class="map-card" data-map-index="5">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 6</div>
</div>
<div class="map-card" data-map-index="6">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 7</div>
</div>
<div class="map-card" data-map-index="7">
<img
width="360"
height="180"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='360' height='180'%3E%3Crect fill='%23333' width='360' height='180'/%3E%3C/svg%3E"
alt="Loading..."
/>
<div class="map-card-name" style="opacity: 0">Map 8</div>
</div>
</div>
<!-- Dropdown for other maps -->
<div class="welcome-selector">
<label for="welcomeMapSelect">Or select from all maps:</label>
<select id="welcomeMapSelect">
<option value="">Loading maps...</option>
</select>
</div>
</div>
</div>
<!-- Top overlay panel -->
<div class="top-panel">
<h1>Pathfinding Playground</h1>
<div class="scenario-selector">
<select id="scenarioSelect">
<option value="">Loading...</option>
</select>
</div>
<div class="status-section">
<span id="status">Select a scenario to begin</span>
</div>
</div>
<!-- Debug controls panel (left) -->
<div class="debug-panel">
<div class="debug-panel-row">
<button class="toggle-button" id="showInitialPath" data-active="false">
Initial Path
</button>
<button class="toggle-button" id="showUsedGateways" data-active="false">
Used Gateways
</button>
</div>
<div class="debug-panel-row">
<button class="toggle-button" id="showGateways" data-active="false">
Gateways
</button>
<button class="toggle-button" id="showSectorGrid" data-active="false">
Sectors
</button>
<button class="toggle-button" id="showEdges" data-active="false">
Edges
</button>
</div>
</div>
<!-- View controls panel (right) -->
<div class="view-panel">
<div class="zoom-control">
<input type="range" id="zoom" min="0.1" max="5" step="0.1" value="1" />
<span id="zoomValue">1.0x</span>
</div>
<button class="toggle-button" id="showColoredMap" data-active="false">
Colored Map
</button>
<button id="clearPoints" class="clear-button">Clear Points</button>
</div>
<!-- Timings panel (left side) -->
<div class="timings-panel" id="timingsPanel">
<div class="timings-header">Performance</div>
<div class="timing-section">
<div class="timing-label">
<button
class="refresh-icon"
id="refreshNavMesh"
title="Recompute NavMesh path"
>
<span></span>
</button>
NavMesh <span class="timing-label-detail" id="navMeshTiles"></span>
</div>
<div class="timing-value-large" id="navMeshTime"></div>
<div class="timing-breakdown" id="timingBreakdown">
<div class="timing-item" id="timingEarlyExit" style="display: none">
<span class="timing-name">Early Exit:</span>
<span class="timing-value" id="timingEarlyExitValue"></span>
</div>
<div
class="timing-item"
id="timingFindGateways"
style="display: none"
>
<span class="timing-name">Find Gateways:</span>
<span class="timing-value" id="timingFindGatewaysValue"></span>
</div>
<div class="timing-item" id="timingGatewayPath" style="display: none">
<span class="timing-name">Gateway Path:</span>
<span class="timing-value" id="timingGatewayPathValue"></span>
</div>
<div class="timing-item" id="timingInitialPath" style="display: none">
<span class="timing-name">Initial Path:</span>
<span class="timing-value" id="timingInitialPathValue"></span>
</div>
<div class="timing-item" id="timingSmoothPath" style="display: none">
<span class="timing-name">Smooth Path:</span>
<span class="timing-value" id="timingSmoothPathValue"></span>
</div>
</div>
</div>
<div class="timing-section" id="pfMiniRequestSection">
<button
id="requestPfMini"
class="timing-button"
title="PathFinder.Mini is slow (50-1800ms per path). Click to compare."
disabled
>
Request PathFinder.Mini
</button>
</div>
<div
class="timing-section"
id="pfMiniTimingSection"
style="display: none"
>
<div class="timing-label">
<button
class="refresh-icon"
id="refreshPfMini"
title="Recompute PF.Mini path"
>
<span></span>
</button>
PF.Mini <span class="timing-label-detail" id="pfMiniTiles"></span>
</div>
<div class="timing-value-large" id="pfMiniTime"></div>
</div>
<div class="timing-section" id="speedupSection" style="display: none">
<div class="timing-label">Speedup</div>
<div class="timing-value-speedup" id="speedupValue"></div>
</div>
</div>
<!-- Legend panel -->
<div class="legend-panel">
<div class="legend-header">Legend</div>
<div class="legend">
<div class="legend-item">
<div
class="legend-color"
style="background: #ff4444; height: 8px"
></div>
<span>Start Point</span>
</div>
<div class="legend-item">
<div
class="legend-color"
style="background: #44ff44; height: 8px"
></div>
<span>End Point</span>
</div>
<div class="legend-item" id="pfMiniLegend" style="display: none">
<div class="legend-color" style="background: #ffaa00"></div>
<span>PathFinder.Mini</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #00ffff"></div>
<span>NavMesh</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ff00ff"></div>
<span>Initial Path</span>
</div>
<div class="legend-item">
<div
class="legend-color"
style="background: #ffff00; height: 8px"
></div>
<span>Used Gateways</span>
</div>
<div class="legend-item">
<div
class="legend-color"
style="
background: #aaaaaa;
height: 6px;
width: 6px;
border-radius: 50%;
"
></div>
<span>Gateways</span>
</div>
<div class="legend-item">
<div
class="legend-color"
style="background: #777777; height: 2px"
></div>
<span>Sectors</span>
</div>
<div class="legend-item">
<div
class="legend-color"
style="background: #00ffaa; height: 2px"
></div>
<span>Edges</span>
</div>
</div>
</div>
<!-- Error notification (toast) -->
<div id="error" class="error-toast"></div>
<!-- Tooltip -->
<div id="tooltip"></div>
<script src="client.js"></script>
</body>
</html>
@@ -0,0 +1,795 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: #3c3c3c;
color: #e0e0e0;
overflow: hidden;
}
/* Welcome screen */
.welcome-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(28, 28, 28, 0.98);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.welcome-screen.hidden {
display: none;
}
.welcome-content {
text-align: center;
max-width: 1100px;
padding: 40px;
background: rgba(42, 42, 42, 0.95);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.welcome-content h1 {
font-size: 42px;
color: #fff;
margin: 0 0 16px 0;
font-weight: 600;
}
.welcome-content p {
font-size: 18px;
color: #aaa;
margin: 0 0 30px 0;
line-height: 1.5;
}
/* Map grid */
.map-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 30px;
}
.map-card {
background: #1a1a1a;
border: 2px solid #404040;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.map-card:hover {
border-color: #0066cc;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3);
}
.map-card img {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
}
.map-card-name {
padding: 10px;
font-size: 14px;
color: #e0e0e0;
font-weight: 500;
text-align: center;
background: #2a2a2a;
transition: opacity 0.3s ease;
}
.welcome-selector {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.welcome-selector label {
font-size: 14px;
color: #aaa;
font-weight: 400;
}
.welcome-selector select {
width: 400px;
padding: 14px 18px;
font-size: 16px;
background: #1a1a1a;
color: #e0e0e0;
border: 2px solid #404040;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
}
.welcome-selector select:hover {
border-color: #0066cc;
}
.welcome-selector select:focus {
outline: none;
border-color: #0066cc;
}
/* Fullscreen canvas container */
.canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #3c3c3c;
z-index: 1;
}
.canvas-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #0a0a0a;
cursor: grab;
}
.canvas-wrapper:active {
cursor: grabbing;
}
.canvas-wrapper.selecting {
cursor: crosshair;
}
canvas {
position: absolute;
top: 0;
left: 0;
display: block;
transform-origin: 0 0;
}
#mapCanvas {
z-index: 1;
}
#overlayCanvas {
z-index: 2;
pointer-events: none;
}
/* Top panel - overlay on map */
.top-panel {
position: fixed;
top: 20px;
left: 20px;
max-width: 900px;
background: rgba(42, 42, 42, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.top-panel h1 {
font-size: 24px;
color: #fff;
margin: 0 0 15px 0;
}
.scenario-selector {
margin-bottom: 15px;
}
.status-section {
padding-top: 15px;
border-top: 1px solid #404040;
}
.scenario-selector select {
width: 350px;
padding: 12px 16px;
font-size: 16px;
background: #1a1a1a;
color: #e0e0e0;
border: 1px solid #404040;
border-radius: 6px;
cursor: pointer;
}
.scenario-selector select:focus {
outline: none;
border-color: #0066cc;
}
.info {
padding-top: 15px;
border-top: 1px solid #404040;
}
.info-row {
display: flex;
gap: 30px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
}
/* Debug panel (bottom left) */
.debug-panel {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(42, 42, 42, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 10px;
}
.debug-panel-row {
display: flex;
gap: 10px;
align-items: center;
}
/* View panel (bottom right) */
.view-panel {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(42, 42, 42, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 10px;
min-width: 160px;
}
.zoom-control {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #404040;
margin-bottom: 2px;
}
.zoom-control input[type="range"] {
width: 100%;
}
.zoom-control span {
font-size: 13px;
color: #aaa;
font-weight: 500;
}
.clear-button {
background: #cc3333;
color: white;
border: 2px solid #dd4444;
padding: 10px 12px 10px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
width: 100%;
}
.clear-button:hover {
background: #aa2222;
border-color: #cc3333;
}
.toggle-button {
background: #333;
color: #e0e0e0;
border: 2px solid #555;
padding: 10px 12px 10px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
min-width: 100px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.toggle-button::before {
content: "☐";
font-size: 24px;
line-height: 1;
color: #888;
}
.toggle-button:hover {
background: #404040;
border-color: #666;
}
.toggle-button[data-active="true"] {
background: #0066cc;
border-color: #0088ff;
color: white;
box-shadow: 0 0 10px rgba(0, 102, 204, 0.4);
}
.toggle-button[data-active="true"]::before {
content: "☑";
color: #00ff88;
}
.toggle-button[data-active="true"]:hover {
background: #0052a3;
border-color: #0066cc;
}
/* Timings panel (left side) */
.timings-panel {
position: fixed;
top: 250px;
left: 20px;
background: rgba(42, 42, 42, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 20px 20px 15px 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 280px;
}
.timings-header {
display: none;
}
.timing-section {
margin-bottom: 0;
padding-bottom: 0;
}
.timing-section + .timing-section {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #404040;
}
.timing-label {
font-size: 18px;
color: #e0e0e0;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.refresh-icon {
background: none;
border: none;
color: #00aaff;
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
transition: color 0.2s;
border-radius: 4px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
transform: translateY(1px);
}
.refresh-icon span {
display: block;
line-height: 1;
}
.refresh-icon:hover {
color: #00ccff;
background: rgba(0, 170, 255, 0.1);
}
.refresh-icon.spinning span {
animation: spin 0.6s ease-in-out;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.timing-label-detail {
color: #888;
text-transform: none;
font-size: 14px;
}
.timing-value-large {
font-size: 48px;
font-weight: bold;
color: #00ff88;
font-family: "Courier New", monospace;
line-height: 1;
margin-bottom: 15px;
}
.timing-value-large.faded {
color: #888888;
opacity: 0.7;
}
.timing-value-speedup {
font-size: 36px;
font-weight: bold;
color: #ffaa00;
font-family: "Courier New", monospace;
line-height: 1;
}
.timing-breakdown {
margin-top: 15px;
}
.timing-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
font-size: 20px;
}
.timing-name {
color: #e0e0e0;
}
.timing-value {
font-family:
"Consolas", "Monaco", "SF Mono", "Roboto Mono", "Courier New", monospace;
color: #f5f5f5;
font-weight: bold;
font-size: 20px;
}
/* Legend panel (right side) */
.legend-panel {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
background: rgba(42, 42, 42, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 10;
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 160px;
}
.legend-header {
font-size: 16px;
font-weight: 600;
color: #e0e0e0;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #404040;
}
.legend {
display: flex;
flex-direction: column;
gap: 10px;
}
.legend-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: #e0e0e0;
}
.legend-color {
width: 28px;
height: 4px;
border-radius: 2px;
flex-shrink: 0;
}
/* Form elements */
label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
}
input[type="range"] {
width: 120px;
}
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
button {
background: #0066cc;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
button:hover {
background: #0052a3;
}
button:disabled {
background: #404040;
cursor: not-allowed;
opacity: 0.6;
}
.timing-button {
width: 100%;
background: #555;
color: white;
border: none;
padding: 10px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.timing-button:hover:not(:disabled) {
background: #666;
}
.timing-button:disabled {
background: #404040;
cursor: not-allowed;
opacity: 0.6;
}
#status {
font-size: 14px;
color: #888;
font-style: italic;
}
#status.loading {
color: #00aaff;
}
#status.error {
color: #ff6b6b;
}
/* Error toast notification */
.error-toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(138, 26, 26, 0.98);
color: #ff6b6b;
padding: 20px 30px;
border-radius: 12px;
font-size: 16px;
font-weight: 500;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.7);
z-index: 100;
display: none;
max-width: 500px;
text-align: center;
border: 2px solid #ff6b6b;
animation: slideIn 0.3s ease-out;
}
.error-toast.visible {
display: block;
}
@keyframes slideIn {
from {
transform: translate(-50%, -60%);
opacity: 0;
}
to {
transform: translate(-50%, -50%);
opacity: 1;
}
}
/* Tooltip */
#tooltip {
position: fixed;
background: rgba(0, 0, 0, 0.95);
color: #fff;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
display: none;
border: 1px solid #666;
max-width: 350px;
line-height: 1.5;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
#tooltip.visible {
display: block;
}
/* Loading spinner */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #404040;
border-top-color: #00aaff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive adjustments */
@media (max-width: 1200px) {
.top-panel {
left: 10px;
right: 10px;
padding: 15px;
}
.debug-panel {
left: 10px;
padding: 12px;
gap: 8px;
}
.debug-panel-row {
gap: 8px;
}
.view-panel {
right: 10px;
padding: 12px;
gap: 8px;
min-width: 140px;
}
.toggle-button {
min-width: auto;
padding: 8px 10px 8px 8px;
font-size: 14px;
}
.clear-button {
padding: 8px 10px 8px 8px;
font-size: 14px;
}
.legend-panel {
right: 10px;
max-width: 200px;
}
h1 {
font-size: 20px;
}
}
@media (max-width: 768px) {
.top-panel h1 {
font-size: 18px;
}
.debug-panel {
gap: 6px;
}
.debug-panel-row {
gap: 6px;
}
.view-panel {
gap: 6px;
min-width: 120px;
}
.toggle-button {
min-width: auto;
padding: 7px 8px 7px 6px;
font-size: 13px;
}
.clear-button {
padding: 7px 8px 7px 6px;
font-size: 13px;
}
.timings-panel {
top: auto;
bottom: 180px;
left: 10px;
transform: none;
min-width: auto;
max-width: calc(100vw - 20px);
}
.legend-panel {
top: auto;
bottom: 200px;
right: 10px;
transform: none;
}
}
+269
View File
@@ -0,0 +1,269 @@
import compression from "compression";
import express, { Request, Response } from "express";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import {
clearCache as clearMapCache,
getMapMetadata,
listMaps,
setConfig,
} from "./api/maps.js";
import {
clearAdapterCaches,
computePath,
computePfMiniPath,
} from "./api/pathfinding.js";
// Parse command-line arguments
const args = process.argv.slice(2);
const noCache = args.includes("--no-cache");
// Configure map loading
if (noCache) {
setConfig({ cachePaths: false });
console.log("Path caching disabled (--no-cache)");
}
const app = express();
const PORT = process.env.PORT ?? 5555;
// Middleware
app.use(compression()); // gzip compression for large responses
app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
// Serve static files from public directory
const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
app.use(express.static(publicDir));
// API Routes
/**
* GET /api/maps
* List all available maps
*/
app.get("/api/maps", (req: Request, res: Response) => {
try {
const maps = listMaps();
res.json({ maps });
} catch (error) {
console.error("Error listing maps:", error);
res.status(500).json({
error: "Failed to list maps",
message: error instanceof Error ? error.message : String(error),
});
}
});
/**
* GET /api/maps/:name
* Get map metadata (map data, dimensions)
*/
app.get("/api/maps/:name", async (req: Request, res: Response) => {
try {
const { name } = req.params;
const metadata = await getMapMetadata(name);
res.json(metadata);
} catch (error) {
console.error(`Error loading map ${req.params.name}:`, error);
if (error instanceof Error && error.message.includes("ENOENT")) {
res.status(404).json({
error: "Map not found",
message: `Map "${req.params.name}" does not exist`,
});
} else {
res.status(500).json({
error: "Failed to load map",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* GET /api/maps/:name/thumbnail
* Get map thumbnail image
*/
app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
try {
const { name } = req.params;
const thumbnailPath = join(
dirname(fileURLToPath(import.meta.url)),
"../../../resources/maps",
name,
"thumbnail.webp",
);
res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
res.status(404).json({
error: "Thumbnail not found",
message: error instanceof Error ? error.message : String(error),
});
}
});
/**
* POST /api/pathfind
* Compute pathfinding between two points
*
* Request body:
* {
* map: string,
* from: [x, y],
* to: [x, y],
* includePfMini?: boolean
* }
*/
app.post("/api/pathfind", async (req: Request, res: Response) => {
try {
const { map, from, to, includePfMini } = req.body;
// Validate request
if (!map || !from || !to) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, from, to",
});
}
if (
!Array.isArray(from) ||
from.length !== 2 ||
!Array.isArray(to) ||
to.length !== 2
) {
return res.status(400).json({
error: "Invalid coordinates",
message: "from and to must be [x, y] coordinate arrays",
});
}
// Compute paths
const result = await computePath(
map,
from as [number, number],
to as [number, number],
{ includePfMini: !!includePfMini },
);
res.json(result);
} catch (error) {
console.error("Error computing path:", error);
if (error instanceof Error && error.message.includes("is not water")) {
res.status(400).json({
error: "Invalid coordinates",
message: error.message,
});
} else {
res.status(500).json({
error: "Failed to compute path",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* POST /api/pathfind-pfmini
* Compute only PathFinder.Mini path
*
* Request body:
* {
* map: string,
* from: [x, y],
* to: [x, y]
* }
*/
app.post("/api/pathfind-pfmini", async (req: Request, res: Response) => {
try {
const { map, from, to } = req.body;
// Validate request
if (!map || !from || !to) {
return res.status(400).json({
error: "Invalid request",
message: "Missing required fields: map, from, to",
});
}
if (
!Array.isArray(from) ||
from.length !== 2 ||
!Array.isArray(to) ||
to.length !== 2
) {
return res.status(400).json({
error: "Invalid coordinates",
message: "from and to must be [x, y] coordinate arrays",
});
}
// Compute PF.Mini path only
const result = await computePfMiniPath(
map,
from as [number, number],
to as [number, number],
);
res.json(result);
} catch (error) {
console.error("Error computing PF.Mini path:", error);
if (error instanceof Error && error.message.includes("is not water")) {
res.status(400).json({
error: "Invalid coordinates",
message: error.message,
});
} else {
res.status(500).json({
error: "Failed to compute PF.Mini path",
message: error instanceof Error ? error.message : String(error),
});
}
}
});
/**
* POST /api/cache/clear
* Clear all caches (useful for development)
*/
app.post("/api/cache/clear", (req: Request, res: Response) => {
try {
clearMapCache();
clearAdapterCaches();
res.json({ message: "Caches cleared successfully" });
} catch (error) {
console.error("Error clearing caches:", error);
res.status(500).json({
error: "Failed to clear caches",
message: error instanceof Error ? error.message : String(error),
});
}
});
// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: any) => {
console.error("Unhandled error:", err);
res.status(500).json({
error: "Internal server error",
message: err.message,
});
});
// Start server
app.listen(PORT, () => {
console.log(`
╔════════════════════════════════════════════════════════════╗
║ Pathfinding Playground Server ║
╚════════════════════════════════════════════════════════════╝
Server running at: http://localhost:${PORT}
Configuration:
- Path caching: ${noCache ? "disabled" : "enabled"}
Press Ctrl+C to stop
`);
});
+231
View File
@@ -0,0 +1,231 @@
import fs from "fs";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
import {
Difficulty,
Game,
GameMapSize,
GameMapType,
GameMode,
GameType,
PlayerInfo,
} from "../../src/core/game/Game";
import { createGame } from "../../src/core/game/GameImpl";
import { TileRef } from "../../src/core/game/GameMap";
import {
genTerrainFromBin,
MapManifest,
} from "../../src/core/game/TerrainMapLoader";
import { UserSettings } from "../../src/core/game/UserSettings";
import { NavMesh } from "../../src/core/pathfinding/navmesh/NavMesh";
import { PathFinder, PathFinders } from "../../src/core/pathfinding/PathFinder";
import { GameConfig } from "../../src/core/Schemas";
import { TestConfig } from "../util/TestConfig";
export type BenchmarkRoute = {
name: string;
from: TileRef;
to: TileRef;
};
export type BenchmarkResult = {
route: string;
executionTime: number | null;
pathLength: number | null;
};
export type BenchmarkSummary = {
totalRoutes: number;
successfulRoutes: number;
timedRoutes: number;
totalDistance: number;
totalTime: number;
avgTime: number;
};
export function getAdapter(game: Game, name: string): PathFinder {
switch (name) {
case "legacy":
return PathFinders.WaterLegacy(game, {
iterations: 500_000,
maxTries: 50,
});
case "hpa": {
// Recreate NavMesh without cache, this approach was chosen
// over adding cache toggles to the existing game instance
// to avoid adding side effect from benchmark to the game
const navMesh = new NavMesh(game, { cachePaths: false });
navMesh.initialize();
(game as any)._navMesh = navMesh;
return PathFinders.Water(game);
}
case "hpa.cached":
return PathFinders.Water(game);
default:
throw new Error(`Unknown pathfinding adapter: ${name}`);
}
}
export async function getScenario(
scenarioName: string,
adapterName: string = "hpa",
) {
const scenario = await import(`./benchmark/scenarios/${scenarioName}.js`);
const enableNavMesh = adapterName.startsWith("hpa");
// Time game creation (includes NavMesh initialization for default adapter)
const start = performance.now();
const currentDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(currentDir, "../..");
const mapsDirectory = path.join(projectRoot, "resources/maps");
const game = await setupFromPath(mapsDirectory, scenario.MAP_NAME, {
disableNavMesh: !enableNavMesh,
});
const initTime = performance.now() - start;
const routes = scenario.ROUTES.map(([fromName, toName]: [string, string]) => {
const fromCoord: [number, number] = scenario.PORTS[fromName];
const toCoord: [number, number] = scenario.PORTS[toName];
return {
name: `${fromName}${toName}`,
from: game.ref(fromCoord[0], fromCoord[1]),
to: game.ref(toCoord[0], toCoord[1]),
};
});
return {
game,
routes,
initTime,
};
}
export function measurePathLength(
adapter: PathFinder,
route: BenchmarkRoute,
): number | null {
const path = adapter.findPath(route.from, route.to);
return path ? path.length : null;
}
export function measureTime<T>(fn: () => T): { result: T; time: number } {
const start = performance.now();
const result = fn();
const end = performance.now();
return { result, time: end - start };
}
export function measureExecutionTime(
adapter: PathFinder,
route: BenchmarkRoute,
executions: number = 1,
): number | null {
const { time } = measureTime(() => {
for (let i = 0; i < executions; i++) {
adapter.findPath(route.from, route.to);
}
});
return time / executions;
}
export function calculateStats(results: BenchmarkResult[]): BenchmarkSummary {
const successful = results.filter((r) => r.pathLength !== null);
const timed = results.filter((r) => r.executionTime !== null);
const totalDistance = successful.reduce((sum, r) => sum + r.pathLength!, 0);
const totalTime = timed.reduce((sum, r) => sum + r.executionTime!, 0);
const avgTime = timed.length > 0 ? totalTime / timed.length : 0;
return {
totalRoutes: results.length,
successfulRoutes: successful.length,
timedRoutes: timed.length,
totalDistance,
totalTime,
avgTime,
};
}
export function printRow(columns: (string | number)[], widths: number[]): void {
const formatted = columns.map((col, i) => {
const str = typeof col === "number" ? col.toString() : col;
return str.padEnd(widths[i]);
});
console.log(formatted.join(" "));
}
export function printSeparator(width: number = 80): void {
console.log("-".repeat(width));
}
export function printHeader(title: string, width: number = 80): void {
printSeparator(width);
console.log(title);
printSeparator(width);
console.log("");
}
export async function setupFromPath(
mapDirectory: string,
mapName: string,
gameConfig: Partial<GameConfig> = {},
humans: PlayerInfo[] = [],
): Promise<Game> {
// Suppress console.debug for tests
console.debug = () => {};
// Load map files from specified directory
const mapBinPath = path.join(mapDirectory, mapName, "map.bin");
const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");
const manifestPath = path.join(mapDirectory, mapName, "manifest.json");
// Check if files exist
if (!fs.existsSync(mapBinPath)) {
throw new Error(`Map not found: ${mapBinPath}`);
}
if (!fs.existsSync(miniMapBinPath)) {
throw new Error(`Mini map not found: ${miniMapBinPath}`);
}
if (!fs.existsSync(manifestPath)) {
throw new Error(`Manifest not found: ${manifestPath}`);
}
const mapBinBuffer = fs.readFileSync(mapBinPath);
const miniMapBinBuffer = fs.readFileSync(miniMapBinPath);
const manifest = JSON.parse(
fs.readFileSync(manifestPath, "utf8"),
) satisfies MapManifest;
const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer);
const miniGameMap = await genTerrainFromBin(manifest.map4x, miniMapBinBuffer);
// Configure the game
const config = new TestConfig(
new (await import("../util/TestServerConfig")).TestServerConfig(),
{
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
gameType: GameType.Singleplayer,
difficulty: Difficulty.Medium,
disableNations: false,
donateGold: false,
donateTroops: false,
bots: 0,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
randomSpawn: false,
...gameConfig,
},
new UserSettings(),
false,
);
return createGame(humans, [], gameMap, miniGameMap, config);
}
+4 -4
View File
@@ -1,7 +1,7 @@
import Benchmark from "benchmark";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { PathFinder } from "../../src/core/pathfinding/PathFinding";
import { MiniPathFinder } from "../../src/core/pathfinding/PathFinding";
import { setup } from "../util/Setup";
const game = await setup(
@@ -13,19 +13,19 @@ const game = await setup(
new Benchmark.Suite()
.add("top-left-to-bottom-right", () => {
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
game.ref(0, 0),
game.ref(4077, 1929),
);
})
.add("hawaii to svalbard", () => {
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
game.ref(186, 800),
game.ref(2205, 52),
);
})
.add("black sea to california", () => {
PathFinder.Mini(game, 10_000_000_000, true, 1).nextTile(
new MiniPathFinder(game, 10_000_000_000, true, 1).nextTile(
game.ref(2349, 455),
game.ref(511, 536),
);
+325
View File
@@ -0,0 +1,325 @@
{
"map": {
"height": 1000,
"num_land_tiles": 651761,
"width": 2000
},
"map16x": {
"height": 250,
"num_land_tiles": 37185,
"width": 500
},
"map4x": {
"height": 500,
"num_land_tiles": 157860,
"width": 1000
},
"name": "World",
"nations": [
{
"coordinates": [375, 272],
"flag": "us",
"name": "United States"
},
{
"coordinates": [372, 136],
"flag": "ca",
"name": "Canada"
},
{
"coordinates": [375, 374],
"flag": "mx",
"name": "Mexico"
},
{
"coordinates": [500, 378],
"flag": "cu",
"name": "Cuba"
},
{
"coordinates": [524, 474],
"flag": "co",
"name": "Colombia"
},
{
"coordinates": [593, 473],
"flag": "ve",
"name": "Venezuela"
},
{
"coordinates": [596, 705],
"flag": "ar",
"name": "Argentina"
},
{
"coordinates": [637, 567],
"flag": "br",
"name": "Brazil"
},
{
"coordinates": [1280, 975],
"flag": "aq",
"name": "Antarctica"
},
{
"coordinates": [709, 57],
"flag": "gl",
"name": "Greenland"
},
{
"coordinates": [831, 112],
"flag": "is",
"name": "Iceland"
},
{
"coordinates": [925, 186],
"flag": "gb",
"name": "United Kingdom"
},
{
"coordinates": [887, 183],
"flag": "ie",
"name": "Ireland"
},
{
"coordinates": [908, 264],
"flag": "es",
"name": "Spain"
},
{
"coordinates": [1004, 250],
"flag": "it",
"name": "Italy"
},
{
"coordinates": [958, 220],
"flag": "fr",
"name": "France"
},
{
"coordinates": [997, 205],
"flag": "de",
"name": "Germany"
},
{
"coordinates": [1064, 101],
"flag": "se",
"name": "Sweden"
},
{
"coordinates": [1046, 193],
"flag": "pl",
"name": "Poland"
},
{
"coordinates": [1061, 188],
"flag": "by",
"name": "Belarus"
},
{
"coordinates": [1073, 243],
"flag": "ro",
"name": "Romania"
},
{
"coordinates": [1161, 274],
"flag": "tr",
"name": "Turkey"
},
{
"coordinates": [969, 133],
"flag": "no",
"name": "Norway"
},
{
"coordinates": [1062, 133],
"flag": "fi",
"name": "Finland"
},
{
"coordinates": [1099, 211],
"flag": "ua",
"name": "Ukraine"
},
{
"coordinates": [1344, 136],
"flag": "ru",
"name": "Russia"
},
{
"coordinates": [1537, 186],
"flag": "mn",
"name": "Mongolia"
},
{
"coordinates": [1524, 328],
"flag": "cn",
"name": "China"
},
{
"coordinates": [1368, 373],
"flag": "in",
"name": "India"
},
{
"coordinates": [1276, 239],
"flag": "kz",
"name": "Kazakhstan"
},
{
"coordinates": [1238, 309],
"flag": "ir",
"name": "Islamic Republic Of Iran"
},
{
"coordinates": [1178, 351],
"flag": "sa",
"name": "Saudi Arabia"
},
{
"coordinates": [1679, 657],
"flag": "au",
"name": "Australia"
},
{
"coordinates": [1890, 775],
"flag": "nz",
"name": "New Zealand"
},
{
"coordinates": [918, 342],
"flag": "dz",
"name": "Algeria"
},
{
"coordinates": [1030, 332],
"flag": "ly",
"name": "Libyan Arab Jamahiriya"
},
{
"coordinates": [1092, 335],
"flag": "eg",
"name": "Egypt"
},
{
"coordinates": [963, 410],
"flag": "ne",
"name": "Niger"
},
{
"coordinates": [1112, 406],
"flag": "sd",
"name": "Sudan"
},
{
"coordinates": [1074, 508],
"flag": "cd",
"name": "DR Congo"
},
{
"coordinates": [1154, 443],
"flag": "et",
"name": "Ethiopia"
},
{
"coordinates": [1075, 707],
"flag": "za",
"name": "South Africa"
},
{
"coordinates": [1194, 627],
"flag": "mg",
"name": "Madagascar"
},
{
"coordinates": [1052, 420],
"flag": "td",
"name": "Chad"
},
{
"coordinates": [1030, 665],
"flag": "na",
"name": "Namibia"
},
{
"coordinates": [1632, 465],
"flag": "ph",
"name": "Philippines"
},
{
"coordinates": [1537, 426],
"flag": "th",
"name": "Thailand"
},
{
"coordinates": [1610, 364],
"flag": "tw",
"name": "Taiwan"
},
{
"coordinates": [1710, 290],
"flag": "jp",
"name": "Japan"
},
{
"coordinates": [1869, 119],
"flag": "ru",
"name": "Siberia"
},
{
"coordinates": [74, 117],
"flag": "polar_bears",
"name": "Polar Bears"
},
{
"coordinates": [419, 975],
"flag": "aq",
"name": "West Antarctica"
},
{
"coordinates": [542, 603],
"flag": "pe",
"name": "Peru"
},
{
"coordinates": [1075, 615],
"flag": "zm",
"name": "Zambia"
},
{
"coordinates": [1099, 165],
"flag": "lv",
"name": "Latvia"
},
{
"coordinates": [1427, 336],
"flag": "bt",
"name": "Bhutan"
},
{
"coordinates": [1511, 524],
"flag": "id",
"name": "Indonesia"
},
{
"coordinates": [1809, 977],
"flag": "aq",
"name": "East Antarctica"
},
{
"coordinates": [1255, 382],
"flag": "om",
"name": "Oman"
},
{
"coordinates": [853, 373],
"flag": "ma",
"name": "Morocco"
},
{
"coordinates": [656, 678],
"flag": "uy",
"name": "Uruguay"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+4
View File
@@ -14,6 +14,10 @@ export class TestConfig extends DefaultConfig {
private _defaultNukeSpeed: number = 4;
private _spawnImmunityDuration: number = 0;
disableNavMesh(): boolean {
return this.gameConfig().disableNavMesh ?? true;
}
radiusPortSpawn(): number {
return 1;
}