Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
127 changes: 125 additions & 2 deletions api/src/lib/mapNpcStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ 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;

function toFiniteNumber(value: unknown): number | null {
Expand All @@ -25,7 +28,7 @@ function toFiniteNumber(value: unknown): number | null {
return null;
}

function normalizePlacement(
export function normalizePlacement(
value: unknown,
fallbackMapNum?: number,
): MapNpcPlacement | null {
Expand All @@ -47,9 +50,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 +67,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 +127,121 @@ 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<{ 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 && !options.isValidNpcIndex(placement.npcIndex)) {
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.` };
}

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

Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated
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.` };
}

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 }> {
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) };
}
124 changes: 124 additions & 0 deletions api/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import {
loadMapNpcPlacements,
placeMapNpc,
moveMapNpc,
removeMapNpc,
MAX_NPCS_PER_MAP
} from "./lib/mapNpcStorage";
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 +3074,119 @@ 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 npcs = await loadMapNpcPlacements(config.mapsSourceDir, mapNum);
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Outdated
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 result = await placeMapNpc(
config.mapsSourceDir,
{ ...request.body, mapNum },
{
maxNpcs: MAX_NPCS_PER_MAP,
isValidNpcIndex: (idx) => 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);
const { fromX, fromY, toX, toY } = request.body ?? {};

if (![mapNum, 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 result = await moveMapNpc(
config.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 (![mapNum, x, y].every(Number.isInteger)) {
response.status(400).json({ error: "Parámetros inválidos." });
return;
}

const result = await removeMapNpc(config.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