347 lines
10 KiB
TypeScript
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;
|
|
};
|