diff --git a/api/src/lib/mapNpcStorage.ts b/api/src/lib/mapNpcStorage.ts index c6dfa7ab..6b669368 100644 --- a/api/src/lib/mapNpcStorage.ts +++ b/api/src/lib/mapNpcStorage.ts @@ -10,8 +10,39 @@ export type MapNpcPlacement = { movement?: number; }; +export const MAX_NPCS_PER_MAP = 50; +export const MAP_GRID_SIZE = 100; + const MAP_DIR_PATTERN = /^mapa_(\d+)$/i; +const mapMutexes = new Map>(); + +export async function withMapLock( + mapNum: number, + operation: () => Promise, +): Promise { + const previousLock = mapMutexes.get(mapNum) ?? Promise.resolve(); + let releaseLock: () => void = () => {}; + const operationLock = new Promise((resolve) => { + releaseLock = resolve; + }); + const queuedLock = previousLock + .catch(() => undefined) + .then(() => operationLock); + + mapMutexes.set(mapNum, queuedLock); + + try { + await previousLock.catch(() => undefined); + return await operation(); + } finally { + releaseLock(); + if (mapMutexes.get(mapNum) === queuedLock) { + mapMutexes.delete(mapNum); + } + } +} + function toFiniteNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; @@ -25,7 +56,7 @@ function toFiniteNumber(value: unknown): number | null { return null; } -function normalizePlacement( +export function normalizePlacement( value: unknown, fallbackMapNum?: number, ): MapNpcPlacement | null { @@ -47,9 +78,11 @@ function normalizePlacement( x === null || !Number.isInteger(x) || x <= 0 || + x > MAP_GRID_SIZE || y === null || !Number.isInteger(y) || y <= 0 || + y > MAP_GRID_SIZE || npcIndex === null || !Number.isInteger(npcIndex) || npcIndex <= 0 @@ -62,7 +95,7 @@ function normalizePlacement( : { mapNum, x, y, npcIndex }; } -function sortPlacements(placements: MapNpcPlacement[]): MapNpcPlacement[] { +export function sortPlacements(placements: MapNpcPlacement[]): MapNpcPlacement[] { return [...placements].sort( (left, right) => left.mapNum - right.mapNum || @@ -122,3 +155,130 @@ export async function loadAllMapNpcPlacements( return sortPlacements(placements.flat()); } + +export async function saveMapNpcPlacements( + mapsSourceDir: string, + mapNum: number, + placements: MapNpcPlacement[], +): Promise { + const mapDir = path.join(mapsSourceDir, `mapa_${mapNum}`); + if (!existsSync(mapDir)) { + await fs.mkdir(mapDir, { recursive: true }); + } + + const filePath = path.join(mapDir, "npcs.json"); + const formatted = sortPlacements(placements).map((p) => ({ + mapNum: p.mapNum, + x: p.x, + y: p.y, + npcIndex: p.npcIndex, + ...(p.movement !== undefined ? { movement: p.movement } : {}), + })); + + await fs.writeFile(filePath, JSON.stringify(formatted, null, 2), "utf8"); +} + +export async function placeMapNpc( + mapsSourceDir: string, + rawPlacement: unknown, + options: { + maxNpcs?: number; + isTileBlocked?: (x: number, y: number) => boolean; + isValidNpcIndex?: (npcIndex: number) => boolean | Promise; + } = {}, +): Promise<{ ok: true; placements: MapNpcPlacement[] } | { ok: false; reason: string }> { + const placement = normalizePlacement(rawPlacement); + if (!placement) { + return { ok: false, reason: "Formato de colocación de NPC inválido o coordenadas fuera de límites (1-100)." }; + } + + if (options.isValidNpcIndex) { + const valid = await options.isValidNpcIndex(placement.npcIndex); + if (!valid) { + return { ok: false, reason: `El npcIndex ${placement.npcIndex} no existe en el catálogo.` }; + } + } + + if (options.isTileBlocked && options.isTileBlocked(placement.x, placement.y)) { + return { ok: false, reason: `La coordenada (${placement.x}, ${placement.y}) es un tile bloqueado.` }; + } + + return withMapLock(placement.mapNum, async () => { + const currentPlacements = await loadMapNpcPlacements(mapsSourceDir, placement.mapNum); + + const alreadyAtTile = currentPlacements.some((p) => p.x === placement.x && p.y === placement.y); + if (alreadyAtTile) { + return { ok: false, reason: `Ya existe un NPC colocado en la coordenada (${placement.x}, ${placement.y}).` }; + } + + const maxAllowed = options.maxNpcs ?? MAX_NPCS_PER_MAP; + if (currentPlacements.length >= maxAllowed) { + return { ok: false, reason: `Se alcanzó el límite máximo de ${maxAllowed} NPCs para el mapa ${placement.mapNum}.` }; + } + + const updated = [...currentPlacements, placement]; + await saveMapNpcPlacements(mapsSourceDir, placement.mapNum, updated); + + return { ok: true, placements: sortPlacements(updated) }; + }); +} + +export async function moveMapNpc( + mapsSourceDir: string, + mapNum: number, + fromX: number, + fromY: number, + toX: number, + toY: number, + options: { + isTileBlocked?: (x: number, y: number) => boolean; + } = {}, +): Promise<{ ok: true; placements: MapNpcPlacement[] } | { ok: false; reason: string }> { + if (toX < 1 || toX > MAP_GRID_SIZE || toY < 1 || toY > MAP_GRID_SIZE) { + return { ok: false, reason: "Las coordenadas de destino están fuera de la grilla (1-100)." }; + } + + if (options.isTileBlocked && options.isTileBlocked(toX, toY)) { + return { ok: false, reason: `La coordenada de destino (${toX}, ${toY}) es un tile bloqueado.` }; + } + + return withMapLock(mapNum, async () => { + const currentPlacements = await loadMapNpcPlacements(mapsSourceDir, mapNum); + + const sourceIndex = currentPlacements.findIndex((p) => p.x === fromX && p.y === fromY); + if (sourceIndex === -1) { + return { ok: false, reason: `No se encontró ningún NPC en (${fromX}, ${fromY}) en el mapa ${mapNum}.` }; + } + + const destOccupied = currentPlacements.some((p) => p.x === toX && p.y === toY && !(p.x === fromX && p.y === fromY)); + if (destOccupied) { + return { ok: false, reason: `La coordenada de destino (${toX}, ${toY}) ya está ocupada por otro NPC.` }; + } + + const targetNpc = currentPlacements[sourceIndex]; + const updated = currentPlacements.filter((_, idx) => idx !== sourceIndex); + updated.push({ ...targetNpc, x: toX, y: toY }); + + await saveMapNpcPlacements(mapsSourceDir, mapNum, updated); + return { ok: true, placements: sortPlacements(updated) }; + }); +} + +export async function removeMapNpc( + mapsSourceDir: string, + mapNum: number, + x: number, + y: number, +): Promise<{ ok: true; placements: MapNpcPlacement[] } | { ok: false; reason: string }> { + return withMapLock(mapNum, async () => { + const currentPlacements = await loadMapNpcPlacements(mapsSourceDir, mapNum); + const filtered = currentPlacements.filter((p) => !(p.x === x && p.y === y)); + + if (filtered.length === currentPlacements.length) { + return { ok: false, reason: `No se encontró ningún NPC en (${x}, ${y}) para remover del mapa ${mapNum}.` }; + } + + await saveMapNpcPlacements(mapsSourceDir, mapNum, filtered); + return { ok: true, placements: sortPlacements(filtered) }; + }); +} diff --git a/api/src/repositories/gameNpcs.ts b/api/src/repositories/gameNpcs.ts index 8d8a66d7..f2afa681 100644 --- a/api/src/repositories/gameNpcs.ts +++ b/api/src/repositories/gameNpcs.ts @@ -535,3 +535,11 @@ export async function exportFrontendNpcs(): Promise< ]), ); } + +export async function isValidGameNpcIndex(id: number): Promise { + if (!Number.isInteger(id) || id <= 0) { + return false; + } + const npc = await getGameNpcById(id); + return npc !== null; +} diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 4402a924..ff6cdae1 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -614,7 +614,7 @@ export async function clearTile( * specials.json, npcs.json, meta.json) y la API los re-exporta para el * frontend. Si la copia fuente no existe, el mapa no es editable. */ -function resolveMapsSourceDir(): string { +export function resolveMapsSourceDir(): string { const candidates = [ path.resolve(__dirname, ".."), path.resolve(__dirname, "..", "..", "src"), diff --git a/api/src/server.ts b/api/src/server.ts index 2b309610..b059d75f 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,3 +1,12 @@ +import { + loadMapNpcPlacements, + placeMapNpc, + moveMapNpc, + removeMapNpc, + MAX_NPCS_PER_MAP +} from "./lib/mapNpcStorage"; +import { resolveMapsSourceDir } from "./repositories/worldBuilder"; +import { isValidGameNpcIndex } from "./repositories/gameNpcs"; import express from "express"; import config from "./config"; import pool from "./db"; @@ -3066,3 +3075,128 @@ app.get("/user-online-stats", async (request, response) => { }); void start(); + +/** + * Endpoints del Modo Construcción: Gestión de NPCs en mapas (#8) + */ +app.get("/admin/game-data/maps/:mapNum/npcs", 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: "Número de mapa inválido." }); + return; + } + + const mapsSourceDir = resolveMapsSourceDir(); + const npcs = await loadMapNpcPlacements(mapsSourceDir, mapNum); + response.json({ mapNum, npcs }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +app.post("/admin/game-data/maps/:mapNum/npcs", 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: "Número de mapa inválido." }); + return; + } + + const mapsSourceDir = resolveMapsSourceDir(); + const result = await placeMapNpc( + mapsSourceDir, + { ...request.body, mapNum }, + { + maxNpcs: MAX_NPCS_PER_MAP, + isValidNpcIndex: async (idx) => await isValidGameNpcIndex(idx) + } + ); + + if (!result.ok) { + response.status(400).json({ error: result.reason }); + return; + } + + response.status(201).json({ ok: true, npcs: result.placements }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +app.put("/admin/game-data/maps/:mapNum/npcs/move", 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: "Número de mapa inválido." }); + return; + } + + const { fromX, fromY, toX, toY } = request.body ?? {}; + + if (![fromX, fromY, toX, toY].every(Number.isInteger)) { + response.status(400).json({ error: "Parámetros de coordenadas inválidos." }); + return; + } + + const mapsSourceDir = resolveMapsSourceDir(); + const result = await moveMapNpc( + mapsSourceDir, + mapNum, + fromX, + fromY, + toX, + toY + ); + + if (!result.ok) { + response.status(400).json({ error: result.reason }); + return; + } + + response.json({ ok: true, npcs: result.placements }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +app.delete("/admin/game-data/maps/:mapNum/npcs/: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 (!Number.isInteger(mapNum) || mapNum <= 0 || !Number.isInteger(x) || !Number.isInteger(y)) { + response.status(400).json({ error: "Parámetros inválidos." }); + return; + } + + const mapsSourceDir = resolveMapsSourceDir(); + const result = await removeMapNpc(mapsSourceDir, mapNum, x, y); + + if (!result.ok) { + response.status(400).json({ error: result.reason }); + return; + } + + response.json({ ok: true, npcs: result.placements }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); diff --git a/api/src/tests/mapNpcPlacement.test.ts b/api/src/tests/mapNpcPlacement.test.ts new file mode 100644 index 00000000..9218ab22 --- /dev/null +++ b/api/src/tests/mapNpcPlacement.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { + MAX_NPCS_PER_MAP, + loadMapNpcPlacements, + moveMapNpc, + placeMapNpc, + removeMapNpc, +} from "../lib/mapNpcStorage"; + +describe("mapNpcPlacement - colocación y persistencia de NPCs (#8)", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openao-npc-test-")); + }); + + afterAll(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("MAX_NPCS_PER_MAP constante nombrada exportada", () => { + assert.equal(typeof MAX_NPCS_PER_MAP, "number"); + assert.equal(MAX_NPCS_PER_MAP, 50); + }); + + test("placeMapNpc - coloca un NPC exitosamente y persiste en disco", async () => { + const result = await placeMapNpc(tempDir, { + mapNum: 1, + x: 50, + y: 50, + npcIndex: 1, + movement: 0, + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.placements.length, 1); + assert.deepEqual(result.placements[0], { + mapNum: 1, + x: 50, + y: 50, + npcIndex: 1, + movement: 0, + }); + } + + const fromDisk = await loadMapNpcPlacements(tempDir, 1); + assert.equal(fromDisk.length, 1); + assert.equal(fromDisk[0].x, 50); + assert.equal(fromDisk[0].y, 50); + }); + + test("placeMapNpc - rechaza coordenadas fuera de rango (1-100)", async () => { + const resZero = await placeMapNpc(tempDir, { mapNum: 1, x: 0, y: 50, npcIndex: 1 }); + assert.equal(resZero.ok, false); + + const resOver = await placeMapNpc(tempDir, { mapNum: 1, x: 101, y: 50, npcIndex: 1 }); + assert.equal(resOver.ok, false); + }); + + test("placeMapNpc - rechaza npcIndex inválido o inexistente", async () => { + const resInvalid = await placeMapNpc(tempDir, { mapNum: 1, x: 10, y: 10, npcIndex: -5 }); + assert.equal(resInvalid.ok, false); + + const resCatalog = await placeMapNpc( + tempDir, + { mapNum: 1, x: 10, y: 10, npcIndex: 9999 }, + { isValidNpcIndex: (idx) => idx <= 340 } + ); + assert.equal(resCatalog.ok, false); + if (!resCatalog.ok) { + assert.match(resCatalog.reason, /no existe en el catálogo/); + } + }); + + test("placeMapNpc - rechaza colocación sobre tile bloqueado", async () => { + const blockedTile = (x: number, y: number) => x === 20 && y === 20; + const res = await placeMapNpc( + tempDir, + { mapNum: 1, x: 20, y: 20, npcIndex: 1 }, + { isTileBlocked: blockedTile } + ); + + assert.equal(res.ok, false); + if (!res.ok) { + assert.match(res.reason, /tile bloqueado/); + } + }); + + test("placeMapNpc - rechaza apilar dos NPCs en el mismo tile", async () => { + await placeMapNpc(tempDir, { mapNum: 2, x: 15, y: 15, npcIndex: 1 }); + const resDup = await placeMapNpc(tempDir, { mapNum: 2, x: 15, y: 15, npcIndex: 2 }); + + assert.equal(resDup.ok, false); + if (!resDup.ok) { + assert.match(resDup.reason, /Ya existe un NPC/); + } + }); + + test("placeMapNpc - respeta el límite máximo por mapa", async () => { + const mapNum = 3; + for (let i = 1; i <= 3; i++) { + await placeMapNpc(tempDir, { mapNum, x: i, y: 10, npcIndex: 1 }, { maxNpcs: 3 }); + } + + const resLimit = await placeMapNpc( + tempDir, + { mapNum, x: 4, y: 10, npcIndex: 1 }, + { maxNpcs: 3 } + ); + assert.equal(resLimit.ok, false); + if (!resLimit.ok) { + assert.match(resLimit.reason, /límite máximo/); + } + }); + + test("placeMapNpc - serializa escrituras concurrentes sin perder NPCs", async () => { + const mapNum = 7; + const [first, second] = await Promise.all([ + placeMapNpc(tempDir, { mapNum, x: 10, y: 20, npcIndex: 1 }), + placeMapNpc(tempDir, { mapNum, x: 11, y: 20, npcIndex: 2 }), + ]); + + assert.equal(first.ok, true); + assert.equal(second.ok, true); + + const persisted = await loadMapNpcPlacements(tempDir, mapNum); + assert.equal(persisted.length, 2); + assert.deepEqual( + persisted.map(({ x, y, npcIndex }) => ({ x, y, npcIndex })), + [ + { x: 10, y: 20, npcIndex: 1 }, + { x: 11, y: 20, npcIndex: 2 }, + ], + ); + }); + + test("moveMapNpc - mueve un NPC existente a otra coordenada válida", async () => { + const mapNum = 4; + await placeMapNpc(tempDir, { mapNum, x: 10, y: 10, npcIndex: 5 }); + + const moveRes = await moveMapNpc(tempDir, mapNum, 10, 10, 12, 14); + assert.equal(moveRes.ok, true); + + const loaded = await loadMapNpcPlacements(tempDir, mapNum); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].x, 12); + assert.equal(loaded[0].y, 14); + }); + + test("moveMapNpc - mover a la misma casilla actual no falla (caso autodesplazamiento)", async () => { + const mapNum = 4; + const selfMoveRes = await moveMapNpc(tempDir, mapNum, 12, 14, 12, 14); + assert.equal(selfMoveRes.ok, true); + + const loaded = await loadMapNpcPlacements(tempDir, mapNum); + assert.equal(loaded.length, 1); + assert.equal(loaded[0].x, 12); + assert.equal(loaded[0].y, 14); + }); + + test("moveMapNpc - rechaza mover a un tile bloqueado u ocupado", async () => { + const mapNum = 5; + await placeMapNpc(tempDir, { mapNum, x: 10, y: 10, npcIndex: 1 }); + await placeMapNpc(tempDir, { mapNum, x: 11, y: 10, npcIndex: 2 }); + + const resOccupied = await moveMapNpc(tempDir, mapNum, 10, 10, 11, 10); + assert.equal(resOccupied.ok, false); + + const resBlocked = await moveMapNpc( + tempDir, + mapNum, + 10, + 10, + 30, + 30, + { isTileBlocked: (x, y) => x === 30 && y === 30 } + ); + assert.equal(resBlocked.ok, false); + }); + + test("removeMapNpc - quita un NPC y actualiza el archivo", async () => { + const mapNum = 6; + await placeMapNpc(tempDir, { mapNum, x: 5, y: 5, npcIndex: 1 }); + await placeMapNpc(tempDir, { mapNum, x: 6, y: 6, npcIndex: 2 }); + + const removeRes = await removeMapNpc(tempDir, mapNum, 5, 5); + assert.equal(removeRes.ok, true); + + const remaining = await loadMapNpcPlacements(tempDir, mapNum); + assert.equal(remaining.length, 1); + assert.equal(remaining[0].x, 6); + assert.equal(remaining[0].y, 6); + }); +});