Skip to content

Comments

Various bug fixes and performance fixes#211

Merged
1brucben merged 34 commits intov0.2.3aifrom
feb26
Feb 23, 2026
Merged

Various bug fixes and performance fixes#211
1brucben merged 34 commits intov0.2.3aifrom
feb26

Conversation

@1brucben
Copy link
Owner

Multiple bug fixes.
This pull request introduces several improvements and refactors across the codebase, focusing on UI/UX enhancements, code maintainability, and backend integration. The most notable changes include the addition of a robust lobby update system with WebSocket and HTTP fallback, improvements to game configuration update handling, and a series of map and naming consistency updates.

Lobby system and event handling improvements:

  • Added a new PublicLobbySocket class in src/client/LobbySocket.ts to manage real-time lobby updates via WebSocket, with automatic fallback to HTTP polling if the WebSocket connection fails. This ensures users always see up-to-date lobby information.
  • Integrated a new custom event "update-game-config" in HostLobbyModal (src/client/HostLobbyModal.ts) and wired it into the main client event system (src/client/Main.ts). This decouples the UI from direct HTTP calls and allows for more flexible game configuration updates. [1] [2] [3] [4] [5]

Gameplay and UI enhancements:

  • Improved boat attack logic in ClientGameRunner (src/client/ClientGameRunner.ts), including a new canAutoBoat method for smarter auto-boat attack decisions and refactored related logic for clarity and maintainability. [1] [2] [3]
  • Added a "Spectate" option to the English language resources, enhancing the user interface for game spectators.

Map and naming consistency updates:

  • Standardized nation names across multiple map files for clarity (e.g., "DR Congo" instead of full country names, "Algeria" instead of longer forms, etc.). [1] [2] [3] [4] [5] [6]
  • Added a comprehensive test map map-generator/assets/test_maps/world/info.json listing nations and their coordinates, likely for development or testing purposes.

Build and project structure:

  • Removed the gatekeeper submodule from .gitmodules, indicating a change in how dependencies are managed.
  • Updated ESLint configuration to ignore additional test playground files, reducing unnecessary linting noise.

1brucben and others added 30 commits February 21, 2026 01:31
…to eliminate gatekeeper usage for rate limiting and security checks.
in src/core/pseudorandom.ts
a helper function of nextint is defined but never used.
instead, this.rng is used. those are replaced.

also, shuffleArray originally would self-shuffle at i=0 for the
conditional being i>=0, fixed to 0.

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

