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; levelMap: Record; } interface ParsedCityConst { initCity?: unknown[]; regionMap?: Record; levelMap?: Record; } 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(); 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 = {}; 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 => { 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) : undefined, levelMap: levelMapRaw ? (parsePhpArray(levelMapRaw) as Record) : undefined, }; } catch { return {}; } }; const resolveScenarioFile = async (scenario: string): Promise => { 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 => { 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 | undefined) => { const idToName: Record = {}; const nameToId: Record = {}; 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, regionMap: ReturnType ): MapLayoutCity[] => { const rows = initCity.filter(Array.isArray) as unknown[][]; const nameToId = new Map(); 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 => { 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; };