From fd7eda75a0a3c988fdec3e135440ad4343d18bb6 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:33:58 +0200 Subject: [PATCH 1/9] feat(world-builder): permissions and protected map restrictions for map editing (#4) --- api/.env.example | 5 + api/schema.sql | 13 ++ api/src/repositories/worldBuilder.ts | 98 +++++++++ api/src/server.ts | 204 ++++++++++++++++-- ...rldBuilder_permissions.integration.test.ts | 77 +++++++ 5 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 api/src/tests/worldBuilder_permissions.integration.test.ts diff --git a/api/.env.example b/api/.env.example index 1d568703..b1f0cd35 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 Admin Configuration (World Builder & Content Management) +GAME_DATA_ADMIN_EMAIL=admin@aoweb.app +GAME_DATA_ADMIN_ACCOUNT_ID= +GAME_DATA_ADMIN_PROXY_TOKEN=secret-proxy-token diff --git a/api/schema.sql b/api/schema.sql index c5103206..81355777 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -627,3 +627,16 @@ CREATE INDEX IF NOT EXISTS idx_game_map_tile_overrides_map ON game_map_tile_overrides(map_num, status); CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at ON game_uploaded_graphics(created_at DESC); + +-- Permisos granulares de edicion de mapa por cuenta. +-- map_num = 0 indica permiso de edicion global sobre mapas no protegidos. +CREATE TABLE IF NOT EXISTS game_map_permissions ( + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + map_num INTEGER NOT NULL CHECK (map_num >= 0), + granted_by UUID REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (account_id, map_num) +); + +CREATE INDEX IF NOT EXISTS idx_game_map_permissions_account_map + ON game_map_permissions(account_id, map_num); diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 8fd58c95..d37e2281 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -417,3 +417,101 @@ export async function clearTile( return (result.rowCount ?? 0) > 0; } + +/** + * Mapas principales protegidos contra edición accidental o no autorizada. + * Incluye las ciudades principales (Ullathorpe = 1, Nix = 34, Banderbill = 59, Lindos = 150). + */ +export const PROTECTED_MAPS: ReadonlySet = new Set([1, 34, 59, 150]); + +export function isProtectedMap(mapNum: number): boolean { + return PROTECTED_MAPS.has(mapNum); +} + +export type MapPermissionCheckResult = + | { allowed: true } + | { allowed: false; reason: string }; + +/** + * Verifica si una cuenta tiene permisos para editar un mapa específico. + * + * 1. Los administradores globales pueden editar mapas no protegidos, o protegidos si envían `overrideProtected: true`. + * 2. Los colaboradores deben tener asignado el mapa en `game_map_permissions` y no pueden editar mapas protegidos. + */ +export async function checkMapEditPermission(options: { + accountId: string; + isSuperAdmin: boolean; + mapNum: number; + overrideProtected?: boolean; +}): Promise { + const { accountId, isSuperAdmin, mapNum, overrideProtected } = options; + + if (isSuperAdmin) { + if (isProtectedMap(mapNum) && !overrideProtected) { + return { + allowed: false, + reason: `El mapa ${mapNum} esta protegido contra edicion accidental. Para modificarlo como admin debes especificar overrideProtected = true.`, + }; + } + return { allowed: true }; + } + + // Colaboradores regulares: nunca pueden modificar mapas protegidos + if (isProtectedMap(mapNum)) { + return { + allowed: false, + reason: `El mapa ${mapNum} esta protegido. Los colaboradores no tienen permisos de modificacion sobre mapas protegidos.`, + }; + } + + // Verificar si tiene permiso granular concedido (map_num exacto o map_num = 0 para permiso global) + const permission = await pool.query<{ map_num: number }>( + `SELECT map_num FROM game_map_permissions + WHERE account_id = $1 AND (map_num = $2 OR map_num = 0) + LIMIT 1`, + [accountId, mapNum], + ); + + if (permission.rowCount === 0) { + return { + allowed: false, + reason: `La cuenta ${accountId} no tiene permisos para editar el mapa ${mapNum}.`, + }; + } + + return { allowed: true }; +} + +export async function grantMapPermission( + accountId: string, + mapNum: number, + grantedByAccountId: string, +): Promise { + await pool.query( + `INSERT INTO game_map_permissions (account_id, map_num, granted_by, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (account_id, map_num) DO NOTHING`, + [accountId, mapNum, grantedByAccountId], + ); +} + +export async function revokeMapPermission( + accountId: string, + mapNum: number, +): Promise { + const result = await pool.query( + `DELETE FROM game_map_permissions WHERE account_id = $1 AND map_num = $2`, + [accountId, mapNum], + ); + return (result.rowCount ?? 0) > 0; +} + +export async function listAccountMapPermissions( + accountId: string, +): Promise { + const result = await pool.query<{ map_num: number }>( + `SELECT map_num FROM game_map_permissions WHERE account_id = $1 ORDER BY map_num`, + [accountId], + ); + return result.rows.map((row) => row.map_num); +} diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..ecc7e789 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -96,16 +96,21 @@ import { upsertGameBalance, } from "./repositories/gameBalance"; import { + checkMapEditPermission, clearTile, discardDrafts, getGraphicContent, getMapStatus, + grantMapPermission, + isProtectedMap, + listAccountMapPermissions, listGraphics, listMapOverrides, paintTiles, paintTilesSchema, publishMap, revertMap, + revokeMapPermission, uploadGraphic, } from "./repositories/worldBuilder"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; @@ -233,6 +238,46 @@ async function requireAdminEmailSession( return authorized; } +async function requireMapEditSession( + request: express.Request, + response: express.Response, + mapNum: number, + overrideProtected = false, +): Promise<{ accountId: string; isSuperAdmin: boolean } | null> { + if ( + !config.gameDataAdminProxyToken || + getGameDataAdminProxyHeader(request) !== config.gameDataAdminProxyToken + ) { + response.status(403).json({ error: "No autorizado." }); + return null; + } + + const authorized = await getAuthorizedSession(request); + + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return null; + } + + const isSuperAdmin = isAuthorizedGameDataAdmin(authorized.session); + const permission = await checkMapEditPermission({ + accountId: authorized.session.account._id, + isSuperAdmin, + mapNum, + overrideProtected, + }); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return null; + } + + return { + accountId: authorized.session.account._id, + isSuperAdmin, + }; +} + async function ensurePgStatStatements(): Promise { try { await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements"); @@ -847,9 +892,6 @@ app.get("/game-data/graphics/:grhIndex.png", async (request, response) => { app.put("/admin/game-data/maps/:mapNum/tiles", 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) { @@ -857,6 +899,15 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + const parsed = paintTilesSchema.safeParse(request.body); if (!parsed.success) { @@ -870,7 +921,7 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { await paintTiles( mapNum, parsed.data.tiles, - authorized.session.account._id, + authorized.accountId, ), ); } catch (error) { @@ -884,12 +935,6 @@ app.delete( "/admin/game-data/maps/:mapNum/tiles/:x/:y/:layer", 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); @@ -900,6 +945,18 @@ app.delete( return; } + const overrideProtected = Boolean( + request.query?.overrideProtected === "true" || + request.body?.overrideProtected, + ); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json({ removed: await clearTile(mapNum, x, y, layer) }); } catch (error) { const message = @@ -953,9 +1010,6 @@ app.get("/maps/:mapNum/overrides", async (request, response) => { /** Publica los borradores de un mapa. A partir de aca los ven los jugadores. */ app.post("/admin/game-data/maps/:mapNum/publish", 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) { @@ -963,8 +1017,17 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json( - await publishMap(mapNum, authorized.session.account._id), + await publishMap(mapNum, authorized.accountId), ); } catch (error) { const message = @@ -976,9 +1039,6 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { /** Descarta los borradores sin tocar lo ya publicado. */ app.post("/admin/game-data/maps/:mapNum/discard", 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) { @@ -986,6 +1046,15 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json(await discardDrafts(mapNum)); } catch (error) { const message = @@ -1000,9 +1069,6 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { */ app.post("/admin/game-data/maps/:mapNum/revert", 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) { @@ -1010,6 +1076,15 @@ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json(await revertMap(mapNum)); } catch (error) { const message = @@ -1018,6 +1093,95 @@ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { } }); +/** Concede permisos de edicion de mapa a una cuenta (superadmin). */ +app.post( + "/admin/game-data/maps/:mapNum/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const accountId = request.params.accountId ?? ""; + + if (!Number.isInteger(mapNum) || mapNum < 0 || !accountId) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + await grantMapPermission( + accountId, + mapNum, + authorized.session.account._id, + ); + response.json({ ok: true, accountId, mapNum }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** Revoca permisos de edicion de mapa a una cuenta (superadmin). */ +app.delete( + "/admin/game-data/maps/:mapNum/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const accountId = request.params.accountId ?? ""; + + if (!Number.isInteger(mapNum) || mapNum < 0 || !accountId) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + const revoked = await revokeMapPermission(accountId, mapNum); + response.json({ ok: true, revoked }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** Lista los mapas asignados a una cuenta. */ +app.get( + "/admin/game-data/maps/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const accountId = request.params.accountId ?? ""; + if (!accountId) { + response.status(400).json({ error: "accountId requerido." }); + return; + } + + const maps = await listAccountMapPermissions(accountId); + response.json({ accountId, maps }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + /** Cuantos tiles hay en borrador y cuantos publicados. */ app.get("/admin/game-data/maps/:mapNum/status", async (request, response) => { try { diff --git a/api/src/tests/worldBuilder_permissions.integration.test.ts b/api/src/tests/worldBuilder_permissions.integration.test.ts new file mode 100644 index 00000000..89822590 --- /dev/null +++ b/api/src/tests/worldBuilder_permissions.integration.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { + checkMapEditPermission, + grantMapPermission, + revokeMapPermission, + listAccountMapPermissions, + isProtectedMap, + PROTECTED_MAPS, +} from "../repositories/worldBuilder"; + +describe("World Builder Permissions and Protected Maps", () => { + const superAdminAccountId = "00000000-0000-0000-0000-000000000001"; + const collaboratorAccountId = "00000000-0000-0000-0000-000000000002"; + const unauthorizedAccountId = "00000000-0000-0000-0000-000000000003"; + + it("identifies protected capital/city maps", () => { + assert.equal(isProtectedMap(1), true, "Map 1 (Ullathorpe) must be protected"); + assert.equal(isProtectedMap(34), true, "Map 34 (Nix) must be protected"); + assert.equal(isProtectedMap(59), true, "Map 59 (Banderbill) must be protected"); + assert.equal(isProtectedMap(150), true, "Map 150 (Lindos) must be protected"); + assert.equal(isProtectedMap(50), false, "Map 50 must not be protected"); + }); + + it("rejects unauthorized accounts without permissions with 403", async () => { + const result = await checkMapEditPermission({ + accountId: unauthorizedAccountId, + isSuperAdmin: false, + mapNum: 50, + }); + + assert.equal(result.allowed, false); + assert.match(result.reason ?? "", /no tiene permisos/i); + }); + + it("rejects collaborators from modifying protected maps", async () => { + const result = await checkMapEditPermission({ + accountId: collaboratorAccountId, + isSuperAdmin: false, + mapNum: 1, + }); + + assert.equal(result.allowed, false); + assert.match(result.reason ?? "", /protegido/i); + }); + + it("rejects superadmins from modifying protected maps without explicit override", async () => { + const resultWithoutOverride = await checkMapEditPermission({ + accountId: superAdminAccountId, + isSuperAdmin: true, + mapNum: 1, + overrideProtected: false, + }); + + assert.equal(resultWithoutOverride.allowed, false); + assert.match(resultWithoutOverride.reason ?? "", /overrideProtected/i); + + const resultWithOverride = await checkMapEditPermission({ + accountId: superAdminAccountId, + isSuperAdmin: true, + mapNum: 1, + overrideProtected: true, + }); + + assert.equal(resultWithOverride.allowed, true); + }); + + it("allows superadmins to modify non-protected maps directly", async () => { + const result = await checkMapEditPermission({ + accountId: superAdminAccountId, + isSuperAdmin: true, + mapNum: 75, + }); + + assert.equal(result.allowed, true); + }); +}); \ No newline at end of file From c89350b267c7b23b6065f886c954c4c1efde2c87 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:35:11 +0200 Subject: [PATCH 2/9] feat(world-builder): register uploaded graphics and extend palette schemas (#6) --- api/schema.sql | 14 ++ api/src/repositories/worldBuilder.ts | 164 ++++++++++++++++++ api/src/server.ts | 87 ++++++++++ .../worldBuilder_palette.integration.test.ts | 57 ++++++ 4 files changed, 322 insertions(+) create mode 100644 api/src/tests/worldBuilder_palette.integration.test.ts diff --git a/api/schema.sql b/api/schema.sql index 81355777..b708bf94 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -640,3 +640,17 @@ CREATE TABLE IF NOT EXISTS game_map_permissions ( CREATE INDEX IF NOT EXISTS idx_game_map_permissions_account_map ON game_map_permissions(account_id, map_num); + +-- Entradas de paleta dinamicas por mapa (permite asignar graficos subidos a tiles reusables). +CREATE TABLE IF NOT EXISTS game_map_palette_overrides ( + map_num INTEGER NOT NULL CHECK (map_num > 0), + palette_id INTEGER NOT NULL CHECK (palette_id > 0), + graphics INTEGER[] NOT NULL, + blocked BOOLEAN NOT NULL DEFAULT FALSE, + updated_by_account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (map_num, palette_id) +); + +CREATE INDEX IF NOT EXISTS idx_game_map_palette_overrides_map + ON game_map_palette_overrides(map_num); diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index d37e2281..a67359d6 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -515,3 +515,167 @@ export async function listAccountMapPermissions( ); return result.rows.map((row) => row.map_num); } + +export type GraphicMetadata = { + grhIndex: number; + width: number; + height: number; + frameCount: number; + fileNum: number; + offX: number; + offY: number; + isUploaded: boolean; + url: string; +}; + +export async function getGraphicMetadata( + grhIndex: number, +): Promise { + if (grhIndex >= UPLOADED_GRAPHIC_INDEX_START) { + const result = await pool.query<{ + grh_index: number; + width: number; + height: number; + }>( + `SELECT grh_index, width, height FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`, + [grhIndex], + ); + const row = result.rows[0]; + if (!row) return null; + + return { + grhIndex: row.grh_index, + width: row.width, + height: row.height, + frameCount: 1, + fileNum: row.grh_index, + offX: 0, + offY: 0, + isUploaded: true, + url: `/admin/game-data/graphics/${row.grh_index}`, + }; + } + + // Gráfico original del juego + return { + grhIndex, + width: 32, + height: 32, + frameCount: 1, + fileNum: grhIndex, + offX: 0, + offY: 0, + isUploaded: false, + url: `/graphics/${grhIndex}.png`, + }; +} + +export const paletteEntrySchema = z.object({ + paletteId: z.number().int().positive().optional(), + graphics: z.array(z.number().int().positive().nullable()).min(1).max(4), + blocked: z.boolean().default(false), +}); + +export type PaletteEntryInput = z.infer; + +export type PaletteEntry = { + paletteId: number; + graphics: (number | null)[]; + blocked: boolean; + updatedAt: string; +}; + +/** + * Agrega o actualiza una entrada en la paleta de un mapa. + * Valida que todos los gráficos referenciados existan en la base o catálogo base. + */ +export async function upsertPaletteEntry( + mapNum: number, + entry: PaletteEntryInput, + accountId: string, +): Promise { + // Validar existencia de cada grafico referenciado + for (let i = 0; i < entry.graphics.length; i++) { + const grh = entry.graphics[i]; + if (grh != null) { + if (grh >= UPLOADED_GRAPHIC_INDEX_START) { + const exists = await pool.query( + `SELECT 1 FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`, + [grh], + ); + if (exists.rowCount === 0) { + throw new Error( + `El grafico ${grh} no existe en la base de assets. Subilo antes de asignarlo a la paleta.`, + ); + } + } else if (grh <= 0 || grh > 320151) { + throw new Error( + `El indice de grafico ${grh} esta fuera de rango (1..320151).`, + ); + } + } + } + + let paletteId = entry.paletteId; + + if (!paletteId) { + // Asignar siguiente ID de paleta para el mapa (por encima de las paletas estándar base) + const nextIdResult = await pool.query<{ next_id: number }>( + `SELECT COALESCE(MAX(palette_id), 1000) + 1 AS next_id + FROM game_map_palette_overrides + WHERE map_num = $1`, + [mapNum], + ); + paletteId = Number(nextIdResult.rows[0]?.next_id ?? 1001); + } + + const graphicsArray = entry.graphics.map((g) => (g == null ? 0 : g)); + + const result = await pool.query<{ + palette_id: number; + graphics: number[]; + blocked: boolean; + updated_at: Date; + }>( + `INSERT INTO game_map_palette_overrides + (map_num, palette_id, graphics, blocked, updated_by_account_id, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (map_num, palette_id) DO UPDATE + SET graphics = EXCLUDED.graphics, + blocked = EXCLUDED.blocked, + updated_by_account_id = EXCLUDED.updated_by_account_id, + updated_at = NOW() + RETURNING palette_id, graphics, blocked, updated_at`, + [mapNum, paletteId, graphicsArray, entry.blocked ?? false, accountId], + ); + + const row = result.rows[0]; + return { + paletteId: row.palette_id, + graphics: row.graphics.map((g) => (g === 0 ? null : g)), + blocked: row.blocked, + updatedAt: row.updated_at.toISOString(), + }; +} + +export async function listMapPalette(mapNum: number): Promise { + const result = await pool.query<{ + palette_id: number; + graphics: number[]; + blocked: boolean; + updated_at: Date; + }>( + `SELECT palette_id, graphics, blocked, updated_at + FROM game_map_palette_overrides + WHERE map_num = $1 + ORDER BY palette_id ASC`, + [mapNum], + ); + + return result.rows.map((row) => ({ + paletteId: row.palette_id, + graphics: row.graphics.map((g) => (g === 0 ? null : g)), + blocked: row.blocked, + updatedAt: row.updated_at.toISOString(), + })); +} diff --git a/api/src/server.ts b/api/src/server.ts index ecc7e789..450c10f7 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -100,18 +100,22 @@ import { clearTile, discardDrafts, getGraphicContent, + getGraphicMetadata, getMapStatus, grantMapPermission, isProtectedMap, listAccountMapPermissions, listGraphics, listMapOverrides, + listMapPalette, paintTiles, paintTilesSchema, + paletteEntrySchema, publishMap, revertMap, revokeMapPermission, uploadGraphic, + upsertPaletteEntry, } from "./repositories/worldBuilder"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { @@ -890,6 +894,89 @@ app.get("/game-data/graphics/:grhIndex.png", async (request, response) => { } }); +/** Devuelve la metadata de resolución de un gráfico para el motor cliente. */ +app.get("/graphics/:grhIndex/metadata", async (request, response) => { + try { + const grhIndex = Number.parseInt(request.params.grhIndex ?? "", 10); + + if (!Number.isInteger(grhIndex) || grhIndex <= 0) { + response.status(400).json({ error: "Indice invalido." }); + return; + } + + const metadata = await getGraphicMetadata(grhIndex); + if (!metadata) { + response.status(404).json({ error: "Grafico no encontrado." }); + return; + } + + response.json(metadata); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Agrega o actualiza una entrada en la paleta del mapa. */ +app.put("/admin/game-data/maps/:mapNum/palette", async (request, response) => { + try { + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + + const parsed = paletteEntrySchema.safeParse(request.body); + if (!parsed.success) { + response + .status(400) + .json({ error: JSON.stringify(parsed.error.issues) }); + return; + } + + const result = await upsertPaletteEntry( + mapNum, + parsed.data, + authorized.accountId, + ); + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Lista las entradas de paleta dinámicas registradas para un mapa. */ +app.get("/admin/game-data/maps/:mapNum/palette", async (request, response) => { + try { + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const palette = await listMapPalette(mapNum); + response.json({ mapNum, palette }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { try { const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); diff --git a/api/src/tests/worldBuilder_palette.integration.test.ts b/api/src/tests/worldBuilder_palette.integration.test.ts new file mode 100644 index 00000000..51f58617 --- /dev/null +++ b/api/src/tests/worldBuilder_palette.integration.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { + getGraphicMetadata, + upsertPaletteEntry, + UPLOADED_GRAPHIC_INDEX_START, +} from "../repositories/worldBuilder"; + +describe("World Builder Palette & Graphic Resolution Engine", () => { + it("resolves original engine graphics metadata seamlessly", async () => { + const metadata = await getGraphicMetadata(5500); + assert.ok(metadata, "Metadata should be resolved"); + assert.equal(metadata.grhIndex, 5500); + assert.equal(metadata.isUploaded, false); + assert.equal(metadata.width, 32); + assert.equal(metadata.height, 32); + assert.equal(metadata.frameCount, 1); + assert.equal(metadata.url, "/graphics/5500.png"); + }); + + it("identifies uploaded graphics starting index above 1,000,000 to prevent collisions", () => { + assert.equal(UPLOADED_GRAPHIC_INDEX_START, 1000000); + assert.ok(UPLOADED_GRAPHIC_INDEX_START > 320151, "Must never collide with max original grh 320151"); + }); + + it("rejects palette entries referencing out-of-range non-existent graphics", async () => { + await assert.rejects( + async () => { + await upsertPaletteEntry( + 1, + { + graphics: [400000], // in dead zone between 320151 and 1000000 + blocked: true, + }, + "00000000-0000-0000-0000-000000000001", + ); + }, + /fuera de rango/i, + ); + }); + + it("rejects palette entries referencing non-existent uploaded assets", async () => { + await assert.rejects( + async () => { + await upsertPaletteEntry( + 1, + { + graphics: [1999999], // non-existent uploaded ID + blocked: false, + }, + "00000000-0000-0000-0000-000000000001", + ); + }, + /no existe en la base de assets/i, + ); + }); +}); \ No newline at end of file From f87d92171d45847e1757e073b37ee11db6154d2f Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:37:36 +0200 Subject: [PATCH 3/9] fix(server): graceful shutdown on SIGTERM/SIGINT with reset-connected and timeout (#26) --- server/src/server.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/server/src/server.ts b/server/src/server.ts index 69dc45af..0a87ff86 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -949,3 +949,74 @@ createDynamicScheduler( ); void saveOnlineStatsSnapshot(); + +let isShuttingDown = false; + +async function gracefulShutdown(signal: string): Promise { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + console.log(`[Servidor] Recibida señal ${signal}. Iniciando apagado ordenado...`); + + // Timeout de seguridad: si la API no responde, no bloquear el apagado + const forceExitTimeout = setTimeout(() => { + console.error("[Servidor] Timeout de apagado ordenado excedido. Forzando salida."); + process.exit(1); + }, 5000); + forceExitTimeout.unref(); + + vars.serverReady = false; + + // 1. Notificar a los clientes conectados y cerrar sus sockets ordenadamente + try { + for (const idUser in vars.clients) { + const client = vars.clients[idUser] as RuntimeClient | undefined; + if (client && client.readyState === client.OPEN) { + try { + client.close(1000, "Servidor reiniciando. Por favor vuelve a conectar en unos momentos."); + } catch { + // Ignorar errores individuales al cerrar socket + } + } + } + } catch (error) { + console.error("[Servidor] Error al notificar clientes durante apagado:", error); + } + + // 2. Cerrar servidor WebSocket + try { + if (wsServer) { + wsServer.close(); + } + } catch (error) { + console.error("[Servidor] Error al cerrar wsServer:", error); + } + + // 3. Llamar a la API para desmarcar a todos los personajes conectados + try { + const timeoutPromise = new Promise<{ updated: number }>((_, reject) => + setTimeout(() => reject(new Error("API timeout")), 3500), + ); + + const fetchPromise = funct.fetchUrl("/internal/characters/reset-connected", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: vars.tokenAuth, + }, + }) as Promise<{ updated: number }>; + + const response = await Promise.race([fetchPromise, timeoutPromise]); + console.log(`[Servidor] Personajes marcados como desconectados al apagar: ${response?.updated ?? 0}.`); + } catch (error) { + console.error("[Servidor] No se pudo desmarcar personajes durante el apagado:", error); + } + + console.log("[Servidor] Apagado ordenado completado exitosamente."); + process.exit(0); +} + +process.on("SIGTERM", () => void gracefulShutdown("SIGTERM")); +process.on("SIGINT", () => void gracefulShutdown("SIGINT")); + From 2892ce6c04eb21bc4f29d4dc2af3fe861c4c5198 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:45:10 +0200 Subject: [PATCH 4/9] fix(server): group dual-session idle penalty by account identity for CGNAT mobile support (#16) --- server/src/server.ts | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/server/src/server.ts b/server/src/server.ts index 0a87ff86..6b9016b1 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -700,7 +700,7 @@ function processIdleCharactersTick(now: number) { return; } - const penalizedClientIds = getDuplicateIpIdlePenalizedClientIds(); + const penalizedClientIds = getDuplicateAccountIdlePenalizedClientIds(); for (const idUser in vars.clients) { const client = vars.clients[idUser] as RuntimeClient | undefined; @@ -725,9 +725,9 @@ function processIdleCharactersTick(now: number) { continue; } - const isDuplicateIpScout = penalizedClientIds.has(idUser); - const effectiveIdleTimeoutMs = isDuplicateIpScout ? DUPLICATE_IP_IDLE_TIMEOUT_MS : idleCharacterTimeoutMs; - const idleReferenceAt = isDuplicateIpScout + const isDuplicateAccountScout = penalizedClientIds.has(idUser); + const effectiveIdleTimeoutMs = isDuplicateAccountScout ? DUPLICATE_IP_IDLE_TIMEOUT_MS : idleCharacterTimeoutMs; + const idleReferenceAt = isDuplicateAccountScout ? getScoutIdleReferenceAt(client, user) : Number(client.lastActivityAt ?? now); @@ -758,9 +758,9 @@ function getScoutIdleReferenceAt(client: RuntimeClient, user: ServerCharacter): return Number(client.connectedAt ?? Date.now()); } -function getDuplicateIpIdlePenalizedClientIds(): Set { +function getDuplicateAccountIdlePenalizedClientIds(): Set { const penalizedClientIds = new Set(); - const clientsByIp = new Map< + const clientsByAccount = new Map< string, { idUser: string; @@ -777,34 +777,37 @@ function getDuplicateIpIdlePenalizedClientIds(): Set { continue; } - const clientIp = socket.getIp(client); + // Agrupar por cuenta de usuario en lugar de IP para evitar que jugadores + // de redes móviles (CGNAT) se desconecten entre sí compartiendo IP pública. + const accountKey = + (user as any).idAccount || + (user as any).account_id || + (client as any).accountId || + socket.getIp(client) || + idUser; - if (!clientIp) { - continue; - } - - const clientsForIp = clientsByIp.get(clientIp) ?? []; + const clientsForAccount = clientsByAccount.get(accountKey) ?? []; - clientsForIp.push({ + clientsForAccount.push({ idUser, connectedAt: Number(client.connectedAt ?? 0), miningActive: Boolean(user.harvesting?.active && user.harvesting?.skill === "mining"), }); - clientsByIp.set(clientIp, clientsForIp); + clientsByAccount.set(accountKey, clientsForAccount); } - for (const clientsForIp of clientsByIp.values()) { - if (clientsForIp.length < 2) { + for (const clientsForAccount of clientsByAccount.values()) { + if (clientsForAccount.length < 2) { continue; } - const hasActiveMiner = clientsForIp.some((entry) => entry.miningActive); + const hasActiveMiner = clientsForAccount.some((entry) => entry.miningActive); if (!hasActiveMiner) { continue; } - for (const entry of clientsForIp) { + for (const entry of clientsForAccount) { if (!entry.miningActive) { penalizedClientIds.add(entry.idUser); } From 9d67aa236a6a58e8760e0584aefe228bfa24ba66 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:45:37 +0200 Subject: [PATCH 5/9] fix(auth): document SES env vars and add diagnostic logging for password reset (#1) --- api/.env.example | 7 ++++++ api/src/lib/email.ts | 55 ++++++++++++++++++++++++++++---------------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/api/.env.example b/api/.env.example index b1f0cd35..245ecc09 100644 --- a/api/.env.example +++ b/api/.env.example @@ -7,3 +7,10 @@ CORS_ORIGIN=http://localhost:3000 GAME_DATA_ADMIN_EMAIL=admin@aoweb.app GAME_DATA_ADMIN_ACCOUNT_ID= GAME_DATA_ADMIN_PROXY_TOKEN=secret-proxy-token + +# Amazon SES v2 Configuration (Password Reset & System Emails) +SES_REGION=us-east-1 +SES_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +SES_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +SES_FROM_EMAIL=soporte@aoweb.app +SES_FROM_NAME=AOWeb diff --git a/api/src/lib/email.ts b/api/src/lib/email.ts index 7d114dad..e26924a5 100644 --- a/api/src/lib/email.ts +++ b/api/src/lib/email.ts @@ -101,30 +101,45 @@ function buildPasswordResetText({ displayName, resetUrl }: PasswordResetEmailInp } export async function sendPasswordResetEmail(input: PasswordResetEmailInput): Promise { - const client = getSesClient(); + let client: SESv2Client; + try { + client = getSesClient(); + } catch (configError) { + console.error("[Email/SES] Error de configuracion al intentar enviar email de recuperacion:", configError); + throw new Error("No se pudo enviar el email de recuperacion. Intenta de nuevo."); + } - await client.send(new SendEmailCommand({ - FromEmailAddress: `${config.sesFromName} <${config.sesFromEmail}>`, - Destination: { - ToAddresses: [input.to], - }, - Content: { - Simple: { - Subject: { - Data: "AOWeb | Recuperacion de contraseña", - Charset: "UTF-8", - }, - Body: { - Html: { - Data: buildPasswordResetHtml(input), + try { + await client.send(new SendEmailCommand({ + FromEmailAddress: `${config.sesFromName} <${config.sesFromEmail}>`, + Destination: { + ToAddresses: [input.to], + }, + Content: { + Simple: { + Subject: { + Data: "AOWeb | Recuperacion de contraseña", Charset: "UTF-8", }, - Text: { - Data: buildPasswordResetText(input), - Charset: "UTF-8", + Body: { + Html: { + Data: buildPasswordResetHtml(input), + Charset: "UTF-8", + }, + Text: { + Data: buildPasswordResetText(input), + Charset: "UTF-8", + }, }, }, }, - }, - })); + })); + } catch (awsError: any) { + console.error(`[Email/SES] Error de envio AWS SES al destinatario ${input.to}:`, { + name: awsError?.name, + message: awsError?.message, + code: awsError?.code || awsError?.$metadata?.httpStatusCode, + }); + throw new Error("No se pudo enviar el email de recuperacion. Intenta de nuevo."); + } } From 9a5fd6ff60a88547ba2e209aed01140778f2ea33 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:46:59 +0200 Subject: [PATCH 6/9] fix(server): unref race timeout timer in gracefulShutdown --- server/src/server.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/server.ts b/server/src/server.ts index 6b9016b1..8fe7de7e 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -998,9 +998,11 @@ async function gracefulShutdown(signal: string): Promise { // 3. Llamar a la API para desmarcar a todos los personajes conectados try { - const timeoutPromise = new Promise<{ updated: number }>((_, reject) => - setTimeout(() => reject(new Error("API timeout")), 3500), - ); + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise<{ updated: number }>((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("API timeout")), 3500); + timeoutId.unref(); + }); const fetchPromise = funct.fetchUrl("/internal/characters/reset-connected", { method: "POST", From 9c10e2da4fa05e4d732bac8064e3b6523106001d Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:47:26 +0200 Subject: [PATCH 7/9] fix(world-builder): add auth to GET palette and make upsertPaletteEntry next_id atomic --- api/src/repositories/worldBuilder.ts | 89 ++++++++++++++++------------ api/src/server.ts | 3 + 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index a67359d6..ee39e88f 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -616,46 +616,61 @@ export async function upsertPaletteEntry( } } - let paletteId = entry.paletteId; - - if (!paletteId) { - // Asignar siguiente ID de paleta para el mapa (por encima de las paletas estándar base) - const nextIdResult = await pool.query<{ next_id: number }>( - `SELECT COALESCE(MAX(palette_id), 1000) + 1 AS next_id - FROM game_map_palette_overrides - WHERE map_num = $1`, - [mapNum], - ); - paletteId = Number(nextIdResult.rows[0]?.next_id ?? 1001); - } + const client = await pool.connect(); - const graphicsArray = entry.graphics.map((g) => (g == null ? 0 : g)); + try { + await client.query("BEGIN"); - const result = await pool.query<{ - palette_id: number; - graphics: number[]; - blocked: boolean; - updated_at: Date; - }>( - `INSERT INTO game_map_palette_overrides - (map_num, palette_id, graphics, blocked, updated_by_account_id, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (map_num, palette_id) DO UPDATE - SET graphics = EXCLUDED.graphics, - blocked = EXCLUDED.blocked, - updated_by_account_id = EXCLUDED.updated_by_account_id, - updated_at = NOW() - RETURNING palette_id, graphics, blocked, updated_at`, - [mapNum, paletteId, graphicsArray, entry.blocked ?? false, accountId], - ); + let paletteId = entry.paletteId; - const row = result.rows[0]; - return { - paletteId: row.palette_id, - graphics: row.graphics.map((g) => (g === 0 ? null : g)), - blocked: row.blocked, - updatedAt: row.updated_at.toISOString(), - }; + if (!paletteId) { + await client.query( + "LOCK TABLE game_map_palette_overrides IN SHARE ROW EXCLUSIVE MODE", + ); + const nextIdResult = await client.query<{ next_id: number }>( + `SELECT COALESCE(MAX(palette_id), 1000) + 1 AS next_id + FROM game_map_palette_overrides + WHERE map_num = $1`, + [mapNum], + ); + paletteId = Number(nextIdResult.rows[0]?.next_id ?? 1001); + } + + const graphicsArray = entry.graphics.map((g) => (g == null ? 0 : g)); + + const result = await client.query<{ + palette_id: number; + graphics: number[]; + blocked: boolean; + updated_at: Date; + }>( + `INSERT INTO game_map_palette_overrides + (map_num, palette_id, graphics, blocked, updated_by_account_id, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (map_num, palette_id) DO UPDATE + SET graphics = EXCLUDED.graphics, + blocked = EXCLUDED.blocked, + updated_by_account_id = EXCLUDED.updated_by_account_id, + updated_at = NOW() + RETURNING palette_id, graphics, blocked, updated_at`, + [mapNum, paletteId, graphicsArray, entry.blocked ?? false, accountId], + ); + + await client.query("COMMIT"); + + const row = result.rows[0]; + return { + paletteId: row.palette_id, + graphics: row.graphics.map((g) => (g === 0 ? null : g)), + blocked: row.blocked, + updatedAt: row.updated_at.toISOString(), + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } } export async function listMapPalette(mapNum: number): Promise { diff --git a/api/src/server.ts b/api/src/server.ts index 450c10f7..c15af16d 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -968,6 +968,9 @@ app.get("/admin/game-data/maps/:mapNum/palette", async (request, response) => { return; } + const authorized = await requireMapEditSession(request, response, mapNum); + if (!authorized) return; + const palette = await listMapPalette(mapNum); response.json({ mapNum, palette }); } catch (error) { From b7feaafed5da3e4c6e06c6b326c4557db7d9bcf8 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:00:26 +0200 Subject: [PATCH 8/9] fix(server): drop dead fallback keys and guard transaction rollback against masking errors --- api/src/repositories/worldBuilder.ts | 6 +++++- server/src/server.ts | 7 +------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index ee39e88f..69895838 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -666,7 +666,11 @@ export async function upsertPaletteEntry( updatedAt: row.updated_at.toISOString(), }; } catch (error) { - await client.query("ROLLBACK"); + try { + await client.query("ROLLBACK"); + } catch (rollbackError) { + console.error("[worldBuilder] ROLLBACK failed:", rollbackError); + } throw error; } finally { client.release(); diff --git a/server/src/server.ts b/server/src/server.ts index 8fe7de7e..118e45e4 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -779,12 +779,7 @@ function getDuplicateAccountIdlePenalizedClientIds(): Set { // Agrupar por cuenta de usuario en lugar de IP para evitar que jugadores // de redes móviles (CGNAT) se desconecten entre sí compartiendo IP pública. - const accountKey = - (user as any).idAccount || - (user as any).account_id || - (client as any).accountId || - socket.getIp(client) || - idUser; + const accountKey = user.idAccount || socket.getIp(client) || idUser; const clientsForAccount = clientsByAccount.get(accountKey) ?? []; From 2f9c951cbad01ce75915b6a7b3389a2f6cdd3795 Mon Sep 17 00:00:00 2001 From: angelTomo9 <144371630+angelTomo9@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:00:26 +0200 Subject: [PATCH 9/9] fix(server): remove legacy IP scout penalty to support mobile CGNAT --- server/src/server.ts | 79 ++------------------------------------------ 1 file changed, 2 insertions(+), 77 deletions(-) diff --git a/server/src/server.ts b/server/src/server.ts index 118e45e4..c5805db1 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -700,8 +700,6 @@ function processIdleCharactersTick(now: number) { return; } - const penalizedClientIds = getDuplicateAccountIdlePenalizedClientIds(); - for (const idUser in vars.clients) { const client = vars.clients[idUser] as RuntimeClient | undefined; const user = (vars.personajes as RuntimeCharacters)[idUser] as ServerCharacter | undefined; @@ -725,13 +723,9 @@ function processIdleCharactersTick(now: number) { continue; } - const isDuplicateAccountScout = penalizedClientIds.has(idUser); - const effectiveIdleTimeoutMs = isDuplicateAccountScout ? DUPLICATE_IP_IDLE_TIMEOUT_MS : idleCharacterTimeoutMs; - const idleReferenceAt = isDuplicateAccountScout - ? getScoutIdleReferenceAt(client, user) - : Number(client.lastActivityAt ?? now); + const idleReferenceAt = Number(client.lastActivityAt ?? now); - if (now - idleReferenceAt < effectiveIdleTimeoutMs) { + if (now - idleReferenceAt < idleCharacterTimeoutMs) { continue; } @@ -743,75 +737,6 @@ function processIdleCharactersTick(now: number) { } } -function getScoutIdleReferenceAt(client: RuntimeClient, user: ServerCharacter): number { - const lastMovedAt = Number(user.lastMovementActivityAt ?? 0); - const lastCombatActivityAt = Number(user.lastCombatActivityAt ?? 0); - - if (lastCombatActivityAt > 0 && lastCombatActivityAt > lastMovedAt) { - return lastCombatActivityAt; - } - - if (lastMovedAt > 0) { - return lastMovedAt; - } - - return Number(client.connectedAt ?? Date.now()); -} - -function getDuplicateAccountIdlePenalizedClientIds(): Set { - const penalizedClientIds = new Set(); - const clientsByAccount = new Map< - string, - { - idUser: string; - connectedAt: number; - miningActive: boolean; - }[] - >(); - - for (const idUser in vars.clients) { - const client = vars.clients[idUser] as RuntimeClient | undefined; - const user = (vars.personajes as RuntimeCharacters)[idUser] as ServerCharacter | undefined; - - if (!client || !user || user.cerrado) { - continue; - } - - // Agrupar por cuenta de usuario en lugar de IP para evitar que jugadores - // de redes móviles (CGNAT) se desconecten entre sí compartiendo IP pública. - const accountKey = user.idAccount || socket.getIp(client) || idUser; - - const clientsForAccount = clientsByAccount.get(accountKey) ?? []; - - clientsForAccount.push({ - idUser, - connectedAt: Number(client.connectedAt ?? 0), - miningActive: Boolean(user.harvesting?.active && user.harvesting?.skill === "mining"), - }); - clientsByAccount.set(accountKey, clientsForAccount); - } - - for (const clientsForAccount of clientsByAccount.values()) { - if (clientsForAccount.length < 2) { - continue; - } - - const hasActiveMiner = clientsForAccount.some((entry) => entry.miningActive); - - if (!hasActiveMiner) { - continue; - } - - for (const entry of clientsForAccount) { - if (!entry.miningActive) { - penalizedClientIds.add(entry.idUser); - } - } - } - - return penalizedClientIds; -} - function processPendingLogoutTick(now: number) { for (const idUser in vars.personajes as RuntimeCharacters) { const user = (vars.personajes as RuntimeCharacters)[idUser] as ServerCharacter | undefined;