From fe0615e7383979e9b6e656de924603729dbf0430 Mon Sep 17 00:00:00 2001 From: Pedro Mineo Date: Fri, 16 Oct 2026 17:53:05 -0300 Subject: [PATCH] fix: resolve #10 Etapa 2: edicion de salidas entre mapas Automated fix and regression tests validated by Oenim autonomous engine. --- api/src/lib/mapExitStorage.ts | 255 +++++++++++++++++++++++++++++++++ api/src/server.ts | 102 +++++++++++++ api/src/tests/mapExits.test.ts | 109 ++++++++++++++ 3 files changed, 466 insertions(+) create mode 100644 api/src/lib/mapExitStorage.ts create mode 100644 api/src/tests/mapExits.test.ts diff --git a/api/src/lib/mapExitStorage.ts b/api/src/lib/mapExitStorage.ts new file mode 100644 index 00000000..faa3627e --- /dev/null +++ b/api/src/lib/mapExitStorage.ts @@ -0,0 +1,255 @@ +import { existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; +import { MAP_GRID_SIZE, listAvailableMapIds, withMapLock } from "./mapNpcStorage"; + +export type MapExitDestination = { map: number; x: number; y: number }; + +export type MapExit = { + mapNum: number; + x: number; + y: number; + destination: MapExitDestination; +}; + +export type MapExitResult = + | ({ ok: true; warnings: string[] } & T) + | { ok: false; reason: string }; + +type SpecialsFile = { + id?: number; + exits?: Record; + [key: string]: unknown; +}; + +const mapDir = (dir: string, mapNum: number) => path.join(dir, `mapa_${mapNum}`); + +const inGrid = (value: unknown): value is number => + Number.isInteger(value) && (value as number) >= 1 && (value as number) <= MAP_GRID_SIZE; + +function parseDestination(raw: unknown): MapExitDestination | null { + if (!raw || typeof raw !== "object") return null; + const { map, x, y } = raw as Record; + if (!Number.isInteger(map) || typeof x !== "number" || typeof y !== "number") return null; + return { map: map as number, x, y }; +} + +/** Una entrada de specials.json puede ser un destino o `{ destinations: [...] }`. */ +function destinationsOf(raw: unknown): MapExitDestination[] { + const list = (raw as { destinations?: unknown })?.destinations; + const candidates = Array.isArray(list) ? list : [raw]; + return candidates + .map(parseDestination) + .filter((d): d is MapExitDestination => d !== null); +} + +async function readSpecials(dir: string, mapNum: number): Promise { + const file = path.join(mapDir(dir, mapNum), "specials.json"); + if (!existsSync(file)) return { id: mapNum, exits: {} }; + const parsed = JSON.parse(await fs.readFile(file, "utf8")) as SpecialsFile; + return { ...parsed, exits: parsed.exits ?? {} }; +} + +async function writeSpecials(dir: string, mapNum: number, specials: SpecialsFile) { + await fs.writeFile( + path.join(mapDir(dir, mapNum), "specials.json"), + JSON.stringify(specials), + "utf8", + ); +} + +export function mapExists(dir: string, mapNum: number): boolean { + return Number.isInteger(mapNum) && mapNum > 0 && existsSync(path.join(mapDir(dir, mapNum), "terrain.json")); +} + +/** Tile bloqueado segun la paleta de terrain.json (coordenadas base 1). */ +export async function isMapTileBlocked(dir: string, mapNum: number, x: number, y: number): Promise { + const terrain = JSON.parse( + await fs.readFile(path.join(mapDir(dir, mapNum), "terrain.json"), "utf8"), + ) as { palette?: Record; rows?: unknown[][] }; + const id = Number(terrain.rows?.[y - 1]?.[x - 1] ?? 0); + return id > 0 && Boolean(terrain.palette?.[String(id)]?.blocked); +} + +async function validateDestination(dir: string, dest: MapExitDestination): Promise { + if (!mapExists(dir, dest.map)) return `El mapa destino ${dest.map} no existe.`; + if (!inGrid(dest.x) || !inGrid(dest.y)) { + return `La coordenada destino (${dest.x}, ${dest.y}) está fuera de la grilla (1-${MAP_GRID_SIZE}).`; + } + if (await isMapTileBlocked(dir, dest.map, dest.x, dest.y)) { + return `La coordenada destino (${dest.x}, ${dest.y}) del mapa ${dest.map} es un tile bloqueado.`; + } + return null; +} + +async function validateSource(dir: string, mapNum: number, x: number, y: number): Promise { + if (!mapExists(dir, mapNum)) return `El mapa origen ${mapNum} no existe.`; + if (!inGrid(x) || !inGrid(y)) { + return `La coordenada origen (${x}, ${y}) está fuera de la grilla (1-${MAP_GRID_SIZE}).`; + } + return null; +} + +export async function listMapExits(dir: string, mapNum: number): Promise { + const specials = await readSpecials(dir, mapNum); + const exits: MapExit[] = []; + for (const [key, raw] of Object.entries(specials.exits ?? {})) { + const [x, y] = key.split(",").map((v) => Number.parseInt(v, 10)); + if (!Number.isInteger(x) || !Number.isInteger(y)) continue; + for (const destination of destinationsOf(raw)) exits.push({ mapNum, x, y, destination }); + } + return exits.sort((a, b) => a.y - b.y || a.x - b.x); +} + +async function listAllExits(dir: string): Promise { + const ids = await listAvailableMapIds(dir); + return (await Promise.all(ids.map((id) => listMapExits(dir, id)))).flat(); +} + +/** Salidas de otros mapas que apuntan a `mapNum`. */ +export async function listIncomingExits(dir: string, mapNum: number): Promise { + return (await listAllExits(dir)).filter((e) => e.destination.map === mapNum && e.mapNum !== mapNum); +} + +export async function listMapExitsWithIncoming(dir: string, mapNum: number) { + return { mapNum, exits: await listMapExits(dir, mapNum), incoming: await listIncomingExits(dir, mapNum) }; +} + +/** Salidas que llevan a un mapa que ya no existe. */ +export async function findOrphanExits(dir: string): Promise { + return (await listAllExits(dir)).filter((e) => !mapExists(dir, e.destination.map)); +} + +/** Mapas sin ninguna salida entrante desde otro mapa. */ +export async function findMapsWithoutIncomingExits(dir: string): Promise { + const incoming = new Set( + (await listAllExits(dir)).filter((e) => e.mapNum !== e.destination.map).map((e) => e.destination.map), + ); + return (await listAvailableMapIds(dir)).filter((id) => !incoming.has(id)); +} + +/** Mapas a los que no se puede llegar siguiendo salidas desde `rootMaps`. */ +export async function findUnreachableMaps(dir: string, rootMaps: number[] = [1]): Promise { + const exits = await listAllExits(dir); + const graph = new Map(); + for (const e of exits) graph.set(e.mapNum, [...(graph.get(e.mapNum) ?? []), e.destination.map]); + + const seen = new Set(rootMaps); + const queue = [...rootMaps]; + while (queue.length > 0) { + for (const next of graph.get(queue.shift() as number) ?? []) { + if (!seen.has(next)) { + seen.add(next); + queue.push(next); + } + } + } + return (await listAvailableMapIds(dir)).filter((id) => !seen.has(id)); +} + +async function orphanedTargetWarnings(dir: string, previous: MapExitDestination[]): Promise { + const warnings: string[] = []; + for (const map of new Set(previous.map((d) => d.map))) { + if (mapExists(dir, map) && (await listIncomingExits(dir, map)).length === 0) { + warnings.push(`El mapa ${map} quedó sin ninguna salida entrante.`); + } + } + return warnings; +} + +/** Crea o modifica la salida en (x, y) de un mapa. */ +export async function setMapExit( + dir: string, + mapNum: number, + x: number, + y: number, + destination: unknown, +): Promise> { + const dest = parseDestination(destination); + if (!dest) return { ok: false, reason: "Destino de salida inválido." }; + const invalid = (await validateSource(dir, mapNum, x, y)) ?? (await validateDestination(dir, dest)); + if (invalid) return { ok: false, reason: invalid }; + + const previous = await withMapLock(mapNum, async () => { + const specials = await readSpecials(dir, mapNum); + const key = `${x},${y}`; + const old = key in (specials.exits ?? {}) ? destinationsOf(specials.exits![key]) : null; + specials.exits![key] = dest; + await writeSpecials(dir, mapNum, specials); + return old; + }); + + const warnings = previous ? await orphanedTargetWarnings(dir, previous) : []; + return { ok: true, created: previous === null, warnings, exits: await listMapExits(dir, mapNum) }; +} + +export async function deleteMapExit( + dir: string, + mapNum: number, + x: number, + y: number, +): Promise> { + if (!mapExists(dir, mapNum)) return { ok: false, reason: `El mapa ${mapNum} no existe.` }; + + const removed = await withMapLock(mapNum, async () => { + const specials = await readSpecials(dir, mapNum); + const key = `${x},${y}`; + if (!(key in (specials.exits ?? {}))) return null; + const old = destinationsOf(specials.exits![key]); + delete specials.exits![key]; + await writeSpecials(dir, mapNum, specials); + return old; + }); + if (!removed) return { ok: false, reason: `No hay salida en (${x}, ${y}) del mapa ${mapNum}.` }; + + return { + ok: true, + warnings: await orphanedTargetWarnings(dir, removed), + exits: await listMapExits(dir, mapNum), + }; +} + +type ExitSpec = { from: MapExitDestination; to: MapExitDestination }; + +/** + * Crea ida y vuelta en una sola operación. `forward` va de `from` a `to`; + * `back` por defecto sale de `forward.to` y llega a `forward.from`. + * Ambos lados se validan antes de escribir, de modo que un lado inválido no + * deja una salida a medias. + */ +export async function createExitPair( + dir: string, + forward: ExitSpec, + back?: ExitSpec, +): Promise> { + const f = { from: parseDestination(forward?.from), to: parseDestination(forward?.to) }; + const b = back + ? { from: parseDestination(back.from), to: parseDestination(back.to) } + : { from: f.to, to: f.from }; + if (!f.from || !f.to || !b.from || !b.to) { + return { ok: false, reason: "Extremos de la pareja de salidas inválidos." }; + } + const legs = [f, b] as Array<{ from: MapExitDestination; to: MapExitDestination }>; + + for (const leg of legs) { + const invalid = + (await validateSource(dir, leg.from.map, leg.from.x, leg.from.y)) ?? + (await validateDestination(dir, leg.to)); + if (invalid) return { ok: false, reason: invalid }; + } + + const maps = [...new Set(legs.map((l) => l.from.map))].sort((l, r) => l - r); + const lockAll = (index: number, op: () => Promise): Promise => + index >= maps.length ? op() : withMapLock(maps[index], () => lockAll(index + 1, op)); + + await lockAll(0, async () => { + for (const { from, to } of legs) { + const specials = await readSpecials(dir, from.map); + specials.exits![`${from.x},${from.y}`] = { map: to.map, x: to.x, y: to.y }; + await writeSpecials(dir, from.map, specials); + } + }); + + const exits = (await Promise.all(maps.map((m) => listMapExits(dir, m)))).flat(); + return { ok: true, warnings: [], exits }; +} diff --git a/api/src/server.ts b/api/src/server.ts index b059d75f..77fd2048 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -5,6 +5,15 @@ import { removeMapNpc, MAX_NPCS_PER_MAP } from "./lib/mapNpcStorage"; +import { + createExitPair, + deleteMapExit, + findMapsWithoutIncomingExits, + findOrphanExits, + findUnreachableMaps, + listMapExitsWithIncoming, + setMapExit +} from "./lib/mapExitStorage"; import { resolveMapsSourceDir } from "./repositories/worldBuilder"; import { isValidGameNpcIndex } from "./repositories/gameNpcs"; import express from "express"; @@ -3200,3 +3209,96 @@ app.delete("/admin/game-data/maps/:mapNum/npcs/:x/:y", async (request, response) response.status(400).json({ error: message }); } }); + +/** + * Endpoints del Modo Construcción: salidas entre mapas (#10) + */ +async function handleExitRoute( + request: express.Request, + response: express.Response, + action: (mapsSourceDir: string, mapNum: number) => Promise | unknown, + successStatus = 200 +) { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(String(request.params.mapNum ?? "0"), 10); + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Número de mapa inválido." }); + return; + } + + const result = (await action(resolveMapsSourceDir(), mapNum)) as { ok?: boolean; reason?: string }; + if (result && result.ok === false) { + response.status(400).json({ error: result.reason }); + return; + } + response.status(successStatus).json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +} + +app.get("/admin/game-data/exits/check", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const dir = resolveMapsSourceDir(); + const root = Number.parseInt(String(request.query.root ?? "1"), 10); + response.json({ + orphanExits: await findOrphanExits(dir), + unreachableMaps: await findUnreachableMaps(dir, [Number.isInteger(root) ? root : 1]), + mapsWithoutIncomingExits: await findMapsWithoutIncomingExits(dir) + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +app.post("/admin/game-data/exits/pair", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const result = await createExitPair(resolveMapsSourceDir(), request.body?.forward, request.body?.back); + if (!result.ok) { + response.status(400).json({ error: result.reason }); + return; + } + response.status(201).json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +app.get("/admin/game-data/maps/:mapNum/exits", (request, response) => + handleExitRoute(request, response, (dir, mapNum) => listMapExitsWithIncoming(dir, mapNum)) +); + +app.put("/admin/game-data/maps/:mapNum/exits/:x/:y", (request, response) => + handleExitRoute(request, response, (dir, mapNum) => + setMapExit( + dir, + mapNum, + Number.parseInt(String(request.params.x), 10), + Number.parseInt(String(request.params.y), 10), + request.body + ) + ) +); + +app.delete("/admin/game-data/maps/:mapNum/exits/:x/:y", (request, response) => + handleExitRoute(request, response, (dir, mapNum) => + deleteMapExit( + dir, + mapNum, + Number.parseInt(String(request.params.x), 10), + Number.parseInt(String(request.params.y), 10) + ) + ) +); diff --git a/api/src/tests/mapExits.test.ts b/api/src/tests/mapExits.test.ts new file mode 100644 index 00000000..8ed1cd10 --- /dev/null +++ b/api/src/tests/mapExits.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeEach, describe, test } from "vitest"; +import { + createExitPair, + deleteMapExit, + findMapsWithoutIncomingExits, + findOrphanExits, + findUnreachableMaps, + listMapExits, + listMapExitsWithIncoming, + setMapExit, +} from "../lib/mapExitStorage"; + +describe("mapExitStorage - salidas entre mapas (#10)", () => { + let dir: string; + const dirs: string[] = []; + + async function makeMap(id: number, blocked: Array<[number, number]> = []) { + const rows = Array.from({ length: 100 }, () => Array(100).fill(1)); + for (const [x, y] of blocked) rows[y - 1][x - 1] = 2; + const mapDir = path.join(dir, `mapa_${id}`); + await fs.mkdir(mapDir, { recursive: true }); + await fs.writeFile( + path.join(mapDir, "terrain.json"), + JSON.stringify({ id, width: 100, height: 100, palette: { "1": { graphics: [1] }, "2": { graphics: [2], blocked: true } }, rows }), + ); + await fs.writeFile(path.join(mapDir, "specials.json"), JSON.stringify({ id, exits: {}, objects: { "1,1": 3 } })); + } + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "openao-exit-test-")); + dirs.push(dir); + await makeMap(1); + await makeMap(2, [[10, 10]]); + await makeMap(3); + }); + + afterAll(async () => { + await Promise.all(dirs.map((d) => fs.rm(d, { recursive: true, force: true }))); + }); + + test("crea, modifica y borra una salida, y persiste sin tocar otras claves", async () => { + const created = await setMapExit(dir, 1, 5, 5, { map: 2, x: 20, y: 20 }); + assert.ok(created.ok && created.created); + + const updated = await setMapExit(dir, 1, 5, 5, { map: 3, x: 1, y: 1 }); + assert.ok(updated.ok && !updated.created); + assert.ok(updated.ok && updated.warnings.length === 1); + + const onDisk = JSON.parse(await fs.readFile(path.join(dir, "mapa_1", "specials.json"), "utf8")); + assert.deepEqual(onDisk.exits["5,5"], { map: 3, x: 1, y: 1 }); + assert.deepEqual(onDisk.objects, { "1,1": 3 }); + + const deleted = await deleteMapExit(dir, 1, 5, 5); + assert.ok(deleted.ok && deleted.warnings.length === 1); + assert.equal((await listMapExits(dir, 1)).length, 0); + assert.equal((await deleteMapExit(dir, 1, 5, 5)).ok, false); + }); + + test("rechaza destino a mapa inexistente", async () => { + const res = await setMapExit(dir, 1, 5, 5, { map: 99, x: 1, y: 1 }); + assert.equal(res.ok, false); + assert.equal((await listMapExits(dir, 1)).length, 0); + }); + + test("rechaza destino a tile bloqueado", async () => { + const res = await setMapExit(dir, 1, 5, 5, { map: 2, x: 10, y: 10 }); + assert.equal(res.ok, false); + if (!res.ok) assert.match(res.reason, /bloqueado/); + assert.equal((await listMapExits(dir, 1)).length, 0); + }); + + test("rechaza destino fuera de la grilla", async () => { + assert.equal((await setMapExit(dir, 1, 5, 5, { map: 2, x: 0, y: 5 })).ok, false); + assert.equal((await setMapExit(dir, 1, 5, 5, { map: 2, x: 101, y: 5 })).ok, false); + }); + + test("crea ida y vuelta en una operación", async () => { + const res = await createExitPair(dir, { from: { map: 1, x: 5, y: 5 }, to: { map: 2, x: 20, y: 20 } }); + assert.ok(res.ok); + assert.deepEqual((await listMapExits(dir, 1))[0].destination, { map: 2, x: 20, y: 20 }); + assert.deepEqual((await listMapExits(dir, 2))[0], { mapNum: 2, x: 20, y: 20, destination: { map: 1, x: 5, y: 5 } }); + }); + + test("la pareja es atómica: un lado inválido no escribe nada", async () => { + const res = await createExitPair(dir, { from: { map: 1, x: 5, y: 5 }, to: { map: 2, x: 20, y: 20 } }, { from: { map: 2, x: 20, y: 20 }, to: { map: 1, x: 5, y: 5 } }); + assert.ok(res.ok); + const bad = await createExitPair(dir, { from: { map: 1, x: 6, y: 6 }, to: { map: 2, x: 10, y: 10 } }); + assert.equal(bad.ok, false); + assert.equal((await listMapExits(dir, 1)).length, 1); + assert.equal((await listMapExits(dir, 2)).length, 1); + }); + + test("lista entrantes, huérfanas y mapas inalcanzables", async () => { + await createExitPair(dir, { from: { map: 1, x: 5, y: 5 }, to: { map: 2, x: 20, y: 20 } }); + const info = await listMapExitsWithIncoming(dir, 2); + assert.equal(info.incoming.length, 1); + assert.deepEqual(await findUnreachableMaps(dir, [1]), [3]); + assert.deepEqual(await findMapsWithoutIncomingExits(dir), [3]); + + await fs.writeFile(path.join(dir, "mapa_3", "specials.json"), JSON.stringify({ id: 3, exits: { "2,2": { map: 77, x: 1, y: 1 } } })); + const orphans = await findOrphanExits(dir); + assert.equal(orphans.length, 1); + assert.equal(orphans[0].destination.map, 77); + }); +});