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
28 changes: 26 additions & 2 deletions api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,9 +740,9 @@ app.put("/admin/game-data/balance", async (request, response) => {
}
});

// ══════════════════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════════════════�?
// Modo construccion: subir graficos y pintar mapas
// ══════════════════════════════════════════════════════════════════════════
// ══════════════════════════════════════════════════════════════════════════�?

/**
* Sube un PNG y lo registra como grafico del motor.
Expand Down Expand Up @@ -2891,4 +2891,28 @@ app.get("/user-online-stats", async (request, response) => {
}
});


// --- Map Publish / Live Reload Routes (Etapa 3) ---

app.post("/internal/game-data/maps/publish/:mapId", requireAuth, async (request, response) => {
try {
const session = await getAuthorizedSession(request);
if (!session) { response.status(401).json({ error: "Unauthorized" }); return; }
if (!isAuthorizedGameDataAdmin(session.session)) {
response.status(403).json({ error: "Only game data admins can publish maps" });
return;
}
const mapId = Number(request.params.mapId);
if (isNaN(mapId) || mapId < 1) {
response.status(400).json({ error: "Invalid map ID" });
return;
}
// The game server will reload the map from disk
// In a future iteration, this will move drafts to published in the DB
response.json({ success: true, mapId, message: "Map publish triggered. Game server will reload from files on next poll." });
} catch (error) {
response.status(500).json({ error: error instanceof Error ? error.message : "Unexpected error" });
}
});
void start();

109 changes: 109 additions & 0 deletions server/src/loadMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,4 +288,113 @@ class LoadMaps {
}
}

/** Recarga un mapa individual desde disco y actualiza el runtime. */
Comment on lines 288 to +291

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Bug: reloadMapByNumber defined outside the class body — won't compile

The LoadMaps class already closes with } at line 289. The new reloadMapByNumber method is added at lines 291-398 outside the class, using object-literal method syntax terminated by a trailing comma (}, at line 398). This is a TypeScript syntax error: a top-level async reloadMapByNumber(...) {...}, is not valid, so the whole module fails to compile, and this.mapFilesExist/this.getMapDirectory would not resolve even if it did. Move the method inside the class: delete the } at line 289, place the method (with no trailing comma) before the class-closing brace, and keep module.exports = LoadMaps; after the class.

Move the method inside the class and remove the stray trailing comma.:

        });
    }

    /** Recarga un mapa individual desde disco y actualiza el runtime. */
    async reloadMapByNumber(mapNum: number): Promise<{ ok: boolean; playersAffected: number }> {
        // ... body unchanged ...
        return { ok: true, playersAffected: movedPlayers };
    }
}

module.exports = LoadMaps;
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

