diff --git a/api/.env.example b/api/.env.example index 1d568703..a26bd12e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,3 +2,8 @@ PORT=3001 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aoweb TOKEN_AUTH=changeme CORS_ORIGIN=http://localhost:3000 + +# Game Data Administration & Map Builder +GAME_DATA_ADMIN_EMAIL=admin@aoweb.app +GAME_DATA_ADMIN_ACCOUNT_ID= +GAME_DATA_ADMIN_PROXY_TOKEN= diff --git a/api/src/repositories/__tests__/paletteValidation.test.ts b/api/src/repositories/__tests__/paletteValidation.test.ts new file mode 100644 index 00000000..82948514 --- /dev/null +++ b/api/src/repositories/__tests__/paletteValidation.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { + paletteEntrySchema, + UPLOADED_GRAPHIC_INDEX_START, +} from "../worldBuilder"; + +describe("Palette Entry Schema and Validation (#6)", () => { + it("should accept valid multi-layer palette entries with blocking flag", () => { + const valid = paletteEntrySchema.safeParse({ + graphics: [5500, 581], + blocked: true, + }); + expect(valid.success).toBe(true); + if (valid.success) { + expect(valid.data.graphics).toEqual([5500, 581]); + expect(valid.data.blocked).toBe(true); + } + }); + + it("should reject palette entries with empty graphics array", () => { + const invalid = paletteEntrySchema.safeParse({ + graphics: [], + blocked: false, + }); + expect(invalid.success).toBe(false); + }); + + it("should reject palette entries exceeding maximum layers (4)", () => { + const invalid = paletteEntrySchema.safeParse({ + graphics: [1, 2, 3, 4, 5], + }); + expect(invalid.success).toBe(false); + }); + + it("should enforce non-colliding reserved range for uploaded graphics", () => { + expect(UPLOADED_GRAPHIC_INDEX_START).toBe(1_000_000); + // Original game graphics reach up to 320151, well below 1_000_000 + expect(UPLOADED_GRAPHIC_INDEX_START).toBeGreaterThan(320151); + }); +}); diff --git a/api/src/repositories/__tests__/worldBuilderPermissions.test.ts b/api/src/repositories/__tests__/worldBuilderPermissions.test.ts new file mode 100644 index 00000000..5355c452 --- /dev/null +++ b/api/src/repositories/__tests__/worldBuilderPermissions.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { + PROTECTED_MAPS, + isMapProtected, + canAccountEditMap, +} from "../worldBuilder"; + +describe("World Builder Map Permissions and Protections (#4)", () => { + it("should identify city maps as protected by default", () => { + expect(isMapProtected(1)).toBe(true); // Ullathorpe + expect(isMapProtected(34)).toBe(true); // Nix + expect(isMapProtected(59)).toBe(true); // Banderbill + expect(isMapProtected(50)).toBe(false); // Regular map + }); + + it("should reject edits to protected maps when override is false", () => { + const result = canAccountEditMap("admin_123", 1, true, undefined, false); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("protegido contra modificaciones"); + }); + + it("should allow edits to protected maps when override is true", () => { + const result = canAccountEditMap("admin_123", 1, true, undefined, true); + expect(result.allowed).toBe(true); + }); + + it("should allow superadmin to edit non-protected maps", () => { + const result = canAccountEditMap("admin_123", 50, true, undefined, false); + expect(result.allowed).toBe(true); + }); + + it("should allow collaborator to edit specifically assigned map", () => { + const result = canAccountEditMap("collab_456", 50, false, [50, 51], false); + expect(result.allowed).toBe(true); + }); + + it("should reject collaborator attempting to edit unassigned map", () => { + const result = canAccountEditMap("collab_456", 100, false, [50, 51], false); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("no tiene permisos"); + }); +}); diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 8fd58c95..fa969996 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -12,6 +12,44 @@ export const UPLOADED_GRAPHIC_INDEX_START = 1_000_000; /** Los mapas del juego son de 100x100. */ export const MAP_SIZE = 100; +/** + * Mapas protegidos contra modificaciones destructivas (ciudades principales). + * Requieren autorizacion explicita con override para ser modificados. + */ +export const PROTECTED_MAPS = new Set([1, 34, 59, 60, 61]); + +export function isMapProtected(mapNum: number): boolean { + return PROTECTED_MAPS.has(mapNum); +} + +export function canAccountEditMap( + accountId: string, + mapNum: number, + isSuperAdmin: boolean, + allowedMapsForAccount?: number[], + allowProtectedOverride = false, +): { allowed: boolean; reason?: string } { + if (isMapProtected(mapNum) && !allowProtectedOverride) { + return { + allowed: false, + reason: `El mapa ${mapNum} esta protegido contra modificaciones.`, + }; + } + + if (isSuperAdmin) { + return { allowed: true }; + } + + if (allowedMapsForAccount && allowedMapsForAccount.includes(mapNum)) { + return { allowed: true }; + } + + return { + allowed: false, + reason: `La cuenta ${accountId} no tiene permisos para editar el mapa ${mapNum}.`, + }; +} + export type UploadedGraphic = { grhIndex: number; checksum: string; @@ -180,6 +218,41 @@ export async function listGraphics(limit = 100): Promise { })); } +export const paletteEntrySchema = z.object({ + graphics: z.array(z.number().int().positive()).min(1).max(4), + blocked: z.boolean().optional(), +}); + +export type PaletteEntry = z.infer; + +/** + * Valida que los graficos de una entrada de paleta existan (originales o subidos). + */ +export async function validatePaletteEntry( + entry: PaletteEntry, +): Promise<{ valid: boolean; reason?: string }> { + for (const grhIndex of entry.graphics) { + if (grhIndex >= UPLOADED_GRAPHIC_INDEX_START) { + const exists = await pool.query( + `SELECT 1 FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`, + [grhIndex], + ); + if (exists.rowCount === 0) { + return { + valid: false, + reason: `El grafico ${grhIndex} no existe en el motor ni en assets subidos.`, + }; + } + } else if (grhIndex <= 0) { + return { + valid: false, + reason: `Indice de grafico invalido: ${grhIndex}.`, + }; + } + } + return { valid: true }; +} + export const tilePaintSchema = z.object({ x: z.coerce.number().int().min(1).max(MAP_SIZE), y: z.coerce.number().int().min(1).max(MAP_SIZE), diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..4543a06d 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -96,10 +96,12 @@ import { upsertGameBalance, } from "./repositories/gameBalance"; import { + canAccountEditMap, clearTile, discardDrafts, getGraphicContent, getMapStatus, + isMapProtected, listGraphics, listMapOverrides, paintTiles, @@ -857,6 +859,20 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { return; } + const allowOverride = request.headers["x-protected-map-override"] === "true"; + const permission = canAccountEditMap( + authorized.session.account._id, + mapNum, + true, + undefined, + allowOverride, + ); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return; + } + const parsed = paintTilesSchema.safeParse(request.body); if (!parsed.success) { @@ -900,6 +916,20 @@ app.delete( return; } + const allowOverride = request.headers["x-protected-map-override"] === "true"; + const permission = canAccountEditMap( + authorized.session.account._id, + mapNum, + true, + undefined, + allowOverride, + ); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return; + } + response.json({ removed: await clearTile(mapNum, x, y, layer) }); } catch (error) { const message = @@ -963,6 +993,20 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { return; } + const allowOverride = request.headers["x-protected-map-override"] === "true"; + const permission = canAccountEditMap( + authorized.session.account._id, + mapNum, + true, + undefined, + allowOverride, + ); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return; + } + response.json( await publishMap(mapNum, authorized.session.account._id), ); @@ -986,6 +1030,20 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { return; } + const allowOverride = request.headers["x-protected-map-override"] === "true"; + const permission = canAccountEditMap( + authorized.session.account._id, + mapNum, + true, + undefined, + allowOverride, + ); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return; + } + response.json(await discardDrafts(mapNum)); } catch (error) { const message = @@ -1010,6 +1068,20 @@ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { return; } + const allowOverride = request.headers["x-protected-map-override"] === "true"; + const permission = canAccountEditMap( + authorized.session.account._id, + mapNum, + true, + undefined, + allowOverride, + ); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return; + } + response.json(await revertMap(mapNum)); } catch (error) { const message = diff --git a/api/src/tests/shutdownReset.unit.test.ts b/api/src/tests/shutdownReset.unit.test.ts new file mode 100644 index 00000000..4e11a847 --- /dev/null +++ b/api/src/tests/shutdownReset.unit.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { test, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); + +vi.mock("../db", () => ({ + default: { query }, +})); + +import { resetAllCharactersConnectedStatus } from "../repositories/characters"; +import { resetAllArenaRoomMembersConnectedStatus } from "../repositories/arenas"; + +test("reset helpers clear connected characters and arena members", async () => { + query + .mockReset() + .mockResolvedValueOnce({ rowCount: 4 }) + .mockResolvedValueOnce({ rowCount: 2 }); + + const [updatedCharacters, updatedArenaMembers] = await Promise.all([ + resetAllCharactersConnectedStatus(), + resetAllArenaRoomMembersConnectedStatus(), + ]); + + assert.equal(updatedCharacters, 4); + assert.equal(updatedArenaMembers, 2); + assert.equal(query.mock.calls.length, 2); + + const sqlStatements = query.mock.calls.map(([sql]) => String(sql)); + assert.equal( + sqlStatements.some( + (sql) => + sql.includes("UPDATE characters") && + sql.includes("connected = FALSE") && + sql.includes("deleted_at IS NULL"), + ), + true, + ); + assert.equal( + sqlStatements.some( + (sql) => + sql.includes("UPDATE arena_room_members") && + sql.includes("connected = FALSE"), + ), + true, + ); +}); diff --git a/server/src/gracefulShutdown.test.ts b/server/src/gracefulShutdown.test.ts new file mode 100644 index 00000000..a1f38a9b --- /dev/null +++ b/server/src/gracefulShutdown.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + gracefulShutdown, + type GracefulShutdownDependencies, + type ShutdownClient, +} from "./gracefulShutdown"; + +test("gracefulShutdown closes clients and resets connected characters", async () => { + const closedClients: string[] = []; + const requests: Array<{ url: string; options: RequestInit }> = []; + const output: string[] = []; + const errors: string[] = []; + let exitCode: number | undefined; + let clearedTimer = false; + + const clients: Record = { + open: { + readyState: 1, + OPEN: 1, + close: () => closedClients.push("open"), + }, + closed: { + readyState: 3, + OPEN: 1, + close: () => closedClients.push("closed"), + }, + }; + + const dependencies: GracefulShutdownDependencies = { + clients, + tokenAuth: "test-token", + fetchUrl: async (url, options) => { + requests.push({ url, options }); + return { updated: 4 }; + }, + exit: (code) => { + exitCode = code; + }, + setTimeout: (callback) => { + void callback; + return setTimeout(() => undefined, 60_000); + }, + clearTimeout: (timer) => { + clearedTimer = true; + clearTimeout(timer); + }, + writeOut: (message) => output.push(message), + writeErr: (message) => errors.push(message), + }; + + await gracefulShutdown("SIGTERM", dependencies); + + assert.deepEqual(closedClients, ["open"]); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.url, "/internal/characters/reset-connected"); + assert.equal(requests[0]?.options.method, "POST"); + assert.equal( + (requests[0]?.options.headers as Record).Authorization, + "test-token", + ); + assert.equal(exitCode, 0); + assert.equal(clearedTimer, true); + assert.equal(errors.length, 0); + assert.equal(output.some((message) => message.includes("4 personajes")), true); +}); diff --git a/server/src/gracefulShutdown.ts b/server/src/gracefulShutdown.ts new file mode 100644 index 00000000..b575fbc6 --- /dev/null +++ b/server/src/gracefulShutdown.ts @@ -0,0 +1,85 @@ +export type ShutdownClient = { + readyState: number; + OPEN: number; + close: (code: number, reason: string) => void; +}; + +export type GracefulShutdownDependencies = { + clients: Record; + tokenAuth: string; + fetchUrl: ( + url: string, + options: RequestInit, + ) => Promise<{ updated?: number } | undefined>; + exit: (code: number) => void; + setTimeout: ( + callback: () => void, + delayMs: number, + ) => ReturnType; + clearTimeout: (timeout: ReturnType) => void; + writeOut: (message: string) => void; + writeErr: (message: string) => void; +}; + +const SHUTDOWN_TIMEOUT_MS = 8000; +const SHUTDOWN_MESSAGE = "[Servidor] El servidor se está reiniciando."; + +let isShuttingDown = false; + +export async function gracefulShutdown( + signal: string, + dependencies: GracefulShutdownDependencies, +): Promise { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + dependencies.writeOut( + `[Servidor] Recibida señal ${signal}. Iniciando apagado seguro...\n`, + ); + + const shutdownTimeout = dependencies.setTimeout(() => { + dependencies.writeErr( + "[Servidor] Tiempo de apagado agotado. Forzando salida.\n", + ); + dependencies.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + + try { + for (const client of Object.values(dependencies.clients)) { + if (!client || client.readyState !== client.OPEN) { + continue; + } + + try { + client.close(1001, SHUTDOWN_MESSAGE); + } catch { + // A client that is already closing must not block shutdown. + } + } + + const resetCharactersResponse = await dependencies.fetchUrl( + "/internal/characters/reset-connected", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: dependencies.tokenAuth, + }, + }, + ); + + dependencies.writeOut( + `[Servidor] Apagado seguro: ${resetCharactersResponse?.updated ?? 0} personajes marcados como desconectados.\n`, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + dependencies.writeErr( + `[Servidor] Error durante el apagado seguro: ${message}\n`, + ); + } finally { + dependencies.clearTimeout(shutdownTimeout); + dependencies.exit(0); + } +} diff --git a/server/src/server.ts b/server/src/server.ts index 69dc45af..2acbb5bb 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -14,6 +14,11 @@ import type { } from "./types/runtime"; import { getClientById } from "./runtimeRegistry"; import * as safeZone from "./safeZone"; +import { + gracefulShutdown, + type GracefulShutdownDependencies, + type ShutdownClient, +} from "./gracefulShutdown"; export {}; const config = require("./config"); @@ -949,3 +954,26 @@ createDynamicScheduler( ); void saveOnlineStatsSnapshot(); + +function getGracefulShutdownDependencies(): GracefulShutdownDependencies { + return { + clients: vars.clients as Record, + tokenAuth: vars.tokenAuth, + fetchUrl: funct.fetchUrl, + exit: (code) => process.exit(code), + setTimeout, + clearTimeout, + writeOut: (message) => process.stdout.write(message), + writeErr: (message) => process.stderr.write(message), + }; +} + +export { gracefulShutdown }; + +process.on("SIGINT", () => + void gracefulShutdown("SIGINT", getGracefulShutdownDependencies()), +); +process.on("SIGTERM", () => + void gracefulShutdown("SIGTERM", getGracefulShutdownDependencies()), +); +