jack_45183
Player names are hidden, except small sized fonts, when zoomed in too
much(near max), improving user experience by allowing users to see
structures clearly without the obstruction of text

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file(no need)
- [x] I have added relevant tests to the test directory(n/a)
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
[video](https://streamable.com/e/mlrfqo?)
regression is found:
_federalagent
Added tests/NationNameLength.test.ts to enforce nation name length ≤27.
Updated manifest.json files in resources/maps with shorter names to pass
the test.
This fix ensures that names will no longer be truncated

Examples of country names that are too long and have their endings cut
off
<img width="231" height="331" alt="スクリーンショット 2025-10-01 10 51 40"
src="https://github.com/user-attachments/assets/8c4611ab-f97b-4606-9834-7816dbd1ee8d"
/>
- Replaced overly long country names with shorter common forms:
  - "The Democratic Republic of the Congo" -> "DR Congo"
  - "Democratic Republic of the Congo" -> "DR Congo"
  - "Lao People's Democratic Republic" -> "Laos"
  - "Federated States of Micronesia" -> "Micronesia"
  - "People's Democratic Republic of Algeria" -> "Algeria"
  - "People's Republic of Algeria'" -> "Algeria"

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

aotumuri
Fix null socket crashing the log

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

Mr.Box
…57 (#2365)

Before, the condition was "if it's not singleplayer", but replays are
counted as singleplayer game for some reason (will need to fix the
underlying issue) so it wasn't robust enough Now the condition is based
on are we in replay or not,.

Related to issue openfrontio/OpenFrontIO#2357

I cannot get to replay games locally for some reason (client just throws
an error that it cannot load the lobby), so I made sure that the
singleplayer text did not change (at least no regression) cf screenshot:

<img width="1920" height="963" alt="Screenshot 2025-11-02 at 17 46 57"
src="https://github.com/user-attachments/assets/27e055a8-3813-46bd-a8ae-0c463a94d1a8"
/>

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

sorikairo
Previously, the zod schemas for troop and gold donation allowed for
negative values which could open the game up to vulnerabilities through
undefined behavior in the future. We mitigate these vulnerabilities but
adding `.nonnegative` to the `DonateGoldIntentSchema` and
`DonateTroopIntentShcema` respectively. Today, code exists to prevent
this deeper in the codebase, but we should also prevent this earlier if
possible during intent validation.

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

haticus
- Implemented a new server for pathfinding playground with API routes for maps and pathfinding.
- Added utility functions for benchmarking pathfinding performance.
- Created a comprehensive test map manifest and associated binary files for testing.
- Enhanced setup utility to support binary map formats and legacy PNG formats.
- Introduced performance tests for A* pathfinding with various scenarios.
- Updated HTML structure to replace references from "Gateways" to "Nodes" and "Used Gateways" to "Used Nodes".
- Changed button IDs and labels to reflect the new pathfinding algorithm (HPA*).
- Removed unused PF.Mini pathfinding API endpoint and adjusted the main pathfinding endpoint to accept comparison adapters.
- Enhanced CSS styles for comparison rows in the UI.
- Updated pathfinding utility functions to support new pathfinding algorithms and configurations.
- Deleted obsolete performance test for A* pathfinding.
This PR introduces final change to the pathfinding - path refinement. It
optimizes Line of Sight refinement by searching with for the best tile
with a binary search instead of linearly. And then spends the recovered
budget on better refinement of the first and last 50 tiles of the
journey - the place where user is most likely to look at. Additionally
this PR re-introduces magnitude check and makes the ships prefer sailing
close to the coast, but not too close.

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

| Before | After |
| :--- | :--- |
| <img width="1097" height="1117" alt="image"
src="https://github.com/user-attachments/assets/4a0b300d-10ef-4151-b6dc-33acfb49f992"
/> | <img width="1093" height="1119" alt="image"
src="https://github.com/user-attachments/assets/cf81c515-c145-40f4-91e5-a4353986907b"
/> |
| <img width="1096" height="1129" alt="image"
src="https://github.com/user-attachments/assets/21b46bce-f961-4259-88f6-fe4a66180270"
/> | <img width="1098" height="1126" alt="image"
src="https://github.com/user-attachments/assets/d92587d1-e6b6-4353-b4a4-1efe71bca43d"
/> |

There is actually a severe performance impact of these changes. The path
initial path takes almost 2x as long to generate - this is because pre
processing can only do so much if the initial path is ugly. Luckily in
real gameplay we only need to do this calculation once per edge, so the
actual observed performance impact should be much smaller. Cache FTW.

| | No Cache | Cache |
| :--- | :--- | :--- |
| Before | 277.04ms | 208.58ms |
| After | 498.34ms | 264.27ms |

Small utility, it allows any code to be easily instrumented for
performance. The idea is the same as with [OTEL
Spans](https://opentelemetry.io/docs/concepts/signals/traces/). Produce
a span, create sub-spans, measure whatever you need. Works only when
`globalThis.__DEBUG_SPAN_ENABLED__ === true`, otherwise no-op.

Cool stuff, try it out:
```ts
// Convenient wrapper, small performance impact
return DebugSpan.wrap('add', () => a + b)

// Synchronous API, basically free
DebugSpan.start('work')
work()
DebugSpan.end()

// Create sub spans
DebugSpan.wrap('complex', () => {
  const aPlusB = DebugSpan.wrap('add', () => a + b)
  DebugSpan.set('additionResult', () => aPlusB)  // Store data
  return aPlusB * c
})

// Access spans, data and timing
const span = DebugSpan.getLast()
const compelxSpan = DebugSpan.getLast('complex')

console.log(complexSpan.duration, complexSpan.data['additionResult'])
```

These are virtually free and can be enabled on-demand **in production**
and available in the devtools. Under the hood devtools integration is
just a wrapper around [Performance
API](https://developer.mozilla.org/en-US/docs/Web/API/Performance_API).
For clarity data keys not prefixed by `$` are omitted from the
integration. Every key prefixed with `$` must be fully JSON
serializable.

<img width="977" height="799" alt="image"
src="https://github.com/user-attachments/assets/b4d43506-1639-4f78-a611-30e61de12a07"
/>
https://pf-pt-4.openfront.dev/

Hello again! Pathfinding. It's fast, but inaccurate. This PR makes it
more accurate and actually faster. Sadly it is _faster_ because of a
blunder in previous PR (using BucketQueue where MinHeap would be
better), not because of a new tech. More importantly, it is more
accurate. And that's what people apparently want.

Most of the functional changes relate to `SpatialQuery` module. This is
the thingy that answers "we know the target, which tile of my territory
is the best to launch an invasion". To make it compute a path from South
America to the deep inland China river, it has to work on a coerced map,
one with a very small resolution, so small in fact, that every 4096 map
tiles gets compressed to just one pixel. I hope you see where this is
going.

Previously we selected a random coastal tile within this big pixel
(honestly it wasn't random at all, but could very well be for the
illustrative purposes). Now, we try to be a bit more deliberate. Since
we already know the rough location of the probably best tile, we can
exclude all other tiles from the computation. Imagine a player's
territory spans both Americas on global map - that's a lot of shores.
But since we already know the best tile is somewhere close to Miami, the
problem space was greatly reduced, no need to consider all other shores.
But pathing to the target in China from Miami is still crazy expensive.

This is where second trick comes to play - instead of pathing all the
way to China, we select a _waypoint_ in the rough direction of China,
about 100 to 200 tiles away. This way we fairly cheaply select best tile
to launch an invasion towards this abstract point. And chances are, this
point is far enough, the newly computed path is very close to being
optimal. When you throw a dart from far away, the difference between
scoring 10 and missing is very small. This is why aiming in the general
direction of the board - as opposed to the ceiling - is usually good
enough.
opposed bank of a river?!

Well, pathing from America to China is cool, but most players wouldn't
notice the difference on such long paths, what about the short ones? We
now try more accurate pathing first and defer to hierarchy only if it
fails. This produces much better paths for short invasions. While the
fix described above ensures the accuracy is improved also on
medium-to-long routes.

Yes.

https://github.com/user-attachments/assets/9cf9586f-c99a-416d-b856-8cf0a21c35ed

Grab a 🥕. Remember `tests/pathfinding/playground` is mostly generated
code and go easy on it. It's enough for it to work and do it's job of
visualizing the paths. No need for throughout review of these files.

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

moleole
…oercingTransformer (#2937)

**Describe the PR.**

This PR improves how pathfinding finds a starting water tile when
launching a transport ship from a shore.

Previously, the code simply picked the first water neighbor it found.
This caused issues where, if a boat were traveling east, it might launch
out of a northern tile from a shore.

<img width="896" height="353" alt="image"
src="https://github.com/user-attachments/assets/69d83012-3397-43b3-8ab0-9ebde6ffea97"
/>

<img width="342" height="219" alt="image"
src="https://github.com/user-attachments/assets/a191f5cf-97da-4e34-a191-55ce14c794f0"
/>

The new logic checks all water neighbors and picks the "best" one by
counting how many water tiles surround it. This ensures transport ships
launch into the main body of water instead of suboptimal positions.

If two tiles have water neighbors with the same score, they are
tie-broken through a euclidean distance check.

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

Scisyph

---------

Co-authored-by: WilliamT-byte <williamt2023@tamu.edu>
Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
As reported on Discord, warship could get stuck. This PR fixes the
issue.

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

moleole
Math was not mathing, increased the bounds to 260x260, it is a bit
slower but should work better. The short path was breaking when player
owned a lot of shores. This is because the bounding box of tiles with
less than 120 distance + 10 padding could be as big as 260x260 and the
optimized array was set to 140x140. I made mistake of calculating it as
`2 * (60 + 10)` instead of `2 * (120 + 10)`.

Previously, we ran 2 passes of LoS smoothing on the path. However, since
we are effectively tracing the same path, the line of sight is
essentially the same. This PR makes second line of sight stop on water
tiles with magnitude `n + 1` compared to first path. Practically, this
means it'll attempt LoS exactly 1 tile after previous corner. See
screenshot.

<img width="1299" height="1151" alt="image"
src="https://github.com/user-attachments/assets/726be236-1ff8-406c-896a-02902a762ab0"
/>

The flow of sending transport ships is currently strange. This PR makes
the flow more sane.

**Old flow**
```
- Player clicks TARGET tile, it can be deep inland
- Client asks Worker for the best START tile to TARGET tile
- Worker answers `false`, since the tile is inland
- Client sends BoatAttackIntent with START=false and TARGET tiles set
- Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET
- Worker re-computes best START to DESTINATION
- Worker sends boat from START to DESTINATION
```

**New flow**
```
- Player clicks TARGET tile, it can be deep inland
- Client sends BoatAttackIntent with TARGET
- Worker accepts BoatAttackIntent, computes DESTINATION as closest shore to TARGET
- Worker computes START as the best tile to DESTINATION
- Worker sends boat from START to DESTINATION
```

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

moleole
Optimize border-tile maintenance in `GameImpl` to reduce per-conquest
overhead.

Border tiles are updated whenever ownership changes; this PR trims
allocations and avoids unnecessary iteration in the hot path.

- `src/core/game/GameImpl.ts`
- `updateBorders(tile)` no longer allocates an array of tiles; it
updates the changed tile and its 4-neighbors directly via
`forEachNeighbor`.
- `calcIsBorder(tile)` no longer calls `neighbors(tile)` / loops an
array; it checks the four cardinal neighbors via `x/y` bounds and
`ownerID`.

- `GameImpl.updateBorders(tile: TileRef)`
- `GameImpl.calcIsBorder(tile: TileRef): boolean`
- Call sites impacted by behavior/perf:
  - `GameImpl.conquer(owner, tile)`
  - `GameImpl.relinquish(tile)`

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

DISCORD_USERNAME
…rove performance

feat(UnitGrid): enhance nearby unit search with fast path for single-type queries
feat(DefaultConfig): add unit info caching to improve unit info retrieval efficiency
Comment on lines +228 to +236
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`,
{
method: "POST",
headers: {
[config.adminHeader()]: config.adminToken(),
},
);

if (!response.ok) {
throw new Error(`Failed to kick player: ${response.statusText}`);
}
},
);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

General fix: Avoid constructing the internal request URL directly from user input. Instead, use the user input only as a key into trusted server-side data (for instance, a map of active games to worker ports and worker endpoints). Use the result of that lookup to build the URL (or even just post to a known admin endpoint on the worker), and keep the path and host under full server control.

Best concrete fix here: publicLobbiesData already exists and presumably tracks current games. We can use gameID and clientID to look up a corresponding public lobby entry and derive trusted data (like worker port and the canonical game/client IDs) from that object. We then construct the URL using those trusted values. This way, the request URL is no longer based directly on the untrusted params but on server-side state that must already be valid to exist. This addresses CodeQL’s taint-tracking while preserving functionality (we still kick the same player in the same game, but only if that game/client pair exists in our tracked lobbies).

Concretely in src/server/Master.ts around the /api/kick_player/:gameID/:clientID handler:

  1. After validating gameID and clientID, look up the corresponding lobby in publicLobbiesData (or whatever structure is holding active games). If no entry matches, return 404 (or 400).
  2. Derive a trusted workerPort from the lobby’s stored gameID (or stored port if present).
  3. Construct the URL using the lobby’s stored gameID and clientID instead of the raw req.params, so the taint stops at the lookup.
  4. Keep the rest of the logic (admin header check, fetch, response handling) unchanged.

This requires only edits inside the shown handler body and uses existing imports and variables; no new external dependencies are needed.

Suggested changeset 1
src/server/Master.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/server/Master.ts b/src/server/Master.ts
--- a/src/server/Master.ts
+++ b/src/server/Master.ts
@@ -224,9 +224,22 @@
     return;
   }
 
+  // Look up the lobby using the validated IDs so that the internal request
+  // is based on trusted server-side state rather than raw user input.
+  const lobby = publicLobbiesData.find(
+    (lobby) => lobby.gameID === gameID && lobby.clientID === clientID,
+  );
+
+  if (!lobby) {
+    res.sendStatus(404);
+    return;
+  }
+
+  const workerPort = config.workerPort(lobby.gameID);
+
   try {
     const response = await fetch(
-      `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`,
+      `http://localhost:${workerPort}/api/kick_player/${lobby.gameID}/${lobby.clientID}`,
       {
         method: "POST",
         headers: {
EOF
@@ -224,9 +224,22 @@
return;
}

// Look up the lobby using the validated IDs so that the internal request
// is based on trusted server-side state rather than raw user input.
const lobby = publicLobbiesData.find(
(lobby) => lobby.gameID === gameID && lobby.clientID === clientID,
);

if (!lobby) {
res.sendStatus(404);
return;
}

const workerPort = config.workerPort(lobby.gameID);

try {
const response = await fetch(
`http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`,
`http://localhost:${workerPort}/api/kick_player/${lobby.gameID}/${lobby.clientID}`,
{
method: "POST",
headers: {
Copilot is powered by AI and may make mistakes. Always verify output.
const metadata = await getMapMetadata(name);
res.json(metadata);
} catch (error) {
console.error(`Error loading map ${req.params.name}:`, error);

Check failure

Code scanning / CodeQL

Use of externally-controlled format string High test

Format string depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

In general, to fix externally controlled format string issues with Node’s console (or util.format), ensure that untrusted data is never part of the format string argument. Instead, use a constant format string containing %s (or appropriate specifier) and pass the untrusted value as a separate argument, or avoid formatting features by concatenating into a single string and passing only one argument.

For this specific case in tests/pathfinding/playground/server.ts, we should change the console.error call in the /api/maps/:name route so that the first argument is a constant string containing %s, and pass req.params.name and error as subsequent arguments. Functionality remains the same—the logged message content is equivalent—but we no longer allow user input to influence the format string. No new imports or helper methods are needed; we only adjust the single logging statement at line 53.

Concretely:

  • In tests/pathfinding/playground/server.ts, locate the console.error line inside the catch block of the /api/maps/:name route.
  • Replace:
    • console.error(`Error loading map ${req.params.name}:`, error);
  • With:
    • console.error("Error loading map %s:", req.params.name, error);

No other lines or files need to change.

Suggested changeset 1
tests/pathfinding/playground/server.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts
--- a/tests/pathfinding/playground/server.ts
+++ b/tests/pathfinding/playground/server.ts
@@ -50,7 +50,7 @@
     const metadata = await getMapMetadata(name);
     res.json(metadata);
   } catch (error) {
-    console.error(`Error loading map ${req.params.name}:`, error);
+    console.error("Error loading map %s:", req.params.name, error);
 
     if (error instanceof Error && error.message.includes("ENOENT")) {
       res.status(404).json({
EOF
@@ -50,7 +50,7 @@
const metadata = await getMapMetadata(name);
res.json(metadata);
} catch (error) {
console.error(`Error loading map ${req.params.name}:`, error);
console.error("Error loading map %s:", req.params.name, error);

if (error instanceof Error && error.message.includes("ENOENT")) {
res.status(404).json({
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +73 to +90
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),
});
}
});

Check failure

Code scanning / CodeQL

Missing rate limiting High test

This route handler performs
a file system access
, but is not rate-limited.

Copilot Autofix

AI 2 days ago

In general, the fix is to introduce a rate-limiting middleware that caps how many requests a client can make to handlers that perform filesystem access, in this case the /api/maps/:name/thumbnail route. The usual way in Express is to use a library such as express-rate-limit to define a limiter (specifying a time window and a max number of requests per IP) and then apply that limiter either globally or to specific routes.

The minimal, non-breaking change here is:

  • Import express-rate-limit.
  • Create a dedicated limiter for thumbnail requests (e.g., allow a reasonable number per minute).
  • Apply that limiter only to the /api/maps/:name/thumbnail route so existing behavior of other endpoints is unchanged.

Concretely in tests/pathfinding/playground/server.ts:

  1. Add an import for express-rate-limit near the top.
  2. After creating the Express app and before defining routes, define a thumbnailRateLimiter using rateLimit({ windowMs: ..., max: ... }).
  3. Modify the app.get("/api/maps/:name/thumbnail", ...) registration to include the limiter as middleware: app.get("/api/maps/:name/thumbnail", thumbnailRateLimiter, (req, res) => { ... }).

No other logic inside the handler needs to change.

Suggested changeset 1
tests/pathfinding/playground/server.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts
--- a/tests/pathfinding/playground/server.ts
+++ b/tests/pathfinding/playground/server.ts
@@ -2,6 +2,7 @@
 import express, { Request, Response } from "express";
 import { dirname, join } from "path";
 import { fileURLToPath } from "url";
+import rateLimit from "express-rate-limit";
 import {
   clearCache as clearMapCache,
   getMapMetadata,
@@ -13,6 +14,12 @@
 const app = express();
 const PORT = process.env.PORT ?? 5555;
 
+// Rate limiting
+const thumbnailRateLimiter = rateLimit({
+  windowMs: 60 * 1000, // 1 minute
+  max: 60, // limit each IP to 60 thumbnail requests per minute
+});
+
 // Middleware
 app.use(compression()); // gzip compression for large responses
 app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
@@ -70,24 +77,28 @@
  * 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),
-    });
-  }
-});
+app.get(
+  "/api/maps/:name/thumbnail",
+  thumbnailRateLimiter,
+  (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
EOF
@@ -2,6 +2,7 @@
import express, { Request, Response } from "express";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import rateLimit from "express-rate-limit";
import {
clearCache as clearMapCache,
getMapMetadata,
@@ -13,6 +14,12 @@
const app = express();
const PORT = process.env.PORT ?? 5555;

// Rate limiting
const thumbnailRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // limit each IP to 60 thumbnail requests per minute
});

// Middleware
app.use(compression()); // gzip compression for large responses
app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
@@ -70,24 +77,28 @@
* 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),
});
}
});
app.get(
"/api/maps/:name/thumbnail",
thumbnailRateLimiter,
(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
Copilot is powered by AI and may make mistakes. Always verify output.
name,
"thumbnail.webp",
);
res.sendFile(thumbnailPath);

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High test

This path depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

In general, to fix uncontrolled path usage, normalize the computed path and enforce that it remains within a designated root directory, or restrict the user input to a simple, safe filename pattern (e.g., alphanumerics and dashes only).

For this specific code, the least intrusive fix is to treat ../../../resources/maps as a root directory, resolve any requested thumbnail path relative to that root using path.resolve, and then verify that the final path still starts with the root directory. If it does not, return a 400/403 error instead of calling res.sendFile. This preserves existing behavior for valid map names while blocking traversal attempts. We can reuse the existing dirname and join imports; we need to also import resolve from "path" to build safe absolute paths.

Concretely, in tests/pathfinding/playground/server.ts:

  • Update the import from "path" to also import resolve.
  • Inside the /api/maps/:name/thumbnail route:
    • Compute a mapsRoot directory once (using join(dirname(fileURLToPath(import.meta.url)), "../../../resources/maps")).
    • Compute requestedPath using resolve(mapsRoot, name, "thumbnail.webp"), which normalizes any .. segments.
    • Check that requestedPath starts with mapsRoot (after normalizing both to absolute paths); if not, respond with 400 or 403.
    • Call res.sendFile(requestedPath) instead of the old thumbnailPath.

This keeps functionality the same for legitimate names but blocks malicious ones.

Suggested changeset 1
tests/pathfinding/playground/server.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts
--- a/tests/pathfinding/playground/server.ts
+++ b/tests/pathfinding/playground/server.ts
@@ -1,6 +1,6 @@
 import compression from "compression";
 import express, { Request, Response } from "express";
-import { dirname, join } from "path";
+import { dirname, join, resolve } from "path";
 import { fileURLToPath } from "url";
 import {
   clearCache as clearMapCache,
@@ -73,12 +73,21 @@
 app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
   try {
     const { name } = req.params;
-    const thumbnailPath = join(
+    const mapsRoot = join(
       dirname(fileURLToPath(import.meta.url)),
       "../../../resources/maps",
-      name,
-      "thumbnail.webp",
     );
+    const thumbnailPath = resolve(mapsRoot, name, "thumbnail.webp");
+
+    // Ensure the resolved path is within the mapsRoot directory to prevent path traversal
+    if (!thumbnailPath.startsWith(mapsRoot)) {
+      res.status(403).json({
+        error: "Forbidden",
+        message: "Invalid map name",
+      });
+      return;
+    }
+
     res.sendFile(thumbnailPath);
   } catch (error) {
     console.error(`Error loading thumbnail for ${req.params.name}:`, error);
EOF
@@ -1,6 +1,6 @@
import compression from "compression";
import express, { Request, Response } from "express";
import { dirname, join } from "path";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import {
clearCache as clearMapCache,
@@ -73,12 +73,21 @@
app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
try {
const { name } = req.params;
const thumbnailPath = join(
const mapsRoot = join(
dirname(fileURLToPath(import.meta.url)),
"../../../resources/maps",
name,
"thumbnail.webp",
);
const thumbnailPath = resolve(mapsRoot, name, "thumbnail.webp");

// Ensure the resolved path is within the mapsRoot directory to prevent path traversal
if (!thumbnailPath.startsWith(mapsRoot)) {
res.status(403).json({
error: "Forbidden",
message: "Invalid map name",
});
return;
}

res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
Copilot is powered by AI and may make mistakes. Always verify output.
);
res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);

Check failure

Code scanning / CodeQL

Use of externally-controlled format string High test

Format string depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

In general, to fix externally-controlled format string issues with console.* or util.format, avoid embedding untrusted input directly into the format string. Instead, use literal format strings (e.g. "Error loading thumbnail for %s:") and pass untrusted data as subsequent arguments, or concatenate using +/template literals without relying on formatting semantics when multiple arguments are used.

Here, the safest and smallest change is to keep the log message semantics but move req.params.name out of the template literal and into a separate argument. For example, change:

console.error(`Error loading thumbnail for ${req.params.name}:`, error);

to:

console.error("Error loading thumbnail for %s:", req.params.name, error);

This ensures the format string is a constant, and the user-controlled name is only substituted into the %s placeholder. No change in behavior is introduced: logs will still show the map name followed by the error. This change is localized to tests/pathfinding/playground/server.ts line 84, and no new imports or helper methods are required.

Suggested changeset 1
tests/pathfinding/playground/server.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts
--- a/tests/pathfinding/playground/server.ts
+++ b/tests/pathfinding/playground/server.ts
@@ -81,7 +81,7 @@
     );
     res.sendFile(thumbnailPath);
   } catch (error) {
-    console.error(`Error loading thumbnail for ${req.params.name}:`, error);
+    console.error("Error loading thumbnail for %s:", req.params.name, error);
     res.status(404).json({
       error: "Thumbnail not found",
       message: error instanceof Error ? error.message : String(error),
EOF
@@ -81,7 +81,7 @@
);
res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
console.error("Error loading thumbnail for %s:", req.params.name, error);
res.status(404).json({
error: "Thumbnail not found",
message: error instanceof Error ? error.message : String(error),
Copilot is powered by AI and may make mistakes. Always verify output.
const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");

// Check if files exist
if (!fs.existsSync(mapBinPath)) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High test

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

To fix uncontrolled path usage, we should constrain user-provided map identifiers (mapName / name / map) so they cannot escape the intended maps directory. The safest and least intrusive approach here is:

  1. Treat mapName as a simple identifier (no path separators or ..), and reject anything that doesn’t match a strict pattern.
  2. Additionally, resolve the final paths under a known maps root (mapDirectory / maps dir for thumbnails) and verify that the resolved path still lies within that root before accessing it.

This preserves existing behavior for legitimate map names but prevents directory traversal.

Concretely:

  • In tests/pathfinding/utils.ts:

    • Add a small helper to validate mapName (e.g., only alphanumerics, _, -, .) and throw if invalid.
    • In setupFromPath, before constructing paths, validate mapName.
    • Build the base directory as const baseDir = path.resolve(mapDirectory);.
    • Build mapBinPath and miniMapBinPath as path.resolve(baseDir, mapName, "map.bin") / "map4x.bin".
    • After resolving, check that each path starts with baseDir + path.sep (or equals baseDir) to enforce containment. If not, throw an error.
  • In tests/pathfinding/playground/server.ts:

    • For /api/maps/:name and /api/maps/:name/thumbnail, validate name with the same pattern; if invalid, respond with 400.
    • For /api/pathfind and /api/spatial-query, validate map similarly before passing it to computePath / computeSpatialQuery (which eventually reach setupFromPath), returning 400 on invalid input.
    • For the thumbnail endpoint, compute a safe root maps directory (the same one getMapsDirectory() uses; since we don’t see that code, we approximate with the existing join(dirname(fileURLToPath(import.meta.url)), "../../../resources/maps") resolved with path.resolve), then:
      • Build the thumbnail path with path.resolve(mapsRoot, name, "thumbnail.webp").
      • Ensure the resolved path starts with mapsRoot + path.sep; if not, return 400 or 404.
      • Only then call res.sendFile.

We will implement a single central validator function (e.g., isValidMapName) in server.ts and a similar or identical copy in utils.ts (because they are in different modules and we must not assume extra wiring). These changes only add validation and containment checks and do not otherwise alter the logic of loading and using maps.


Suggested changeset 2
tests/pathfinding/utils.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts
--- a/tests/pathfinding/utils.ts
+++ b/tests/pathfinding/utils.ts
@@ -26,6 +26,12 @@
 import { GameConfig, PeaceTimerDuration } from "../../src/core/Schemas";
 import { TestConfig } from "../util/TestConfig";
 
+function isValidMapName(mapName: string): boolean {
+  // Allow simple identifiers: letters, numbers, underscore, hyphen, and dot
+  // Disallow any path separators or traversal sequences.
+  return /^[a-zA-Z0-9._-]+$/.test(mapName);
+}
+
 export type BenchmarkRoute = {
   name: string;
   from: TileRef;
@@ -223,10 +229,26 @@
   // 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");
+  if (!isValidMapName(mapName)) {
+    throw new Error(`Invalid map name: ${mapName}`);
+  }
 
+  const baseDir = path.resolve(mapDirectory);
+
+  // Load map files from specified directory, ensuring paths stay within baseDir
+  const mapBinPath = path.resolve(baseDir, mapName, "map.bin");
+  const miniMapBinPath = path.resolve(baseDir, mapName, "map4x.bin");
+
+  if (!mapBinPath.startsWith(baseDir + path.sep)) {
+    throw new Error(`Resolved map path escapes base directory: ${mapBinPath}`);
+  }
+
+  if (!miniMapBinPath.startsWith(baseDir + path.sep)) {
+    throw new Error(
+      `Resolved mini map path escapes base directory: ${miniMapBinPath}`,
+    );
+  }
+
   // Check if files exist
   if (!fs.existsSync(mapBinPath)) {
     throw new Error(`Map not found: ${mapBinPath}`);
EOF
@@ -26,6 +26,12 @@
import { GameConfig, PeaceTimerDuration } from "../../src/core/Schemas";
import { TestConfig } from "../util/TestConfig";

function isValidMapName(mapName: string): boolean {
// Allow simple identifiers: letters, numbers, underscore, hyphen, and dot
// Disallow any path separators or traversal sequences.
return /^[a-zA-Z0-9._-]+$/.test(mapName);
}

export type BenchmarkRoute = {
name: string;
from: TileRef;
@@ -223,10 +229,26 @@
// 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");
if (!isValidMapName(mapName)) {
throw new Error(`Invalid map name: ${mapName}`);
}

const baseDir = path.resolve(mapDirectory);

// Load map files from specified directory, ensuring paths stay within baseDir
const mapBinPath = path.resolve(baseDir, mapName, "map.bin");
const miniMapBinPath = path.resolve(baseDir, mapName, "map4x.bin");

if (!mapBinPath.startsWith(baseDir + path.sep)) {
throw new Error(`Resolved map path escapes base directory: ${mapBinPath}`);
}

if (!miniMapBinPath.startsWith(baseDir + path.sep)) {
throw new Error(
`Resolved mini map path escapes base directory: ${miniMapBinPath}`,
);
}

// Check if files exist
if (!fs.existsSync(mapBinPath)) {
throw new Error(`Map not found: ${mapBinPath}`);
tests/pathfinding/playground/server.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts
--- a/tests/pathfinding/playground/server.ts
+++ b/tests/pathfinding/playground/server.ts
@@ -1,6 +1,6 @@
 import compression from "compression";
 import express, { Request, Response } from "express";
-import { dirname, join } from "path";
+import { dirname, join, resolve } from "path";
 import { fileURLToPath } from "url";
 import {
   clearCache as clearMapCache,
@@ -10,6 +10,12 @@
 import { clearAdapterCaches, computePath } from "./api/pathfinding.js";
 import { computeSpatialQuery } from "./api/spatialQuery.js";
 
+function isValidMapName(mapName: string): boolean {
+  // Allow simple identifiers: letters, numbers, underscore, hyphen, and dot
+  // Disallow any path separators or traversal sequences.
+  return /^[a-zA-Z0-9._-]+$/.test(mapName);
+}
+
 const app = express();
 const PORT = process.env.PORT ?? 5555;
 
@@ -47,6 +53,14 @@
 app.get("/api/maps/:name", async (req: Request, res: Response) => {
   try {
     const { name } = req.params;
+
+    if (!isValidMapName(name)) {
+      return res.status(400).json({
+        error: "Invalid map name",
+        message: "Map name contains invalid characters",
+      });
+    }
+
     const metadata = await getMapMetadata(name);
     res.json(metadata);
   } catch (error) {
@@ -73,12 +87,27 @@
 app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
   try {
     const { name } = req.params;
-    const thumbnailPath = join(
+
+    if (!isValidMapName(name)) {
+      return res.status(400).json({
+        error: "Invalid map name",
+        message: "Map name contains invalid characters",
+      });
+    }
+
+    const mapsRoot = resolve(
       dirname(fileURLToPath(import.meta.url)),
       "../../../resources/maps",
-      name,
-      "thumbnail.webp",
     );
+    const thumbnailPath = resolve(mapsRoot, name, "thumbnail.webp");
+
+    if (!thumbnailPath.startsWith(mapsRoot + "/")) {
+      return res.status(400).json({
+        error: "Invalid map name",
+        message: "Resolved thumbnail path escapes maps directory",
+      });
+    }
+
     res.sendFile(thumbnailPath);
   } catch (error) {
     console.error(`Error loading thumbnail for ${req.params.name}:`, error);
@@ -119,6 +145,13 @@
       });
     }
 
+    if (!isValidMapName(map)) {
+      return res.status(400).json({
+        error: "Invalid map name",
+        message: "Map name contains invalid characters",
+      });
+    }
+
     if (
       !Array.isArray(from) ||
       from.length !== 2 ||
@@ -179,6 +212,13 @@
       });
     }
 
+    if (!isValidMapName(map)) {
+      return res.status(400).json({
+        error: "Invalid map name",
+        message: "Map name contains invalid characters",
+      });
+    }
+
     if (!Array.isArray(ownedTiles)) {
       return res.status(400).json({
         error: "Invalid ownedTiles",
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
throw new Error(`Map not found: ${mapBinPath}`);
}

if (!fs.existsSync(miniMapBinPath)) {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High test

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

In general, the fix is to ensure that any user-controlled path component is constrained to a safe root directory or strictly sanitized. Here we should (1) compute a canonical path under a known root (the maps directory), (2) normalize it via path.resolve, and (3) verify that the resulting path still resides under that root. If it does not, we should reject the request. This keeps behavior the same for valid map names but prevents directory traversal via .. or absolute paths. We can implement this once in setupFromPath, since both getMapMetadata and the /api/pathfind and /api/spatial-query endpoints funnel map loading through that function. Additionally, we should harden the thumbnail route to ensure the name parameter can only select files inside the maps directory.

Concrete changes:

  1. In tests/pathfinding/utils.ts:

    • Introduce a small helper (or inline logic) in setupFromPath that:
      • Resolves mapDirectory to an absolute path.
      • Resolves a candidate map directory as path.resolve(rootDir, mapName).
      • Verifies that this resolved path starts with the root directory path plus a path separator (to avoid false positives on prefixes like /maps/foo vs /maps/foobar).
      • If the check fails, throws an error (in tests, throwing is fine; callers already handle errors).
    • Then build mapBinPath and miniMapBinPath using that safe resolved directory (e.g., const mapDir = ...; const mapBinPath = path.join(mapDir, "map.bin");), instead of joining raw mapDirectory and mapName.
    • This preserves existing functionality for valid map names while preventing a user from escaping the maps directory.
  2. In tests/pathfinding/playground/server.ts:

    • For the thumbnail route /api/maps/:name/thumbnail, make sure name is validated or constrained. Since we already have a safe way of resolving map directories via getMapsDirectory and setupFromPath, the safest and most consistent approach is:
      • Import getMapsDirectory (if not already).
      • Resolve the maps root directory inside the route (or reuse a module-level constant).
      • Use the same root-directory enforcement strategy as in setupFromPath: resolve name against the maps root, ensure the resulting path is still under the root, and then build the thumbnail path as join(safeMapDir, "thumbnail.webp").
    • This prevents directory traversal via the name parameter in the thumbnail route as well.

These changes require no new imports beyond path, which is already imported in both files. No extra npm dependencies are needed.


Suggested changeset 2
tests/pathfinding/utils.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts
--- a/tests/pathfinding/utils.ts
+++ b/tests/pathfinding/utils.ts
@@ -223,10 +223,25 @@
   // 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");
+  // Resolve and validate map directory to prevent directory traversal
+  const rootDir = path.resolve(mapDirectory);
+  const resolvedMapDir = path.resolve(rootDir, mapName);
 
+  // Ensure the resolved directory is within the root directory
+  const rootDirWithSep = rootDir.endsWith(path.sep)
+    ? rootDir
+    : rootDir + path.sep;
+  if (
+    resolvedMapDir !== rootDir &&
+    !resolvedMapDir.startsWith(rootDirWithSep)
+  ) {
+    throw new Error(`Invalid map name: ${mapName}`);
+  }
+
+  // Load map files from validated directory
+  const mapBinPath = path.join(resolvedMapDir, "map.bin");
+  const miniMapBinPath = path.join(resolvedMapDir, "map4x.bin");
+
   // Check if files exist
   if (!fs.existsSync(mapBinPath)) {
     throw new Error(`Map not found: ${mapBinPath}`);
EOF
@@ -223,10 +223,25 @@
// 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");
// Resolve and validate map directory to prevent directory traversal
const rootDir = path.resolve(mapDirectory);
const resolvedMapDir = path.resolve(rootDir, mapName);

// Ensure the resolved directory is within the root directory
const rootDirWithSep = rootDir.endsWith(path.sep)
? rootDir
: rootDir + path.sep;
if (
resolvedMapDir !== rootDir &&
!resolvedMapDir.startsWith(rootDirWithSep)
) {
throw new Error(`Invalid map name: ${mapName}`);
}

// Load map files from validated directory
const mapBinPath = path.join(resolvedMapDir, "map.bin");
const miniMapBinPath = path.join(resolvedMapDir, "map4x.bin");

// Check if files exist
if (!fs.existsSync(mapBinPath)) {
throw new Error(`Map not found: ${mapBinPath}`);
tests/pathfinding/playground/server.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts
--- a/tests/pathfinding/playground/server.ts
+++ b/tests/pathfinding/playground/server.ts
@@ -1,6 +1,6 @@
 import compression from "compression";
 import express, { Request, Response } from "express";
-import { dirname, join } from "path";
+import { dirname, join, resolve, sep } from "path";
 import { fileURLToPath } from "url";
 import {
   clearCache as clearMapCache,
@@ -21,6 +21,12 @@
 const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
 app.use(express.static(publicDir));
 
+// Base directory for map resources (thumbnails, binaries, etc.)
+const mapsRootDir = resolve(
+  dirname(fileURLToPath(import.meta.url)),
+  "../../../resources/maps",
+);
+
 // API Routes
 
 /**
@@ -73,12 +79,23 @@
 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",
-    );
+
+    // Resolve and validate map directory to prevent directory traversal
+    const resolvedMapDir = resolve(mapsRootDir, name);
+    const rootWithSep = mapsRootDir.endsWith(sep)
+      ? mapsRootDir
+      : mapsRootDir + sep;
+    if (
+      resolvedMapDir !== mapsRootDir &&
+      !resolvedMapDir.startsWith(rootWithSep)
+    ) {
+      return res.status(400).json({
+        error: "Invalid map name",
+        message: `Map name "${name}" is not allowed`,
+      });
+    }
+
+    const thumbnailPath = join(resolvedMapDir, "thumbnail.webp");
     res.sendFile(thumbnailPath);
   } catch (error) {
     console.error(`Error loading thumbnail for ${req.params.name}:`, error);
EOF
@@ -1,6 +1,6 @@
import compression from "compression";
import express, { Request, Response } from "express";
import { dirname, join } from "path";
import { dirname, join, resolve, sep } from "path";
import { fileURLToPath } from "url";
import {
clearCache as clearMapCache,
@@ -21,6 +21,12 @@
const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
app.use(express.static(publicDir));

// Base directory for map resources (thumbnails, binaries, etc.)
const mapsRootDir = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../../resources/maps",
);

// API Routes

/**
@@ -73,12 +79,23 @@
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",
);

// Resolve and validate map directory to prevent directory traversal
const resolvedMapDir = resolve(mapsRootDir, name);
const rootWithSep = mapsRootDir.endsWith(sep)
? mapsRootDir
: mapsRootDir + sep;
if (
resolvedMapDir !== mapsRootDir &&
!resolvedMapDir.startsWith(rootWithSep)
) {
return res.status(400).json({
error: "Invalid map name",
message: `Map name "${name}" is not allowed`,
});
}

const thumbnailPath = join(resolvedMapDir, "thumbnail.webp");
res.sendFile(thumbnailPath);
} catch (error) {
console.error(`Error loading thumbnail for ${req.params.name}:`, error);
Copilot is powered by AI and may make mistakes. Always verify output.
throw new Error(`Mini map not found: ${miniMapBinPath}`);
}

const mapBinBuffer = fs.readFileSync(mapBinPath);

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High test

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

In general, to fix uncontrolled path usage, either (a) normalize the constructed path and ensure it stays within a known root directory, or (b) restrict user input to a simple, validated name (or allow‑listed set), never allowing path separators or traversal sequences.

Here, the simplest, least invasive and most robust fix is:

  1. In tests/pathfinding/utils.ts’s setupFromPath, treat mapDirectory as the trusted root and validate mapName:

    • Resolve safeBase = path.resolve(mapDirectory).
    • Resolve mapPath = path.resolve(safeBase, mapName).
    • Check that mapPath starts with safeBase + path.sep (or equals safeBase if you want to allow that); if not, throw an error.
    • Build mapBinPath and miniMapBinPath using this validated mapPath.
      This ensures mapName cannot escape the intended mapDirectory.
  2. In tests/pathfinding/playground/server.ts, validate name used for thumbnails:

    • Add a small helper function like isValidMapName(name: string): boolean that enforces a conservative pattern (e.g. ^[A-Za-z0-9_-]+$), rejecting any path separators or traversal characters.
    • Before building thumbnailPath, check isValidMapName(name) and return 400 if invalid.
    • Optionally reuse this helper for other routes that accept :name to keep behavior consistent.

These changes avoid altering existing functionality for valid map names (they still resolve to the same directories/files), while preventing directory traversal via malicious values. No new external dependencies are strictly required; Node’s built‑in path is sufficient.


Suggested changeset 2
tests/pathfinding/utils.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts
--- a/tests/pathfinding/utils.ts
+++ b/tests/pathfinding/utils.ts
@@ -223,9 +223,20 @@
   // Suppress console.debug for tests
   console.debug = () => {};
 
+  // Normalize and validate map path to prevent directory traversal
+  const safeBaseDir = path.resolve(mapDirectory);
+  const resolvedMapDir = path.resolve(safeBaseDir, mapName);
+
+  if (
+    resolvedMapDir !== safeBaseDir &&
+    !resolvedMapDir.startsWith(safeBaseDir + path.sep)
+  ) {
+    throw new Error(`Invalid map name: ${mapName}`);
+  }
+
   // Load map files from specified directory
-  const mapBinPath = path.join(mapDirectory, mapName, "map.bin");
-  const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");
+  const mapBinPath = path.join(resolvedMapDir, "map.bin");
+  const miniMapBinPath = path.join(resolvedMapDir, "map4x.bin");
 
   // Check if files exist
   if (!fs.existsSync(mapBinPath)) {
EOF
@@ -223,9 +223,20 @@
// Suppress console.debug for tests
console.debug = () => {};

// Normalize and validate map path to prevent directory traversal
const safeBaseDir = path.resolve(mapDirectory);
const resolvedMapDir = path.resolve(safeBaseDir, mapName);

if (
resolvedMapDir !== safeBaseDir &&
!resolvedMapDir.startsWith(safeBaseDir + path.sep)
) {
throw new Error(`Invalid map name: ${mapName}`);
}

// Load map files from specified directory
const mapBinPath = path.join(mapDirectory, mapName, "map.bin");
const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");
const mapBinPath = path.join(resolvedMapDir, "map.bin");
const miniMapBinPath = path.join(resolvedMapDir, "map4x.bin");

// Check if files exist
if (!fs.existsSync(mapBinPath)) {
tests/pathfinding/playground/server.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/playground/server.ts b/tests/pathfinding/playground/server.ts
--- a/tests/pathfinding/playground/server.ts
+++ b/tests/pathfinding/playground/server.ts
@@ -13,6 +13,12 @@
 const app = express();
 const PORT = process.env.PORT ?? 5555;
 
+// Restrictive validation for map names used in filesystem paths
+function isValidMapName(name: string): boolean {
+  // Allow only simple names without path separators or traversal characters
+  return /^[A-Za-z0-9_-]+$/.test(name);
+}
+
 // Middleware
 app.use(compression()); // gzip compression for large responses
 app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
@@ -73,6 +79,12 @@
 app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
   try {
     const { name } = req.params;
+    if (!isValidMapName(name)) {
+      return res.status(400).json({
+        error: "Invalid map name",
+        message: "Map name contains invalid characters",
+      });
+    }
     const thumbnailPath = join(
       dirname(fileURLToPath(import.meta.url)),
       "../../../resources/maps",
EOF
@@ -13,6 +13,12 @@
const app = express();
const PORT = process.env.PORT ?? 5555;

// Restrictive validation for map names used in filesystem paths
function isValidMapName(name: string): boolean {
// Allow only simple names without path separators or traversal characters
return /^[A-Za-z0-9_-]+$/.test(name);
}

// Middleware
app.use(compression()); // gzip compression for large responses
app.use(express.json({ limit: "50mb" })); // JSON body parser with larger limit
@@ -73,6 +79,12 @@
app.get("/api/maps/:name/thumbnail", (req: Request, res: Response) => {
try {
const { name } = req.params;
if (!isValidMapName(name)) {
return res.status(400).json({
error: "Invalid map name",
message: "Map name contains invalid characters",
});
}
const thumbnailPath = join(
dirname(fileURLToPath(import.meta.url)),
"../../../resources/maps",
Copilot is powered by AI and may make mistakes. Always verify output.
}

const mapBinBuffer = fs.readFileSync(mapBinPath);
const miniMapBinBuffer = fs.readFileSync(miniMapBinPath);

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High test

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 2 days ago

In general, to fix uncontrolled use of user input in path expressions, we need to (1) define a trusted root directory, (2) normalize any path derived from user input relative to that root, and (3) verify after normalization that the resulting path stays within the root. For simple “map name” semantics, we can also validate that mapName is a simple filename-style identifier (for example, only letters, numbers, underscores, and dashes) and reject anything containing path separators or ...

In this codebase, the single best fix without changing existing functionality is to sanitize and validate mapName inside setupFromPath in tests/pathfinding/utils.ts, because all the tainted flows ultimately converge there. We will introduce a small helper sanitizeMapName that enforces a strict allowlist of characters and rejects names with path separators or traversal patterns. This keeps the semantics “one directory per logical map” intact while preventing ../../ or similar tricks. Then we’ll use the sanitized name to build mapBinPath and miniMapBinPath. Since mapDirectory comes from getMapsDirectory() and is not user-controlled in the shown snippets, validating mapName is sufficient to break the taint flow and prevent traversal.

Concretely:

  • In tests/pathfinding/utils.ts, add a sanitizeMapName function near the top of the file.
  • In setupFromPath, before building paths, derive const safeMapName = sanitizeMapName(mapName); and use safeMapName in the path.join calls.
  • The helper will:
    • Accept only names that match /^[A-Za-z0-9_\-]+$/.
    • Reject any name containing /, \, or ...
    • Throw a descriptive error if validation fails.
      This adds a narrow, explicit validation layer and leaves all other behavior unchanged.

Suggested changeset 1
tests/pathfinding/utils.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts
--- a/tests/pathfinding/utils.ts
+++ b/tests/pathfinding/utils.ts
@@ -26,6 +26,25 @@
 import { GameConfig, PeaceTimerDuration } from "../../src/core/Schemas";
 import { TestConfig } from "../util/TestConfig";
 
+/**
+ * Sanitize a map name to prevent path traversal and invalid characters.
+ * Allows only simple identifiers (letters, numbers, underscore, dash).
+ */
+export function sanitizeMapName(mapName: string): string {
+  // Disallow obvious traversal or path separator characters
+  if (mapName.includes("..") || mapName.includes("/") || mapName.includes("\\")) {
+    throw new Error(`Invalid map name: ${mapName}`);
+  }
+
+  // Allow only a safe subset of characters for directory names
+  const safePattern = /^[A-Za-z0-9_-]+$/;
+  if (!safePattern.test(mapName)) {
+    throw new Error(`Invalid map name: ${mapName}`);
+  }
+
+  return mapName;
+}
+
 export type BenchmarkRoute = {
   name: string;
   from: TileRef;
@@ -223,9 +242,12 @@
   // Suppress console.debug for tests
   console.debug = () => {};
 
+  // Sanitize map name to prevent path traversal
+  const safeMapName = sanitizeMapName(mapName);
+
   // Load map files from specified directory
-  const mapBinPath = path.join(mapDirectory, mapName, "map.bin");
-  const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");
+  const mapBinPath = path.join(mapDirectory, safeMapName, "map.bin");
+  const miniMapBinPath = path.join(mapDirectory, safeMapName, "map4x.bin");
 
   // Check if files exist
   if (!fs.existsSync(mapBinPath)) {
EOF
@@ -26,6 +26,25 @@
import { GameConfig, PeaceTimerDuration } from "../../src/core/Schemas";
import { TestConfig } from "../util/TestConfig";

/**
* Sanitize a map name to prevent path traversal and invalid characters.
* Allows only simple identifiers (letters, numbers, underscore, dash).
*/
export function sanitizeMapName(mapName: string): string {
// Disallow obvious traversal or path separator characters
if (mapName.includes("..") || mapName.includes("/") || mapName.includes("\\")) {
throw new Error(`Invalid map name: ${mapName}`);
}

// Allow only a safe subset of characters for directory names
const safePattern = /^[A-Za-z0-9_-]+$/;
if (!safePattern.test(mapName)) {
throw new Error(`Invalid map name: ${mapName}`);
}

return mapName;
}

export type BenchmarkRoute = {
name: string;
from: TileRef;
@@ -223,9 +242,12 @@
// Suppress console.debug for tests
console.debug = () => {};

// Sanitize map name to prevent path traversal
const safeMapName = sanitizeMapName(mapName);

// Load map files from specified directory
const mapBinPath = path.join(mapDirectory, mapName, "map.bin");
const miniMapBinPath = path.join(mapDirectory, mapName, "map4x.bin");
const mapBinPath = path.join(mapDirectory, safeMapName, "map.bin");
const miniMapBinPath = path.join(mapDirectory, safeMapName, "map4x.bin");

// Check if files exist
if (!fs.existsSync(mapBinPath)) {
Copilot is powered by AI and may make mistakes. Always verify output.
@El-Magico777
Copy link
Collaborator

Hey, nice work on this PR — the pathfinding rewrite and perf optimizations are solid. Found a few things worth flagging:


🔴 1. Build-Breaking TypeScript Error in RadialMenu.ts

SendBoatAttackIntentEvent constructor was simplified from 4 params to 2 (dst, troops), but the call site in RadialMenu.ts at line 487–492 still passes the old 4 arguments (targetID, dst, troops, src):

// RadialMenu.ts:487-492 — passes 4 args
new SendBoatAttackIntentEvent(
  this.g.owner(tile).id(),  // targetID — removed from constructor
  dst,
  this.uiState.attackRatio * myPlayer.troops(),
  src,                       // src — removed from constructor
)
// Transport.ts:99-103 — constructor only accepts 2
export class SendBoatAttackIntentEvent implements GameEvent {
  constructor(
    public readonly dst: TileRef,
    public readonly troops: number,
  ) {}
}

TSC error: TS2554: Expected 2 arguments, but got 4.
This prevents the project from building.


🟡 2. Gatekeeper / Rate Limiting Fully Removed

Gatekeeper.ts is deleted entirely and all gatekeeper.httpHandler() / gatekeeper.wsHandler() wrappers are stripped from every API endpoint in Worker.ts and every WebSocket message handler in GameServer.ts. This means the server currently has no rate limiting — no protection against endpoint flooding, WebSocket spam, or other abuse.

If this branch is heading toward production, some form of rate limiting should be in place or planned as a follow-up.


🟡 3. Trade Ship Capture Behavior Changed

The old huntDownTradeShip() in WarshipExecution.ts had a nuanced 3-way check at the moment of capture:

  1. Enemy trade ships → capture + clear trade route metadata + spawn CapturedTradeShipReturnExecution to navigate it home
  2. Neutral trade ships with war/embargo endpoints → don't capture, just turn the ship around (setReturning(true))
  3. Otherwise → disengage entirely

The new code unconditionally captures any trade ship the warship reaches:

case PathStatus.COMPLETE:
  this.warship.owner().captureUnit(this.warship.targetUnit()!);
  this.warship.setTargetUnit(undefined);
  this.warship.move(this.warship.tile());
  return;

Specifically:

  • No secondary war/embargo re-check at the moment of capture (diplomacy could change during the multi-tick chase)
  • The neutral "turn around" behavior is gone — neutral ships are now captured outright
  • setTradeRouteOwners(null, null) is no longer called on capture to clear trade route metadata

The new TradeShipExecution.ts does handle the post-capture routing to a port, so that part is covered. Just wanted to confirm the simplification of the engagement rules at capture time is intentional.

@El-Magico777 El-Magico777 added the enhancement New feature or request label Feb 22, 2026
@El-Magico777 El-Magico777 added this to the v0.3.0 milestone Feb 22, 2026
@El-Magico777
Copy link
Collaborator

Related to #215

This was linked to issues Feb 22, 2026
@1brucben 1brucben merged commit 0fb3134 into v0.2.3ai Feb 23, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimize AI performance Create better nation AI

2 participants