diff --git a/api/schema.sql b/api/schema.sql index d0008678..7a39fe45 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -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); diff --git a/api/src/lib/graphicCatalog.ts b/api/src/lib/graphicCatalog.ts new file mode 100644 index 00000000..fe32ef49 --- /dev/null +++ b/api/src/lib/graphicCatalog.ts @@ -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; + offset: { x: number; y: number }; +}; + +export type GraphicExistence = + | { ok: true; source: "engine" | "uploaded" } + | { ok: false; reason: string }; + +let cachedEngineIds: Set | 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> { + 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(); + + 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, +): Promise { + 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, + uploadedExists: (grhIndex: number) => Promise, +): 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 }; +} diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 4402a924..cb3d29b4 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -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; @@ -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( @@ -670,7 +666,7 @@ export async function getMapTerrainPalette( palette?: Record; }; - const palette: TerrainPaletteEntry[] = []; + const byId = new Map(); for (const [id, entry] of Object.entries(terrain.palette ?? {})) { const parsedId = Number.parseInt(id, 10); @@ -688,14 +684,25 @@ 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, @@ -703,3 +710,30 @@ export async function getMapTerrainPalette( 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 { + 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; + }; + + 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; +} diff --git a/api/src/repositories/worldBuilderPalette.ts b/api/src/repositories/worldBuilderPalette.ts new file mode 100644 index 00000000..6ebe0fd5 --- /dev/null +++ b/api/src/repositories/worldBuilderPalette.ts @@ -0,0 +1,232 @@ +/** + * Etapa 1 (#6): entradas de paleta nuevas sobre mapas, con validacion de + * graficos contra el catalogo del motor + assets subidos. + * + * No reescribe terrain.json: las entradas viven en Postgres y se fusionan en + * getMapTerrainPalette, igual que los tile overrides sobre el mapa base. + */ + +import { z } from "zod"; +import pool from "../db"; +import { + UPLOADED_GRAPHIC_INDEX_START, + uploadedGraphicToEngineEntry, + validatePaletteGraphics, +} from "../lib/graphicCatalog"; + +export { UPLOADED_GRAPHIC_INDEX_START }; + +export const paletteEntrySchema = z.object({ + /** Si se omite, se asigna el siguiente id libre para el mapa. */ + id: z.coerce.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 PaletteOverrideEntry = { + id: number; + graphics: Array; + blocked: boolean; + updatedAt: string; + source: "override"; +}; + +export class PaletteValidationError extends Error { + readonly code = "palette_validation"; + + constructor(message: string) { + super(message); + this.name = "PaletteValidationError"; + } +} + +async function uploadedGraphicExists(grhIndex: number): Promise { + const result = await pool.query( + `SELECT 1 FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`, + [grhIndex], + ); + return (result.rowCount ?? 0) > 0; +} + +/** + * Valida capas de paleta (o un unico grh al pintar) contra motor + subidos. + */ +export async function assertGraphicsExist( + graphics: Array, +): Promise { + const result = await validatePaletteGraphics( + graphics, + uploadedGraphicExists, + ); + if (!result.ok) { + throw new PaletteValidationError(result.reason); + } +} + +async function nextPaletteId(mapNum: number): Promise { + // Max entre terrain.json (via caller) no aplica aca: solo IDs de overrides + // + un techo alto. El merge en getMapTerrainPalette evita colision con + // ids fuente buscando el max entre fuente y overrides. + const result = await pool.query<{ next_id: number }>( + `SELECT COALESCE(MAX(palette_id), 0) + 1 AS next_id + FROM game_map_palette_overrides + WHERE map_num = $1`, + [mapNum], + ); + return Number(result.rows[0]?.next_id ?? 1); +} + +/** + * Asigna un id que no colisione con la paleta fuente del mapa ni con overrides. + */ +export async function allocatePaletteId( + mapNum: number, + sourceMaxId: number, +): Promise { + const overrideNext = await nextPaletteId(mapNum); + return Math.max(sourceMaxId + 1, overrideNext); +} + +export async function listPaletteOverrides( + mapNum: number, +): Promise { + const result = await pool.query<{ + palette_id: number; + graphics: unknown; + 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) => ({ + id: row.palette_id, + graphics: normalizeGraphics(row.graphics), + blocked: row.blocked, + updatedAt: row.updated_at.toISOString(), + source: "override" as const, + })); +} + +function normalizeGraphics(raw: unknown): Array { + if (!Array.isArray(raw)) { + return []; + } + return raw.map((value) => { + if (value == null) { + return null; + } + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + }); +} + +export async function upsertPaletteEntry( + mapNum: number, + entry: PaletteEntryInput, + accountId: string, + sourceMaxId: number, +): Promise { + await assertGraphicsExist(entry.graphics); + + const paletteId = + entry.id ?? (await allocatePaletteId(mapNum, sourceMaxId)); + + if (!Number.isInteger(paletteId) || paletteId <= 0) { + throw new PaletteValidationError("Id de paleta invalido."); + } + + const result = await pool.query<{ + palette_id: number; + graphics: unknown; + 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::jsonb, $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, + JSON.stringify(entry.graphics), + entry.blocked, + accountId, + ], + ); + + const row = result.rows[0]; + if (!row) { + throw new Error("No se pudo guardar la entrada de paleta."); + } + + return { + id: row.palette_id, + graphics: normalizeGraphics(row.graphics), + blocked: row.blocked, + updatedAt: row.updated_at.toISOString(), + source: "override", + }; +} + +export async function deletePaletteEntry( + mapNum: number, + paletteId: number, +): Promise { + const result = await pool.query( + `DELETE FROM game_map_palette_overrides + WHERE map_num = $1 AND palette_id = $2`, + [mapNum, paletteId], + ); + return (result.rowCount ?? 0) > 0; +} + +/** + * Indice al estilo graficos.json para los PNG subidos (criterio #6.2). + * Complementa GET /game-data/graphics (metadatos) para clientes que quieran + * mergear sin reinventar la forma del catalogo. + */ +export async function listUploadedGraphicsIndex( + limit = 500, +): Promise>> { + const result = await pool.query<{ + grh_index: number; + width: number; + height: number; + }>( + `SELECT grh_index, width, height + FROM game_uploaded_graphics + ORDER BY created_at DESC + LIMIT $1`, + [limit], + ); + + const index: Record< + string, + ReturnType + > = {}; + + for (const row of result.rows) { + index[String(row.grh_index)] = uploadedGraphicToEngineEntry({ + grhIndex: row.grh_index, + width: row.width, + height: row.height, + }); + } + + return index; +} diff --git a/api/src/server.ts b/api/src/server.ts index 2b309610..0b2f4715 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -101,6 +101,7 @@ import { getGraphicContent, getMapStatus, getMapTerrainPalette, + getSourcePaletteMaxId, listGraphics, listMapOverrides, listMapTileEntities, @@ -113,6 +114,13 @@ import { tileEntitySchema, uploadGraphic, } from "./repositories/worldBuilder"; +import { + PaletteValidationError, + deletePaletteEntry, + listUploadedGraphicsIndex, + paletteEntrySchema, + upsertPaletteEntry, +} from "./repositories/worldBuilderPalette"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -815,6 +823,23 @@ app.get("/game-data/graphics", async (_request, response) => { } }); +/** + * Indice de graficos subidos con forma graficos.json (#6). + * El cliente puede mergearlo al catalogo sin transformar metadatos. + */ +app.get("/game-data/graphics/index", async (_request, response) => { + try { + response.json({ + graphics: await listUploadedGraphicsIndex(500), + uploadedGraphicIndexStart: 1_000_000, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + /** * Sirve un grafico subido. Es publico a proposito: cualquier jugador que entre * a un mapa editado necesita poder descargarlo, igual que los graficos @@ -1134,6 +1159,102 @@ app.get( }, ); +/** + * Crea o actualiza una entrada de paleta (#6): capas + blocked, con validacion + * de que cada grafico exista en el motor o en assets subidos. + */ +app.put( + "/admin/game-data/maps/:mapNum/palette", + 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) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const parsed = paletteEntrySchema.safeParse(request.body); + + if (!parsed.success) { + response + .status(400) + .json({ error: JSON.stringify(parsed.error.issues) }); + return; + } + + const sourceMaxId = await getSourcePaletteMaxId(mapNum); + const entry = await upsertPaletteEntry( + mapNum, + parsed.data, + authorized.session.account._id, + sourceMaxId, + ); + + response.json({ mapNum, entry }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + const status = + error instanceof PaletteValidationError + ? 400 + : message.startsWith("El mapa") + ? 404 + : 400; + response.status(status).json({ error: message }); + } + }, +); + +/** Elimina una entrada de paleta override (no toca terrain.json fuente). */ +app.delete( + "/admin/game-data/maps/:mapNum/palette/:paletteId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const paletteId = Number.parseInt( + request.params.paletteId ?? "", + 10, + ); + + if ( + !Number.isInteger(mapNum) || + mapNum <= 0 || + !Number.isInteger(paletteId) || + paletteId <= 0 + ) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + const removed = await deletePaletteEntry(mapNum, paletteId); + + if (!removed) { + response.status(404).json({ error: "Entrada de paleta no encontrada." }); + return; + } + + response.json({ mapNum, paletteId, removed: true }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + /** Coloca un objeto o un NPC en un tile, como borrador. */ app.put( "/admin/game-data/maps/:mapNum/entities", diff --git a/api/src/tests/graphicCatalogPalette.test.ts b/api/src/tests/graphicCatalogPalette.test.ts new file mode 100644 index 00000000..74d8bf0d --- /dev/null +++ b/api/src/tests/graphicCatalogPalette.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + UPLOADED_GRAPHIC_INDEX_START, + checkGraphicExists, + clearEngineGraphicIdCache, + isReservedUploadedIndex, + loadEngineGraphicIds, + uploadedGraphicToEngineEntry, + validatePaletteGraphics, +} from "../lib/graphicCatalog"; + +test("UPLOADED_GRAPHIC_INDEX_START is 1_000_000 and reserved helper matches", () => { + assert.equal(UPLOADED_GRAPHIC_INDEX_START, 1_000_000); + assert.equal(isReservedUploadedIndex(999_999), false); + assert.equal(isReservedUploadedIndex(1_000_000), true); +}); + +test("uploadedGraphicToEngineEntry matches graficos.json static tile shape", () => { + const entry = uploadedGraphicToEngineEntry({ + grhIndex: 1_000_042, + width: 32, + height: 64, + }); + assert.equal(entry.numFrames, 1); + assert.equal(entry.numFile, "1000042"); + assert.equal(entry.width, 32); + assert.equal(entry.height, 64); + assert.deepEqual(entry.frames, { "1": "1000042" }); + assert.deepEqual(entry.offset, { x: 0, y: 0 }); +}); + +test("loadEngineGraphicIds reads real graficos catalog and stays below reserved range", async () => { + clearEngineGraphicIdCache(); + const ids = await loadEngineGraphicIds(true); + assert.ok(ids.size > 1000); + assert.ok(ids.has(1)); + assert.ok(ids.has(5500)); + assert.equal(ids.has(UPLOADED_GRAPHIC_INDEX_START), false); + for (const id of ids) { + assert.ok(id < UPLOADED_GRAPHIC_INDEX_START); + } +}); + +test("checkGraphicExists: engine hit, missing engine, uploaded probe", async () => { + clearEngineGraphicIdCache(); + await loadEngineGraphicIds(true); + + const engine = await checkGraphicExists(5500, async () => false); + assert.deepEqual(engine, { ok: true, source: "engine" }); + + const missing = await checkGraphicExists(999_998, async () => false); + assert.equal(missing.ok, false); + if (!missing.ok) { + assert.match(missing.reason, /catalogo del motor/); + } + + const uploadedOk = await checkGraphicExists( + UPLOADED_GRAPHIC_INDEX_START, + async () => true, + ); + assert.deepEqual(uploadedOk, { ok: true, source: "uploaded" }); + + const uploadedMissing = await checkGraphicExists( + UPLOADED_GRAPHIC_INDEX_START + 7, + async () => false, + ); + assert.equal(uploadedMissing.ok, false); + if (!uploadedMissing.ok) { + assert.match(uploadedMissing.reason, /no existe/); + } +}); + +test("validatePaletteGraphics requires a non-null layer and rejects ghosts", async () => { + clearEngineGraphicIdCache(); + await loadEngineGraphicIds(true); + + const empty = await validatePaletteGraphics([null, null], async () => false); + assert.equal(empty.ok, false); + + const ok = await validatePaletteGraphics( + [5500, null, 581], + async () => false, + ); + assert.equal(ok.ok, true); + + const ghost = await validatePaletteGraphics( + [UPLOADED_GRAPHIC_INDEX_START + 99], + async () => false, + ); + assert.equal(ghost.ok, false); +}); diff --git a/docs/uploaded-graphics-range.md b/docs/uploaded-graphics-range.md new file mode 100644 index 00000000..3090c660 --- /dev/null +++ b/docs/uploaded-graphics-range.md @@ -0,0 +1,29 @@ +# Rango reservado de graficos subidos (#6) + +## Contrato + +| Espacio | Rango | Fuente | +| --- | --- | --- | +| Graficos originales del motor | IDs presentes en `frontend/public/init/graficos(_optimized).json` (hoy hasta ~52k; comentarios legacy mencionan 320151) | Archivo estatico | +| Graficos subidos (modo construccion) | `>= 1_000_000` (`UPLOADED_GRAPHIC_INDEX_START`) | Tabla `game_uploaded_graphics` | + +La constante `UPLOADED_GRAPHIC_INDEX_START = 1_000_000` se comparte entre: + +- `api/src/lib/graphicCatalog.ts` +- `api/src/repositories/worldBuilder.ts` +- `frontend/utils/gameLoader.ts` +- `CHECK (grh_index >= 1000000)` en `api/schema.sql` + +## Por que no `50000–99999` + +Ese rango **colisiona** con indices reales del catalogo optimizado (max observado > 52000). El puente PNG→motor usa `1_000_000+` a proposito. + +## Resolucion en el cliente + +1. El cliente carga `graficos_optimized.json` / `graficos.json`. +2. `mergeUploadedGraphics` pide `GET /game-data/graphics` y agrega cada PNG subido al mismo catalogo en memoria, con forma compatible (`numFile` = indice, frame completo). +3. Alternativa explicita: `GET /game-data/graphics/index` devuelve el mismo shape graficos.json para merge manual. + +## Paleta + +`PUT /admin/game-data/maps/:mapNum/palette` crea/actualiza una entrada (`graphics` 1–4 capas + `blocked`) tras validar cada ID contra el catalogo real (no solo un techo numerico). Las entradas viven en `game_map_palette_overrides` y se fusionan en `GET /admin/game-data/maps/:mapNum/terrain`. diff --git a/frontend/lib/editor/editorApi.ts b/frontend/lib/editor/editorApi.ts index ee5b4a72..b073df03 100644 --- a/frontend/lib/editor/editorApi.ts +++ b/frontend/lib/editor/editorApi.ts @@ -347,4 +347,33 @@ export async function isGameDataAdmin(): Promise { // Sin red se asume que no hay permiso: es el caso seguro. return false; } -} \ No newline at end of file +} + +export type PaletteEntryInput = { + id?: number; + graphics: Array; + blocked?: boolean; +}; + +/** Crea o actualiza una entrada de paleta (#6). */ +export async function upsertPaletteEntry( + mapNum: number, + entry: PaletteEntryInput, +): Promise<{ status: number; ok: boolean; data: { entry?: TerrainPaletteEntry; error?: string } }> { + return requestJson(editorPath(`maps/${mapNum}/palette`), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(entry), + }); +} + +/** Elimina una entrada de paleta override. */ +export async function deletePaletteEntry( + mapNum: number, + paletteId: number, +): Promise<{ status: number; ok: boolean; data: { removed?: boolean; error?: string } }> { + return requestJson(editorPath(`maps/${mapNum}/palette/${paletteId}`), { + method: "DELETE", + }); +} +