diff --git a/api/src/lib/mapExits.ts b/api/src/lib/mapExits.ts new file mode 100644 index 00000000..d685d6d6 --- /dev/null +++ b/api/src/lib/mapExits.ts @@ -0,0 +1,339 @@ +import { existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; + +const MAP_DIR_PATTERN = /^mapa_(\d+)$/i; +const MAP_SIZE = 100; + +// ─── Types ──────────────────────────────────────────────────────────────── + +export type ExitDestination = { + map: number; + x: number; + y: number; +}; + +export type SpecialsFile = { + id: number; + exits: Record; +}; + +export type BlockedTilesFile = Record; + +export type ExitValidationError = + | { ok: false; reason: "destination_map_not_found"; detail: string } + | { ok: false; reason: "destination_tile_blocked"; detail: string } + | { ok: false; reason: "destination_out_of_bounds"; detail: string } + | { ok: false; reason: "grid_coordinate_invalid"; detail: string } + | { ok: false; reason: "map_not_found"; detail: string } + | { ok: false; reason: "exit_not_found"; detail: string }; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function gridKey(x: number, y: number): string { + return `${x},${y}`; +} + +// ─── Read / Write ───────────────────────────────────────────────────────── + +async function readJson(filePath: string): Promise { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + return JSON.parse(await fs.readFile(filePath, "utf8")) as T; +} + +async function writeJson(filePath: string, data: unknown): Promise { + await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf8"); +} + +export function getSpecialsPath(mapsSourceDir: string, mapNum: number): string { + return path.join(mapsSourceDir, `mapa_${mapNum}`, "specials.json"); +} + +function getTerrainPath(mapsSourceDir: string, mapNum: number): string { + return path.join(mapsSourceDir, `mapa_${mapNum}`, "terrain.json"); +} + +export async function loadSpecials( + mapsSourceDir: string, + mapNum: number, +): Promise { + const filePath = getSpecialsPath(mapsSourceDir, mapNum); + if (!existsSync(filePath)) { + return { id: mapNum, exits: {} }; + } + return readJson(filePath); +} + +export async function saveSpecials( + mapsSourceDir: string, + specials: SpecialsFile, +): Promise { + const filePath = getSpecialsPath(mapsSourceDir, specials.id); + await writeJson(filePath, specials); +} + +async function loadBlockedTiles( + mapsSourceDir: string, + mapNum: number, +): Promise { + const filePath = getTerrainPath(mapsSourceDir, mapNum); + if (!existsSync(filePath)) { + return {}; + } + // terrain.json has { "x,y": blockValue, ... } or a more complex structure + const terrain = await readJson>(filePath); + // Filter only blocked tiles (value > 0 and not undefined) + const blocked: BlockedTilesFile = {}; + for (const [key, value] of Object.entries(terrain)) { + const num = toFiniteNumber(value); + if (num !== null && num > 0) { + // Only include entries where key matches "x,y" format + const parts = key.split(","); + if (parts.length === 2) { + const x = Number.parseInt(parts[0], 10); + const y = Number.parseInt(parts[1], 10); + if (Number.isInteger(x) && Number.isInteger(y)) { + blocked[key] = num; + } + } + } + } + return blocked; +} + +export async function listAvailableMapIds( + sourceDir: string, +): Promise { + if (!existsSync(sourceDir)) return []; + const entries = await fs.readdir(sourceDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name.match(MAP_DIR_PATTERN)) + .filter((match): match is RegExpMatchArray => Boolean(match)) + .map((match) => Number.parseInt(match[1], 10)) + .filter((mapId) => Number.isInteger(mapId) && mapId > 0) + .sort((a, b) => a - b); +} + +// ─── Validation ─────────────────────────────────────────────────────────── + +export async function validateExit( + mapsSourceDir: string, + fromMap: number, + fromX: number, + fromY: number, + destMap: number, + destX: number, + destY: number, +): Promise { + // Validate grid coordinates + if ( + !Number.isInteger(fromX) || fromX < 0 || fromX >= MAP_SIZE || + !Number.isInteger(fromY) || fromY < 0 || fromY >= MAP_SIZE + ) { + return { ok: false, reason: "grid_coordinate_invalid", detail: `(${fromX},${fromY}) fuera de la grilla 0..${MAP_SIZE - 1}` }; + } + + if ( + !Number.isInteger(destX) || destX < 0 || destX >= MAP_SIZE || + !Number.isInteger(destY) || destY < 0 || destY >= MAP_SIZE + ) { + return { ok: false, reason: "destination_out_of_bounds", detail: `Destino (${destX},${destY}) fuera de la grilla 0..${MAP_SIZE - 1}` }; + } + + // Validate source map exists + const sourceDir = path.join(mapsSourceDir, `mapa_${fromMap}`); + if (!existsSync(sourceDir)) { + return { ok: false, reason: "map_not_found", detail: `Mapa origen ${fromMap} no existe` }; + } + + // Validate destination map exists + const destDir = path.join(mapsSourceDir, `mapa_${destMap}`); + if (!existsSync(destDir)) { + return { ok: false, reason: "destination_map_not_found", detail: `Mapa destino ${destMap} no existe` }; + } + + // Validate destination tile is not blocked + const blocked = await loadBlockedTiles(mapsSourceDir, destMap); + const destKey = gridKey(destX, destY); + if (destKey in blocked) { + return { ok: false, reason: "destination_tile_blocked", detail: `Tile (${destX},${destY}) del mapa ${destMap} esta bloqueado` }; + } + + return { ok: true }; +} + +// ─── CRUD Operations ────────────────────────────────────────────────────── + +/** + * Lista todas las salidas de un mapa. + */ +export async function listExits( + mapsSourceDir: string, + mapNum: number, +): Promise<{ exits: Record; inbound: Record }> { + const specials = await loadSpecials(mapsSourceDir, mapNum); + const exits = specials.exits || {}; + + // Find inbound exits (other maps pointing to this one) + const allMapIds = await listAvailableMapIds(mapsSourceDir); + const inbound: Record = {}; + + for (const otherMapNum of allMapIds) { + if (otherMapNum === mapNum) continue; + const otherSpecials = await loadSpecials(mapsSourceDir, otherMapNum); + if (otherSpecials.exits) { + for (const [key, dest] of Object.entries(otherSpecials.exits)) { + if (dest.map === mapNum) { + if (!inbound[key]) inbound[key] = []; + inbound[key].push({ map: otherMapNum, x: dest.x, y: dest.y }); + } + } + } + } + + return { exits, inbound }; +} + +/** + * Crea o actualiza una salida en un mapa. + */ +export async function createOrUpdateExit( + mapsSourceDir: string, + mapNum: number, + x: number, + y: number, + destMap: number, + destX: number, + destY: number, +): Promise { + const validation = await validateExit(mapsSourceDir, mapNum, x, y, destMap, destX, destY); + if (!validation.ok) return validation; + + const specials = await loadSpecials(mapsSourceDir, mapNum); + if (!specials.exits) specials.exits = {}; + specials.exits[gridKey(x, y)] = { map: destMap, x: destX, y: destY }; + await saveSpecials(mapsSourceDir, specials); + return { ok: true }; +} + +/** + * Crea una salida de ida y vuelta (pair) en una sola operacion. + */ +export async function createRoundTripExit( + mapsSourceDir: string, + mapA: number, + ax: number, + ay: number, + mapB: number, + bx: number, + by: number, +): Promise { + // Validate both exits + const valA = await validateExit(mapsSourceDir, mapA, ax, ay, mapB, bx, by); + if (!valA.ok) return valA; + const valB = await validateExit(mapsSourceDir, mapB, bx, by, mapA, ax, ay); + if (!valB.ok) return valB; + + // Create both exits + const specialsA = await loadSpecials(mapsSourceDir, mapA); + if (!specialsA.exits) specialsA.exits = {}; + specialsA.exits[gridKey(ax, ay)] = { map: mapB, x: bx, y: by }; + await saveSpecials(mapsSourceDir, specialsA); + + const specialsB = await loadSpecials(mapsSourceDir, mapB); + if (!specialsB.exits) specialsB.exits = {}; + specialsB.exits[gridKey(bx, by)] = { map: mapA, x: ax, y: ay }; + await saveSpecials(mapsSourceDir, specialsB); + + return { ok: true }; +} + +/** + * Borra una salida de un mapa. + */ +export async function deleteExit( + mapsSourceDir: string, + mapNum: number, + x: number, + y: number, +): Promise { + if ( + !Number.isInteger(x) || x < 0 || x >= MAP_SIZE || + !Number.isInteger(y) || y < 0 || y >= MAP_SIZE + ) { + return { ok: false, reason: "grid_coordinate_invalid", detail: `(${x},${y}) fuera de la grilla` }; + } + + const specials = await loadSpecials(mapsSourceDir, mapNum); + const key = gridKey(x, y); + if (!specials.exits || !(key in specials.exits)) { + return { ok: false, reason: "exit_not_found", detail: `No hay salida en (${x},${y}) del mapa ${mapNum}` }; + } + + delete specials.exits[key]; + await saveSpecials(mapsSourceDir, specials); + return { ok: true }; +} + +/** + * Detecta mapas inalcanzables (sin ninguna salida entrante). + */ +export async function findInaccessibleMaps( + mapsSourceDir: string, +): Promise { + const allMapIds = await listAvailableMapIds(mapsSourceDir); + if (allMapIds.length <= 1) return []; + + const reachable = new Set(); + // Map 1 is always reachable (starting point) + reachable.add(1); + + // BFS from map 1 through exits + const queue = [1]; + while (queue.length > 0) { + const current = queue.shift()!; + const specials = await loadSpecials(mapsSourceDir, current); + if (specials.exits) { + for (const dest of Object.values(specials.exits)) { + if (!reachable.has(dest.map)) { + reachable.add(dest.map); + queue.push(dest.map); + } + } + } + } + + return allMapIds.filter((id) => !reachable.has(id)); +} + +export async function findOrphanExits( + mapsSourceDir: string, +): Promise> { + const allMapIds = await listAvailableMapIds(mapsSourceDir); + const orphans: Array<{ fromMap: number; fromKey: string; destination: ExitDestination }> = []; + + for (const mapNum of allMapIds) { + const specials = await loadSpecials(mapsSourceDir, mapNum); + if (!specials.exits) continue; + for (const [key, dest] of Object.entries(specials.exits)) { + const destDir = path.join(mapsSourceDir, `mapa_${dest.map}`); + if (!existsSync(destDir)) { + orphans.push({ fromMap: mapNum, fromKey: key, destination: dest }); + } + } + } + + return orphans; +} diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..baa06bb0 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -2,6 +2,7 @@ import express from "express"; import config from "./config"; import pool from "./db"; import { requireAuth } from "./middleware/auth"; +import path from "path"; import { confirmPasswordReset, consumeGameTicket, @@ -108,6 +109,14 @@ import { revertMap, uploadGraphic, } from "./repositories/worldBuilder"; +import { + createOrUpdateExit, + createRoundTripExit, + deleteExit, + findInaccessibleMaps, + findOrphanExits, + listExits, +} from "./lib/mapExits"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -1039,6 +1048,167 @@ app.get("/admin/game-data/maps/:mapNum/status", async (request, response) => { } }); + +/** + * Lista las salidas de un mapa y las entrantes (otros mapas que apuntan a este). + */ +app.get("/admin/game-data/maps/:mapNum/exits", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const mapsSourceDir = require("path").join(__dirname, "mapas_source"); + const result = await listExits(mapsSourceDir, mapNum); + response.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** + * Crea o actualiza una salida en un mapa. + */ +app.put("/admin/game-data/maps/:mapNum/exits/:x/:y", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const x = Number.parseInt(request.params.x ?? "", 10); + const y = Number.parseInt(request.params.y ?? "", 10); + + if (![mapNum, x, y].every((n) => Number.isInteger(n) && n > 0)) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + const { destMap, destX, destY } = request.body || {}; + if (!destMap || !Number.isInteger(destMap) || !Number.isInteger(destX) || !Number.isInteger(destY)) { + response.status(400).json({ error: "Cuerpo invalido. Se requiere { destMap, destX, destY }." }); + return; + } + + const mapsSourceDir = require("path").join(__dirname, "..", "mapas_source"); + const result = await createOrUpdateExit(mapsSourceDir, mapNum, x, y, destMap, destX, destY); + + if (!result.ok) { + response.status(422).json({ error: result.reason, detail: result.detail }); + return; + } + + response.status(200).json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** + * Crea una salida de ida y vuelta (pair) en una sola operacion. + */ +app.post("/admin/game-data/maps/:mapNum/exits/:x/:y/round-trip", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const x = Number.parseInt(request.params.x ?? "", 10); + const y = Number.parseInt(request.params.y ?? "", 10); + const { destMap, destX, destY } = request.body || {}; + + if (![mapNum, x, y, destMap, destX, destY].every((n) => Number.isInteger(n) && n > 0)) { + response.status(400).json({ error: "Parametros invalidos. Se requiere body: { destMap, destX, destY }." }); + return; + } + + const mapsSourceDir = require("path").join(__dirname, "..", "mapas_source"); + const result = await createRoundTripExit(mapsSourceDir, mapNum, x, y, destMap, destX, destY); + + if (!result.ok) { + response.status(422).json({ error: result.reason, detail: result.detail }); + return; + } + + response.status(201).json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** + * Borra una salida de un mapa. + */ +app.delete("/admin/game-data/maps/:mapNum/exits/:x/:y", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const x = Number.parseInt(request.params.x ?? "", 10); + const y = Number.parseInt(request.params.y ?? "", 10); + + if (![mapNum, x, y].every((n) => Number.isInteger(n) && n > 0)) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + const mapsSourceDir = require("path").join(__dirname, "..", "mapas_source"); + const result = await deleteExit(mapsSourceDir, mapNum, x, y); + + if (!result.ok) { + response.status(404).json({ error: result.reason, detail: result.detail }); + return; + } + + response.status(200).json({ ok: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** + * Detecta mapas inalcanzables (sin ninguna salida entrante). + */ +app.get("/admin/game-data/maps/inaccessible", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapsSourceDir = require("path").join(__dirname, "..", "mapas_source"); + const inaccessible = await findInaccessibleMaps(mapsSourceDir); + response.json({ inaccessible }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** + * Detecta salidas huerfanas (apuntan a mapas que ya no existen). + */ +app.get("/admin/game-data/maps/orphan-exits", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapsSourceDir = require("path").join(__dirname, "..", "mapas_source"); + const orphans = await findOrphanExits(mapsSourceDir); + response.json({ orphans }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + app.get( "/internal/game-data/objects", requireAuth, diff --git a/api/src/tests/exits.integration.test.ts b/api/src/tests/exits.integration.test.ts new file mode 100644 index 00000000..11f2a4f5 --- /dev/null +++ b/api/src/tests/exits.integration.test.ts @@ -0,0 +1,79 @@ +import { describe, it, beforeAll, afterAll } from "vitest"; +import assert from "node:assert/strict"; +import { existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; +import { + createOrUpdateExit, + createRoundTripExit, + deleteExit, + findInaccessibleMaps, + findOrphanExits, + listExits, + loadSpecials, +} from "../lib/mapExits"; + +const TEST_MAPS_DIR = path.join(__dirname, "..", "..", "src", "mapas_source"); + +describe("mapExits", () => { + describe("listExits", () => { + it("devuelve salidas de un mapa existente", async () => { + const result = await listExits(TEST_MAPS_DIR, 1); + assert.ok(result.exits); + assert.ok(Object.keys(result.exits).length > 0); + }); + + it("devuelve inbound vacio para mapa 1 (ninguno apunta)", async () => { + const result = await listExits(TEST_MAPS_DIR, 1); + // Map 1 may have inbound from other maps + assert.ok(Array.isArray(result.inbound)); + }); + + it("devuelve mapa sin salidas para mapa inexistente", async () => { + const result = await listExits(TEST_MAPS_DIR, 99999); + assert.deepStrictEqual(result.exits, {}); + }); + }); + + describe("validateExit", () => { + it("rechaza destino fuera de grilla", async () => { + const result = await createOrUpdateExit(TEST_MAPS_DIR, 1, 5, 5, 2, 200, 200); + assert.ok(!result.ok); + if (!result.ok) { + assert.strictEqual(result.reason, "destination_out_of_bounds"); + } + }); + + it("rechaza mapa destino inexistente", async () => { + const result = await createOrUpdateExit(TEST_MAPS_DIR, 1, 5, 5, 99999, 10, 10); + assert.ok(!result.ok); + if (!result.ok) { + assert.strictEqual(result.reason, "destination_map_not_found"); + } + }); + }); + + describe("deleteExit", () => { + it("rechaza borrar salida inexistente", async () => { + const result = await deleteExit(TEST_MAPS_DIR, 99999, 5, 5); + assert.ok(!result.ok); + if (!result.ok) { + assert.strictEqual(result.reason, "grid_coordinate_invalid"); + } + }); + }); + + describe("findInaccessibleMaps", () => { + it("devuelve array de mapas inalcanzables", async () => { + const inaccessible = await findInaccessibleMaps(TEST_MAPS_DIR); + assert.ok(Array.isArray(inaccessible)); + }); + }); + + describe("findOrphanExits", () => { + it("devuelve array de salidas huerfanas", async () => { + const orphans = await findOrphanExits(TEST_MAPS_DIR); + assert.ok(Array.isArray(orphans)); + }); + }); +});