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
18 changes: 18 additions & 0 deletions api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,21 @@ CREATE INDEX IF NOT EXISTS idx_game_map_tile_entities_map
ON game_map_tile_entities(map_num, status);
CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at
ON game_uploaded_graphics(created_at DESC);

-- Entradas de paleta dinamicas por mapa (#6).
-- No reescriben terrain.json: se fusionan en getMapTerrainPalette.
-- graphics es JSONB para preservar capas nulas como en el terrain fuente.
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 JSONB 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),
CONSTRAINT game_map_palette_overrides_graphics_is_array
CHECK (jsonb_typeof(graphics) = 'array')
);

CREATE INDEX IF NOT EXISTS idx_game_map_palette_overrides_map
ON game_map_palette_overrides(map_num);
200 changes: 200 additions & 0 deletions api/src/lib/graphicCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Catalogo de graficos del motor (#6).
*
* Los PNG subidos viven en Postgres con indices >= UPLOADED_GRAPHIC_INDEX_START.
* Los originales viven en graficos(_optimized).json. Validar contra el catalogo
* real (no un techo hardcodeado) evita aceptar IDs "en rango" que el renderer
* no puede resolver.
*/

import { existsSync } from "fs";
import fs from "fs/promises";
import path from "path";

/** Debe coincidir con worldBuilder / frontend gameLoader / schema CHECK. */
export const UPLOADED_GRAPHIC_INDEX_START = 1_000_000;

export type EngineGraphicEntry = {
numFrames: number;
numFile: string;
sX: number;
sY: number;
width: number;
height: number;
frames: Record<string, string>;
offset: { x: number; y: number };
};

export type GraphicExistence =
| { ok: true; source: "engine" | "uploaded" }
| { ok: false; reason: string };

let cachedEngineIds: Set<number> | null = null;
let cachedEnginePath: string | null = null;

export function resolveGraficosPath(): string {
const candidates = [
path.resolve(
__dirname,
"../../../frontend/public/init/graficos_optimized.json",
),
path.resolve(__dirname, "../../../frontend/public/init/graficos.json"),
path.resolve(
__dirname,
"../../../../frontend/public/init/graficos_optimized.json",
),
path.resolve(__dirname, "../../../../frontend/public/init/graficos.json"),
];

for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}

return candidates[0];
}

export async function loadEngineGraphicIds(
forceReload = false,
): Promise<Set<number>> {
const graficosPath = resolveGraficosPath();

if (
!forceReload &&
cachedEngineIds &&
cachedEnginePath === graficosPath
) {
return cachedEngineIds;
}

if (!existsSync(graficosPath)) {
throw new Error(
`No se encontro graficos.json en ${graficosPath}. El catalogo del motor es obligatorio para validar paletas.`,
);
}

const raw = JSON.parse(await fs.readFile(graficosPath, "utf8")) as Record<
string,
unknown
>;
const ids = new Set<number>();

for (const key of Object.keys(raw)) {
const parsed = Number.parseInt(key, 10);
if (Number.isInteger(parsed) && parsed > 0) {
ids.add(parsed);
}
}

cachedEngineIds = ids;
cachedEnginePath = graficosPath;
return ids;
}

/** Resetea el cache (tests). */
export function clearEngineGraphicIdCache(): void {
cachedEngineIds = null;
cachedEnginePath = null;
}

/**
* Forma graficos.json para un PNG subido (tile estatico completo).
* El cliente ya hace mergeUploadedGraphics; este helper documenta el contrato.
*/
export function uploadedGraphicToEngineEntry(graphic: {
grhIndex: number;
width: number;
height: number;
}): EngineGraphicEntry {
const key = String(graphic.grhIndex);
return {
numFrames: 1,
numFile: key,
sX: 0,
sY: 0,
width: graphic.width,
height: graphic.height,
frames: { "1": key },
offset: { x: 0, y: 0 },
};
}

export function isReservedUploadedIndex(grhIndex: number): boolean {
return Number.isInteger(grhIndex) && grhIndex >= UPLOADED_GRAPHIC_INDEX_START;
}

/**
* Valida un unico grhIndex contra el catalogo del motor o el predicado de
* subidos (inyectable para tests / transacciones).
*/
export async function checkGraphicExists(
grhIndex: number,
uploadedExists: (grhIndex: number) => Promise<boolean>,
): Promise<GraphicExistence> {
if (!Number.isInteger(grhIndex) || grhIndex <= 0) {
return {
ok: false,
reason: `Indice de grafico invalido: ${grhIndex}.`,
};
}

if (isReservedUploadedIndex(grhIndex)) {
const exists = await uploadedExists(grhIndex);
if (!exists) {
return {
ok: false,
reason: `El grafico subido ${grhIndex} no existe. Subilo antes de usarlo.`,
};
}
return { ok: true, source: "uploaded" };
}

const engineIds = await loadEngineGraphicIds();
if (!engineIds.has(grhIndex)) {
return {
ok: false,
reason: `El grafico ${grhIndex} no existe en el catalogo del motor (graficos.json).`,
};
}

return { ok: true, source: "engine" };
}

