diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 4402a924..77a3824a 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -12,6 +12,9 @@ import { validatePngUpload } from "../lib/pngValidation"; */ export const UPLOADED_GRAPHIC_INDEX_START = 1_000_000; +/** Ultimo indice de grafico incluido no engine original. */ +export const MAX_ENGINE_GRAPHIC_INDEX = 320_151; + /** Los mapas del juego son de 100x100. */ export const MAP_SIZE = 100; @@ -195,6 +198,44 @@ 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 || + grhIndex > MAX_ENGINE_GRAPHIC_INDEX + ) { + 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), @@ -246,21 +287,13 @@ export async function paintTiles( await client.query("BEGIN"); for (const tile of tiles) { - // Un grafico referenciado tiene que existir: o es uno original del - // juego (por debajo del rango de subidos) o uno que subimos. - if ( - tile.grhIndex != null && - tile.grhIndex >= UPLOADED_GRAPHIC_INDEX_START - ) { - const exists = await client.query( - `SELECT 1 FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`, - [tile.grhIndex], - ); - - if (exists.rowCount === 0) { - throw new Error( - `El grafico ${tile.grhIndex} no existe. Subilo antes de usarlo.`, - ); + if (tile.grhIndex != null) { + const validation = await validatePaletteEntry({ + graphics: [tile.grhIndex], + }); + + if (!validation.valid) { + throw new Error(validation.reason); } } diff --git a/api/src/tests/paletteValidation.test.ts b/api/src/tests/paletteValidation.test.ts new file mode 100644 index 00000000..78f84322 --- /dev/null +++ b/api/src/tests/paletteValidation.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + MAX_ENGINE_GRAPHIC_INDEX, + paletteEntrySchema, + UPLOADED_GRAPHIC_INDEX_START, + validatePaletteEntry, +} from "../repositories/worldBuilder"; +import pool from "../db"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +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( + MAX_ENGINE_GRAPHIC_INDEX, + ); + }); + + it("should accept an original engine graphic without a database lookup", async () => { + const query = vi.spyOn(pool, "query"); + const result = await validatePaletteEntry({ + graphics: [MAX_ENGINE_GRAPHIC_INDEX], + }); + + expect(result).toEqual({ valid: true }); + expect(query).not.toHaveBeenCalled(); + }); + + it("should reject graphic indices outside the original engine range", async () => { + const query = vi.spyOn(pool, "query"); + const result = await validatePaletteEntry({ + graphics: [MAX_ENGINE_GRAPHIC_INDEX + 1], + }); + + expect(result.valid).toBe(false); + expect(result.reason).toContain("Indice de grafico invalido"); + expect(query).not.toHaveBeenCalled(); + }); + + it("should validate uploaded graphics against the database", async () => { + const query = vi + .spyOn(pool, "query") + .mockResolvedValue({ rowCount: 1 } as never); + + const result = await validatePaletteEntry({ + graphics: [UPLOADED_GRAPHIC_INDEX_START], + }); + + expect(result).toEqual({ valid: true }); + expect(query).toHaveBeenCalledWith( + expect.stringContaining("game_uploaded_graphics"), + [UPLOADED_GRAPHIC_INDEX_START], + ); + }); + + it("should reject an uploaded graphic that is not registered", async () => { + vi.spyOn(pool, "query").mockResolvedValue({ rowCount: 0 } as never); + + const result = await validatePaletteEntry({ + graphics: [UPLOADED_GRAPHIC_INDEX_START + 1], + }); + + expect(result.valid).toBe(false); + expect(result.reason).toContain("no existe"); + }); +}); diff --git a/server/src/server.ts b/server/src/server.ts index f2c3de84..fdf1242c 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -797,7 +797,7 @@ function processIdleCharactersTick(now: number) { continue; } - if (typeof client.lastActivityAt !== "number" && typeof client.lastPingAt !== "number") { + if (typeof client.lastActivityAt !== "number") { client.lastActivityAt = now; continue; } @@ -824,10 +824,11 @@ function processIdleCharactersTick(now: number) { function getClientLivenessReferenceAt(client: RuntimeClient, now: number): number { const lastActivityAt = Number(client.lastActivityAt ?? 0); - const lastPingAt = Number(client.lastPingAt ?? 0); const connectedAt = Number(client.connectedAt ?? now); - return Math.max(lastActivityAt, lastPingAt, connectedAt); + // Pings prove transport liveness but are not player activity, so they + // must not feed the AFK idle reference. + return Math.max(lastActivityAt, connectedAt); } function getScoutIdleReferenceAt(client: RuntimeClient, user: ServerCharacter): number {