diff --git a/api/src/lib/classicMapParser.ts b/api/src/lib/classicMapParser.ts new file mode 100644 index 00000000..85497381 --- /dev/null +++ b/api/src/lib/classicMapParser.ts @@ -0,0 +1,565 @@ +import { Buffer } from "buffer"; + +export interface ClassicMapHeader { + version: number; + mapNum: number; + name: string; +} + +export interface ClassicTile { + x: number; // 1..100 + y: number; // 1..100 + blocked: boolean; + layer1: number; + layer2: number; + layer3: number; + layer4: number; + trigger: number; +} + +export interface ClassicInfExit { + map: number; + x: number; + y: number; +} + +export interface ClassicInfObject { + objIndex: number; + amount: number; +} + +export interface ClassicInfNpc { + npcIndex: number; +} + +export interface ClassicMapData { + header: ClassicMapHeader; + tiles: ClassicTile[][]; // 100x100 + exits: Record; // "x,y" => Exit + objects: Record; // "x,y" => Object + npcs: Record; // "x,y" => NPC + metadata: Record; +} + +export interface OpenAOMeta { + id: number; + name: string; + musicNum: number; + magiaSinEfecto: number; + noEncriptarMp: number; + terreno: string; + zona: string; + restringir: string; + maxLevel: number; + backup: number; + pk: number; +} + +export interface OpenAONpcPlacement { + mapNum: number; + x: number; + y: number; + npcIndex: number; + movement?: number; +} + +export interface OpenAOSpecials { + id: number; + exits?: Record }>; + objects?: Record; + npcs?: Record; + triggers?: Record; + blocked?: Record; +} + +export interface OpenAOMapBundle { + meta: OpenAOMeta; + terrain: number[][][]; // [100][100][4] or layers + npcs: OpenAONpcPlacement[]; + specials: OpenAOSpecials; +} + +export interface TranslationAuditReport { + mapId: number; + success: boolean; + translatedTiles: number; + skippedOrShiftedGraphics: Array<{ tile: string; layer: number; originalId: number; mappedId: number }>; + unmappedTriggers: Array<{ tile: string; triggerId: number; reason: string }>; + droppedObjects: Array<{ tile: string; reason: string }>; + warnings: string[]; +} + +/** + * Cleanroom parser for classic Argentum Online .map binary format. + */ +export function parseClassicMapBuffer(buffer: Buffer): { header: ClassicMapHeader; tiles: ClassicTile[][] } { + let offset = 0; + + // Version (Int16) + const version = buffer.readInt16LE(offset); + offset += 2; + + // Header Name (32 bytes string) + const nameBytes = buffer.subarray(offset, offset + 32); + const name = nameBytes.toString("latin1").replace(/\0.*$/g, "").trim(); + offset += 32; + + // Map Number (Int16) + const mapNum = buffer.readInt16LE(offset); + offset += 2; + + // Skip reserved header space if any (padding to 64 bytes) + if (offset < 64) { + offset = 64; + } + + const header: ClassicMapHeader = { version, mapNum, name }; + const tiles: ClassicTile[][] = []; + + for (let y = 1; y <= 100; y++) { + const row: ClassicTile[] = []; + for (let x = 1; x <= 100; x++) { + if (offset + 10 > buffer.length) { + // Return default empty tile if buffer truncated + row.push({ x, y, blocked: false, layer1: 0, layer2: 0, layer3: 0, layer4: 0, trigger: 0 }); + continue; + } + + const blocked = buffer.readUInt8(offset) !== 0; + offset += 1; + + const layer1 = buffer.readUInt16LE(offset); + offset += 2; + + const layer2 = buffer.readUInt16LE(offset); + offset += 2; + + const layer3 = buffer.readUInt16LE(offset); + offset += 2; + + const layer4 = buffer.readUInt16LE(offset); + offset += 2; + + const trigger = buffer.readUInt8(offset); + offset += 1; + + row.push({ x, y, blocked, layer1, layer2, layer3, layer4, trigger }); + } + tiles.push(row); + } + + return { header, tiles }; +} + +/** + * Serialize OpenAO map data back to classic binary .map format. + */ +export function encodeClassicMapBuffer(header: ClassicMapHeader, tiles: ClassicTile[][]): Buffer { + const buffer = Buffer.alloc(64 + 100 * 100 * 10); + let offset = 0; + + buffer.writeInt16LE(header.version, offset); + offset += 2; + + const nameBuf = Buffer.alloc(32); + nameBuf.write(header.name, 0, 32, "latin1"); + nameBuf.copy(buffer, offset); + offset += 32; + + buffer.writeInt16LE(header.mapNum, offset); + offset += 2; + + // Pad header to 64 bytes + offset = 64; + + for (let y = 0; y < 100; y++) { + for (let x = 0; x < 100; x++) { + const tile = tiles[y]?.[x] ?? { + x: x + 1, + y: y + 1, + blocked: false, + layer1: 0, + layer2: 0, + layer3: 0, + layer4: 0, + trigger: 0, + }; + + buffer.writeUInt8(tile.blocked ? 1 : 0, offset); + offset += 1; + + buffer.writeUInt16LE(tile.layer1, offset); + offset += 2; + + buffer.writeUInt16LE(tile.layer2, offset); + offset += 2; + + buffer.writeUInt16LE(tile.layer3, offset); + offset += 2; + + buffer.writeUInt16LE(tile.layer4, offset); + offset += 2; + + buffer.writeUInt8(tile.trigger, offset); + offset += 1; + } + } + + return buffer; +} + +/** + * Parse classic .inf file (INI text format for exits, objects, npcs). + */ +export function parseClassicInfText(infContent: string): { + exits: Record; + objects: Record; + npcs: Record; +} { + const exits: Record = {}; + const objects: Record = {}; + const npcs: Record = {}; + + let currentSection = ""; + + for (const rawLine of infContent.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith(";") || line.startsWith("#")) { + continue; + } + + if (line.startsWith("[") && line.endsWith("]")) { + currentSection = line.slice(1, -1).trim(); + continue; + } + + const matchTile = currentSection.match(/^(\d+)-(\d+)$/); + if (!matchTile) { + continue; + } + + const x = Number.parseInt(matchTile[1], 10); + const y = Number.parseInt(matchTile[2], 10); + const tileKey = `${x},${y}`; + + const parts = line.split("="); + if (parts.length < 2) { + continue; + } + + const key = parts[0].trim().toUpperCase(); + const value = parts.slice(1).join("=").trim(); + + if (key === "OBJ") { + const [objIdxStr, amountStr] = value.split(","); + const objIndex = Number.parseInt(objIdxStr, 10); + const amount = Number.parseInt(amountStr ?? "1", 10); + if (Number.isInteger(objIndex) && objIndex > 0) { + objects[tileKey] = { objIndex, amount: Number.isInteger(amount) ? amount : 1 }; + } + } else if (key === "NPC") { + const npcIndex = Number.parseInt(value, 10); + if (Number.isInteger(npcIndex) && npcIndex > 0) { + npcs[tileKey] = { npcIndex }; + } + } else if (key === "EXIT") { + const [mapStr, exitXStr, exitYStr] = value.split("-"); + const map = Number.parseInt(mapStr, 10); + const exitX = Number.parseInt(exitXStr, 10); + const exitY = Number.parseInt(exitYStr, 10); + if (Number.isInteger(map) && Number.isInteger(exitX) && Number.isInteger(exitY)) { + exits[tileKey] = { map, x: exitX, y: exitY }; + } + } + } + + return { exits, objects, npcs }; +} + +/** + * Parse classic .dat header metadata INI content. + */ +export function parseClassicDatText(datContent: string): Record { + const metadata: Record = {}; + + for (const rawLine of datContent.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith(";") || line.startsWith("#") || line.startsWith("[")) { + continue; + } + + const parts = line.split("="); + if (parts.length < 2) { + continue; + } + + const key = parts[0].trim(); + const rawVal = parts.slice(1).join("=").trim(); + const numVal = Number(rawVal); + + metadata[key] = Number.isFinite(numVal) ? numVal : rawVal; + } + + return metadata; +} + +/** + * Convert classic map data bundle into OpenAO schema. + */ +export function convertClassicToOpenAO( + classicData: ClassicMapData, + graphicsMapping?: Record, +): { bundle: OpenAOMapBundle; report: TranslationAuditReport } { + const { header, tiles, exits, objects, npcs, metadata } = classicData; + const mapId = header.mapNum || 1; + + const skippedOrShiftedGraphics: TranslationAuditReport["skippedOrShiftedGraphics"] = []; + const unmappedTriggers: TranslationAuditReport["unmappedTriggers"] = []; + const droppedObjects: TranslationAuditReport["droppedObjects"] = []; + const warnings: string[] = []; + + const meta: OpenAOMeta = { + id: mapId, + name: (metadata.Name as string) || header.name || `Mapa ${mapId}`, + musicNum: (metadata.MusicNum as number) ?? 1, + magiaSinEfecto: (metadata.MagiaSinEfecto as number) ?? 0, + noEncriptarMp: (metadata.NoEncriptarMp as number) ?? 0, + terreno: (metadata.Terreno as string) || "BOSQUE", + zona: (metadata.Zona as string) || "CAMPO", + restringir: (metadata.Restringir as string) || "No", + maxLevel: (metadata.MaxLevel as number) ?? 0, + backup: (metadata.Backup as number) ?? 1, + pk: (metadata.Pk as number) ?? 1, + }; + + const terrainGrid: number[][][] = []; + const openAONpcs: OpenAONpcPlacement[] = []; + const specialsExits: Record = {}; + const specialsObjects: Record = {}; + const specialsTriggers: Record = {}; + + let translatedTilesCount = 0; + + for (let yIndex = 0; yIndex < 100; yIndex++) { + const rowLayers: number[][] = []; + for (let xIndex = 0; xIndex < 100; xIndex++) { + const tile = tiles[yIndex]?.[xIndex]; + const posX = xIndex + 1; + const posY = yIndex + 1; + const tileKey = `${posX},${posY}`; + + if (!tile) { + rowLayers.push([0, 0, 0, 0]); + continue; + } + + translatedTilesCount++; + + // Graphic index remapping + const mapGfx = (gfxId: number, layer: number): number => { + if (gfxId <= 0) return 0; + if (graphicsMapping && graphicsMapping[gfxId] !== undefined) { + const mapped = graphicsMapping[gfxId]; + if (mapped !== gfxId) { + skippedOrShiftedGraphics.push({ tile: tileKey, layer, originalId: gfxId, mappedId: mapped }); + } + return mapped; + } + return gfxId; + }; + + const l1 = mapGfx(tile.layer1, 1); + const l2 = mapGfx(tile.layer2, 2); + const l3 = mapGfx(tile.layer3, 3); + const l4 = mapGfx(tile.layer4, 4); + + rowLayers.push([l1, l2, l3, l4]); + + // Triggers + if (tile.trigger > 0) { + if (tile.trigger > 10) { + unmappedTriggers.push({ + tile: tileKey, + triggerId: tile.trigger, + reason: "Trigger ID exceeds standard OpenAO triggers limit", + }); + } + specialsTriggers[tileKey] = tile.trigger; + } + } + terrainGrid.push(rowLayers); + } + + // Exits + for (const [key, exit] of Object.entries(exits)) { + specialsExits[key] = { map: exit.map, x: exit.x, y: exit.y }; + } + + // Objects + for (const [key, obj] of Object.entries(objects)) { + if (obj.objIndex <= 0) { + droppedObjects.push({ tile: key, reason: "Invalid object index <= 0" }); + continue; + } + specialsObjects[key] = { objIndex: obj.objIndex, amount: obj.amount }; + } + + // NPCs + for (const [key, npc] of Object.entries(npcs)) { + const [xStr, yStr] = key.split(","); + const x = Number.parseInt(xStr, 10); + const y = Number.parseInt(yStr, 10); + openAONpcs.push({ mapNum: mapId, x, y, npcIndex: npc.npcIndex }); + } + + const specials: OpenAOSpecials = { + id: mapId, + exits: Object.keys(specialsExits).length > 0 ? specialsExits : undefined, + objects: Object.keys(specialsObjects).length > 0 ? specialsObjects : undefined, + triggers: Object.keys(specialsTriggers).length > 0 ? specialsTriggers : undefined, + }; + + const report: TranslationAuditReport = { + mapId, + success: true, + translatedTiles: translatedTilesCount, + skippedOrShiftedGraphics, + unmappedTriggers, + droppedObjects, + warnings, + }; + + return { + bundle: { + meta, + terrain: terrainGrid, + npcs: openAONpcs, + specials, + }, + report, + }; +} + +/** + * Convert OpenAO map schema back to Classic Map Data for export. + */ +export function convertOpenAOToClassic(bundle: OpenAOMapBundle): ClassicMapData { + const { meta, terrain, npcs, specials } = bundle; + + const header: ClassicMapHeader = { + version: 1, + mapNum: meta.id, + name: meta.name || `Mapa ${meta.id}`, + }; + + const tiles: ClassicTile[][] = []; + const exits: Record = {}; + const objects: Record = {}; + const classicNpcs: Record = {}; + + for (let yIndex = 0; yIndex < 100; yIndex++) { + const row: ClassicTile[] = []; + for (let xIndex = 0; xIndex < 100; xIndex++) { + const posX = xIndex + 1; + const posY = yIndex + 1; + const tileKey = `${posX},${posY}`; + + const layerVals = terrain[yIndex]?.[xIndex] || [0, 0, 0, 0]; + const triggerVal = specials?.triggers?.[tileKey] ?? 0; + + row.push({ + x: posX, + y: posY, + blocked: false, + layer1: layerVals[0] || 0, + layer2: layerVals[1] || 0, + layer3: layerVals[2] || 0, + layer4: layerVals[3] || 0, + trigger: triggerVal, + }); + } + tiles.push(row); + } + + if (specials?.exits) { + for (const [key, val] of Object.entries(specials.exits)) { + if ("map" in val) { + exits[key] = { map: val.map, x: val.x, y: val.y }; + } + } + } + + if (specials?.objects) { + for (const [key, val] of Object.entries(specials.objects)) { + objects[key] = { objIndex: val.objIndex, amount: val.amount }; + } + } + + for (const npc of npcs) { + const tileKey = `${npc.x},${npc.y}`; + classicNpcs[tileKey] = { npcIndex: npc.npcIndex }; + } + + return { + header, + tiles, + exits, + objects, + npcs: classicNpcs, + metadata: { + Name: meta.name, + MusicNum: meta.musicNum, + MagiaSinEfecto: meta.magiaSinEfecto, + NoEncriptarMp: meta.noEncriptarMp, + Terreno: meta.terreno, + Zona: meta.zona, + Restringir: meta.restringir, + MaxLevel: meta.maxLevel, + Backup: meta.backup, + Pk: meta.pk, + }, + }; +} + +/** + * Round-trip validation test (Classic -> OpenAO -> Classic). + */ +export function validateRoundTrip(originalData: ClassicMapData): { valid: boolean; differences: string[] } { + const { bundle } = convertClassicToOpenAO(originalData); + const reexportedData = convertOpenAOToClassic(bundle); + + const differences: string[] = []; + + if (originalData.header.mapNum !== reexportedData.header.mapNum) { + differences.push(`MapNum mismatch: original ${originalData.header.mapNum} vs reexported ${reexportedData.header.mapNum}`); + } + + let tileMismatchCount = 0; + for (let y = 0; y < 100; y++) { + for (let x = 0; x < 100; x++) { + const origTile = originalData.tiles[y]?.[x]; + const reexpTile = reexportedData.tiles[y]?.[x]; + + if (!origTile || !reexpTile) continue; + + if ( + origTile.layer1 !== reexpTile.layer1 || + origTile.layer2 !== reexpTile.layer2 || + origTile.layer3 !== reexpTile.layer3 || + origTile.layer4 !== reexpTile.layer4 || + origTile.trigger !== reexpTile.trigger + ) { + tileMismatchCount++; + } + } + } + + if (tileMismatchCount > 0) { + differences.push(`Found ${tileMismatchCount} mismatched tiles in round-trip conversion`); + } + + return { + valid: differences.length === 0, + differences, + }; +} diff --git a/api/src/scripts/convertClassicMap.ts b/api/src/scripts/convertClassicMap.ts new file mode 100644 index 00000000..6e1bb03b --- /dev/null +++ b/api/src/scripts/convertClassicMap.ts @@ -0,0 +1,93 @@ +import fs from "fs"; +import path from "path"; +import { + convertClassicToOpenAO, + convertOpenAOToClassic, + encodeClassicMapBuffer, + parseClassicDatText, + parseClassicInfText, + parseClassicMapBuffer, + validateRoundTrip, +} from "../lib/classicMapParser"; + +function getArg(name: string): string | null { + const idx = process.argv.findIndex((arg) => arg === `--${name}`); + if (idx < 0) return null; + return process.argv[idx + 1] ?? null; +} + +async function main(): Promise { + const mapPath = getArg("map"); + const infPath = getArg("inf"); + const datPath = getArg("dat"); + const outputDir = getArg("out"); + const validateOnly = process.argv.includes("--validate"); + + if (!mapPath) { + console.log("Usage: tsx src/scripts/convertClassicMap.ts --map [--inf ] [--dat ] --out "); + return; + } + + const mapBuffer = fs.readFileSync(path.resolve(mapPath)); + const parsedMap = parseClassicMapBuffer(mapBuffer); + + let exits = {}; + let objects = {}; + let npcs = {}; + if (infPath && fs.existsSync(infPath)) { + const infContent = fs.readFileSync(path.resolve(infPath), "utf8"); + const parsedInf = parseClassicInfText(infContent); + exits = parsedInf.exits; + objects = parsedInf.objects; + npcs = parsedInf.npcs; + } + + let metadata = {}; + if (datPath && fs.existsSync(datPath)) { + const datContent = fs.readFileSync(path.resolve(datPath), "utf8"); + metadata = parseClassicDatText(datContent); + } + + const classicData = { + header: parsedMap.header, + tiles: parsedMap.tiles, + exits, + objects, + npcs, + metadata, + }; + + const { bundle, report } = convertClassicToOpenAO(classicData); + + console.log(`[Import Report] Map ${report.mapId}: Translated ${report.translatedTiles} tiles.`); + if (report.skippedOrShiftedGraphics.length > 0) { + console.log(`[Import Report] Shifted Graphics: ${report.skippedOrShiftedGraphics.length}`); + } + if (report.unmappedTriggers.length > 0) { + console.log(`[Import Report] Unmapped Triggers: ${report.unmappedTriggers.length}`); + } + + const roundTrip = validateRoundTrip(classicData); + console.log(`[Round-Trip Validation]: ${roundTrip.valid ? "PASSED" : "FAILED"}`); + + if (validateOnly) { + return; + } + + if (outputDir) { + const targetDir = path.resolve(outputDir); + fs.mkdirSync(targetDir, { recursive: true }); + + fs.writeFileSync(path.join(targetDir, "meta.json"), JSON.stringify(bundle.meta, null, 2)); + fs.writeFileSync(path.join(targetDir, "terrain.json"), JSON.stringify(bundle.terrain)); + fs.writeFileSync(path.join(targetDir, "npcs.json"), JSON.stringify(bundle.npcs, null, 2)); + fs.writeFileSync(path.join(targetDir, "specials.json"), JSON.stringify(bundle.specials, null, 2)); + + console.log(`[Success] Saved OpenAO map bundle to ${targetDir}`); + } +} + +main().catch((err) => { + console.error("Error converting map:", err); + process.exit(1); +}); diff --git a/api/src/tests/classicMapParser.test.ts b/api/src/tests/classicMapParser.test.ts new file mode 100644 index 00000000..b26d0ede --- /dev/null +++ b/api/src/tests/classicMapParser.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { + ClassicMapData, + ClassicTile, + convertClassicToOpenAO, + convertOpenAOToClassic, + encodeClassicMapBuffer, + parseClassicDatText, + parseClassicInfText, + parseClassicMapBuffer, + validateRoundTrip, +} from "../lib/classicMapParser"; + +describe("Classic Map Importer / Exporter (Issue #23)", () => { + it("should correctly parse and encode binary .map format", () => { + const dummyTiles: ClassicTile[][] = []; + for (let y = 1; y <= 100; y++) { + const row: ClassicTile[] = []; + for (let x = 1; x <= 100; x++) { + row.push({ + x, + y, + blocked: (x + y) % 2 === 0, + layer1: x * 10, + layer2: y * 5, + layer3: 0, + layer4: 0, + trigger: (x === 10 && y === 10) ? 1 : 0, + }); + } + dummyTiles.push(row); + } + + const header = { version: 1, mapNum: 42, name: "Test World" }; + const buffer = encodeClassicMapBuffer(header, dummyTiles); + + const parsed = parseClassicMapBuffer(buffer); + + expect(parsed.header.version).toBe(1); + expect(parsed.header.mapNum).toBe(42); + expect(parsed.header.name).toBe("Test World"); + expect(parsed.tiles.length).toBe(100); + expect(parsed.tiles[0].length).toBe(100); + expect(parsed.tiles[9][9].trigger).toBe(1); + expect(parsed.tiles[0][0].layer1).toBe(10); + }); + + it("should parse classic .inf INI format for exits, objects, and NPCs", () => { + const infText = ` +[10-15] +OBJ=148,2 +NPC=536 +EXIT=5-12-90 +`; + const parsed = parseClassicInfText(infText); + + expect(parsed.objects["10,15"]).toEqual({ objIndex: 148, amount: 2 }); + expect(parsed.npcs["10,15"]).toEqual({ npcIndex: 536 }); + expect(parsed.exits["10,15"]).toEqual({ map: 5, x: 12, y: 90 }); + }); + + it("should parse classic .dat header metadata INI format", () => { + const datText = ` +[MAPA1] +Name=Ciudad de Ullathorpe +MusicNum=4 +Terreno=BOSQUE +Pk=1 +`; + const metadata = parseClassicDatText(datText); + + expect(metadata.Name).toBe("Ciudad de Ullathorpe"); + expect(metadata.MusicNum).toBe(4); + expect(metadata.Terreno).toBe("BOSQUE"); + expect(metadata.Pk).toBe(1); + }); + + it("should convert classic map format into OpenAO schema and generate an audit report", () => { + const tiles: ClassicTile[][] = []; + for (let y = 1; y <= 100; y++) { + const row: ClassicTile[] = []; + for (let x = 1; x <= 100; x++) { + row.push({ + x, + y, + blocked: false, + layer1: 100, + layer2: 0, + layer3: 0, + layer4: 0, + trigger: 0, + }); + } + tiles.push(row); + } + + const classicData: ClassicMapData = { + header: { version: 1, mapNum: 1, name: "Ullathorpe" }, + tiles, + exits: { "50,50": { map: 2, x: 10, y: 20 } }, + objects: { "30,30": { objIndex: 148, amount: 1 } }, + npcs: { "15,15": { npcIndex: 536 } }, + metadata: { Name: "Ullathorpe", MusicNum: 4, Terreno: "BOSQUE" }, + }; + + const { bundle, report } = convertClassicToOpenAO(classicData); + + expect(bundle.meta.id).toBe(1); + expect(bundle.meta.name).toBe("Ullathorpe"); + expect(bundle.npcs[0]).toEqual({ mapNum: 1, x: 15, y: 15, npcIndex: 536 }); + expect(bundle.specials.exits?.["50,50"]).toEqual({ map: 2, x: 10, y: 20 }); + expect(bundle.specials.objects?.["30,30"]).toEqual({ objIndex: 148, amount: 1 }); + expect(report.success).toBe(true); + expect(report.translatedTiles).toBe(10000); + }); + + it("should support graphics index remapping and track shifted graphics in audit report", () => { + const tiles: ClassicTile[][] = []; + for (let y = 1; y <= 100; y++) { + const row: ClassicTile[] = []; + for (let x = 1; x <= 100; x++) { + row.push({ + x, + y, + blocked: false, + layer1: (x === 1 && y === 1) ? 999 : 100, + layer2: 0, + layer3: 0, + layer4: 0, + trigger: 0, + }); + } + tiles.push(row); + } + + const classicData: ClassicMapData = { + header: { version: 1, mapNum: 1, name: "Shift Test" }, + tiles, + exits: {}, + objects: {}, + npcs: {}, + metadata: {}, + }; + + const graphicsMapping = { 999: 1200 }; // Remap old graphic 999 to 1200 + const { bundle, report } = convertClassicToOpenAO(classicData, graphicsMapping); + + expect(bundle.terrain[0][0][0]).toBe(1200); + expect(report.skippedOrShiftedGraphics.length).toBe(1); + expect(report.skippedOrShiftedGraphics[0]).toEqual({ + tile: "1,1", + layer: 1, + originalId: 999, + mappedId: 1200, + }); + }); + + it("should pass round-trip validation (Classic -> OpenAO -> Classic)", () => { + const tiles: ClassicTile[][] = []; + for (let y = 1; y <= 100; y++) { + const row: ClassicTile[] = []; + for (let x = 1; x <= 100; x++) { + row.push({ + x, + y, + blocked: false, + layer1: 50, + layer2: 0, + layer3: 0, + layer4: 0, + trigger: (x === 5 && y === 5) ? 2 : 0, + }); + } + tiles.push(row); + } + + const originalData: ClassicMapData = { + header: { version: 1, mapNum: 1, name: "Round Trip Test" }, + tiles, + exits: { "12,12": { map: 3, x: 4, y: 5 } }, + objects: { "20,20": { objIndex: 50, amount: 5 } }, + npcs: { "10,10": { npcIndex: 24 } }, + metadata: { Name: "Round Trip Test" }, + }; + + const { valid, differences } = validateRoundTrip(originalData); + expect(valid).toBe(true); + expect(differences).toEqual([]); + + const bundle = convertClassicToOpenAO(originalData).bundle; + const reexported = convertOpenAOToClassic(bundle); + + expect(reexported.exits["12,12"]).toEqual({ map: 3, x: 4, y: 5 }); + expect(reexported.objects["20,20"]).toEqual({ objIndex: 50, amount: 5 }); + expect(reexported.npcs["10,10"]).toEqual({ npcIndex: 24 }); + }); +}); diff --git a/frontend/components/game/core/useAssetPipeline.ts b/frontend/components/game/core/useAssetPipeline.ts index e782aa27..62d6293a 100644 --- a/frontend/components/game/core/useAssetPipeline.ts +++ b/frontend/components/game/core/useAssetPipeline.ts @@ -482,6 +482,12 @@ export function useAssetPipeline({ return; } + // Skip prefetching if user has Save-Data or data saver enabled + const nav = typeof navigator !== "undefined" ? (navigator as any) : null; + if (nav?.connection?.saveData === true) { + return; + } + const nearbyMaps = collectAdjacentMapNumbers( engine.mapData, engine.mapNumber, @@ -490,18 +496,22 @@ export function useAssetPipeline({ return; } + // Limit prefetch to a maximum of 2 concurrent adjacent maps to conserve RAM & network + const MAX_CONCURRENT_PREFETCH = 2; + const targetMaps = nearbyMaps.slice(0, MAX_CONCURRENT_PREFETCH); + updateLoadingProgress( "Precargando alrededores", 88, - `Analizando ${nearbyMaps.length} mapas cercanos...`, + `Analizando ${targetMaps.length} mapas cercanos...`, ); - for (let index = 0; index < nearbyMaps.length; index++) { + for (let index = 0; index < targetMaps.length; index++) { if (engine.isDestroyed) { return; } - const targetMap = nearbyMaps[index]; + const targetMap = targetMaps[index]; try { const nextMapData = await loadMapData(targetMap); const nextMapDimensions = getMapDimensions( @@ -523,7 +533,7 @@ export function useAssetPipeline({ ); updateLoadingProgress( "Precargando alrededores", - 88 + Math.round(((index + 1) / nearbyMaps.length) * 12), + 88 + Math.round(((index + 1) / targetMaps.length) * 12), `Mapa ${targetMap} listo para transicion rapida.`, ); } catch (error) { diff --git a/frontend/components/game/core/useRendererBootstrap.ts b/frontend/components/game/core/useRendererBootstrap.ts index f0a43306..351be1b7 100644 --- a/frontend/components/game/core/useRendererBootstrap.ts +++ b/frontend/components/game/core/useRendererBootstrap.ts @@ -349,6 +349,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { ), autoDensity: true, }); + app.ticker.maxFPS = 60; return app; } catch (error) { app.destroy({ removeView: true }, { children: true }); @@ -780,7 +781,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { text: options.fpsDisplayTextRef.current, style: fpsStyle, }); - fpsText.resolution = 1; + fpsText.resolution = app.renderer.resolution; fpsText.x = 10; fpsText.y = 8; fpsText.zIndex = 1000; @@ -790,7 +791,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { text: options.pingDisplayTextRef.current, style: fpsStyle, }); - pingText.resolution = 1; + pingText.resolution = app.renderer.resolution; pingText.x = 10; pingText.y = 22; pingText.zIndex = 1000; @@ -800,7 +801,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { text: "", style: getHudStatusTextStyle(0xff3b30), }); - seguroText.resolution = 1; + seguroText.resolution = app.renderer.resolution; seguroText.x = 10; seguroText.y = 36; seguroText.zIndex = 1000; @@ -810,7 +811,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { text: "", style: getHudStatusTextStyle(0xff3b30), }); - clanSeguroText.resolution = 1; + clanSeguroText.resolution = app.renderer.resolution; clanSeguroText.x = 10; clanSeguroText.y = 50; clanSeguroText.zIndex = 1000; @@ -825,7 +826,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { stroke: { color: 0x000000, width: 1.5 }, }), }); - debugCombatText.resolution = 1; + debugCombatText.resolution = app.renderer.resolution; debugCombatText.x = 10; debugCombatText.y = 50; debugCombatText.zIndex = 1000; @@ -883,7 +884,11 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { ); } - window.setTimeout(() => { + const scheduleIdleTask = typeof window !== "undefined" && typeof (window as any).requestIdleCallback === "function" + ? (cb: () => void) => (window as any).requestIdleCallback(cb, { timeout: 1500 }) + : (cb: () => void) => window.setTimeout(cb, 16); + + scheduleIdleTask(() => { if (!engine.isDestroyed) { const pendingSnapshot = options.pendingUserSnapshotRef.current?.map === @@ -942,7 +947,7 @@ export function useRendererBootstrap(options: UseRendererBootstrapOptions) { } }); } - }, 0); + }); } catch (err) { if (isDisposed) { return;