/**
* Valida una lista de capas de paleta. Null = capa vacia (como terrain.json).
* Al menos una capa debe referenciar un grafico real.
*/
export async function validatePaletteGraphics(
graphics: Array<number | null>,
uploadedExists: (grhIndex: number) => Promise<boolean>,
): Promise<{ ok: true } | { ok: false; reason: string }> {
if (!Array.isArray(graphics) || graphics.length < 1 || graphics.length > 4) {
return {
ok: false,
reason: "La paleta admite entre 1 y 4 capas de graficos.",
};
}

let nonNull = 0;

for (const grh of graphics) {
if (grh == null) {
continue;
}
nonNull += 1;
const result = await checkGraphicExists(grh, uploadedExists);
if (!result.ok) {
return result;
}
}

if (nonNull === 0) {
return {
ok: false,
reason: "La entrada de paleta necesita al menos un grafico no nulo.",
};
}

return { ok: true };
}
78 changes: 56 additions & 22 deletions api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ import fs from "fs/promises";
import path from "path";
import { z } from "zod";
import pool from "../db";
import {
UPLOADED_GRAPHIC_INDEX_START as CATALOG_UPLOADED_START,
} from "../lib/graphicCatalog";
import { validatePngUpload } from "../lib/pngValidation";
import {
assertGraphicsExist,
listPaletteOverrides,
} from "./worldBuilderPalette";

/**
* Los indices originales del juego llegan hasta 320151. El rango de graficos
* subidos arranca muy por encima para que no puedan colisionar nunca.
* Los indices originales del juego llegan hasta ~320151. El rango de graficos
* subidos arranca en 1_000_000 para que no puedan colisionar nunca.
* Fuente de verdad compartida con graphicCatalog / frontend gameLoader.
*/
export const UPLOADED_GRAPHIC_INDEX_START = 1_000_000;
export const UPLOADED_GRAPHIC_INDEX_START = CATALOG_UPLOADED_START;

/** Los mapas del juego son de 100x100. */
export const MAP_SIZE = 100;
Expand Down Expand Up @@ -246,22 +254,10 @@ 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.`,
);
}
// Valida contra graficos.json (originales) y game_uploaded_graphics
// (subidos). Un ID "en rango" pero ausente del catalogo ya no pasa.
if (tile.grhIndex != null) {
await assertGraphicsExist([tile.grhIndex]);
}

await client.query(
Expand Down Expand Up @@ -670,7 +666,7 @@ export async function getMapTerrainPalette(
palette?: Record<string, { blocked?: boolean; graphics?: unknown }>;
};

const palette: TerrainPaletteEntry[] = [];
const byId = new Map<number, TerrainPaletteEntry>();

for (const [id, entry] of Object.entries(terrain.palette ?? {})) {
const parsedId = Number.parseInt(id, 10);
Expand All @@ -688,18 +684,56 @@ export async function getMapTerrainPalette(
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});

palette.push({
byId.set(parsedId, {
id: parsedId,
graphics,
blocked: Boolean(entry.blocked),
});
}

palette.sort((left, right) => left.id - right.id);
// Overrides de paleta (#6): agregan o reemplazan entradas sin tocar el
// terrain.json fuente. Asi un PNG subido puede convertirse en brush
// reutilizable (capas + blocked) igual que un tile original.
for (const override of await listPaletteOverrides(mapNum)) {
byId.set(override.id, {
id: override.id,
graphics: override.graphics,
blocked: override.blocked,
});
}

const palette = [...byId.values()].sort((left, right) => left.id - right.id);

return {
mapNum,
palette,
uploadedGraphics: await listGraphics(MAX_PALETTE_UPLOADED_GRAPHICS),
};
}

/** Maximo id de paleta en terrain.json (para asignar ids nuevos sin colision). */
export async function getSourcePaletteMaxId(mapNum: number): Promise<number> {
const mapsSourceDir = resolveMapsSourceDir();
const terrainPath = path.join(
mapsSourceDir,
`mapa_${mapNum}`,
"terrain.json",
);

if (!existsSync(terrainPath)) {
return 0;
}

const terrain = JSON.parse(await fs.readFile(terrainPath, "utf8")) as {
palette?: Record<string, unknown>;
};

let maxId = 0;
for (const id of Object.keys(terrain.palette ?? {})) {
const parsed = Number.parseInt(id, 10);
if (Number.isInteger(parsed) && parsed > maxId) {
maxId = parsed;
}
}
return maxId;
}
Loading