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
63 changes: 48 additions & 15 deletions api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -195,6 +198,44 @@ export async function listGraphics(limit = 100): Promise<UploadedGraphic[]> {
}));
}

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<typeof paletteEntrySchema>;

/**
* 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.`,
};
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
} else if (
grhIndex <= 0 ||
grhIndex > MAX_ENGINE_GRAPHIC_INDEX
) {
return {
valid: false,
Comment thread
gitar-bot[bot] marked this conversation as resolved.
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),
Expand Down Expand Up @@ -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);
}
}

Expand Down
97 changes: 97 additions & 0 deletions api/src/tests/paletteValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 4 additions & 3 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 {
Expand Down