Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 162 additions & 2 deletions api/src/lib/mapNpcStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Promise<void>>();

export async function withMapLock<T>(
mapNum: number,
operation: () => Promise<T>,
): Promise<T> {
const previousLock = mapMutexes.get(mapNum) ?? Promise.resolve();
let releaseLock: () => void = () => {};
const operationLock = new Promise<void>((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;
Expand All @@ -25,7 +56,7 @@ function toFiniteNumber(value: unknown): number | null {
return null;
}

function normalizePlacement(
export function normalizePlacement(
value: unknown,
fallbackMapNum?: number,
): MapNpcPlacement | null {
Expand All @@ -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
Expand All @@ -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 ||
Expand Down Expand Up @@ -122,3 +155,130 @@ export async function loadAllMapNpcPlacements(

return sortPlacements(placements.flat());
}

export async function saveMapNpcPlacements(
mapsSourceDir: string,
mapNum: number,
placements: MapNpcPlacement[],
): Promise<void> {
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<boolean>;
} = {},
): 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) };
});
}
8 changes: 8 additions & 0 deletions api/src/repositories/gameNpcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,11 @@ export async function exportFrontendNpcs(): Promise<
]),
);
}

export async function isValidGameNpcIndex(id: number): Promise<boolean> {
if (!Number.isInteger(id) || id <= 0) {
return false;
}
const npc = await getGameNpcById(id);
return npc !== null;
}
2 changes: 1 addition & 1 deletion api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
134 changes: 134 additions & 0 deletions api/src/server.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
gitar-bot[bot] marked this conversation as resolved.
import express from "express";
import config from "./config";
import pool from "./db";
Expand Down Expand Up @@ -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;
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

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 });
}
});
Loading