Fix map land tile lookup broken by asset URL migration 🗺️ (#3826)

## Description:

`MapLandTiles` was fetching map manifests via HTTP at
`http://localhost:3000/maps/<map>/manifest.json`. This stopped working
after PR #3494 moved all public assets from stable paths (e.g.
`/maps/...`) to content-hashed paths under `/_assets/...`. The master
server no longer serves `/maps/` -- requests fell through to the SPA
handler, returned `index.html`, failed JSON parsing, and silently fell
back to the default `1_000_000` land tile count.

With 1M tiles, `calculateMapPlayerCounts` produces `[50, 40, 25]`
instead of the real values (e.g. `[185, 140, 95]` for Alps), causing all
public lobbies to be capped at 25-50 players regardless of map.

Fixes this by reading map manifests directly from disk instead of via
HTTP:
- In production: resolves the source path through
`getRuntimeAssetManifest()` to the hashed file under `static/_assets/`,
then reads it with `fs.readFile`.
- In dev: falls back to `resources/maps/<name>/manifest.json` directly
(the Dockerfile removes `resources/maps` in production, so this branch
only runs locally).

Also adds an in-process cache so each map manifest is only read once per
server lifetime.

Verified that it works on
https://fix-map-land-tiles-asset-manifest.openfront.dev/ and locally.

## Please complete the following:

- [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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
This commit is contained in:
FloPinguin
2026-05-03 22:30:25 +02:00
committed by evanpelle
parent ac679b68c5
commit 3a24383e88
+45 -14
View File
@@ -1,26 +1,57 @@
import { FetchGameMapLoader } from "src/core/game/FetchGameMapLoader";
import fs from "fs/promises";
import path from "path";
import { normalizeAssetPath } from "src/core/AssetUrls";
import { GameMapType } from "src/core/game/Game";
import { GameMapLoader } from "src/core/game/GameMapLoader";
import { fileURLToPath } from "url";
import { logger } from "./Logger";
let mapLoader: GameMapLoader | null = null;
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
const log = logger.child({ component: "MapLandTiles" });
// Gets or creates the map loader, uses FetchGameMapLoader pointing to the master server.
function getMapLoader(): GameMapLoader {
mapLoader ??= new FetchGameMapLoader("http://localhost:3000/maps");
return mapLoader;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const staticDir = path.join(__dirname, "../../static");
const resourcesDir = path.join(__dirname, "../../resources");
const landTilesCache = new Map<GameMapType, number>();
function mapDirName(map: GameMapType): string {
const key = (
Object.keys(GameMapType) as Array<keyof typeof GameMapType>
).find((k) => GameMapType[k] === map);
if (!key) throw new Error(`Unknown map: ${map}`);
return key.toLowerCase();
}
// Gets the number of land tiles for a map
// FetchGameMapLoader already caches maps, so no need for additional caching here.
async function readManifestFile(map: GameMapType): Promise<string> {
const relativePath = `maps/${mapDirName(map)}/manifest.json`;
// Production: resolve via the asset manifest to the hashed file under static/_assets/.
const assetManifest = await getRuntimeAssetManifest();
const hashedUrl = assetManifest[relativePath];
if (hashedUrl) {
return fs.readFile(
path.join(staticDir, normalizeAssetPath(hashedUrl)),
"utf8",
);
}
// Dev: read directly from resources/. The Dockerfile deletes resources/maps in
// production, so this branch only runs locally.
return fs.readFile(path.join(resourcesDir, relativePath), "utf8");
}
// Gets the number of land tiles for a map.
export async function getMapLandTiles(map: GameMapType): Promise<number> {
const cached = landTilesCache.get(map);
if (cached !== undefined) return cached;
try {
const loader = getMapLoader();
const mapData = loader.getMapData(map);
const manifest = await mapData.manifest();
return manifest.map.num_land_tiles;
const raw = await readManifestFile(map);
const tiles = (JSON.parse(raw) as { map: { num_land_tiles: number } }).map
.num_land_tiles;
landTilesCache.set(map, tiles);
return tiles;
} catch (error) {
log.error(`Failed to load manifest for ${map}: ${error}`, { map });
return 1_000_000; // Default fallback