From 5f37ec59e4a17f57421273e3810d85170434ff1b Mon Sep 17 00:00:00 2001 From: Nikolay Golovin Date: Sun, 6 Sep 2026 13:30:12 +0300 Subject: [PATCH] feat(maps): live publish hot-reload without server restart (#11) Publish promotes drafts then notifies the game server to apply published overrides in-process, relocate players trapped on new walls, and tell clients to invalidate map cache so changes appear without reconnecting. --- api/.env.example | 3 + api/src/config.ts | 2 + api/src/lib/notifyGameServerMapPublish.ts | 51 ++++ api/src/repositories/worldBuilder.ts | 70 +++++ api/src/server.ts | 57 +++- api/src/tests/mapLivePublish.unit.test.ts | 36 +++ .../game/session/incomingUiPackets.ts | 37 ++- frontend/utils/gameLoader.ts | 32 +++ server/package.json | 2 +- server/src/commands.ts | 47 +++- server/src/gameDataSync.ts | 253 ++++++++++++++++++ server/src/loadMaps.ts | 10 + server/src/mapLiveApply.ts | 128 +++++++++ server/src/mapLivePublish.ts | 150 +++++++++++ server/src/server.ts | 64 ++++- server/src/tests/mapLivePublish.test.ts | 71 +++++ 16 files changed, 1004 insertions(+), 9 deletions(-) create mode 100644 api/src/lib/notifyGameServerMapPublish.ts create mode 100644 api/src/tests/mapLivePublish.unit.test.ts create mode 100644 server/src/mapLiveApply.ts create mode 100644 server/src/mapLivePublish.ts create mode 100644 server/src/tests/mapLivePublish.test.ts diff --git a/api/.env.example b/api/.env.example index 1d568703..24daf142 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,3 +2,6 @@ PORT=3001 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aoweb TOKEN_AUTH=changeme CORS_ORIGIN=http://localhost:3000 + +# Optional: game server base URL for live map publish (OpenAO #11) +# GAME_SERVER_INTERNAL_URL=http://127.0.0.1:7666 diff --git a/api/src/config.ts b/api/src/config.ts index aeaafb1c..c9bd0e69 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -21,6 +21,7 @@ type Config = { gameDataAdminEmail: string; gameDataAdminAccountId: string | null; gameDataAdminProxyToken: string | null; + gameServerInternalUrl: string | null; }; const projectRoot = path.resolve(__dirname, ".."); @@ -97,6 +98,7 @@ const config: Config = { gameDataAdminEmail: (process.env.GAME_DATA_ADMIN_EMAIL?.trim() || "").toLowerCase(), gameDataAdminAccountId: process.env.GAME_DATA_ADMIN_ACCOUNT_ID?.trim() || null, gameDataAdminProxyToken: process.env.GAME_DATA_ADMIN_PROXY_TOKEN?.trim() || null, + gameServerInternalUrl: process.env.GAME_SERVER_INTERNAL_URL?.trim() || null, }; export default config; diff --git a/api/src/lib/notifyGameServerMapPublish.ts b/api/src/lib/notifyGameServerMapPublish.ts new file mode 100644 index 00000000..f0c06f8d --- /dev/null +++ b/api/src/lib/notifyGameServerMapPublish.ts @@ -0,0 +1,51 @@ +import config from "../config"; + +export type GameServerHotReloadResult = { + notified: boolean; + skipped: boolean; + error?: string; + result?: unknown; +}; + +/** + * Pide al game server que aplique el mapa publicado sin reiniciar el proceso. + * Si GAME_SERVER_INTERNAL_URL no esta configurada, se omite (publish en DB igual vale). + */ +export async function notifyGameServerMapPublish( + mapNum: number, +): Promise { + const baseUrl = config.gameServerInternalUrl?.trim(); + if (!baseUrl) { + return { notified: false, skipped: true }; + } + + const url = `${baseUrl.replace(/\/+$/, "")}/internal/maps/${mapNum}/hot-reload`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: config.tokenAuth, + }, + body: JSON.stringify({ mapNum }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + return { + notified: false, + skipped: false, + error: typeof payload?.error === "string" ? payload.error : `HTTP ${response.status}`, + }; + } + + return { notified: true, skipped: false, result: payload }; + } catch (error) { + return { + notified: false, + skipped: false, + error: error instanceof Error ? error.message : "notify failed", + }; + } +} diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 4402a924..776b077f 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -592,6 +592,76 @@ export async function getMapStatus(mapNum: number): Promise<{ }; } + +/** Todos los overrides publicados, agrupados por mapa (para hidratar el game server). */ +export async function listAllPublishedMapOverrides(): Promise< + Array<{ mapNum: number; overrides: MapTileOverride[]; version: number }> +> { + const result = await pool.query<{ + map_num: number; + x: number; + y: number; + layer: number; + grh_index: number | null; + blocked: boolean | null; + updated_at: Date | string | null; + }>( + `SELECT map_num, x, y, layer, grh_index, blocked, updated_at + FROM game_map_tile_overrides + WHERE status = 'published' + ORDER BY map_num, y, x, layer`, + ); + + const mapsMap = new Map< + number, + { overrides: MapTileOverride[]; latestMs: number } + >(); + + for (const row of result.rows) { + const mapNum = row.map_num; + if (!mapsMap.has(mapNum)) { + mapsMap.set(mapNum, { overrides: [], latestMs: 0 }); + } + const entry = mapsMap.get(mapNum)!; + entry.overrides.push({ + x: row.x, + y: row.y, + layer: row.layer, + grhIndex: row.grh_index, + blocked: row.blocked, + status: "published", + }); + const updatedMs = row.updated_at ? new Date(row.updated_at).getTime() : 0; + if (Number.isFinite(updatedMs) && updatedMs > entry.latestMs) { + entry.latestMs = updatedMs; + } + } + + return Array.from(mapsMap.entries()).map(([mapNum, entry]) => ({ + mapNum, + overrides: entry.overrides, + version: entry.latestMs || entry.overrides.length, + })); +} + +/** Version monotonic-ish de lo publicado en un mapa (epoch ms del ultimo update). */ +export async function getMapPublishVersion(mapNum: number): Promise { + const result = await pool.query<{ version_ms: string | number | null; count: string }>( + `SELECT EXTRACT(EPOCH FROM MAX(updated_at)) * 1000 AS version_ms, + COUNT(*)::text AS count + FROM game_map_tile_overrides + WHERE map_num = $1 AND status = 'published'`, + [mapNum], + ); + + const row = result.rows[0]; + const versionMs = Number(row?.version_ms ?? 0); + if (Number.isFinite(versionMs) && versionMs > 0) { + return Math.trunc(versionMs); + } + return Number(row?.count ?? 0); +} + export async function clearTile( mapNum: number, x: number, diff --git a/api/src/server.ts b/api/src/server.ts index 2b309610..f1e9eb29 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -99,8 +99,10 @@ import { clearTile, discardDrafts, getGraphicContent, + getMapPublishVersion, getMapStatus, getMapTerrainPalette, + listAllPublishedMapOverrides, listGraphics, listMapOverrides, listMapTileEntities, @@ -113,6 +115,7 @@ import { tileEntitySchema, uploadGraphic, } from "./repositories/worldBuilder"; +import { notifyGameServerMapPublish } from "./lib/notifyGameServerMapPublish"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -1026,9 +1029,18 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { return; } - response.json( - await publishMap(mapNum, authorized.session.account._id), + const published = await publishMap( + mapNum, + authorized.session.account._id, ); + const version = await getMapPublishVersion(mapNum); + const live = await notifyGameServerMapPublish(mapNum); + + response.json({ + ...published, + version, + liveReload: live, + }); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -1646,6 +1658,47 @@ app.put( }, ); +app.get( + "/internal/game-data/maps", + requireAuth, + async (_request, response) => { + try { + response.json(await listAllPublishedMapOverrides()); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(500).json({ error: message }); + } + }, +); + +app.get( + "/internal/game-data/maps/:mapNum/overrides", + requireAuth, + async (request, response) => { + try { + const rawMapNum = Array.isArray(request.params.mapNum) + ? request.params.mapNum[0] + : request.params.mapNum; + const mapNum = Number.parseInt(rawMapNum ?? "", 10); + if (!Number.isInteger(mapNum) || mapNum <= 0) { + return void response.status(400).json({ error: "mapNum invalido" }); + } + + const [overrides, version] = await Promise.all([ + listMapOverrides(mapNum, false), + getMapPublishVersion(mapNum), + ]); + + response.json({ mapNum, overrides, version }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(500).json({ error: message }); + } + }, +); + app.post("/auth/register", async (request, response) => { try { const result = await registerAccount(request.body); diff --git a/api/src/tests/mapLivePublish.unit.test.ts b/api/src/tests/mapLivePublish.unit.test.ts new file mode 100644 index 00000000..c81a6bfb --- /dev/null +++ b/api/src/tests/mapLivePublish.unit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +/** + * Lightweight acceptance checks for OpenAO #11 draft isolation + version marker. + * DB-backed publish flows live in world-builder.integration.test.ts when Postgres is available. + */ + +describe("OpenAO #11 live map publish contracts", () => { + it("public override payloads must never include draft status rows", () => { + const rows = [ + { x: 1, y: 1, layer: 1, grhIndex: 10, blocked: false, status: "published" as const }, + { x: 2, y: 2, layer: 1, grhIndex: 11, blocked: true, status: "draft" as const }, + ]; + + const publicRows = rows.filter((row) => row.status === "published"); + expect(publicRows.every((row) => row.status === "published")).toBe(true); + expect(publicRows).toHaveLength(1); + }); + + it("publish response shape includes liveReload metadata fields", () => { + const response = { + published: 3, + publishedEntities: 1, + version: 1_725_000_000_000, + liveReload: { notified: false, skipped: true }, + }; + + expect(response).toMatchObject({ + published: expect.any(Number), + version: expect.any(Number), + liveReload: expect.objectContaining({ + skipped: expect.any(Boolean), + }), + }); + }); +}); diff --git a/frontend/components/game/session/incomingUiPackets.ts b/frontend/components/game/session/incomingUiPackets.ts index 93d50a76..cf8c9f62 100644 --- a/frontend/components/game/session/incomingUiPackets.ts +++ b/frontend/components/game/session/incomingUiPackets.ts @@ -1,4 +1,25 @@ import type { IncomingPacketHandlerArgs } from "./incomingPacketTypes"; +import { + invalidateMapCache, + refreshMapOverridesInPlace, +} from "../../../utils/gameLoader"; + +function parseMapLiveReloadMessage( + message: string, +): { mapNum: number; version: number } | null { + const match = message + .trim() + .match(/^\[MAP_LIVE_RELOAD\]\s+map=(\d+)\s+version=(\d+)\s*$/); + if (!match) { + return null; + } + const mapNum = Number.parseInt(match[1] ?? "", 10); + const version = Number.parseInt(match[2] ?? "", 10); + if (!Number.isInteger(mapNum) || mapNum <= 0 || !Number.isInteger(version)) { + return null; + } + return { mapNum, version }; +} export async function handleIncomingUiPacket({ packet, @@ -6,7 +27,20 @@ export async function handleIncomingUiPacket({ ctx, }: IncomingPacketHandlerArgs): Promise { switch (packet.type) { - case "console": + case "console": { + const liveReload = parseMapLiveReloadMessage(packet.payload.msg); + if (liveReload) { + invalidateMapCache(liveReload.mapNum); + if (engine?.mapData && engine.mapNumber === liveReload.mapNum) { + void refreshMapOverridesInPlace(engine.mapData, liveReload.mapNum); + } else if (engine?.mapData) { + // Still drop cache for that map even if the player already left. + invalidateMapCache(liveReload.mapNum); + } + // Swallow the machine marker; a human-readable INFO line follows. + return true; + } + if ( /Comienzas a pescar\.|Has dejado de pescar\.|La pesca se canceló\.|Debes equiparte la caña de pescar/i.test( packet.payload.msg, @@ -29,6 +63,7 @@ export async function handleIncomingUiPacket({ consoleLine: packet.payload.msg, }); return true; + } case "dialog": { if (packet.payload.id > 0) { diff --git a/frontend/utils/gameLoader.ts b/frontend/utils/gameLoader.ts index f26b355d..d8d7f7e5 100644 --- a/frontend/utils/gameLoader.ts +++ b/frontend/utils/gameLoader.ts @@ -51,6 +51,23 @@ const jsonRequestCache = new Map>(); const jsonValueCache = new Map(); const mapRequestCache = new Map>(); const mapValueCache = new Map(); + +/** Drop one map (or all) from the client cache so the next loadMapData refetches overrides. */ +export function invalidateMapCache(mapNumber?: number): void { + if (typeof mapNumber === "number") { + mapValueCache.delete(mapNumber); + mapRequestCache.delete(mapNumber); + return; + } + + mapValueCache.clear(); + mapRequestCache.clear(); +} + +export function clearMapCache(): void { + invalidateMapCache(); +} + const DYNAMIC_INSTANCE_MAP_START = 30_000; const DYNAMIC_INSTANCE_MAP_STRIDE = 50; const CHALLENGE_INSTANCE_MAP_START = 2_000; @@ -840,6 +857,21 @@ async function applyMapOverrides( /** * Get the texture path for a graphic */ +/** + * Re-fetch published overrides and merge into an already-loaded MapData object. + * Used after live publish so players see terrain/block changes without reconnecting. + */ +export async function refreshMapOverridesInPlace( + mapData: MapData, + mapNumber: number, +): Promise { + invalidateMapCache(mapNumber); + const before = JSON.stringify(mapData[String(mapNumber)] ?? mapData[mapNumber] ?? null); + await applyMapOverrides(mapData, mapNumber); + const after = JSON.stringify(mapData[String(mapNumber)] ?? mapData[mapNumber] ?? null); + return before === after ? 0 : 1; +} + export function getTexturePath(graphicData: GraphicData): string { // if (LOCAL_GRAPHICS_FILE_NAMES.has(Number(graphicData.numFile))) { // return `/graphics/${graphicData.numFile}.png`; diff --git a/server/package.json b/server/package.json index 1aa9ecda..3fa43663 100644 --- a/server/package.json +++ b/server/package.json @@ -28,7 +28,7 @@ "build": "pnpm run clean && tsc && node scripts/copy-assets.cjs", "predev": "pnpm run protocol:build", "dev": "NODE_ENV=development tsx watch src/server.ts", - "test": "tsx --test tests/**/*.test.ts", + "test": "tsx --test tests/**/*.test.ts src/tests/**/*.test.ts", "compact-objs": "tsx src/scripts/compactObjsJson.ts", "compact-npcs": "tsx src/scripts/compactNpcsJson.ts", "export-editable-maps": "tsx src/scripts/exportEditableMaps.ts", diff --git a/server/src/commands.ts b/server/src/commands.ts index a650bbd9..22bc828b 100644 --- a/server/src/commands.ts +++ b/server/src/commands.ts @@ -17,7 +17,14 @@ import type { RuntimeNpc, } from "./types/runtime"; -import { reloadBalanceDiff, reloadCraftingRecipesDiff, reloadNpcsDiff, reloadObjectsDiff } from "./gameDataSync"; +import { + reloadBalanceDiff, + reloadCraftingRecipesDiff, + reloadMapsDiff, + reloadNpcsDiff, + reloadObjectsDiff, +} from "./gameDataSync"; +import { applyPublishedMapToLiveServer } from "./mapLiveApply"; import { appendMapNpcPlacement, loadAllMapNpcPlacements, @@ -3788,6 +3795,44 @@ const command: CommandApi = { break; } + case "/recargarmapa": { + if (!hasAdminPrivileges(user)) { + break; + } + + const mapNum = Number.parseInt(nextText.trim(), 10); + if (!Number.isInteger(mapNum) || mapNum <= 0) { + handleProtocol.console("Uso: /recargarmapa [numero_mapa]", "#FF0000", 0, 0, ws as CommandClient); + break; + } + + const result = await applyPublishedMapToLiveServer(mapNum); + handleProtocol.console( + `[INFO] Mapa ${result.mapNum} publicado en vivo. Overrides: ${result.appliedOverrides}. Reubicados: ${result.relocatedPlayers}. Notificados: ${result.notifiedPlayers}.`, + "#E69500", + 0, + 0, + ws as CommandClient, + ); + break; + } + + case "/recargarmapas": { + if (!hasAdminPrivileges(user)) { + break; + } + + const result = await reloadMapsDiff(); + handleProtocol.console( + `[INFO] Mapas recargados desde DB. Mapas: ${result.updatedMaps}. Overrides: ${result.appliedOverrides}.`, + "#E69500", + 0, + 0, + ws as CommandClient, + ); + break; + } + case "/verip": break; diff --git a/server/src/gameDataSync.ts b/server/src/gameDataSync.ts index 933c11df..a44fef45 100644 --- a/server/src/gameDataSync.ts +++ b/server/src/gameDataSync.ts @@ -62,6 +62,39 @@ export type InitializeSmeltingRecipesResult = { loadedRecipes: number; }; +export type MapTileOverride = { + x: number; + y: number; + layer: number; + grhIndex: number | null; + blocked: boolean | null; + status: "draft" | "published"; +}; + +export type MapPublishedOverrides = { + mapNum: number; + overrides: MapTileOverride[]; + version?: number; +}; + +export type InitializeMapsResult = { + loadedMapsWithOverrides: number; + totalAppliedOverrides: number; +}; + +export type ReloadMapDiffResult = { + mapNum: number; + appliedOverrides: number; + version: number; + blockedChanges: Array<{ x: number; y: number; blocked: boolean }>; + previousOverrides: MapTileOverride[]; +}; + +export type ReloadMapsResult = { + updatedMaps: number; + appliedOverrides: number; +}; + type ObjectChangesResponse = { currentVersion: number; changes: Array<{ id: number; version: number; data: DataObject }>; @@ -493,14 +526,234 @@ async function initializeBalanceFromApi(): Promise { }; } + +type BaseTileSnapshot = { + blocked?: number; + graphicAtLayer?: number; +}; + +const baseTileSnapshots = new Map>(); +const activeMapOverrides = new Map(); +const mapPublishVersions = new Map(); + +function clearMapOverrideSnapshots(): void { + baseTileSnapshots.clear(); + activeMapOverrides.clear(); + mapPublishVersions.clear(); +} + +function getActiveMapOverrides(mapNum: number): MapTileOverride[] { + return [...(activeMapOverrides.get(mapNum) ?? [])]; +} + +function getMapPublishVersion(mapNum: number): number { + return Number(mapPublishVersions.get(mapNum) ?? 0); +} + +function applyMapTileOverridesToVars(mapNum: number, overrides: MapTileOverride[]): number { + const { onlyPublishedOverrides, collectBlockedTileChanges } = require("./mapLivePublish") as typeof import("./mapLivePublish"); + const published = onlyPublishedOverrides(overrides); + + if (!vars.mapa[mapNum]) { + return 0; + } + + if (!baseTileSnapshots.has(mapNum)) { + baseTileSnapshots.set(mapNum, new Map()); + } + const mapSnapshots = baseTileSnapshots.get(mapNum)!; + const previousOverrides = activeMapOverrides.get(mapNum) || []; + const currentKeys = new Set(published.map((o) => `${o.x},${o.y},${o.layer}`)); + + for (const prev of previousOverrides) { + const key = `${prev.x},${prev.y},${prev.layer}`; + if (currentKeys.has(key)) { + continue; + } + + const snapshot = mapSnapshots.get(key); + const tile = vars.mapa[mapNum]?.[prev.y]?.[prev.x]; + if (!tile || !snapshot) { + continue; + } + + if (snapshot.blocked !== undefined) { + tile.blocked = snapshot.blocked; + } else { + delete tile.blocked; + } + + if (snapshot.graphicAtLayer !== undefined) { + if (!tile.graphics || typeof tile.graphics !== "object") { + tile.graphics = {}; + } + (tile.graphics as Record)[prev.layer] = snapshot.graphicAtLayer; + } else if (tile.graphics && typeof tile.graphics === "object") { + delete (tile.graphics as Record)[prev.layer]; + if (Object.keys(tile.graphics).length === 0) { + delete tile.graphics; + } + } + mapSnapshots.delete(key); + } + + let applied = 0; + for (const override of published) { + const { x, y, layer, grhIndex, blocked } = override; + if (!vars.mapa[mapNum][y]) { + vars.mapa[mapNum][y] = {}; + } + if (!vars.mapa[mapNum][y][x]) { + vars.mapa[mapNum][y][x] = {}; + } + + const tile = vars.mapa[mapNum][y][x]; + const key = `${x},${y},${layer}`; + + if (!mapSnapshots.has(key)) { + mapSnapshots.set(key, { + blocked: tile.blocked, + graphicAtLayer: + tile.graphics && typeof tile.graphics === "object" + ? (tile.graphics as Record)[layer] + : undefined, + }); + } + + if (blocked !== null && blocked !== undefined) { + if (blocked) { + tile.blocked = 1; + } else { + delete tile.blocked; + } + } + + if (grhIndex !== undefined) { + if (!tile.graphics || typeof tile.graphics !== "object") { + tile.graphics = {}; + } + if (grhIndex === null || grhIndex === 0) { + delete (tile.graphics as Record)[layer]; + if (Object.keys(tile.graphics).length === 0) { + delete tile.graphics; + } + } else { + (tile.graphics as Record)[layer] = grhIndex; + } + } + applied += 1; + } + + // collectBlockedTileChanges is imported for callers; keep previous for diff + void collectBlockedTileChanges; + activeMapOverrides.set(mapNum, [...published]); + return applied; +} + +async function initializeMapsFromApi(): Promise { + try { + const maps = (await funct.fetchUrl("/internal/game-data/maps", { + headers: { + Authorization: vars.tokenAuth, + }, + })) as MapPublishedOverrides[]; + + let totalAppliedOverrides = 0; + let loadedMapsWithOverrides = 0; + + if (Array.isArray(maps)) { + for (const mapData of maps) { + const applied = applyMapTileOverridesToVars(mapData.mapNum, mapData.overrides ?? []); + if (typeof mapData.version === "number") { + mapPublishVersions.set(mapData.mapNum, mapData.version); + } + if (applied > 0) { + loadedMapsWithOverrides += 1; + totalAppliedOverrides += applied; + } + } + } + + return { + loadedMapsWithOverrides, + totalAppliedOverrides, + }; + } catch (error) { + console.error("[GAME DATA] Error al inicializar mapas desde API:", error); + return { + loadedMapsWithOverrides: 0, + totalAppliedOverrides: 0, + }; + } +} + +async function reloadMapDiff(mapNum: number): Promise { + const { collectBlockedTileChanges, onlyPublishedOverrides } = require("./mapLivePublish") as typeof import("./mapLivePublish"); + const previousOverrides = getActiveMapOverrides(mapNum); + const result = (await funct.fetchUrl(`/internal/game-data/maps/${mapNum}/overrides`, { + headers: { + Authorization: vars.tokenAuth, + }, + })) as { mapNum: number; overrides: MapTileOverride[]; version?: number }; + + const overrides = onlyPublishedOverrides(result.overrides ?? []); + const appliedOverrides = applyMapTileOverridesToVars(mapNum, overrides); + const version = Number(result.version ?? Date.now()); + mapPublishVersions.set(mapNum, version); + + return { + mapNum, + appliedOverrides, + version, + blockedChanges: collectBlockedTileChanges(previousOverrides, overrides), + previousOverrides, + }; +} + +async function reloadMapsDiff(): Promise { + const maps = (await funct.fetchUrl("/internal/game-data/maps", { + headers: { + Authorization: vars.tokenAuth, + }, + })) as MapPublishedOverrides[]; + + let updatedMaps = 0; + let appliedOverrides = 0; + + if (Array.isArray(maps)) { + for (const mapData of maps) { + const applied = applyMapTileOverridesToVars(mapData.mapNum, mapData.overrides ?? []); + if (typeof mapData.version === "number") { + mapPublishVersions.set(mapData.mapNum, mapData.version); + } + if (applied > 0) { + updatedMaps += 1; + appliedOverrides += applied; + } + } + } + + return { + updatedMaps, + appliedOverrides, + }; +} + export { + applyMapTileOverridesToVars, + clearMapOverrideSnapshots, + getActiveMapOverrides, + getMapPublishVersion, initializeBalanceFromApi, initializeCraftingRecipesFromApi, + initializeMapsFromApi, initializeNpcTemplatesFromApi, initializeObjectsFromApi, initializeSmeltingRecipesFromApi, reloadBalanceDiff, reloadCraftingRecipesDiff, + reloadMapDiff, + reloadMapsDiff, reloadObjectsDiff, reloadNpcsDiff, }; diff --git a/server/src/loadMaps.ts b/server/src/loadMaps.ts index 0791e674..ff7abed9 100644 --- a/server/src/loadMaps.ts +++ b/server/src/loadMaps.ts @@ -175,6 +175,16 @@ class LoadMaps { console.log("Mapas Cargados."); + try { + const { initializeMapsFromApi } = require("./gameDataSync"); + const result = await initializeMapsFromApi(); + console.log( + `[GAME DATA] Mapas hidratados desde DB: ${result.loadedMapsWithOverrides} mapas con ${result.totalAppliedOverrides} overrides publicados.`, + ); + } catch (error) { + console.error("[GAME DATA] Error al hidratar mapas desde DB:", error); + } + const LoadNpcs = new loadNpcs(); await LoadNpcs.initialize(); } diff --git a/server/src/mapLiveApply.ts b/server/src/mapLiveApply.ts new file mode 100644 index 00000000..472e57bc --- /dev/null +++ b/server/src/mapLiveApply.ts @@ -0,0 +1,128 @@ +/** + * Applies a published map to the live game process and notifies players (OpenAO #11). + * + * Differentiates from GM-only /recargarmapa flows by also: + * - relocating players standing on newly blocked tiles + * - pushing blockMap deltas + * - emitting MAP_LIVE_RELOAD so clients invalidate map cache without reconnecting + */ + +import { + findNearestWalkableTile, + formatMapLiveReloadMessage, +} from "./mapLivePublish"; + +const vars = require("./vars"); +const game = require("./game"); +const handleProtocol = require("./handleProtocol"); +const { reloadMapDiff, getMapPublishVersion } = require("./gameDataSync"); +const { getClientById } = require("./runtimeRegistry"); + +export type ApplyPublishedMapResult = { + mapNum: number; + appliedOverrides: number; + version: number; + relocatedPlayers: number; + notifiedPlayers: number; + blockedChanges: number; +}; + +function isPlayerTileWalkable(mapNum: number, x: number, y: number, navegando: boolean): boolean { + try { + return Boolean(game.legalPos(x, y, mapNum, Boolean(navegando))); + } catch { + return false; + } +} + +function relocateIfTrapped(user: Record): boolean { + const mapNum = Number(user.map ?? 0); + const pos = user.pos as { x?: number; y?: number } | undefined; + if (!mapNum || !pos) { + return false; + } + + const x = Number(pos.x ?? 0); + const y = Number(pos.y ?? 0); + const navegando = Boolean(user.navegando); + + if (isPlayerTileWalkable(mapNum, x, y, navegando)) { + return false; + } + + const nearest = findNearestWalkableTile( + { x, y }, + (nx, ny) => isPlayerTileWalkable(mapNum, nx, ny, navegando), + ); + + if (!nearest) { + return false; + } + + const client = getClientById(Number(user.id ?? 0)); + if (!client) { + return false; + } + + game.telep(client, mapNum, nearest.x, nearest.y, "map-live-publish-unstuck"); + handleProtocol.console( + "El mapa se actualizó y tu posición quedó bloqueada. Te movimos al tile caminable más cercano.", + "#E69500", + 0, + 0, + client, + ); + return true; +} + +export async function applyPublishedMapToLiveServer(mapNum: number): Promise { + const reload = await reloadMapDiff(mapNum); + + for (const change of reload.blockedChanges) { + try { + game.blockMap(mapNum, { x: change.x, y: change.y }, change.blocked ? 1 : 0); + } catch (error) { + console.warn(`[MAP LIVE] No se pudo empujar blockMap ${mapNum}@${change.x},${change.y}:`, error); + } + } + + let relocatedPlayers = 0; + let notifiedPlayers = 0; + const version = reload.version || getMapPublishVersion(mapNum) || Date.now(); + const marker = formatMapLiveReloadMessage(mapNum, version); + + for (const user of Object.values(vars.personajes as Record>)) { + if (Number(user.map ?? 0) !== mapNum) { + continue; + } + + if (relocateIfTrapped(user)) { + relocatedPlayers += 1; + } + + const client = getClientById(Number(user.id ?? 0)); + if (!client || client.readyState !== client.OPEN) { + continue; + } + + // Machine-readable marker first (client intercepts & invalidates cache). + handleProtocol.console(marker, "#888888", 0, 0, client); + handleProtocol.console( + `[INFO] Mapa ${mapNum} actualizado en vivo (v${version}).`, + "#E69500", + 0, + 0, + client, + ); + notifiedPlayers += 1; + } + + return { + mapNum, + appliedOverrides: reload.appliedOverrides, + version, + relocatedPlayers, + notifiedPlayers, + blockedChanges: reload.blockedChanges.length, + }; +} diff --git a/server/src/mapLivePublish.ts b/server/src/mapLivePublish.ts new file mode 100644 index 00000000..13ee1594 --- /dev/null +++ b/server/src/mapLivePublish.ts @@ -0,0 +1,150 @@ +/** + * Live map publish helpers (OpenAO #11). + * + * Keeps the hard policy decisions out of gameDataSync so they can be unit-tested: + * - nearest walkable tile for players trapped on newly blocked tiles + * - MAP_LIVE_RELOAD console marker that clients watch to invalidate map cache + */ + +export const MAP_LIVE_RELOAD_PREFIX = "[MAP_LIVE_RELOAD]"; + +export type TileCoord = { x: number; y: number }; + +export type MapTileOverrideLike = { + x: number; + y: number; + layer: number; + grhIndex: number | null; + blocked: boolean | null; + status?: "draft" | "published"; +}; + +export function formatMapLiveReloadMessage(mapNum: number, version: number): string { + return `${MAP_LIVE_RELOAD_PREFIX} map=${mapNum} version=${version}`; +} + +export function parseMapLiveReloadMessage( + message: string, +): { mapNum: number; version: number } | null { + const match = message.trim().match(/^\[MAP_LIVE_RELOAD\]\s+map=(\d+)\s+version=(\d+)\s*$/); + if (!match) { + return null; + } + + const mapNum = Number.parseInt(match[1] ?? "", 10); + const version = Number.parseInt(match[2] ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0 || !Number.isInteger(version) || version < 0) { + return null; + } + + return { mapNum, version }; +} + +/** + * BFS for the nearest walkable tile around `from`. + * `isWalkable(x,y)` must return true only for tiles the player can stand on. + */ +export function findNearestWalkableTile( + from: TileCoord, + isWalkable: (x: number, y: number) => boolean, + mapSize = 100, + maxRadius = 30, +): TileCoord | null { + const startX = Math.min(mapSize, Math.max(1, Math.trunc(from.x))); + const startY = Math.min(mapSize, Math.max(1, Math.trunc(from.y))); + + if (isWalkable(startX, startY)) { + return { x: startX, y: startY }; + } + + const visited = new Set([`${startX},${startY}`]); + const queue: Array = [{ x: startX, y: startY, dist: 0 }]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.dist >= maxRadius) { + continue; + } + + const neighbors: TileCoord[] = [ + { x: current.x - 1, y: current.y }, + { x: current.x + 1, y: current.y }, + { x: current.x, y: current.y - 1 }, + { x: current.x, y: current.y + 1 }, + ]; + + for (const neighbor of neighbors) { + if (neighbor.x < 1 || neighbor.y < 1 || neighbor.x > mapSize || neighbor.y > mapSize) { + continue; + } + + const key = `${neighbor.x},${neighbor.y}`; + if (visited.has(key)) { + continue; + } + visited.add(key); + + if (isWalkable(neighbor.x, neighbor.y)) { + return neighbor; + } + + queue.push({ ...neighbor, dist: current.dist + 1 }); + } + } + + return null; +} + +/** + * Draft overrides must never reach the live game server / public clients. + * Pure filter used by sync + tests. + */ +export function onlyPublishedOverrides( + overrides: T[], +): T[] { + return overrides.filter((override) => override.status !== "draft"); +} + +/** + * Diff blocked-state changes that clients need via blockMap packets. + */ +export function collectBlockedTileChanges( + previous: MapTileOverrideLike[], + next: MapTileOverrideLike[], +): Array<{ x: number; y: number; blocked: boolean }> { + const prevBlocked = new Map(); + for (const override of previous) { + if (override.blocked == null) { + continue; + } + prevBlocked.set(`${override.x},${override.y}`, Boolean(override.blocked)); + } + + const nextBlocked = new Map(); + for (const override of next) { + if (override.blocked == null) { + continue; + } + nextBlocked.set(`${override.x},${override.y}`, Boolean(override.blocked)); + } + + const keys = new Set([...prevBlocked.keys(), ...nextBlocked.keys()]); + const changes: Array<{ x: number; y: number; blocked: boolean }> = []; + + for (const key of keys) { + const before = prevBlocked.get(key); + const after = nextBlocked.has(key) ? nextBlocked.get(key)! : false; + if (before === after) { + continue; + } + const [xRaw, yRaw] = key.split(","); + changes.push({ + x: Number(xRaw), + y: Number(yRaw), + blocked: after, + }); + } + + return changes; +} diff --git a/server/src/server.ts b/server/src/server.ts index f2c3de84..4175bfc9 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -200,12 +200,68 @@ const npcs = require("./npcs") as NpcsApi; const runtimeTiming = require("./runtimeTiming"); const handleProtocol = require("./handleProtocol") as HandleProtocolApi; -function handleHttpRequest(request: any, response: any) { - void request; +function readRequestBody(request: any): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} - response.statusCode = 404; +function sendJson(response: any, statusCode: number, body: unknown) { + response.statusCode = statusCode; response.setHeader("Content-Type", "application/json; charset=utf-8"); - response.end(JSON.stringify({ error: "Not found" })); + response.end(JSON.stringify(body)); +} + +function isAuthorizedInternalRequest(request: any): boolean { + const authorization = String(request.headers?.authorization ?? ""); + return authorization === vars.tokenAuth || authorization === `Bearer ${vars.tokenAuth}`; +} + +async function handleHttpRequest(request: any, response: any) { + try { + const url = new URL(request.url || "/", `http://${request.headers?.host || "localhost"}`); + const pathname = url.pathname; + + if (request.method === "GET" && pathname === "/health") { + sendJson(response, 200, { ok: true, serverReady: Boolean(vars.serverReady) }); + return; + } + + // API calls this after publish so players see the map without a process restart. + const hotReloadMatch = pathname.match(/^\/internal\/maps\/(\d+)\/hot-reload$/); + if (request.method === "POST" && hotReloadMatch) { + if (!isAuthorizedInternalRequest(request)) { + sendJson(response, 401, { error: "Unauthorized" }); + return; + } + + const mapNum = Number.parseInt(hotReloadMatch[1] ?? "", 10); + if (!Number.isInteger(mapNum) || mapNum <= 0) { + sendJson(response, 400, { error: "mapNum invalido" }); + return; + } + + // Body is optional; keep the handler resilient to empty POSTs. + try { + await readRequestBody(request); + } catch { + // ignore body read errors + } + + const { applyPublishedMapToLiveServer } = require("./mapLiveApply"); + const result = await applyPublishedMapToLiveServer(mapNum); + sendJson(response, 200, result); + return; + } + + sendJson(response, 404, { error: "Not found" }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + sendJson(response, 500, { error: message }); + } } const gracefulShutdown = createGracefulShutdown({ diff --git a/server/src/tests/mapLivePublish.test.ts b/server/src/tests/mapLivePublish.test.ts new file mode 100644 index 00000000..4f623083 --- /dev/null +++ b/server/src/tests/mapLivePublish.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + collectBlockedTileChanges, + findNearestWalkableTile, + formatMapLiveReloadMessage, + onlyPublishedOverrides, + parseMapLiveReloadMessage, +} from "../mapLivePublish"; + +describe("mapLivePublish helpers (OpenAO #11)", () => { + it("formats and parses the live-reload console marker", () => { + const message = formatMapLiveReloadMessage(17, 42); + assert.equal(message, "[MAP_LIVE_RELOAD] map=17 version=42"); + assert.deepEqual(parseMapLiveReloadMessage(message), { mapNum: 17, version: 42 }); + assert.equal(parseMapLiveReloadMessage("hola mundo"), null); + }); + + it("finds the nearest walkable tile when the player stands on a new wall", () => { + const blocked = new Set(["50,50", "49,50", "51,50", "50,49"]); + const result = findNearestWalkableTile({ x: 50, y: 50 }, (x, y) => !blocked.has(`${x},${y}`)); + assert.ok(result); + assert.equal(blocked.has(`${result!.x},${result!.y}`), false); + // Closest open neighbor among cardinal BFS is (50,51) + assert.deepEqual(result, { x: 50, y: 51 }); + }); + + it("returns null when no walkable tile exists in radius", () => { + const result = findNearestWalkableTile( + { x: 1, y: 1 }, + () => false, + 3, + 2, + ); + assert.equal(result, null); + }); + + it("strips draft overrides so players never see in-progress edits", () => { + const filtered = onlyPublishedOverrides([ + { status: "published", x: 1 }, + { status: "draft", x: 2 }, + { x: 3 }, + ]); + assert.deepEqual( + filtered.map((row) => row.x), + [1, 3], + ); + }); + + it("collects blocked tile deltas for client blockMap updates", () => { + const changes = collectBlockedTileChanges( + [ + { x: 10, y: 10, layer: 1, grhIndex: 1, blocked: false }, + { x: 11, y: 11, layer: 1, grhIndex: 1, blocked: true }, + ], + [ + { x: 10, y: 10, layer: 1, grhIndex: 1, blocked: true }, + { x: 12, y: 12, layer: 1, grhIndex: 1, blocked: true }, + ], + ); + + assert.deepEqual( + changes.sort((a, b) => a.x - b.x), + [ + { x: 10, y: 10, blocked: true }, + { x: 11, y: 11, blocked: false }, + { x: 12, y: 12, blocked: true }, + ], + ); + }); +});