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
3 changes: 3 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Config = {
gameDataAdminEmail: string;
gameDataAdminAccountId: string | null;
gameDataAdminProxyToken: string | null;
gameServerInternalUrl: string | null;
};

const projectRoot = path.resolve(__dirname, "..");
Expand Down Expand Up @@ -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;
51 changes: 51 additions & 0 deletions api/src/lib/notifyGameServerMapPublish.ts
Original file line number Diff line number Diff line change
@@ -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<GameServerHotReloadResult> {
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",
};
}
}
70 changes: 70 additions & 0 deletions api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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,
Expand Down
57 changes: 55 additions & 2 deletions api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,10 @@ import {
clearTile,
discardDrafts,
getGraphicContent,
getMapPublishVersion,
getMapStatus,
getMapTerrainPalette,
listAllPublishedMapOverrides,
listGraphics,
listMapOverrides,
listMapTileEntities,
Expand All @@ -113,6 +115,7 @@ import {
tileEntitySchema,
uploadGraphic,
} from "./repositories/worldBuilder";
import { notifyGameServerMapPublish } from "./lib/notifyGameServerMapPublish";
import { MAX_PNG_BYTES } from "./lib/pngValidation";
import {
getGameCraftingRecipeById,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions api/src/tests/mapLivePublish.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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),
}),
});
});
});
37 changes: 36 additions & 1 deletion frontend/components/game/session/incomingUiPackets.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,46 @@
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,
engine,
ctx,
}: IncomingPacketHandlerArgs): Promise<boolean> {
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,
Expand All @@ -29,6 +63,7 @@ export async function handleIncomingUiPacket({
consoleLine: packet.payload.msg,
});
return true;
}

case "dialog": {
if (packet.payload.id > 0) {
Expand Down
32 changes: 32 additions & 0 deletions frontend/utils/gameLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ const jsonRequestCache = new Map<string, Promise<unknown>>();
const jsonValueCache = new Map<string, unknown>();
const mapRequestCache = new Map<number, Promise<MapData>>();
const mapValueCache = new Map<number, MapData>();

/** 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;
Expand Down Expand Up @@ -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<number> {
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`;
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading