Files
core2026/app/game-api/src/maps/mapLayout.ts
T

347 lines
10 KiB
TypeScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { loadMapDefinitionByName } from './mapDefinition.js';
export interface MapLayoutCity {
id: number;
name: string;
level: number;
region: number;
x: number;
y: number;
path: number[];
}
export interface MapLayout {
mapName: string;
cityList: MapLayoutCity[];
regionMap: Record<number, string>;
levelMap: Record<number, string>;
}
interface ParsedCityConst {
initCity?: unknown[];
regionMap?: Record<string, unknown>;
levelMap?: Record<string, unknown>;
}
const LEGACY_SCENARIO_ROOT = path.resolve(process.cwd(), 'legacy/hwe/scenario');
const LEGACY_MAP_ROOT = path.resolve(LEGACY_SCENARIO_ROOT, 'map');
const LEGACY_CITY_CONST = path.resolve(process.cwd(), 'legacy/hwe/sammo/CityConstBase.php');
const layoutCache = new Map<string, MapLayout>();
const stripComments = (value: string): string => value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
const extractPhpArray = (source: string, marker: string): string | null => {
const idx = source.indexOf(marker);
if (idx < 0) {
return null;
}
const start = source.indexOf('[', idx);
if (start < 0) {
return null;
}
let depth = 0;
let inString = false;
let stringChar = '';
for (let i = start; i < source.length; i += 1) {
const char = source[i];
if (inString) {
if (char === stringChar && source[i - 1] !== '\\') {
inString = false;
}
continue;
}
if (char === '"' || char === "'") {
inString = true;
stringChar = char;
continue;
}
if (char === '[') {
depth += 1;
continue;
}
if (char === ']') {
depth -= 1;
if (depth === 0) {
return source.slice(start, i + 1);
}
}
}
return null;
};
const parsePhpArray = (input: string): unknown => {
let index = 0;
const skipWhitespace = () => {
while (index < input.length && /\s/.test(input[index] ?? '')) {
index += 1;
}
};
const parseString = () => {
const quote = input[index];
index += 1;
let value = '';
while (index < input.length) {
const char = input[index];
if (char === quote && input[index - 1] !== '\\') {
index += 1;
return value;
}
value += char;
index += 1;
}
return value;
};
const parseNumber = () => {
let raw = '';
while (index < input.length && /[0-9.+-]/.test(input[index] ?? '')) {
raw += input[index];
index += 1;
}
return Number(raw);
};
const parseValue = (): unknown => {
skipWhitespace();
const char = input[index];
if (!char) {
return null;
}
if (char === '[') {
return parseArray();
}
if (char === '"' || char === "'") {
return parseString();
}
if (/[0-9.+-]/.test(char)) {
return parseNumber();
}
if (input.startsWith('true', index)) {
index += 4;
return true;
}
if (input.startsWith('false', index)) {
index += 5;
return false;
}
if (input.startsWith('null', index)) {
index += 4;
return null;
}
return null;
};
const parseArray = (): unknown => {
const output: unknown[] = [];
const objectOutput: Record<string, unknown> = {};
let hasKeyed = false;
index += 1;
while (index < input.length) {
skipWhitespace();
if (input[index] === ']') {
index += 1;
break;
}
const keyOrValue = parseValue();
skipWhitespace();
if (input.slice(index, index + 2) === '=>') {
hasKeyed = true;
index += 2;
const value = parseValue();
objectOutput[String(keyOrValue)] = value;
} else if (keyOrValue !== null) {
output.push(keyOrValue);
}
skipWhitespace();
if (input[index] === ',') {
index += 1;
}
}
if (hasKeyed) {
return objectOutput;
}
return output;
};
return parseValue();
};
const parseCityConstFile = async (filePath: string): Promise<ParsedCityConst> => {
try {
const raw = await fs.readFile(filePath, 'utf-8');
const source = stripComments(raw);
const initCityRaw = extractPhpArray(source, '$initCity');
const regionMapRaw = extractPhpArray(source, '$regionMap');
const levelMapRaw = extractPhpArray(source, '$levelMap');
return {
initCity: initCityRaw ? (parsePhpArray(initCityRaw) as unknown[]) : undefined,
regionMap: regionMapRaw ? (parsePhpArray(regionMapRaw) as Record<string, unknown>) : undefined,
levelMap: levelMapRaw ? (parsePhpArray(levelMapRaw) as Record<string, unknown>) : undefined,
};
} catch {
return {};
}
};
const resolveScenarioFile = async (scenario: string): Promise<string> => {
const normalized = scenario.replace(/\.json$/i, '');
const candidates = [`${normalized}.json`, `scenario_${normalized}.json`, 'default.json'];
for (const candidate of candidates) {
const fullPath = path.join(LEGACY_SCENARIO_ROOT, candidate);
try {
await fs.access(fullPath);
return fullPath;
} catch {
continue;
}
}
return path.join(LEGACY_SCENARIO_ROOT, 'default.json');
};
const resolveMapName = async (scenario: string): Promise<string> => {
const scenarioPath = await resolveScenarioFile(scenario);
try {
const raw = await fs.readFile(scenarioPath, 'utf-8');
const parsed = JSON.parse(raw) as { map?: { mapName?: string } };
return parsed.map?.mapName ?? 'che';
} catch {
return 'che';
}
};
const buildLookupMap = (raw: Record<string, unknown> | undefined) => {
const idToName: Record<number, string> = {};
const nameToId: Record<string, number> = {};
if (!raw) {
return { idToName, nameToId };
}
for (const [key, value] of Object.entries(raw)) {
const numericKey = Number(key);
if (typeof value === 'string' && Number.isFinite(numericKey)) {
idToName[numericKey] = value;
continue;
}
if (typeof value === 'number') {
nameToId[key] = value;
}
}
return { idToName, nameToId };
};
const normalizeInitCity = (
initCity: unknown[],
levelMap: ReturnType<typeof buildLookupMap>,
regionMap: ReturnType<typeof buildLookupMap>
): MapLayoutCity[] => {
const rows = initCity.filter(Array.isArray) as unknown[][];
const nameToId = new Map<string, number>();
for (const row of rows) {
if (typeof row[0] === 'number' && typeof row[1] === 'string') {
nameToId.set(row[1], row[0]);
}
}
return rows
.map((row) => {
const [id, name, levelLabel, _pop, _agri, _comm, _secu, _def, _wall, regionLabel, x, y, path] = row;
if (typeof id !== 'number' || typeof name !== 'string') {
return null;
}
const levelValue =
typeof levelLabel === 'number'
? levelLabel
: typeof levelLabel === 'string'
? (levelMap.nameToId[levelLabel] ?? Number(levelLabel))
: 0;
const regionValue =
typeof regionLabel === 'number'
? regionLabel
: typeof regionLabel === 'string'
? (regionMap.nameToId[regionLabel] ?? Number(regionLabel))
: 0;
const pathNames = Array.isArray(path) ? (path as string[]) : [];
const pathIds = pathNames
.map((pathName) => nameToId.get(pathName))
.filter((value): value is number => typeof value === 'number');
return {
id,
name,
level: Number.isFinite(levelValue) ? levelValue : 0,
region: Number.isFinite(regionValue) ? regionValue : 0,
x: typeof x === 'number' ? x : 0,
y: typeof y === 'number' ? y : 0,
path: pathIds,
} satisfies MapLayoutCity;
})
.filter((value): value is MapLayoutCity => value !== null);
};
export const loadMapLayout = async (scenario: string): Promise<MapLayout> => {
const mapName = await resolveMapName(scenario);
const cached = layoutCache.get(mapName);
if (cached) {
return cached;
}
const base = await parseCityConstFile(LEGACY_CITY_CONST);
const mapPath = path.join(LEGACY_MAP_ROOT, `${mapName}.php`);
const map = await parseCityConstFile(mapPath);
const regionMapRaw = {
...(base.regionMap ?? {}),
...(map.regionMap ?? {}),
};
const levelMapRaw = {
...(base.levelMap ?? {}),
...(map.levelMap ?? {}),
};
const regionMap = buildLookupMap(regionMapRaw);
const levelMap = buildLookupMap(levelMapRaw);
const initCity = map.initCity ?? base.initCity ?? [];
let cityList = normalizeInitCity(initCity, levelMap, regionMap);
if (cityList.length === 0) {
const resourceMap = await loadMapDefinitionByName(mapName);
cityList = resourceMap.cities.map((city) => ({
id: city.id,
name: city.name,
level: city.level,
region: city.region,
x: city.position.x,
y: city.position.y,
path: [...city.connections],
}));
}
const layout: MapLayout = {
mapName,
cityList,
regionMap: regionMap.idToName,
levelMap: levelMap.idToName,
};
layoutCache.set(mapName, layout);
return layout;
};