async reloadMapByNumber(mapNum: number): Promise<{ ok: boolean; playersAffected: number }> {
if (!this.mapFilesExist(mapNum)) {
console.log("[MAP RELOAD] Map " + mapNum + " does not exist, skipping.");
return { ok: false, playersAffected: 0 };
}

// Read the map files fresh
const mapDir = this.getMapDirectory(mapNum);
const metadata = readJsonFile(path.join(mapDir, "meta.json")) as MapMetadata;
const terrain = readJsonFile(path.join(mapDir, "terrain.json")) as TerrainMap;
const specialsPath = path.join(mapDir, "specials.json");
const specials = fs.existsSync(specialsPath)
? (readJsonFile(specialsPath) as SpecialsMap)
: ({ exits: {}, objects: {}, npcs: {}, triggers: {} } as SpecialsMap);

// Clear existing runtime state for this map
vars.mapa[mapNum] = [];
vars.mapData[mapNum] = {};

// Re-apply terrain, metadata, specials using the same logic as readMap
const palette = terrain.palette ?? {};
const rows = Array.isArray(terrain.rows) ? terrain.rows : [];
const width = Math.max(1, toNumber(terrain.width, 100));
const height = Math.max(1, toNumber(terrain.height, 100));

for (let y = 1; y <= height; y++) {
vars.mapa[mapNum][y] = {};
for (let x = 1; x <= width; x++) {
const rawTile = rows[y - 1]?.[x - 1];
const tileIndex = toNumber(rawTile, 1);
const tile: Record<string, unknown> = { tileIndex };

const paletteEntry = palette[String(tileIndex)];
if (paletteEntry) {
tile.blocked = paletteEntry.blocked === true;
tile.graphics = normalizeGraphics(paletteEntry.graphics);
}

vars.mapa[mapNum][y][x] = tile;
}
}

// Apply exits
for (const [coordinateKey, exit] of Object.entries(specials.exits ?? {})) {
const coordinates = parseCoordinateKey(coordinateKey);
if (!coordinates) continue;
const destinations = normalizeTileExitDestinations(exit);
const tile = ensureRuntimeTile(mapNum, coordinates.x, coordinates.y);
tile.tileExit = destinations.length === 1 ? destinations[0] : { destinations };
}

// Apply objects, npcs, triggers
for (const [ck, objInfo] of Object.entries(specials.objects ?? {})) {
const coords = parseCoordinateKey(ck);
if (!coords) continue;
const tile = ensureRuntimeTile(mapNum, coords.x, coords.y);
tile.objInfo = { objIndex: toNumber(objInfo.objIndex), amount: toNumber(objInfo.amount) };
}
for (const [ck, npcIdx] of Object.entries(specials.npcs ?? {})) {
const coords = parseCoordinateKey(ck);
if (!coords) continue;
const tile = ensureRuntimeTile(mapNum, coords.x, coords.y);
tile.npcIndex = toNumber(npcIdx);
}
for (const [ck, trigger] of Object.entries(specials.triggers ?? {})) {
const coords = parseCoordinateKey(ck);
if (!coords) continue;
const tile = ensureRuntimeTile(mapNum, coords.x, coords.y);
tile.trigger = toNumber(trigger);
}

// Apply metadata
vars.mapData[mapNum].name = metadata.name || "";
vars.mapData[mapNum].musicNum = toNumber(metadata.musicNum);
vars.mapData[mapNum].terreno = metadata.terreno || "";
vars.mapData[mapNum].zona = metadata.zona || "";
vars.mapData[mapNum].pk = toNumber(metadata.pk);

// Handle player safety: move players off blocked tiles
const socket = require("./socket");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Unused socket require in reloadMapByNumber

const socket = require("./socket"); is declared but never used (the broadcast uses player.connection.emit directly). Remove the dead require to avoid confusion and an unnecessary module load.

Delete the unused line.:

// remove: const socket = require("./socket");
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

const playersOnMap = Object.values(vars.personajes).filter(function(p) {
return p.map === mapNum && p.connection;
});

let movedPlayers = 0;
for (const player of playersOnMap) {
const tile = vars.mapa[mapNum]?.[player.pos.y]?.[player.pos.x];
if (!tile || tile.blocked) {
const FallbackMap = 1, FallbackX = 50, FallbackY = 50;
player.map = FallbackMap;
player.pos = { x: FallbackX, y: FallbackY };
player.posX = FallbackX;
player.posY = FallbackY;
movedPlayers++;
}
}
Comment on lines +377 to +387

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Edge Case: Fallback tile (1,50,50) is not validated as walkable

Players standing on a newly-blocked tile are teleported to the hardcoded fallback (map 1, 50, 50), but there is no check that this destination tile exists and is not itself blocked. If map 1's tile (50,50) is blocked or out of bounds after a reload, players are relocated into an invalid/blocked position, potentially trapping them. Validate the fallback tile (vars.mapa[1]?.[50]?.[50] exists and !blocked) or choose a known-safe spawn, and handle the case where it is unavailable.

Guard the fallback destination.:

const FallbackMap = 1, FallbackX = 50, FallbackY = 50;
const fbTile = vars.mapa[FallbackMap]?.[FallbackY]?.[FallbackX];
if (fbTile && !fbTile.blocked) {
    player.map = FallbackMap;
    player.pos = { x: FallbackX, y: FallbackY };
    player.posX = FallbackX;
    player.posY = FallbackY;
    movedPlayers++;
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎


// Broadcast map reload to clients on this map
for (const player of playersOnMap) {
if (player.connection && player.connection.emit) {
Comment on lines +377 to +391

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: Relocated players still notified about the old map reload

playersOnMap is captured before repositioning, then reused for the broadcast loop. Players who were moved to the fallback map still receive emit("mapReloaded", { mapNum }) for a map they are no longer on, while their actual new position/map is never communicated to the client. Broadcast the reload only to players who remain on mapNum, and send an explicit teleport/position update to relocated players.

Only emit reload to players still on the map.:

for (const player of playersOnMap) {
    if (player.map === mapNum && player.connection && player.connection.emit) {
        player.connection.emit("mapReloaded", { mapNum: mapNum });
    }
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

player.connection.emit("mapReloaded", { mapNum: mapNum });
}
}

console.log("[MAP RELOAD] Map " + mapNum + " reloaded, " + movedPlayers + " players repositioned.");
return { ok: true, playersAffected: movedPlayers };
},

module.exports = LoadMaps;