diff --git a/api/package.json b/api/package.json index 74ecba9b..e47d3468 100644 --- a/api/package.json +++ b/api/package.json @@ -18,6 +18,7 @@ "fix-missing-heads:prod": "node dist/scripts/fixCharactersMissingHeads.js", "import-game-data": "tsx src/scripts/importGameData.ts", "import-game-data:prod": "node dist/scripts/importGameData.js npcs", + "import-game-maps": "tsx src/scripts/importGameData.ts maps", "remove-newbie-items": "tsx src/scripts/removeNewbieItemsFromHighLevelCharacters.ts", "remove-newbie-items:prod": "node dist/scripts/removeNewbieItemsFromHighLevelCharacters.js", "reset-spell-stats": "tsx src/scripts/resetSpellStats.ts", diff --git a/api/schema.sql b/api/schema.sql index d0008678..f878b565 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -510,6 +510,48 @@ CREATE TABLE IF NOT EXISTS game_balance ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- OpenAO #3: map persistence (measured: grid as JSONB doc, palette relational) + +CREATE TABLE IF NOT EXISTS game_maps ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + terreno TEXT NOT NULL DEFAULT '', + zona TEXT NOT NULL DEFAULT '', + restringir TEXT NOT NULL DEFAULT '', + min_level INTEGER NOT NULL DEFAULT 0, + max_level INTEGER NOT NULL DEFAULT 0, + pk BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB NOT NULL, + npcs JSONB NOT NULL DEFAULT '[]'::jsonb, + specials JSONB NOT NULL DEFAULT '{}'::jsonb, + checksum TEXT NOT NULL, + source_checksum TEXT NOT NULL, + is_edited BOOLEAN NOT NULL DEFAULT FALSE, + version BIGINT NOT NULL DEFAULT 0, + updated_by_account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS game_map_palette ( + map_id INTEGER NOT NULL REFERENCES game_maps(id) ON DELETE CASCADE, + palette_key INTEGER NOT NULL CHECK (palette_key > 0), + graphics JSONB, + blocked BOOLEAN NOT NULL DEFAULT FALSE, + tile JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (map_id, palette_key) +); + +CREATE TABLE IF NOT EXISTS game_map_grids ( + map_id INTEGER PRIMARY KEY REFERENCES game_maps(id) ON DELETE CASCADE, + width INTEGER NOT NULL CHECK (width > 0), + height INTEGER NOT NULL CHECK (height > 0), + rows JSONB NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_game_map_palette_map + ON game_map_palette(map_id); + CREATE TABLE IF NOT EXISTS game_data_revisions ( id BIGSERIAL PRIMARY KEY, kind TEXT NOT NULL, @@ -522,7 +564,7 @@ CREATE TABLE IF NOT EXISTS game_data_revisions ( ALTER TABLE game_data_revisions DROP CONSTRAINT IF EXISTS game_data_revisions_kind_check; ALTER TABLE game_data_revisions ADD CONSTRAINT game_data_revisions_kind_check - CHECK (kind IN ('objs', 'npcs', 'crafting_recipes', 'smelting_recipes', 'balance')); + CHECK (kind IN ('objs', 'npcs', 'crafting_recipes', 'smelting_recipes', 'balance', 'maps')); CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email); CREATE INDEX IF NOT EXISTS idx_characters_account_id ON characters(account_id); @@ -558,6 +600,10 @@ CREATE INDEX IF NOT EXISTS idx_game_crafting_recipes_item_id ON game_crafting_re CREATE INDEX IF NOT EXISTS idx_game_smelting_recipes_updated_at ON game_smelting_recipes(updated_at DESC); CREATE INDEX IF NOT EXISTS idx_game_smelting_recipes_mineral_item_id ON game_smelting_recipes(mineral_item_id); CREATE INDEX IF NOT EXISTS idx_game_balance_updated_at ON game_balance(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_game_maps_updated_at ON game_maps(updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_game_maps_name_lower ON game_maps(LOWER(name)); +CREATE INDEX IF NOT EXISTS idx_game_maps_zone_terrain ON game_maps(zona, terreno); +CREATE INDEX IF NOT EXISTS idx_game_maps_edited ON game_maps(is_edited) WHERE is_edited = TRUE; CREATE INDEX IF NOT EXISTS idx_game_data_revisions_kind_id ON game_data_revisions(kind, id DESC); CREATE INDEX IF NOT EXISTS idx_challenge_history_finished_at ON challenge_history(finished_at DESC); diff --git a/api/src/lib/mapData.ts b/api/src/lib/mapData.ts new file mode 100644 index 00000000..bd39d9d4 --- /dev/null +++ b/api/src/lib/mapData.ts @@ -0,0 +1,389 @@ +import { existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; + +import { computeChecksum } from "./gameData"; +import { listAvailableMapIds, type MapNpcPlacement } from "./mapNpcStorage"; + +/** + * Tipos y normalizacion de mapas fuente (meta/terrain/npcs/specials). + * + * La capa de persistencia (#3) reutiliza estas formas tanto para importar + * `mapas_source/` como para reconstruir un mapa desde las tablas + * `game_maps` + `game_map_palette` + `game_map_grids`. + */ + +export type GameMapMetadata = { + id: number; + name: string; + musicNum: number; + magiaSinEfecto: number; + noEncriptarMp: number; + terreno: string; + zona: string; + restringir: string; + minLevel: number; + maxLevel: number; + backup: number; + pk: number; + [key: string]: unknown; +}; + +export type GameMapTerrainTile = { + blocked?: boolean; + graphics?: number | Array; + [key: string]: unknown; +}; + +export type GameMapTerrain = { + id: number; + width: number; + height: number; + palette: Record; + rows: number[][]; + [key: string]: unknown; +}; + +export type GameMapSpecials = { + id: number; + exits: Record; + objects: Record; + npcs: Record; + triggers: Record; + [key: string]: unknown; +}; + +export type GameMapRecordData = { + metadata: GameMapMetadata; + terrain: GameMapTerrain; + npcs: MapNpcPlacement[]; + specials: GameMapSpecials; +}; + +export type GameMapPaletteEntry = { + paletteKey: number; + graphics: unknown; + blocked: boolean; + tile: GameMapTerrainTile; +}; + +const MAP_DIR_PATTERN = /^mapa_(\d+)$/i; + +export const DEFAULT_MAPS_SOURCE_DIR = path.resolve( + __dirname, + "../mapas_source", +); + +function toInteger(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.trunc(value); + } + + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.trunc(parsed); + } + } + + return fallback; +} + +function toText(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +} + +function normalizeMetadata(value: unknown, mapId: number): GameMapMetadata { + const raw = asObject(value); + + return { + ...raw, + id: mapId, + name: toText(raw.name, `Mapa ${mapId}`), + musicNum: toInteger(raw.musicNum), + magiaSinEfecto: toInteger(raw.magiaSinEfecto), + noEncriptarMp: toInteger(raw.noEncriptarMp), + terreno: toText(raw.terreno), + zona: toText(raw.zona), + restringir: toText(raw.restringir, "No"), + minLevel: toInteger(raw.minLevel), + maxLevel: toInteger(raw.maxLevel), + backup: toInteger(raw.backup), + pk: toInteger(raw.pk), + }; +} + +function normalizePaletteTile(value: unknown): GameMapTerrainTile | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + return { ...(value as GameMapTerrainTile) }; +} + +export function normalizePalette( + value: unknown, +): Record { + const raw = asObject(value); + const out: Record = {}; + + for (const [key, tileValue] of Object.entries(raw)) { + const paletteKey = toInteger(key, Number.NaN); + if (!Number.isInteger(paletteKey) || paletteKey <= 0) { + continue; + } + + const tile = normalizePaletteTile(tileValue); + if (!tile) { + continue; + } + + out[String(paletteKey)] = tile; + } + + return out; +} + +export function paletteEntriesFromTerrain( + palette: Record, +): GameMapPaletteEntry[] { + return Object.entries(palette) + .map(([key, tile]) => { + const paletteKey = toInteger(key); + return { + paletteKey, + graphics: tile.graphics ?? null, + blocked: Boolean(tile.blocked), + tile, + }; + }) + .filter((entry) => entry.paletteKey > 0) + .sort((left, right) => left.paletteKey - right.paletteKey); +} + +export function terrainFromParts( + mapId: number, + width: number, + height: number, + paletteEntries: GameMapPaletteEntry[], + rows: number[][], +): GameMapTerrain { + const palette: Record = {}; + for (const entry of paletteEntries) { + palette[String(entry.paletteKey)] = entry.tile; + } + + return { + id: mapId, + width: Math.max(1, width), + height: Math.max(1, height), + palette, + rows, + }; +} + +function normalizeRows(value: unknown): number[][] { + if (!Array.isArray(value)) { + return []; + } + + return value.map((row) => + Array.isArray(row) ? row.map((cell) => toInteger(cell)) : [], + ); +} + +function normalizeTerrain(value: unknown, mapId: number): GameMapTerrain { + const raw = asObject(value); + + return { + ...raw, + id: mapId, + width: Math.max(1, toInteger(raw.width, 100)), + height: Math.max(1, toInteger(raw.height, 100)), + palette: normalizePalette(raw.palette), + rows: normalizeRows(raw.rows), + }; +} + +function normalizeSpecials(value: unknown, mapId: number): GameMapSpecials { + const raw = asObject(value); + + return { + ...raw, + id: mapId, + exits: asObject(raw.exits), + objects: asObject(raw.objects), + npcs: asObject(raw.npcs), + triggers: asObject(raw.triggers), + }; +} + +function normalizeNpcPlacement( + value: unknown, + fallbackMapNum: number, +): MapNpcPlacement | null { + if (!value || typeof value !== "object") { + return null; + } + + const raw = value as Record; + const mapNum = toInteger(raw.mapNum, fallbackMapNum); + const x = toInteger(raw.x); + const y = toInteger(raw.y); + const npcIndex = toInteger(raw.npcIndex); + const movement = raw.movement == null ? null : toInteger(raw.movement); + + if (mapNum <= 0 || x <= 0 || y <= 0 || npcIndex <= 0) { + return null; + } + + return movement === null + ? { mapNum, x, y, npcIndex } + : { mapNum, x, y, npcIndex, movement }; +} + +export function sortMapNpcPlacements( + placements: MapNpcPlacement[], +): MapNpcPlacement[] { + return [...placements].sort( + (left, right) => + left.mapNum - right.mapNum || + left.y - right.y || + left.x - right.x || + left.npcIndex - right.npcIndex, + ); +} + +function normalizeNpcPlacements( + value: unknown, + fallbackMapNum: number, +): MapNpcPlacement[] { + if (!Array.isArray(value)) { + return []; + } + + return sortMapNpcPlacements( + value + .map((entry) => normalizeNpcPlacement(entry, fallbackMapNum)) + .filter((entry): entry is MapNpcPlacement => Boolean(entry)), + ); +} + +export function normalizeGameMapData( + input: unknown, + fallbackMapId?: number, +): GameMapRecordData { + const raw = asObject(input); + const rawMetadata = asObject(raw.metadata); + const rawTerrain = asObject(raw.terrain); + const rawSpecials = asObject(raw.specials); + const mapId = + fallbackMapId ?? + toInteger( + rawMetadata.id, + toInteger(rawTerrain.id, toInteger(rawSpecials.id)), + ); + + if (!Number.isInteger(mapId) || mapId <= 0) { + throw new Error("Map id invalido"); + } + + return { + metadata: normalizeMetadata(rawMetadata, mapId), + terrain: normalizeTerrain(rawTerrain, mapId), + npcs: normalizeNpcPlacements(raw.npcs, mapId), + specials: normalizeSpecials(rawSpecials, mapId), + }; +} + +export function computeGameMapChecksum(data: GameMapRecordData): string { + return computeChecksum(normalizeGameMapData(data, data.metadata.id)); +} + +async function readJsonIfExists(filePath: string, fallback: unknown) { + if (!existsSync(filePath)) { + return fallback; + } + + return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; +} + +export async function listGameMapSourceIds( + mapsSourceDir = DEFAULT_MAPS_SOURCE_DIR, +): Promise { + if (!existsSync(mapsSourceDir)) { + return []; + } + + const directoryIds = await listAvailableMapIds(mapsSourceDir); + const idsWithRequiredFiles: number[] = []; + + for (const mapId of directoryIds) { + const mapDir = path.join(mapsSourceDir, `mapa_${mapId}`); + if ( + existsSync(path.join(mapDir, "meta.json")) && + existsSync(path.join(mapDir, "terrain.json")) + ) { + idsWithRequiredFiles.push(mapId); + } + } + + return idsWithRequiredFiles; +} + +export async function loadGameMapFromDirectory( + mapsSourceDir: string, + mapId: number, +): Promise { + const mapDir = path.join(mapsSourceDir, `mapa_${mapId}`); + const metaPath = path.join(mapDir, "meta.json"); + const terrainPath = path.join(mapDir, "terrain.json"); + + if (!existsSync(metaPath) || !existsSync(terrainPath)) { + return null; + } + + const [metadata, terrain, npcs, specials] = await Promise.all([ + readJsonIfExists(metaPath, { id: mapId }), + readJsonIfExists(terrainPath, { id: mapId }), + readJsonIfExists(path.join(mapDir, "npcs.json"), []), + readJsonIfExists(path.join(mapDir, "specials.json"), { id: mapId }), + ]); + + return normalizeGameMapData( + { + metadata, + terrain, + npcs, + specials, + }, + mapId, + ); +} + +export async function loadGameMapsFromDirectory( + mapsSourceDir = DEFAULT_MAPS_SOURCE_DIR, +): Promise { + const mapIds = await listGameMapSourceIds(mapsSourceDir); + const maps = await Promise.all( + mapIds.map((mapId) => loadGameMapFromDirectory(mapsSourceDir, mapId)), + ); + + return maps.filter((map): map is GameMapRecordData => Boolean(map)); +} + +export function parseMapIdFromDirectoryName(name: string): number | null { + const match = name.match(MAP_DIR_PATTERN); + if (!match) { + return null; + } + + const mapId = Number.parseInt(match[1] ?? "", 10); + return Number.isInteger(mapId) && mapId > 0 ? mapId : null; +} diff --git a/api/src/repositories/gameMaps.ts b/api/src/repositories/gameMaps.ts new file mode 100644 index 00000000..abb34669 --- /dev/null +++ b/api/src/repositories/gameMaps.ts @@ -0,0 +1,593 @@ +import type { PoolClient } from "pg"; +import { z } from "zod"; + +import pool from "../db"; +import { + computeGameMapChecksum, + DEFAULT_MAPS_SOURCE_DIR, + loadGameMapFromDirectory, + loadGameMapsFromDirectory, + normalizeGameMapData, + paletteEntriesFromTerrain, + terrainFromParts, + type GameMapPaletteEntry, + type GameMapRecordData, +} from "../lib/mapData"; + +/** + * Persistencia de mapas para OpenAO #3. + * + * Diferencias clave vs enfoques de un solo JSONB por mapa: + * - Schema alineado a la issue: metadata (`game_maps`), paleta relacional + * (`game_map_palette`) y grilla compacta (`game_map_grids.rows` JSONB). + * - Decision medida: NO 10.000 filas/tile (2,9M filas). La grilla va como + * documento por mapa; la paleta sí es consultable fila a fila. + * - Semántica seed vs editado: el import deja `is_edited=false`. La lectura + * con precedencia solo sirve DB cuando el mapa fue editado; si solo está + * importado como seed, se sigue sirviendo el archivo (criterio de la issue). + */ + +type GameMapRow = { + id: number; + name: string; + terreno: string; + zona: string; + restringir: string; + min_level: number; + max_level: number; + pk: boolean; + metadata: GameMapRecordData["metadata"]; + npcs: GameMapRecordData["npcs"]; + specials: GameMapRecordData["specials"]; + checksum: string; + source_checksum: string; + is_edited: boolean; + version: string; + updated_at: Date; +}; + +type GameMapGridRow = { + map_id: number; + width: number; + height: number; + rows: number[][]; +}; + +type GameMapPaletteRow = { + map_id: number; + palette_key: number; + graphics: unknown; + blocked: boolean; + tile: GameMapRecordData["terrain"]["palette"][string]; +}; + +const listFiltersSchema = z.object({ + search: z.string().trim().optional(), + terreno: z.string().trim().optional(), + zona: z.string().trim().optional(), + editedOnly: z.preprocess((value) => { + const raw = Array.isArray(value) ? value[0] : value; + return raw === true || raw === "true" || raw === "1"; + }, z.boolean()), + limit: z.coerce.number().int().min(1).max(200).optional(), + page: z.coerce.number().int().min(1).optional(), +}); + +async function insertRevision( + client: PoolClient, + entityId: number, + checksum: string, +): Promise { + const result = await client.query<{ id: string }>( + ` + INSERT INTO game_data_revisions (kind, entity_id, action, checksum) + VALUES ('maps', $1, 'upsert', $2) + RETURNING id + `, + [entityId, checksum], + ); + + return Number(result.rows[0]?.id ?? 0); +} + +async function replacePaletteAndGrid( + client: PoolClient, + mapId: number, + data: GameMapRecordData, +): Promise { + await client.query(`DELETE FROM game_map_palette WHERE map_id = $1`, [mapId]); + await client.query(`DELETE FROM game_map_grids WHERE map_id = $1`, [mapId]); + + const entries = paletteEntriesFromTerrain(data.terrain.palette); + for (const entry of entries) { + await client.query( + ` + INSERT INTO game_map_palette (map_id, palette_key, graphics, blocked, tile) + VALUES ($1, $2, $3::jsonb, $4, $5::jsonb) + `, + [ + mapId, + entry.paletteKey, + JSON.stringify(entry.graphics), + entry.blocked, + JSON.stringify(entry.tile), + ], + ); + } + + await client.query( + ` + INSERT INTO game_map_grids (map_id, width, height, rows) + VALUES ($1, $2, $3, $4::jsonb) + `, + [ + mapId, + data.terrain.width, + data.terrain.height, + JSON.stringify(data.terrain.rows), + ], + ); +} + +async function loadPaletteEntries( + mapId: number, + client?: PoolClient, +): Promise { + const queryable = client ?? pool; + const result = await queryable.query( + ` + SELECT map_id, palette_key, graphics, blocked, tile + FROM game_map_palette + WHERE map_id = $1 + ORDER BY palette_key ASC + `, + [mapId], + ); + + return result.rows.map((row) => ({ + paletteKey: row.palette_key, + graphics: row.graphics, + blocked: row.blocked, + tile: row.tile, + })); +} + +async function loadGrid( + mapId: number, + client?: PoolClient, +): Promise { + const queryable = client ?? pool; + const result = await queryable.query( + ` + SELECT map_id, width, height, rows + FROM game_map_grids + WHERE map_id = $1 + LIMIT 1 + `, + [mapId], + ); + + return result.rows[0] ?? null; +} + +async function assembleGameMapData( + row: GameMapRow, + client?: PoolClient, +): Promise { + const [paletteEntries, grid] = await Promise.all([ + loadPaletteEntries(row.id, client), + loadGrid(row.id, client), + ]); + + if (!grid) { + throw new Error(`Game map ${row.id} is missing grid rows`); + } + + return normalizeGameMapData( + { + metadata: row.metadata, + terrain: terrainFromParts( + row.id, + grid.width, + grid.height, + paletteEntries, + grid.rows, + ), + npcs: row.npcs, + specials: row.specials, + }, + row.id, + ); +} + +function toSummary( + row: Pick< + GameMapRow, + | "id" + | "name" + | "terreno" + | "zona" + | "restringir" + | "min_level" + | "max_level" + | "pk" + | "is_edited" + | "version" + | "updated_at" + >, + source: "db" | "file", +) { + return { + id: row.id, + name: row.name, + terreno: row.terreno, + zona: row.zona, + restringir: row.restringir, + minLevel: row.min_level, + maxLevel: row.max_level, + pk: row.pk, + isEdited: row.is_edited, + version: Number(row.version), + updatedAt: row.updated_at.toISOString(), + source, + }; +} + +function fileSummary(id: number, data: GameMapRecordData) { + return { + id, + name: data.metadata.name, + terreno: data.metadata.terreno, + zona: data.metadata.zona, + restringir: data.metadata.restringir, + minLevel: data.metadata.minLevel, + maxLevel: data.metadata.maxLevel, + pk: data.metadata.pk === 1, + isEdited: false, + version: 0, + updatedAt: null as string | null, + source: "file" as const, + }; +} + +export async function listGameMaps(filters: unknown) { + const parsed = listFiltersSchema.parse(filters ?? {}); + const values: Array = []; + const conditions: string[] = []; + const pageSize = parsed.limit ?? 100; + const page = parsed.page ?? 1; + const offset = (page - 1) * pageSize; + + if (parsed.search) { + values.push(`%${parsed.search.toLowerCase()}%`); + conditions.push( + `(LOWER(name) LIKE $${values.length} OR CAST(id AS TEXT) LIKE $${values.length})`, + ); + } + + if (parsed.terreno) { + values.push(parsed.terreno.toUpperCase()); + conditions.push(`UPPER(terreno) = $${values.length}`); + } + + if (parsed.zona) { + values.push(parsed.zona.toUpperCase()); + conditions.push(`UPPER(zona) = $${values.length}`); + } + + if (parsed.editedOnly) { + conditions.push(`is_edited = TRUE`); + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const countResult = await pool.query<{ count: string }>( + ` + SELECT COUNT(*)::text AS count + FROM game_maps + ${whereClause} + `, + values, + ); + + values.push(pageSize); + values.push(offset); + const result = await pool.query( + ` + SELECT id, name, terreno, zona, restringir, min_level, max_level, pk, + metadata, npcs, specials, checksum, source_checksum, is_edited, + version::text AS version, updated_at + FROM game_maps + ${whereClause} + ORDER BY id ASC + LIMIT $${values.length - 1} + OFFSET $${values.length} + `, + values, + ); + + const total = Number(countResult.rows[0]?.count ?? 0); + + return { + maps: result.rows.map((row) => toSummary(row, row.is_edited ? "db" : "file")), + pagination: { + page, + pageSize, + total, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; +} + +export async function getGameMapById( + id: number, + mapsSourceDir = DEFAULT_MAPS_SOURCE_DIR, +) { + const result = await pool.query( + ` + SELECT id, name, terreno, zona, restringir, min_level, max_level, pk, + metadata, npcs, specials, checksum, source_checksum, is_edited, + version::text AS version, updated_at + FROM game_maps + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + + const row = result.rows[0]; + + // Precedencia issue #3: solo mapas *editados* se sirven desde DB. + // Un seed importado sigue cayendo al archivo hasta la primera edicion. + if (row?.is_edited) { + const data = await assembleGameMapData(row); + return { + ...toSummary(row, "db"), + checksum: row.checksum, + sourceChecksum: row.source_checksum, + data, + }; + } + + const fileMap = await loadGameMapFromDirectory(mapsSourceDir, id); + if (!fileMap) { + throw new Error("Game map not found"); + } + + return { + ...fileSummary(id, fileMap), + checksum: computeGameMapChecksum(fileMap), + sourceChecksum: row?.source_checksum ?? computeGameMapChecksum(fileMap), + data: fileMap, + seeded: Boolean(row), + }; +} + +async function persistMap( + id: number, + input: unknown, + options: { + markEdited: boolean; + updatedByAccountId?: string | null; + /** Si true, no pisa filas con is_edited=true (usado por el importador). */ + preserveEdited?: boolean; + }, +) { + const data = normalizeGameMapData(input, id); + const checksum = computeGameMapChecksum(data); + + const client = await pool.connect(); + let unchanged = false; + let nextRow: GameMapRow | undefined; + + try { + await client.query("BEGIN"); + + const current = await client.query<{ + checksum: string; + is_edited: boolean; + source_checksum: string; + }>( + ` + SELECT checksum, is_edited, source_checksum + FROM game_maps + WHERE id = $1 + LIMIT 1 + FOR UPDATE + `, + [id], + ); + + const existing = current.rows[0]; + + if (options.preserveEdited && existing?.is_edited) { + unchanged = true; + } else if (existing && existing.checksum === checksum) { + // Mismo contenido: no revision nueva. Si el import re-sincroniza un + // seed, tampoco tocamos updated_at. + unchanged = true; + } else { + const sourceChecksum = options.markEdited + ? (existing?.source_checksum ?? checksum) + : checksum; + const isEdited = options.markEdited + ? true + : Boolean(existing?.is_edited); + + await client.query( + ` + INSERT INTO game_maps ( + id, name, terreno, zona, restringir, min_level, max_level, pk, + metadata, npcs, specials, checksum, source_checksum, is_edited, + version, updated_by_account_id, updated_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, + $9::jsonb, $10::jsonb, $11::jsonb, $12, $13, $14, + 0, $15, NOW() + ) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + terreno = EXCLUDED.terreno, + zona = EXCLUDED.zona, + restringir = EXCLUDED.restringir, + min_level = EXCLUDED.min_level, + max_level = EXCLUDED.max_level, + pk = EXCLUDED.pk, + metadata = EXCLUDED.metadata, + npcs = EXCLUDED.npcs, + specials = EXCLUDED.specials, + checksum = EXCLUDED.checksum, + source_checksum = CASE + WHEN game_maps.is_edited THEN game_maps.source_checksum + ELSE EXCLUDED.source_checksum + END, + is_edited = EXCLUDED.is_edited, + updated_by_account_id = EXCLUDED.updated_by_account_id, + updated_at = NOW() + `, + [ + id, + data.metadata.name, + data.metadata.terreno, + data.metadata.zona, + data.metadata.restringir, + data.metadata.minLevel, + data.metadata.maxLevel, + data.metadata.pk === 1, + JSON.stringify(data.metadata), + JSON.stringify(data.npcs), + JSON.stringify(data.specials), + checksum, + sourceChecksum, + isEdited, + options.updatedByAccountId ?? null, + ], + ); + + await replacePaletteAndGrid(client, id, data); + const version = await insertRevision(client, id, checksum); + await client.query( + `UPDATE game_maps SET version = $2 WHERE id = $1`, + [id, version], + ); + } + + const nextResult = await client.query( + ` + SELECT id, name, terreno, zona, restringir, min_level, max_level, pk, + metadata, npcs, specials, checksum, source_checksum, is_edited, + version::text AS version, updated_at + FROM game_maps + WHERE id = $1 + LIMIT 1 + `, + [id], + ); + nextRow = nextResult.rows[0]; + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + + if (!nextRow) { + throw new Error("Game map not found after upsert"); + } + + const assembled = await assembleGameMapData(nextRow); + return { + unchanged, + map: { + ...toSummary(nextRow, nextRow.is_edited ? "db" : "file"), + checksum: nextRow.checksum, + sourceChecksum: nextRow.source_checksum, + data: assembled, + }, + }; +} + +/** Edicion admin/editor: marca el mapa como editado y deja revision. */ +export async function upsertGameMap( + id: number, + input: unknown, + updatedByAccountId?: string | null, +) { + return persistMap(id, input, { + markEdited: true, + updatedByAccountId, + preserveEdited: false, + }); +} + +/** + * Importador idempotente desde `mapas_source/`. + * No pisa mapas ya editados y no marca seed como editado. + */ +export async function importGameMapsFromSource( + mapsSourceDir = DEFAULT_MAPS_SOURCE_DIR, +): Promise<{ total: number; changed: number; unchanged: number }> { + const maps = await loadGameMapsFromDirectory(mapsSourceDir); + let changed = 0; + let unchanged = 0; + + for (const mapData of maps) { + const result = await persistMap(mapData.metadata.id, mapData, { + markEdited: false, + preserveEdited: true, + }); + if (result.unchanged) { + unchanged += 1; + } else { + changed += 1; + } + } + + return { total: maps.length, changed, unchanged }; +} + +export async function listGameMapChangesSince(sinceVersion: number) { + const result = await pool.query( + ` + SELECT id, name, terreno, zona, restringir, min_level, max_level, pk, + metadata, npcs, specials, checksum, source_checksum, is_edited, + version::text AS version, updated_at + FROM game_maps + WHERE version > $1 AND is_edited = TRUE + ORDER BY version ASC + `, + [sinceVersion], + ); + + const changes = []; + for (const row of result.rows) { + changes.push({ + id: row.id, + version: Number(row.version), + data: await assembleGameMapData(row), + }); + } + + const currentVersion = changes.reduce( + (max, row) => Math.max(max, row.version), + sinceVersion, + ); + + return { currentVersion, changes }; +} + +export async function getCurrentGameMapVersion(): Promise { + const result = await pool.query<{ version: string }>( + "SELECT COALESCE(MAX(version), 0)::text AS version FROM game_maps WHERE is_edited = TRUE", + ); + return Number(result.rows[0]?.version ?? 0); +} + +export async function listEditedGameMapIds(): Promise { + const result = await pool.query<{ id: number }>( + `SELECT id FROM game_maps WHERE is_edited = TRUE ORDER BY id ASC`, + ); + return result.rows.map((row) => row.id); +} diff --git a/api/src/routes/gameMapsRoutes.ts b/api/src/routes/gameMapsRoutes.ts new file mode 100644 index 00000000..a0797fb2 --- /dev/null +++ b/api/src/routes/gameMapsRoutes.ts @@ -0,0 +1,201 @@ +import type { Express, Request, Response, NextFunction } from "express"; +import { + getCurrentGameMapVersion, + getGameMapById, + listGameMapChangesSince, + listGameMaps, + upsertGameMap, +} from "../repositories/gameMaps"; + +type AuthMiddleware = ( + request: Request, + response: Response, + next: NextFunction, +) => unknown; + +type AdminGate = ( + request: Request, + response: Response, +) => Promise; + +/** + * Admin + internal map persistence endpoints for OpenAO #3. + * Kept in a dedicated module so server.ts only wires the registrar. + */ +export function registerGameMapRoutes( + app: Express, + options: { + requireAuth: AuthMiddleware; + requireAdminEmailSession: AdminGate; + }, +): void { + const { requireAuth, requireAdminEmailSession } = options; + + app.get("/admin/game-data/maps", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + response.json(await listGameMaps(request.query)); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } + }); + + app.get("/admin/game-data/maps/:id", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + const rawId = Array.isArray(request.params.id) + ? request.params.id[0] + : request.params.id; + const id = Number.parseInt(rawId, 10); + if (!Number.isInteger(id) || id <= 0) { + response.status(400).json({ error: "id invalido" }); + return; + } + response.json(await getGameMapById(id)); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response + .status(message === "Game map not found" ? 404 : 500) + .json({ error: message }); + } + }); + + app.put("/admin/game-data/maps/:id", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + const rawId = Array.isArray(request.params.id) + ? request.params.id[0] + : request.params.id; + const id = Number.parseInt(rawId, 10); + if (!Number.isInteger(id) || id <= 0) { + response.status(400).json({ error: "id invalido" }); + return; + } + response.json( + await upsertGameMap( + id, + request.body, + authorized.session.account._id, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }); + + app.get("/internal/game-data/maps", requireAuth, async (request, response) => { + try { + response.json(await listGameMaps(request.query)); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } + }); + + app.get( + "/internal/game-data/maps/changes", + requireAuth, + async (request, response) => { + try { + const sinceValue = Array.isArray(request.query.sinceVersion) + ? request.query.sinceVersion[0] + : request.query.sinceVersion; + const sinceVersion = + typeof sinceValue === "string" + ? Number.parseInt(sinceValue, 10) + : 0; + response.json( + await listGameMapChangesSince( + Number.isFinite(sinceVersion) + ? Math.max(0, sinceVersion) + : 0, + ), + ); + } catch (error) { + response.status(500).json({ + error: + error instanceof Error + ? error.message + : "Unexpected error", + }); + } + }, + ); + + app.get( + "/internal/game-data/maps/version", + requireAuth, + async (_request, response) => { + try { + response.json({ + currentVersion: await getCurrentGameMapVersion(), + }); + } catch (error) { + response.status(500).json({ + error: + error instanceof Error + ? error.message + : "Unexpected error", + }); + } + }, + ); + + app.get( + "/internal/game-data/maps/:id", + requireAuth, + async (request, response) => { + try { + const rawId = Array.isArray(request.params.id) + ? request.params.id[0] + : request.params.id; + const id = Number.parseInt(rawId, 10); + if (!Number.isInteger(id) || id <= 0) { + response.status(400).json({ error: "id invalido" }); + return; + } + response.json(await getGameMapById(id)); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response + .status(message === "Game map not found" ? 404 : 500) + .json({ error: message }); + } + }, + ); + + app.put( + "/internal/game-data/maps/:id", + requireAuth, + async (request, response) => { + try { + const rawId = Array.isArray(request.params.id) + ? request.params.id[0] + : request.params.id; + const id = Number.parseInt(rawId, 10); + if (!Number.isInteger(id) || id <= 0) { + response.status(400).json({ error: "id invalido" }); + return; + } + response.json(await upsertGameMap(id, request.body)); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, + ); +} diff --git a/api/src/scripts/importGameData.ts b/api/src/scripts/importGameData.ts index 9598a0db..c28fff62 100644 --- a/api/src/scripts/importGameData.ts +++ b/api/src/scripts/importGameData.ts @@ -15,6 +15,7 @@ import { upsertGameCraftingRecipe } from "../repositories/gameCraftingRecipes"; import { upsertGameNpc } from "../repositories/gameNpcs"; import { upsertGameObject } from "../repositories/gameObjects"; import { upsertGameSmeltingRecipe } from "../repositories/gameSmeltingRecipes"; +import { importGameMapsFromSource } from "../repositories/gameMaps"; function getOptionValue(name: string): string | null { const index = process.argv.findIndex((argument) => argument === `--${name}`); @@ -99,10 +100,11 @@ async function main(): Promise { const npcsPath = resolveOptionalPath(getOptionValue("npcs-path")); const craftingPath = resolveOptionalPath(getOptionValue("crafting-path")); const smeltingPath = resolveOptionalPath(getOptionValue("smelting-path")); + const mapsPath = resolveOptionalPath(getOptionValue("maps-path")); - if (!["all", "objs", "npcs", "crafting", "smelting"].includes(mode)) { + if (!["all", "objs", "npcs", "crafting", "smelting", "maps"].includes(mode)) { throw new Error( - "Uso: pnpm import-game-data [all|objs|npcs|crafting|smelting] [--objects-path ruta] [--npcs-path ruta] [--crafting-path ruta] [--smelting-path ruta]", + "Uso: pnpm import-game-data [all|objs|npcs|crafting|smelting|maps] [--objects-path ruta] [--npcs-path ruta] [--crafting-path ruta] [--smelting-path ruta] [--maps-path ruta]", ); } @@ -133,6 +135,13 @@ async function main(): Promise { `Fundicion importada. Total: ${smelting.total}. Nuevos/actualizados: ${smelting.changed}. Sin cambios: ${smelting.unchanged}.`, ); } + + if (mode === "all" || mode === "maps") { + const maps = await importGameMapsFromSource(mapsPath ?? undefined); + console.log( + `Mapas importados. Total: ${maps.total}. Nuevos/actualizados: ${maps.changed}. Sin cambios: ${maps.unchanged}.`, + ); + } } void main() diff --git a/api/src/server.ts b/api/src/server.ts index 2b309610..8c73d3d4 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -2,6 +2,7 @@ import express from "express"; import config from "./config"; import pool from "./db"; import { requireAuth } from "./middleware/auth"; +import { registerGameMapRoutes } from "./routes/gameMapsRoutes"; import { confirmPasswordReset, consumeGameTicket, @@ -746,6 +747,12 @@ app.put("/admin/game-data/balance", async (request, response) => { } }); + +registerGameMapRoutes(app, { + requireAuth, + requireAdminEmailSession, +}); + // ═══════════════════════════════════════════════════════════════════════════ // Modo construccion: subir graficos y pintar mapas // ═══════════════════════════════════════════════════════════════════════════ diff --git a/api/src/tests/game-maps.integration.test.ts b/api/src/tests/game-maps.integration.test.ts new file mode 100644 index 00000000..bd36219f --- /dev/null +++ b/api/src/tests/game-maps.integration.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { afterAll, beforeAll, beforeEach, test } from "vitest"; + +import pool from "../db"; +import { + getGameMapById, + importGameMapsFromSource, + upsertGameMap, +} from "../repositories/gameMaps"; + +const TEST_MAP_IDS = [9901, 9902]; +let fixtureDir: string | null = null; +let dbReady = false; + +async function writeJson(filePath: string, value: unknown): Promise { + await fs.writeFile(filePath, `${JSON.stringify(value)}\n`, "utf8"); +} + +function buildMapFixture(id: number, name: string) { + return { + metadata: { + id, + name, + musicNum: 1, + magiaSinEfecto: 0, + noEncriptarMp: 0, + terreno: "BOSQUE", + zona: "CAMPO", + restringir: "No", + minLevel: 0, + maxLevel: 0, + backup: 1, + pk: 1, + }, + terrain: { + id, + width: 2, + height: 2, + palette: { + "1": { graphics: [100, 101] }, + "2": { graphics: 200, blocked: true }, + }, + rows: [ + [1, 2], + [2, 1], + ], + }, + npcs: [{ mapNum: id, x: 1, y: 2, npcIndex: 7, movement: 0 }], + specials: { + id, + exits: { "2,2": { map: 1, x: 50, y: 50 } }, + objects: { "1,1": { objIndex: 10, amount: 1 } }, + npcs: { "1,2": 7 }, + triggers: { "2,1": 5 }, + }, + }; +} + +async function createMapFixtureDir(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openao-maps-")); + for (const mapId of TEST_MAP_IDS) { + const map = buildMapFixture(mapId, `Fixture ${mapId}`); + const mapDir = path.join(root, `mapa_${mapId}`); + await fs.mkdir(mapDir, { recursive: true }); + await writeJson(path.join(mapDir, "meta.json"), map.metadata); + await writeJson(path.join(mapDir, "terrain.json"), map.terrain); + await writeJson(path.join(mapDir, "npcs.json"), map.npcs); + await writeJson(path.join(mapDir, "specials.json"), map.specials); + } + return root; +} + +async function cleanTestMaps(): Promise { + await pool.query( + "DELETE FROM game_data_revisions WHERE kind = 'maps' AND entity_id = ANY($1::int[])", + [TEST_MAP_IDS], + ); + await pool.query("DELETE FROM game_maps WHERE id = ANY($1::int[])", [ + TEST_MAP_IDS, + ]); +} + +async function countMapRevisions(mapId: number): Promise { + const result = await pool.query<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM game_data_revisions WHERE kind = 'maps' AND entity_id = $1", + [mapId], + ); + return Number(result.rows[0]?.count ?? 0); +} + +async function countPaletteRows(mapId: number): Promise { + const result = await pool.query<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM game_map_palette WHERE map_id = $1", + [mapId], + ); + return Number(result.rows[0]?.count ?? 0); +} + +beforeAll(async () => { + try { + await pool.query("SELECT 1"); + // Ensure schema fragments exist for local DBs that only apply migrations partially. + await pool.query(` + DO $$ BEGIN + ALTER TABLE game_data_revisions DROP CONSTRAINT IF EXISTS game_data_revisions_kind_check; + ALTER TABLE game_data_revisions + ADD CONSTRAINT game_data_revisions_kind_check + CHECK (kind IN ('objs', 'npcs', 'crafting_recipes', 'smelting_recipes', 'balance', 'maps')); + EXCEPTION WHEN others THEN NULL; + END $$; + `); + dbReady = true; + } catch { + dbReady = false; + } +}); + +beforeEach(async () => { + if (!dbReady) return; + if (!fixtureDir) { + fixtureDir = await createMapFixtureDir(); + } + await cleanTestMaps(); +}); + +afterAll(async () => { + if (dbReady) { + await cleanTestMaps(); + } + if (fixtureDir) { + await fs.rm(fixtureDir, { recursive: true, force: true }); + } +}); + +test("map source import is idempotent, splits palette/grid, and records one revision", async () => { + if (!dbReady) return; + assert.ok(fixtureDir); + + const firstImport = await importGameMapsFromSource(fixtureDir); + const secondImport = await importGameMapsFromSource(fixtureDir); + + assert.deepEqual(firstImport, { total: 2, changed: 2, unchanged: 0 }); + assert.deepEqual(secondImport, { total: 2, changed: 0, unchanged: 2 }); + assert.equal(await countMapRevisions(TEST_MAP_IDS[0]), 1); + assert.equal(await countPaletteRows(TEST_MAP_IDS[0]), 2); + + // Seeded but not edited => still served from file. + const seeded = await getGameMapById(TEST_MAP_IDS[0], fixtureDir); + assert.equal(seeded.source, "file"); + assert.equal(seeded.isEdited, false); + assert.equal((seeded as { seeded?: boolean }).seeded, true); +}); + +test("edited maps are served from DB and advance revisions", async () => { + if (!dbReady) return; + assert.ok(fixtureDir); + + await importGameMapsFromSource(fixtureDir); + const original = await getGameMapById(TEST_MAP_IDS[0], fixtureDir); + const updatedData = { + ...original.data, + metadata: { + ...original.data.metadata, + name: "Fixture 9901 Edited", + }, + }; + + const update = await upsertGameMap(TEST_MAP_IDS[0], updatedData); + const loaded = await getGameMapById(TEST_MAP_IDS[0], fixtureDir); + + assert.equal(update.unchanged, false); + assert.equal(loaded.source, "db"); + assert.equal(loaded.isEdited, true); + assert.equal(loaded.name, "Fixture 9901 Edited"); + assert.equal(loaded.data.metadata.name, "Fixture 9901 Edited"); + assert.ok(loaded.version > original.version); + assert.equal(await countMapRevisions(TEST_MAP_IDS[0]), 2); + assert.equal(await countPaletteRows(TEST_MAP_IDS[0]), 2); +}); + +test("unimported maps keep file fallback behavior", async () => { + if (!dbReady) return; + assert.ok(fixtureDir); + + const loaded = await getGameMapById(TEST_MAP_IDS[1], fixtureDir); + + assert.equal(loaded.source, "file"); + assert.equal(loaded.version, 0); + assert.equal(loaded.name, "Fixture 9902"); + assert.deepEqual(loaded.data.terrain.rows, [ + [1, 2], + [2, 1], + ]); + assert.equal(await countMapRevisions(TEST_MAP_IDS[1]), 0); +}); + +test("re-import does not clobber an edited map", async () => { + if (!dbReady) return; + assert.ok(fixtureDir); + + await importGameMapsFromSource(fixtureDir); + const before = await getGameMapById(TEST_MAP_IDS[0], fixtureDir); + await upsertGameMap(TEST_MAP_IDS[0], { + ...before.data, + metadata: { + ...before.data.metadata, + name: "Keep Me", + }, + }); + + const reimport = await importGameMapsFromSource(fixtureDir); + const loaded = await getGameMapById(TEST_MAP_IDS[0], fixtureDir); + + assert.equal(reimport.unchanged, 2); + assert.equal(loaded.name, "Keep Me"); + assert.equal(loaded.source, "db"); +}); diff --git a/api/src/tests/mapData.unit.test.ts b/api/src/tests/mapData.unit.test.ts new file mode 100644 index 00000000..724291a5 --- /dev/null +++ b/api/src/tests/mapData.unit.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + computeGameMapChecksum, + normalizeGameMapData, + paletteEntriesFromTerrain, + terrainFromParts, +} from "../lib/mapData"; + +test("normalizeGameMapData fills defaults and sorts npc placements", () => { + const normalized = normalizeGameMapData( + { + metadata: { name: "Arena", terreno: "BOSQUE", zona: "CAMPO", pk: 1 }, + terrain: { + width: 2, + height: 2, + palette: { + "2": { graphics: [10, 11], blocked: true }, + "1": { graphics: 9 }, + }, + rows: [ + [1, 2], + [2, 1], + ], + }, + npcs: [ + { mapNum: 7, x: 2, y: 1, npcIndex: 3 }, + { mapNum: 7, x: 1, y: 1, npcIndex: 2 }, + ], + specials: { exits: { "2,2": { map: 1, x: 50, y: 50 } } }, + }, + 7, + ); + + assert.equal(normalized.metadata.id, 7); + assert.equal(normalized.metadata.restringir, "No"); + assert.deepEqual( + normalized.npcs.map((n) => n.npcIndex), + [2, 3], + ); + assert.ok(normalized.specials.exits["2,2"]); +}); + +test("palette/grid round-trip keeps checksum stable", () => { + const original = normalizeGameMapData( + { + metadata: { id: 3, name: "Roundtrip" }, + terrain: { + id: 3, + width: 2, + height: 2, + palette: { "1": { graphics: 5 }, "2": { blocked: true } }, + rows: [ + [1, 2], + [2, 1], + ], + }, + npcs: [], + specials: {}, + }, + 3, + ); + + const rebuilt = normalizeGameMapData( + { + metadata: original.metadata, + terrain: terrainFromParts( + 3, + original.terrain.width, + original.terrain.height, + paletteEntriesFromTerrain(original.terrain.palette), + original.terrain.rows, + ), + npcs: original.npcs, + specials: original.specials, + }, + 3, + ); + + assert.equal(computeGameMapChecksum(original), computeGameMapChecksum(rebuilt)); + assert.equal(paletteEntriesFromTerrain(original.terrain.palette).length, 2); +});