diff --git a/api/schema.sql b/api/schema.sql index d0008678..1f5050f3 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -652,3 +652,75 @@ 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); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Etapa 5: Mapas de usuario, propuestas y moderacion (Issue #25) +-- ═══════════════════════════════════════════════════════════════════════════ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_map_state') THEN + CREATE TYPE user_map_state AS ENUM ( + 'draft', + 'proposed', + 'in_review', + 'published', + 'rejected', + 'archived' + ); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS user_maps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name VARCHAR(64) NOT NULL, + map_num INTEGER UNIQUE, + map_data JSONB NOT NULL DEFAULT '{}', + state user_map_state NOT NULL DEFAULT 'draft', + rejection_reason TEXT, + npc_count INTEGER NOT NULL DEFAULT 0, + obj_count INTEGER NOT NULL DEFAULT 0, + allow_combat BOOLEAN NOT NULL DEFAULT FALSE, + allow_exp BOOLEAN NOT NULL DEFAULT FALSE, + proposed_at TIMESTAMPTZ, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT user_maps_name_length CHECK (char_length(name) >= 3), + CONSTRAINT user_maps_num_range CHECK (map_num IS NULL OR (map_num >= 100000 AND map_num <= 999999)) +); + +CREATE INDEX IF NOT EXISTS idx_user_maps_owner ON user_maps(owner_account_id); +CREATE INDEX IF NOT EXISTS idx_user_maps_num ON user_maps(map_num); +CREATE INDEX IF NOT EXISTS idx_user_maps_state ON user_maps(state); +CREATE INDEX IF NOT EXISTS idx_user_maps_published ON user_maps(published_at DESC) WHERE state = 'published'; + +CREATE TABLE IF NOT EXISTS user_map_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + map_id UUID NOT NULL REFERENCES user_maps(id) ON DELETE CASCADE, + reporter_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT user_map_reports_unique UNIQUE (map_id, reporter_account_id) +); + +CREATE TABLE IF NOT EXISTS user_map_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + map_id UUID NOT NULL REFERENCES user_maps(id) ON DELETE CASCADE, + reviewer_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + action VARCHAR(16) NOT NULL CHECK (action IN ('approved', 'rejected', 'queued', 'unpublished')), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_map_quotas ( + account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, + max_maps INTEGER NOT NULL DEFAULT 5, + max_npcs_per_map INTEGER NOT NULL DEFAULT 20, + max_objs_per_map INTEGER NOT NULL DEFAULT 50, + max_storage_bytes INTEGER NOT NULL DEFAULT 5242880, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + + diff --git a/api/src/lib/mapValidation.ts b/api/src/lib/mapValidation.ts new file mode 100644 index 00000000..ac6359b0 --- /dev/null +++ b/api/src/lib/mapValidation.ts @@ -0,0 +1,397 @@ +/** + * Automated map validation checks for user-submitted maps (Issues #24 and #25) + * + * Implements: + * 1. Reserved ID range & structural isolation: User maps live in 100,000 - 999,999, + * never colliding with or modifying official world maps (1-500), static local maps (500-599), + * challenges (2,000-29,999), or dynamic instances (30,000-99,999). + * 2. World Isolation: Portals/exits in user maps cannot target official world maps. + * 3. Economy Isolation: User maps cannot place gold piles, currency (item 12), high-value + * loot items, or XP/gold-farming NPCs. + * 4. Banned words / offensive language detection in map name and texts. + * 5. Entity limits & quotas validation (NPCs, objects, dimensions, storage bytes). + * 6. Topological connectivity & reachability check (BFS from spawn point). + */ + +export const USER_MAP_START = 100_000; +export const USER_MAP_END = 999_999; +export const OFFICIAL_MAP_START = 1; +export const OFFICIAL_MAP_END = 500; + +export function isUserMapNumber(num: number): boolean { + return Number.isInteger(num) && num >= USER_MAP_START && num <= USER_MAP_END; +} + +export function isOfficialMapNumber(num: number): boolean { + return Number.isInteger(num) && num >= OFFICIAL_MAP_START && num <= OFFICIAL_MAP_END; +} + +export type MapEntityPlacement = { + x: number; + y: number; + id?: number; + entityId?: number; + name?: string; + type?: string; + gold?: number; + exp?: number; + drop?: Array<{ item: number; cant: number }>; + [key: string]: unknown; +}; + +export type MapTileExit = { + map: number; + x: number; + y: number; +}; + +export type UserMapData = { + meta?: { + name?: string; + width?: number; + height?: number; + spawnX?: number; + spawnY?: number; + description?: string; + allowCombat?: boolean; + allowExp?: boolean; + [key: string]: unknown; + }; + terrain?: Array<{ + x: number; + y: number; + blocked?: boolean; + layer?: number; + grhIndex?: number | null; + tileExit?: MapTileExit; + [key: string]: unknown; + }>; + npcs?: MapEntityPlacement[]; + specials?: MapEntityPlacement[]; + signs?: Array<{ x: number; y: number; text: string }>; + exits?: MapTileExit[]; + [key: string]: unknown; +}; + +export type UserMapQuotaLimits = { + maxNpcsPerMap: number; + maxObjsPerMap: number; + maxStorageBytes?: number; + maxWidth?: number; + maxHeight?: number; +}; + +// Profanity / banned words filter (Spanish & English baseline offensive terms) +const BANNED_PATTERNS: RegExp[] = [ + /\b(nazi|hitler|fascist|holocaust|genocide)\b/i, + /\b(puto|puta|maricon|mierda|concha|culiao|pendejo|chupala|pelotudo|hijodeputa)\b/i, + /\b(fuck|shit|bitch|cunt|nigger|nigga|faggot|whore|slut)\b/i, +]; + +// Argentum Online Economy Item IDs that are strictly prohibited in user maps +// Item 12 = Monedas de Oro (Gold coins) +// Items 54, 65, 69, 73, 339, 432, etc. = Official house keys +// Items 474 = Barca (Boats/Ships) +const PROHIBITED_ITEM_IDS = new Set([ + 12, // Oro / Gold currency + 54, 65, 69, 73, 339, 432, 436, 440, // Llaves oficiales de casas + 474, // Barca +]); + +export function checkBannedWords(text: string): { ok: boolean; matched: string[] } { + if (!text || typeof text !== 'string') return { ok: true, matched: [] }; + const matched: string[] = []; + for (const pattern of BANNED_PATTERNS) { + const m = text.match(pattern); + if (m) { + matched.push(m[0]); + } + } + return { ok: matched.length === 0, matched }; +} + +export function validateTextContent(mapName: string, mapData: UserMapData): { ok: boolean; errors: string[] } { + const errors: string[] = []; + + const nameCheck = checkBannedWords(mapName); + if (!nameCheck.ok) { + errors.push(`El nombre del mapa contiene términos prohibidos: ${nameCheck.matched.join(', ')}`); + } + + if (mapData.meta?.description) { + const descCheck = checkBannedWords(mapData.meta.description); + if (!descCheck.ok) { + errors.push(`La descripción del mapa contiene términos prohibidos: ${descCheck.matched.join(', ')}`); + } + } + + if (Array.isArray(mapData.npcs)) { + for (const npc of mapData.npcs) { + if (npc.name) { + const npcCheck = checkBannedWords(npc.name); + if (!npcCheck.ok) { + errors.push(`El NPC en (${npc.x}, ${npc.y}) tiene un nombre no permitido.`); + } + } + } + } + + if (Array.isArray(mapData.signs)) { + for (const sign of mapData.signs) { + if (sign.text) { + const signCheck = checkBannedWords(sign.text); + if (!signCheck.ok) { + errors.push(`El cartel en (${sign.x}, ${sign.y}) contiene texto prohibido.`); + } + } + } + } + + return { ok: errors.length === 0, errors }; +} + +/** + * Validates economy isolation (Issue #24). + * User maps cannot be an infinite gold or high-tier loot generation exploit. + */ +export function validateEconomyIsolation(mapData: UserMapData): { ok: boolean; errors: string[] } { + const errors: string[] = []; + + // 1. Check special objects placed + if (Array.isArray(mapData.specials)) { + for (const obj of mapData.specials) { + const entityId = obj.entityId ?? obj.id; + if (entityId != null && PROHIBITED_ITEM_IDS.has(entityId)) { + errors.push(`El objeto con ID ${entityId} en (${obj.x}, ${obj.y}) está prohibido en mapas de usuario (aislamiento de economía).`); + } + if (obj.gold && obj.gold > 0) { + errors.push(`No se permite colocar pilas de oro directas en mapas de usuario (encontrado en (${obj.x}, ${obj.y})).`); + } + } + } + + // 2. Check NPCs placed (cannot grant unauthorized gold or high-value drops) + if (Array.isArray(mapData.npcs)) { + for (const npc of mapData.npcs) { + if (npc.gold && npc.gold > 0) { + errors.push(`El NPC en (${npc.x}, ${npc.y}) no puede otorgar oro directo.`); + } + if (Array.isArray(npc.drop)) { + for (const d of npc.drop) { + if (d.item === 12 || PROHIBITED_ITEM_IDS.has(d.item)) { + errors.push(`El NPC en (${npc.x}, ${npc.y}) contiene drops de economía oficial prohibidos (ítem ${d.item}).`); + } + } + } + } + } + + return { ok: errors.length === 0, errors }; +} + +/** + * Validates world isolation (Issue #24). + * User map portals/exits cannot target official world maps (1-500). + */ +export function validateWorldIsolation(mapData: UserMapData): { ok: boolean; errors: string[] } { + const errors: string[] = []; + + const checkExit = (exit: MapTileExit, location: string) => { + if (exit && exit.map != null) { + // Cannot link into official world maps (1-500) + if (isOfficialMapNumber(exit.map) || (exit.map >= 1 && exit.map < USER_MAP_START)) { + errors.push(`La salida en ${location} apunta al mapa oficial ${exit.map}. Los mapas de usuario no pueden abrir portales al mundo oficial (aislamiento de mundo).`); + } + } + }; + + if (Array.isArray(mapData.exits)) { + for (let i = 0; i < mapData.exits.length; i++) { + checkExit(mapData.exits[i], `salida general #${i + 1}`); + } + } + + if (Array.isArray(mapData.terrain)) { + for (const t of mapData.terrain) { + if (t.tileExit) { + checkExit(t.tileExit, `(${t.x}, ${t.y})`); + } + } + } + + return { ok: errors.length === 0, errors }; +} + +export function validateQuotasAndLimits( + mapData: UserMapData, + quotas: UserMapQuotaLimits, +): { ok: boolean; errors: string[] } { + const errors: string[] = []; + const npcCount = Array.isArray(mapData.npcs) ? mapData.npcs.length : 0; + const objCount = Array.isArray(mapData.specials) ? mapData.specials.length : 0; + + if (npcCount > quotas.maxNpcsPerMap) { + errors.push(`Supera la cuota máxima de NPCs (${npcCount}/${quotas.maxNpcsPerMap}).`); + } + + if (objCount > quotas.maxObjsPerMap) { + errors.push(`Supera la cuota máxima de objetos especiales (${objCount}/${quotas.maxObjsPerMap}).`); + } + + const width = mapData.meta?.width ?? 100; + const height = mapData.meta?.height ?? 100; + const maxWidth = quotas.maxWidth ?? 100; + const maxHeight = quotas.maxHeight ?? 100; + + if (width < 10 || width > maxWidth || height < 10 || height > maxHeight) { + errors.push(`Dimensiones de mapa inválidas (${width}x${height}). Permitido: 10x10 a ${maxWidth}x${maxHeight}.`); + } + + if (quotas.maxStorageBytes) { + const byteSize = Buffer.byteLength(JSON.stringify(mapData), 'utf8'); + if (byteSize > quotas.maxStorageBytes) { + errors.push(`El tamaño del mapa (${byteSize} bytes) supera la cuota máxima de almacenamiento (${quotas.maxStorageBytes} bytes).`); + } + } + + return { ok: errors.length === 0, errors }; +} + +export function validateReachability(mapData: UserMapData): { + ok: boolean; + errors: string[]; + warnings: string[]; + reachableTilesCount: number; +} { + const errors: string[] = []; + const warnings: string[] = []; + + const width = mapData.meta?.width ?? 100; + const height = mapData.meta?.height ?? 100; + const spawnX = Math.round(mapData.meta?.spawnX ?? Math.floor(width / 2)); + const spawnY = Math.round(mapData.meta?.spawnY ?? Math.floor(height / 2)); + + if (spawnX < 1 || spawnX > width || spawnY < 1 || spawnY > height) { + errors.push(`El punto de aparición (spawn) (${spawnX}, ${spawnY}) está fuera de los límites del mapa.`); + return { ok: false, errors, warnings, reachableTilesCount: 0 }; + } + + const blockedTiles = new Set(); + if (Array.isArray(mapData.terrain)) { + for (const t of mapData.terrain) { + if (t.blocked && t.x >= 1 && t.x <= width && t.y >= 1 && t.y <= height) { + blockedTiles.add(`${t.x},${t.y}`); + } + } + } + + const spawnKey = `${spawnX},${spawnY}`; + if (blockedTiles.has(spawnKey)) { + errors.push(`El punto de entrada o aparición (${spawnX}, ${spawnY}) está bloqueado.`); + return { ok: false, errors, warnings, reachableTilesCount: 0 }; + } + + const visited = new Set(); + const queue: Array<[number, number]> = [[spawnX, spawnY]]; + visited.add(spawnKey); + + const neighbors = [ + [0, 1], + [0, -1], + [1, 0], + [-1, 0], + ]; + + while (queue.length > 0) { + const [cx, cy] = queue.shift()!; + for (const [dx, dy] of neighbors) { + const nx = cx + dx; + const ny = cy + dy; + if (nx >= 1 && nx <= width && ny >= 1 && ny <= height) { + const key = `${nx},${ny}`; + if (!visited.has(key) && !blockedTiles.has(key)) { + visited.add(key); + queue.push([nx, ny]); + } + } + } + } + + if (visited.size < 5) { + errors.push(`El mapa no tiene un área transitable suficiente desde el punto de entrada (solo ${visited.size} tiles alcanzables).`); + } + + if (Array.isArray(mapData.npcs)) { + for (const npc of mapData.npcs) { + const key = `${npc.x},${npc.y}`; + if (blockedTiles.has(key)) { + warnings.push(`El NPC en (${npc.x}, ${npc.y}) está ubicado sobre un tile bloqueado.`); + } else if (!visited.has(key)) { + warnings.push(`El NPC en (${npc.x}, ${npc.y}) no es alcanzable desde el punto de aparición.`); + } + } + } + + if (Array.isArray(mapData.specials)) { + for (const obj of mapData.specials) { + const key = `${obj.x},${obj.y}`; + if (!visited.has(key) && !blockedTiles.has(key)) { + warnings.push(`El objeto en (${obj.x}, ${obj.y}) se encuentra en una región inaccesible.`); + } + } + } + + return { + ok: errors.length === 0, + errors, + warnings, + reachableTilesCount: visited.size, + }; +} + +export type AutomatedCheckResult = { + passed: boolean; + errors: string[]; + warnings: string[]; + checks: { + textFilter: boolean; + quotas: boolean; + reachability: boolean; + economyIsolation: boolean; + worldIsolation: boolean; + }; +}; + +export function runAutomatedMapChecks( + mapName: string, + mapData: UserMapData, + quotas: UserMapQuotaLimits, +): AutomatedCheckResult { + const textRes = validateTextContent(mapName, mapData); + const quotaRes = validateQuotasAndLimits(mapData, quotas); + const reachRes = validateReachability(mapData); + const economyRes = validateEconomyIsolation(mapData); + const worldRes = validateWorldIsolation(mapData); + + const allErrors = [ + ...textRes.errors, + ...quotaRes.errors, + ...reachRes.errors, + ...economyRes.errors, + ...worldRes.errors, + ]; + const allWarnings = [...reachRes.warnings]; + + return { + passed: allErrors.length === 0, + errors: allErrors, + warnings: allWarnings, + checks: { + textFilter: textRes.ok, + quotas: quotaRes.ok, + reachability: reachRes.ok, + economyIsolation: economyRes.ok, + worldIsolation: worldRes.ok, + }, + }; +} diff --git a/api/src/repositories/userMaps.ts b/api/src/repositories/userMaps.ts new file mode 100644 index 00000000..34e7b140 --- /dev/null +++ b/api/src/repositories/userMaps.ts @@ -0,0 +1,770 @@ +import pool from '../db'; +import { + runAutomatedMapChecks, + UserMapData, + UserMapQuotaLimits, + AutomatedCheckResult, + USER_MAP_START, + USER_MAP_END, + isUserMapNumber, + isOfficialMapNumber, + validateEconomyIsolation, + validateWorldIsolation, +} from '../lib/mapValidation'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type UserMapState = + | 'draft' + | 'proposed' + | 'in_review' + | 'published' + | 'rejected' + | 'archived'; + +export type UserMapRecord = { + id: string; + owner_account_id: string; + name: string; + map_num: number; + map_data: UserMapData; + state: UserMapState; + rejection_reason: string | null; + npc_count: number; + obj_count: number; + allow_combat: boolean; + allow_exp: boolean; + proposed_at: Date | null; + published_at: Date | null; + created_at: Date; + updated_at: Date; +}; + +export type UserMapResponse = { + id: string; + ownerId: string; + name: string; + mapNum: number; + state: UserMapState; + rejectionReason: string | null; + npcCount: number; + objCount: number; + allowCombat: boolean; + allowExp: boolean; + proposedAt: Date | null; + publishedAt: Date | null; + createdAt: Date; + updatedAt: Date; + /** Preview data included for owner or moderators */ + mapData?: UserMapData; + reportsCount?: number; +}; + +export type UserMapQuota = { + maxMaps: number; + maxNpcsPerMap: number; + maxObjsPerMap: number; + maxStorageBytes: number; +}; + +export type UserMapReport = { + id: string; + mapId: string; + reporterAccountId: string; + reason: string; + createdAt: Date; +}; + +export type UserMapReview = { + id: string; + mapId: string; + reviewerAccountId: string; + action: 'approved' | 'rejected' | 'queued' | 'unpublished'; + notes: string | null; + createdAt: Date; +}; + +// Default quotas applied to accounts unless overridden in user_map_quotas +export const DEFAULT_QUOTA: UserMapQuota = { + maxMaps: 5, + maxNpcsPerMap: 20, + maxObjsPerMap: 50, + maxStorageBytes: 5 * 1024 * 1024, // 5 MB +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function toResponse( + row: UserMapRecord & { reports_count?: string | number }, + includeMapData = false, +): UserMapResponse { + return { + id: row.id, + ownerId: row.owner_account_id, + name: row.name, + mapNum: row.map_num, + state: row.state, + rejectionReason: row.rejection_reason, + npcCount: row.npc_count, + objCount: row.obj_count, + allowCombat: row.allow_combat ?? false, + allowExp: row.allow_exp ?? false, + proposedAt: row.proposed_at, + publishedAt: row.published_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + ...(includeMapData ? { mapData: row.map_data } : {}), + ...(row.reports_count !== undefined + ? { reportsCount: Number(row.reports_count) } + : {}), + }; +} + +function countEntities(mapData: UserMapData): { npcCount: number; objCount: number } { + const npcs = Array.isArray(mapData?.npcs) ? mapData.npcs.length : 0; + const objs = Array.isArray(mapData?.specials) ? mapData.specials.length : 0; + return { npcCount: npcs, objCount: objs }; +} + +// ── Range & Map Number Allocation ──────────────────────────────────────────── + +export async function allocateUserMapNumber(): Promise { + const res = await pool.query<{ next_num: string | number }>( + `SELECT COALESCE(MAX(map_num), $1 - 1) + 1 AS next_num + FROM user_maps + WHERE map_num >= $1 AND map_num <= $2`, + [USER_MAP_START, USER_MAP_END], + ); + const next = Number(res.rows[0]?.next_num ?? USER_MAP_START); + if (next > USER_MAP_END) { + throw new Error('Se ha agotado el rango de IDs de mapas de usuario disponibles.'); + } + return next; +} + +// ── Quota Management ───────────────────────────────────────────────────────── + +export async function getQuota(accountId: string): Promise { + const res = await pool.query<{ + max_maps: number; + max_npcs_per_map: number; + max_objs_per_map: number; + max_storage_bytes?: number; + }>( + `SELECT max_maps, max_npcs_per_map, max_objs_per_map, max_storage_bytes + FROM user_map_quotas + WHERE account_id = $1`, + [accountId], + ); + if (res.rowCount === 0) return DEFAULT_QUOTA; + const row = res.rows[0]; + return { + maxMaps: row.max_maps, + maxNpcsPerMap: row.max_npcs_per_map, + maxObjsPerMap: row.max_objs_per_map, + maxStorageBytes: row.max_storage_bytes ?? DEFAULT_QUOTA.maxStorageBytes, + }; +} + +export async function setQuota( + accountId: string, + quota: Partial, +): Promise { + const current = await getQuota(accountId); + const updated: UserMapQuota = { + maxMaps: quota.maxMaps ?? current.maxMaps, + maxNpcsPerMap: quota.maxNpcsPerMap ?? current.maxNpcsPerMap, + maxObjsPerMap: quota.maxObjsPerMap ?? current.maxObjsPerMap, + maxStorageBytes: quota.maxStorageBytes ?? current.maxStorageBytes, + }; + + await pool.query( + `INSERT INTO user_map_quotas (account_id, max_maps, max_npcs_per_map, max_objs_per_map, max_storage_bytes, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (account_id) DO UPDATE + SET max_maps = EXCLUDED.max_maps, + max_npcs_per_map = EXCLUDED.max_npcs_per_map, + max_objs_per_map = EXCLUDED.max_objs_per_map, + max_storage_bytes = EXCLUDED.max_storage_bytes, + updated_at = NOW()`, + [accountId, updated.maxMaps, updated.maxNpcsPerMap, updated.maxObjsPerMap, updated.maxStorageBytes], + ); + + return updated; +} + +export async function countOwnedMaps(accountId: string): Promise { + const res = await pool.query<{ count: string }>( + `SELECT COUNT(*) FROM user_maps WHERE owner_account_id = $1 AND state != 'archived'`, + [accountId], + ); + return parseInt(res.rows[0]?.count ?? '0', 10); +} + +// ── Map CRUD & State Transitions ───────────────────────────────────────────── + +export async function createMap( + ownerAccountId: string, + name: string, + mapData: UserMapData, + requestedMapNum?: number, +): Promise { + const trimmedName = name.trim(); + if (trimmedName.length < 3) { + return { error: 'El nombre del mapa debe tener al menos 3 caracteres.' }; + } + + // 1. Quota checks + const quota = await getQuota(ownerAccountId); + const owned = await countOwnedMaps(ownerAccountId); + if (owned >= quota.maxMaps) { + return { error: `Alcanzaste el límite de cuota: máximo ${quota.maxMaps} mapas activos.` }; + } + + const { npcCount, objCount } = countEntities(mapData); + if (npcCount > quota.maxNpcsPerMap) { + return { error: `Cantidad de NPCs (${npcCount}) supera la cuota permitida (${quota.maxNpcsPerMap}).` }; + } + if (objCount > quota.maxObjsPerMap) { + return { error: `Cantidad de objetos (${objCount}) supera la cuota permitida (${quota.maxObjsPerMap}).` }; + } + + const byteSize = Buffer.byteLength(JSON.stringify(mapData), 'utf8'); + if (byteSize > quota.maxStorageBytes) { + return { error: `El peso del mapa (${byteSize} bytes) supera la cuota de almacenamiento permitida (${quota.maxStorageBytes} bytes).` }; + } + + // 2. Economy & World isolation checks + const economyCheck = validateEconomyIsolation(mapData); + if (!economyCheck.ok) { + return { error: `Aislamiento de economía: ${economyCheck.errors.join(' | ')}` }; + } + + const worldCheck = validateWorldIsolation(mapData); + if (!worldCheck.ok) { + return { error: `Aislamiento de mundo: ${worldCheck.errors.join(' | ')}` }; + } + + // 3. Allocate map_num in reserved range (100,000 - 999,999) + let mapNum: number; + if (requestedMapNum !== undefined) { + if (!isUserMapNumber(requestedMapNum)) { + return { + error: `Número de mapa ${requestedMapNum} inválido. Los mapas de usuario deben estar en el rango reservado [${USER_MAP_START} - ${USER_MAP_END}]. No se permite solapar mapas del mundo oficial.`, + }; + } + // Verify not taken + const exists = await pool.query(`SELECT 1 FROM user_maps WHERE map_num = $1`, [requestedMapNum]); + if (exists.rowCount && exists.rowCount > 0) { + return { error: `El número de mapa ${requestedMapNum} ya está en uso.` }; + } + mapNum = requestedMapNum; + } else { + mapNum = await allocateUserMapNumber(); + } + + const res = await pool.query( + `INSERT INTO user_maps (owner_account_id, name, map_num, map_data, npc_count, obj_count, allow_combat, allow_exp, state, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE, 'draft', NOW(), NOW()) + RETURNING *`, + [ownerAccountId, trimmedName, mapNum, JSON.stringify(mapData), npcCount, objCount], + ); + return toResponse(res.rows[0], true); +} + +export async function getMapById( + mapId: string, + requestingAccountId?: string, + isModerator = false, +): Promise { + const res = await pool.query( + `SELECT m.*, + (SELECT COUNT(*) FROM user_map_reports r WHERE r.map_id = m.id) AS reports_count + FROM user_maps m + WHERE m.id = $1`, + [mapId], + ); + if (res.rowCount === 0) return null; + + const map = res.rows[0]; + const isOwner = map.owner_account_id === requestingAccountId; + const canSeeData = isOwner || isModerator; + + // Non-owners and non-moderators can only see published maps + if (!isOwner && !isModerator && map.state !== 'published') { + return null; + } + + return toResponse(map, canSeeData); +} + +export async function getMapByNumber( + mapNum: number, + requestingAccountId?: string, + isModerator = false, +): Promise { + if (!isUserMapNumber(mapNum)) { + return null; + } + + const res = await pool.query( + `SELECT m.*, + (SELECT COUNT(*) FROM user_map_reports r WHERE r.map_id = m.id) AS reports_count + FROM user_maps m + WHERE m.map_num = $1`, + [mapNum], + ); + if (res.rowCount === 0) return null; + + const map = res.rows[0]; + const isOwner = map.owner_account_id === requestingAccountId; + const canSeeData = isOwner || isModerator; + + if (!isOwner && !isModerator && map.state !== 'published') { + return null; + } + + return toResponse(map, canSeeData); +} + +export async function listPublishedMaps( + limit = 20, + offset = 0, +): Promise { + const res = await pool.query( + `SELECT * FROM user_maps + WHERE state = 'published' + ORDER BY published_at DESC + LIMIT $1 OFFSET $2`, + [limit, offset], + ); + return res.rows.map((r) => toResponse(r, false)); +} + +export async function listOwnMaps( + ownerAccountId: string, +): Promise { + const res = await pool.query( + `SELECT * FROM user_maps + WHERE owner_account_id = $1 AND state != 'archived' + ORDER BY updated_at DESC`, + [ownerAccountId], + ); + return res.rows.map((r) => toResponse(r, true)); +} + +export async function updateMapDraft( + mapId: string, + requestingAccountId: string, + updates: { name?: string; mapData?: UserMapData }, +): Promise { + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) return null; + + const current = existing.rows[0]; + + // Ownership check (Issue #24: Solo el dueño puede editar su mapa) + if (current.owner_account_id !== requestingAccountId) { + return { error: 'No autorizado: sólo el dueño puede modificar este mapa.' }; + } + + if (current.state !== 'draft' && current.state !== 'rejected') { + return { error: 'Solo se pueden editar mapas en estado borrador o rechazado.' }; + } + + const newName = updates.name !== undefined ? updates.name.trim() : current.name; + if (newName.length < 3) { + return { error: 'El nombre del mapa debe tener al menos 3 caracteres.' }; + } + + const newMapData = updates.mapData ?? current.map_data; + + // Economy & World isolation validation + const economyCheck = validateEconomyIsolation(newMapData); + if (!economyCheck.ok) { + return { error: `Aislamiento de economía: ${economyCheck.errors.join(' | ')}` }; + } + + const worldCheck = validateWorldIsolation(newMapData); + if (!worldCheck.ok) { + return { error: `Aislamiento de mundo: ${worldCheck.errors.join(' | ')}` }; + } + + const { npcCount, objCount } = countEntities(newMapData); + const quota = await getQuota(requestingAccountId); + if (npcCount > quota.maxNpcsPerMap) { + return { error: `Cantidad de NPCs (${npcCount}) supera la cuota permitida (${quota.maxNpcsPerMap}).` }; + } + if (objCount > quota.maxObjsPerMap) { + return { error: `Cantidad de objetos (${objCount}) supera la cuota permitida (${quota.maxObjsPerMap}).` }; + } + + const byteSize = Buffer.byteLength(JSON.stringify(newMapData), 'utf8'); + if (byteSize > quota.maxStorageBytes) { + return { error: `El peso del mapa (${byteSize} bytes) supera la cuota de almacenamiento (${quota.maxStorageBytes} bytes).` }; + } + + const res = await pool.query( + `UPDATE user_maps + SET name = $1, + map_data = $2, + npc_count = $3, + obj_count = $4, + updated_at = NOW() + WHERE id = $5 + RETURNING *`, + [newName, JSON.stringify(newMapData), npcCount, objCount, mapId], + ); + return toResponse(res.rows[0], true); +} + +export async function deleteMap( + mapId: string, + requestingAccountId: string, +): Promise<{ ok: boolean; error?: string }> { + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) return { ok: false, error: 'Mapa no encontrado.' }; + + const current = existing.rows[0]; + if (current.owner_account_id !== requestingAccountId) { + return { ok: false, error: 'No autorizado: sólo el dueño puede eliminar este mapa.' }; + } + + await pool.query( + `UPDATE user_maps SET state = 'archived', updated_at = NOW() WHERE id = $1`, + [mapId], + ); + return { ok: true }; +} + +/** + * Propose a map for moderation: + * Runs automated pre-filtering checks before the map reaches human moderators. + */ +export async function proposeMap( + mapId: string, + requestingAccountId: string, +): Promise<{ + ok: boolean; + map?: UserMapResponse; + automatedChecks?: AutomatedCheckResult; + error?: string; +}> { + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) { + return { ok: false, error: 'Mapa no encontrado.' }; + } + + const map = existing.rows[0]; + if (map.owner_account_id !== requestingAccountId) { + return { ok: false, error: 'No autorizado: sólo el dueño puede proponer este mapa.' }; + } + + if (map.state !== 'draft' && map.state !== 'rejected') { + return { ok: false, error: `El mapa no se puede proponer desde el estado '${map.state}'.` }; + } + + // 1. Run automated pre-moderation checks + const quota = await getQuota(requestingAccountId); + const checks = runAutomatedMapChecks(map.name, map.map_data, quota); + + if (!checks.passed) { + return { + ok: false, + error: `El mapa no superó los chequeos automáticos: ${checks.errors.join(' | ')}`, + automatedChecks: checks, + }; + } + + // 2. Transition state to 'proposed' + const res = await pool.query( + `UPDATE user_maps + SET state = 'proposed', + rejection_reason = NULL, + proposed_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [mapId], + ); + + return { + ok: true, + map: toResponse(res.rows[0], true), + automatedChecks: checks, + }; +} + +// ── Moderation Queue & Actions ─────────────────────────────────────────────── + +export async function getModerationQueue( + limit = 20, + offset = 0, + stateFilter?: 'proposed' | 'in_review', +): Promise { + const states = stateFilter ? [stateFilter] : ['proposed', 'in_review']; + const res = await pool.query( + `SELECT m.*, + COALESCE(r.report_count, 0) AS reports_count + FROM user_maps m + LEFT JOIN ( + SELECT map_id, COUNT(*) AS report_count + FROM user_map_reports + GROUP BY map_id + ) r ON r.map_id = m.id + WHERE m.state = ANY($1) + ORDER BY reports_count DESC, m.proposed_at ASC NULLS LAST, m.updated_at ASC + LIMIT $2 OFFSET $3`, + [states, limit, offset], + ); + + return res.rows.map((row) => toResponse(row, true)); +} + +export async function claimForReview( + mapId: string, + reviewerAccountId: string, + notes?: string, +): Promise<{ ok: boolean; map?: UserMapResponse; error?: string }> { + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) return { ok: false, error: 'Mapa no encontrado.' }; + + const map = existing.rows[0]; + if (map.state !== 'proposed' && map.state !== 'in_review') { + return { ok: false, error: `No se puede revisar un mapa en estado '${map.state}'.` }; + } + + const updated = await pool.query( + `UPDATE user_maps + SET state = 'in_review', + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [mapId], + ); + + await pool.query( + `INSERT INTO user_map_reviews (map_id, reviewer_account_id, action, notes, created_at) + VALUES ($1, $2, 'queued', $3, NOW())`, + [mapId, reviewerAccountId, notes ?? null], + ); + + return { ok: true, map: toResponse(updated.rows[0], true) }; +} + +export async function approveMap( + mapId: string, + reviewerAccountId: string, + notes?: string, +): Promise<{ ok: boolean; map?: UserMapResponse; error?: string }> { + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) return { ok: false, error: 'Mapa no encontrado.' }; + + const map = existing.rows[0]; + if (map.state !== 'proposed' && map.state !== 'in_review') { + return { ok: false, error: `No se puede aprobar un mapa en estado '${map.state}'.` }; + } + + const updated = await pool.query( + `UPDATE user_maps + SET state = 'published', + published_at = NOW(), + rejection_reason = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [mapId], + ); + + await pool.query( + `INSERT INTO user_map_reviews (map_id, reviewer_account_id, action, notes, created_at) + VALUES ($1, $2, 'approved', $3, NOW())`, + [mapId, reviewerAccountId, notes ?? null], + ); + + return { ok: true, map: toResponse(updated.rows[0], true) }; +} + +export async function rejectMap( + mapId: string, + reviewerAccountId: string, + reason: string, + notes?: string, +): Promise<{ ok: boolean; map?: UserMapResponse; error?: string }> { + const trimmedReason = reason?.trim(); + if (!trimmedReason) { + return { ok: false, error: 'El motivo de rechazo es obligatorio para que el autor pueda corregirlo.' }; + } + + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) return { ok: false, error: 'Mapa no encontrado.' }; + + const map = existing.rows[0]; + if (map.state !== 'proposed' && map.state !== 'in_review') { + return { ok: false, error: `No se puede rechazar un mapa en estado '${map.state}'.` }; + } + + const updated = await pool.query( + `UPDATE user_maps + SET state = 'rejected', + rejection_reason = $1, + updated_at = NOW() + WHERE id = $2 + RETURNING *`, + [trimmedReason, mapId], + ); + + await pool.query( + `INSERT INTO user_map_reviews (map_id, reviewer_account_id, action, notes, created_at) + VALUES ($1, $2, 'rejected', $3, NOW())`, + [mapId, reviewerAccountId, notes ? `${trimmedReason} | ${notes}` : trimmedReason], + ); + + return { ok: true, map: toResponse(updated.rows[0], true) }; +} + +export async function reportMap( + mapId: string, + reporterAccountId: string, + reason: string, +): Promise<{ ok: boolean; error?: string }> { + const trimmedReason = reason?.trim(); + if (!trimmedReason) { + return { ok: false, error: 'El motivo del reporte es obligatorio.' }; + } + + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) return { ok: false, error: 'Mapa no encontrado.' }; + + const map = existing.rows[0]; + if (map.state !== 'published') { + return { ok: false, error: 'Solo se pueden reportar mapas actualmente publicados.' }; + } + + await pool.query( + `INSERT INTO user_map_reports (map_id, reporter_account_id, reason, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (map_id, reporter_account_id) DO UPDATE + SET reason = EXCLUDED.reason, created_at = NOW()`, + [mapId, reporterAccountId, trimmedReason], + ); + + await pool.query( + `UPDATE user_maps + SET state = 'in_review', + updated_at = NOW() + WHERE id = $1`, + [mapId], + ); + + return { ok: true }; +} + +export async function unpublishMap( + mapId: string, + moderatorAccountId: string, + reason: string, +): Promise<{ ok: boolean; map?: UserMapResponse; error?: string }> { + const trimmedReason = reason?.trim(); + if (!trimmedReason) { + return { ok: false, error: 'El motivo de despublicación es obligatorio.' }; + } + + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1`, + [mapId], + ); + if (existing.rowCount === 0) return { ok: false, error: 'Mapa no encontrado.' }; + + const map = existing.rows[0]; + if (map.state !== 'published' && map.state !== 'in_review') { + return { ok: false, error: `No se puede despublicar un mapa en estado '${map.state}'.` }; + } + + const updated = await pool.query( + `UPDATE user_maps + SET state = 'rejected', + rejection_reason = $1, + updated_at = NOW() + WHERE id = $2 + RETURNING *`, + [trimmedReason, mapId], + ); + + await pool.query( + `INSERT INTO user_map_reviews (map_id, reviewer_account_id, action, notes, created_at) + VALUES ($1, $2, 'unpublished', $3, NOW())`, + [mapId, moderatorAccountId, trimmedReason], + ); + + return { ok: true, map: toResponse(updated.rows[0], true) }; +} + +export async function getMapReports(mapId: string): Promise { + const res = await pool.query<{ + id: string; + map_id: string; + reporter_account_id: string; + reason: string; + created_at: Date; + }>( + `SELECT id, map_id, reporter_account_id, reason, created_at + FROM user_map_reports + WHERE map_id = $1 + ORDER BY created_at DESC`, + [mapId], + ); + return res.rows.map((r) => ({ + id: r.id, + mapId: r.map_id, + reporterAccountId: r.reporter_account_id, + reason: r.reason, + createdAt: r.created_at, + })); +} + +export async function getMapReviews(mapId: string): Promise { + const res = await pool.query<{ + id: string; + map_id: string; + reviewer_account_id: string; + action: string; + notes: string | null; + created_at: Date; + }>( + `SELECT id, map_id, reviewer_account_id, action, notes, created_at + FROM user_map_reviews + WHERE map_id = $1 + ORDER BY created_at DESC`, + [mapId], + ); + return res.rows.map((r) => ({ + id: r.id, + mapId: r.map_id, + reviewerAccountId: r.reviewer_account_id, + action: r.action as UserMapReview['action'], + notes: r.notes, + createdAt: r.created_at, + })); +} diff --git a/api/src/server.ts b/api/src/server.ts index 2b309610..83fdcce5 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -113,6 +113,24 @@ import { tileEntitySchema, uploadGraphic, } from "./repositories/worldBuilder"; +import { + approveMap, + claimForReview, + createMap, + deleteMap, + getMapById, + getMapByNumber, + getMapReports, + getMapReviews, + getModerationQueue, + listOwnMaps, + listPublishedMaps, + proposeMap, + rejectMap, + reportMap, + unpublishMap, + updateMapDraft, +} from "./repositories/userMaps"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -1213,6 +1231,430 @@ app.delete( }, ); +// ═══════════════════════════════════════════════════════════════════════════ +// Etapa 5: Mapas de usuario, propuestas y moderacion (Issue #25) +// ═══════════════════════════════════════════════════════════════════════════ + +/** Crear nuevo mapa de usuario como borrador */ +app.post("/maps/user", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + const { name, mapData } = request.body; + if (!name || typeof name !== "string") { + response.status(400).json({ error: "Nombre de mapa requerido." }); + return; + } + + const result = await createMap( + authorized.session.account._id, + name, + mapData ?? {}, + ); + + if ("error" in result) { + response.status(400).json({ error: result.error }); + return; + } + + response.status(201).json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Listar mapas del usuario autenticado */ +app.get("/maps/user/mine", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + const maps = await listOwnMaps(authorized.session.account._id); + response.json({ maps }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Listar mapas publicados de la comunidad */ +app.get("/maps/user/published", async (request, response) => { + try { + const limit = Number.parseInt(String(request.query.limit ?? "20"), 10); + const offset = Number.parseInt(String(request.query.offset ?? "0"), 10); + const maps = await listPublishedMaps(limit, offset); + response.json({ maps }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Obtener un mapa por ID */ +app.get("/maps/user/:id", async (request, response) => { + try { + const mapId = request.params.id; + const authorized = await getAuthorizedSession(request); + const isMod = Boolean( + authorized && isAuthorizedGameDataAdmin(authorized.session), + ); + + const map = await getMapById( + mapId, + authorized?.session.account._id, + isMod, + ); + + if (!map) { + response.status(404).json({ error: "Mapa no encontrado." }); + return; + } + + response.json(map); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Obtener un mapa por número en el rango reservado (100,000 - 999,999) */ +app.get("/maps/user/by-number/:mapNum", async (request, response) => { + try { + const mapNum = Number.parseInt(request.params.mapNum, 10); + if (!Number.isInteger(mapNum)) { + response.status(400).json({ error: "Número de mapa inválido." }); + return; + } + + const authorized = await getAuthorizedSession(request); + const isMod = Boolean( + authorized && isAuthorizedGameDataAdmin(authorized.session), + ); + + const map = await getMapByNumber( + mapNum, + authorized?.session.account._id, + isMod, + ); + + if (!map) { + response.status(404).json({ error: "Mapa no encontrado o no disponible." }); + return; + } + + response.json(map); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Eliminar/archivar mapa (sólo dueño) */ +app.delete("/maps/user/:id", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + const mapId = request.params.id; + const result = await deleteMap(mapId, authorized.session.account._id); + if (!result.ok) { + const status = result.error?.includes("No autorizado") ? 403 : 400; + response.status(status).json({ error: result.error }); + return; + } + + response.json({ ok: true, message: "Mapa archivado correctamente." }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Editar un mapa en borrador o rechazado */ +app.put("/maps/user/:id", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + const mapId = request.params.id; + const { name, mapData } = request.body; + + const result = await updateMapDraft( + mapId, + authorized.session.account._id, + { name, mapData }, + ); + + if (!result) { + response.status(404).json({ error: "Mapa no encontrado." }); + return; + } + + if ("error" in result) { + response.status(400).json({ error: result.error }); + return; + } + + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Proponer un mapa a moderación (corre validaciones automáticas primero) */ +app.post("/maps/user/:id/propose", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + const mapId = request.params.id; + const result = await proposeMap( + mapId, + authorized.session.account._id, + ); + + if (!result.ok) { + response.status(400).json({ + error: result.error, + automatedChecks: result.automatedChecks, + }); + return; + } + + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Reportar un mapa publicado */ +app.post("/maps/user/:id/report", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + const mapId = request.params.id; + const { reason } = request.body; + if (!reason || typeof reason !== "string") { + response.status(400).json({ error: "Motivo del reporte requerido." }); + return; + } + + const result = await reportMap( + mapId, + authorized.session.account._id, + reason, + ); + + if (!result.ok) { + response.status(400).json({ error: result.error }); + return; + } + + response.json({ ok: true, message: "Mapa reportado correctamente." }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +// ── Endpoints de Moderación (Admin / Moderadores) ───────────────────────────── + +/** Cola de moderación con vista previa completa de datos de mapa */ +app.get("/admin/moderation/maps/queue", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const limit = Number.parseInt(String(request.query.limit ?? "20"), 10); + const offset = Number.parseInt(String(request.query.offset ?? "0"), 10); + const filter = + request.query.state === "proposed" || request.query.state === "in_review" + ? request.query.state + : undefined; + + const queue = await getModerationQueue(limit, offset, filter); + response.json({ queue }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Reclamar mapa para revisión ('in_review') */ +app.post("/admin/moderation/maps/:id/claim", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapId = request.params.id; + const result = await claimForReview( + mapId, + authorized.session.account._id, + request.body?.notes, + ); + + if (!result.ok) { + response.status(400).json({ error: result.error }); + return; + } + + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Aprobar mapa ('published') */ +app.post("/admin/moderation/maps/:id/approve", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapId = request.params.id; + const result = await approveMap( + mapId, + authorized.session.account._id, + request.body?.notes, + ); + + if (!result.ok) { + response.status(400).json({ error: result.error }); + return; + } + + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Rechazar mapa con motivo obligatorio ('rejected') */ +app.post("/admin/moderation/maps/:id/reject", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapId = request.params.id; + const { reason, notes } = request.body ?? {}; + + const result = await rejectMap( + mapId, + authorized.session.account._id, + reason, + notes, + ); + + if (!result.ok) { + response.status(400).json({ error: result.error }); + return; + } + + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Despublicar mapa publicado si surge un problema */ +app.post("/admin/moderation/maps/:id/unpublish", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapId = request.params.id; + const { reason } = request.body ?? {}; + + const result = await unpublishMap( + mapId, + authorized.session.account._id, + reason, + ); + + if (!result.ok) { + response.status(400).json({ error: result.error }); + return; + } + + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Consultar reportes de un mapa */ +app.get("/admin/moderation/maps/:id/reports", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapId = request.params.id; + const reports = await getMapReports(mapId); + response.json({ reports }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Consultar historial de revisiones de un mapa */ +app.get("/admin/moderation/maps/:id/reviews", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapId = request.params.id; + const reviews = await getMapReviews(mapId); + response.json({ reviews }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + + app.get( "/internal/game-data/objects", requireAuth, diff --git a/api/src/tests/userMaps.test.ts b/api/src/tests/userMaps.test.ts new file mode 100644 index 00000000..0eb0379f --- /dev/null +++ b/api/src/tests/userMaps.test.ts @@ -0,0 +1,477 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as mapValidation from '../lib/mapValidation'; +import * as userMaps from '../repositories/userMaps'; + +// ── In-Memory Mock Database ────────────────────────────────────────────────── + +type MockMapRecord = { + id: string; + owner_account_id: string; + name: string; + map_num: number; + map_data: mapValidation.UserMapData; + state: userMaps.UserMapState; + rejection_reason: string | null; + npc_count: number; + obj_count: number; + allow_combat: boolean; + allow_exp: boolean; + proposed_at: Date | null; + published_at: Date | null; + created_at: Date; + updated_at: Date; +}; + +type MockReportRecord = { + id: string; + map_id: string; + reporter_account_id: string; + reason: string; + created_at: Date; +}; + +type MockReviewRecord = { + id: string; + map_id: string; + reviewer_account_id: string; + action: string; + notes: string | null; + created_at: Date; +}; + +const mockMaps: MockMapRecord[] = []; +const mockReports: MockReportRecord[] = []; +const mockReviews: MockReviewRecord[] = []; +let nextId = 1; +let nextMapNum = 100_000; + +vi.mock('../db', () => ({ + default: { + query: vi.fn(async (sql: string, params: unknown[] = []) => { + const sqlUpper = sql.toUpperCase(); + + // 1. Quotas + if (sqlUpper.includes('USER_MAP_QUOTAS')) { + if (sqlUpper.includes('SELECT')) { + return { rowCount: 0, rows: [] }; // defaults + } + return { rowCount: 1, rows: [] }; + } + + // 2. Count active maps + if (sqlUpper.includes('COUNT(*)') && sqlUpper.includes('USER_MAPS') && sqlUpper.includes('OWNER_ACCOUNT_ID')) { + const count = mockMaps.filter( + (m) => m.owner_account_id === params[0] && m.state !== 'archived', + ).length; + return { rowCount: 1, rows: [{ count: String(count) }] }; + } + + // 3. Allocate map_num + if (sqlUpper.includes('MAX(MAP_NUM)')) { + const maxNum = mockMaps.reduce((max, m) => Math.max(max, m.map_num), 99999); + return { rowCount: 1, rows: [{ next_num: maxNum + 1 }] }; + } + + // 4. Create Map (INSERT) + if (sqlUpper.includes('INSERT INTO USER_MAPS')) { + const record: MockMapRecord = { + id: `map-${nextId++}`, + owner_account_id: params[0] as string, + name: params[1] as string, + map_num: params[2] as number, + map_data: JSON.parse(params[3] as string), + npc_count: params[4] as number, + obj_count: params[5] as number, + allow_combat: false, + allow_exp: false, + state: 'draft', + rejection_reason: null, + proposed_at: null, + published_at: null, + created_at: new Date(), + updated_at: new Date(), + }; + mockMaps.push(record); + return { rowCount: 1, rows: [record] }; + } + + // 5. Get by ID + if (sqlUpper.includes('SELECT') && sqlUpper.includes('FROM USER_MAPS') && sqlUpper.includes('WHERE M.ID = $1')) { + const found = mockMaps.find((m) => m.id === params[0]); + if (!found) return { rowCount: 0, rows: [] }; + const repCount = mockReports.filter((r) => r.map_id === found.id).length; + return { + rowCount: 1, + rows: [{ ...found, reports_count: String(repCount) }], + }; + } + + // 6. Get by map_num + if (sqlUpper.includes('WHERE M.MAP_NUM = $1')) { + const found = mockMaps.find((m) => m.map_num === params[0]); + if (!found) return { rowCount: 0, rows: [] }; + const repCount = mockReports.filter((r) => r.map_id === found.id).length; + return { + rowCount: 1, + rows: [{ ...found, reports_count: String(repCount) }], + }; + } + + // 7. Select by ID simple + if (sqlUpper.includes('SELECT * FROM USER_MAPS WHERE ID = $1')) { + const found = mockMaps.find((m) => m.id === params[0]); + return { rowCount: found ? 1 : 0, rows: found ? [found] : [] }; + } + + // 8. Update Map Draft + if (sqlUpper.includes('UPDATE USER_MAPS') && sqlUpper.includes('SET NAME = $1')) { + const found = mockMaps.find((m) => m.id === params[4]); + if (found) { + found.name = params[0] as string; + found.map_data = JSON.parse(params[1] as string); + found.npc_count = params[2] as number; + found.obj_count = params[3] as number; + found.updated_at = new Date(); + return { rowCount: 1, rows: [found] }; + } + return { rowCount: 0, rows: [] }; + } + + // 9. Delete/Archive Map + if (sqlUpper.includes("UPDATE USER_MAPS SET STATE = 'ARCHIVED'")) { + const found = mockMaps.find((m) => m.id === params[0]); + if (found) { + found.state = 'archived'; + found.updated_at = new Date(); + return { rowCount: 1, rows: [found] }; + } + return { rowCount: 0, rows: [] }; + } + + // 10. Propose Map (UPDATE state = 'proposed') + if (sqlUpper.includes("SET STATE = 'PROPOSED'")) { + const found = mockMaps.find((m) => m.id === params[0]); + if (found) { + found.state = 'proposed'; + found.rejection_reason = null; + found.proposed_at = new Date(); + found.updated_at = new Date(); + return { rowCount: 1, rows: [found] }; + } + return { rowCount: 0, rows: [] }; + } + + // 11. Claim for Review (UPDATE state = 'in_review') + if (sqlUpper.includes("SET STATE = 'IN_REVIEW'")) { + const found = mockMaps.find((m) => m.id === params[0]); + if (found) { + found.state = 'in_review'; + found.updated_at = new Date(); + return { rowCount: 1, rows: [found] }; + } + return { rowCount: 0, rows: [] }; + } + + // 12. Approve Map (UPDATE state = 'published') + if (sqlUpper.includes("SET STATE = 'PUBLISHED'")) { + const found = mockMaps.find((m) => m.id === params[0]); + if (found) { + found.state = 'published'; + found.rejection_reason = null; + found.published_at = new Date(); + found.updated_at = new Date(); + return { rowCount: 1, rows: [found] }; + } + return { rowCount: 0, rows: [] }; + } + + // 13. Reject / Unpublish (UPDATE state = 'rejected') + if (sqlUpper.includes("SET STATE = 'REJECTED'")) { + const found = mockMaps.find((m) => m.id === params[1]); + if (found) { + found.state = 'rejected'; + found.rejection_reason = params[0] as string; + found.updated_at = new Date(); + return { rowCount: 1, rows: [found] }; + } + return { rowCount: 0, rows: [] }; + } + + // 14. List Moderation Queue + if (sqlUpper.includes('WHERE M.STATE = ANY($1)')) { + const allowedStates = params[0] as string[]; + const queued = mockMaps.filter((m) => allowedStates.includes(m.state)); + const mapped = queued.map((m) => { + const repCount = mockReports.filter((r) => r.map_id === m.id).length; + return { ...m, reports_count: String(repCount) }; + }); + return { rowCount: mapped.length, rows: mapped }; + } + + // 15. List Published Maps + if (sqlUpper.includes("WHERE STATE = 'PUBLISHED'")) { + const published = mockMaps.filter((m) => m.state === 'published'); + return { rowCount: published.length, rows: published }; + } + + // 16. List Own Maps + if (sqlUpper.includes('WHERE OWNER_ACCOUNT_ID = $1')) { + const owned = mockMaps.filter( + (m) => m.owner_account_id === params[0] && m.state !== 'archived', + ); + return { rowCount: owned.length, rows: owned }; + } + + // 17. Reports & Reviews INSERTs + if (sqlUpper.includes('INSERT INTO USER_MAP_REPORTS')) { + mockReports.push({ + id: `rep-${nextId++}`, + map_id: params[0] as string, + reporter_account_id: params[1] as string, + reason: params[2] as string, + created_at: new Date(), + }); + return { rowCount: 1, rows: [] }; + } + + if (sqlUpper.includes('INSERT INTO USER_MAP_REVIEWS')) { + mockReviews.push({ + id: `rev-${nextId++}`, + map_id: params[0] as string, + reviewer_account_id: params[1] as string, + action: params[2] as string, + notes: (params[3] as string) ?? null, + created_at: new Date(), + }); + return { rowCount: 1, rows: [] }; + } + + // 18. Reports SELECT + if (sqlUpper.includes('FROM USER_MAP_REPORTS WHERE MAP_ID = $1')) { + const found = mockReports.filter((r) => r.map_id === params[0]); + return { rowCount: found.length, rows: found }; + } + + // 19. Reviews SELECT + if (sqlUpper.includes('FROM USER_MAP_REVIEWS WHERE MAP_ID = $1')) { + const found = mockReviews.filter((r) => r.map_id === params[0]); + return { rowCount: found.length, rows: found }; + } + + return { rowCount: 0, rows: [] }; + }), + }, +})); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('User Map Isolation & Quotas System (Issue #24) & Moderation (Issue #25)', () => { + const OWNER_ID = 'user-owner-001'; + const OTHER_USER_ID = 'user-intruder-002'; + const MOD_ID = 'moderator-999'; + const PLAYER_ID = 'player-456'; + + const validMapData: mapValidation.UserMapData = { + meta: { name: 'Bosque Épico', width: 50, height: 50, spawnX: 25, spawnY: 25 }, + terrain: [ + { x: 1, y: 1, blocked: true }, + { x: 1, y: 2, blocked: true }, + ], + npcs: [{ x: 26, y: 25, name: 'Mercader Sabio', id: 1 }], + specials: [{ x: 25, y: 26, entityId: 10 }], + }; + + beforeEach(() => { + mockMaps.length = 0; + mockReports.length = 0; + mockReviews.length = 0; + nextId = 1; + nextMapNum = 100_000; + }); + + // ── 1. Issue #24: Reserved ID Range & Structural Isolation ───────────────── + describe('Reserved Map ID Range & Isolation', () => { + it('assigns map_num automatically in the reserved range [100,000 - 999,999]', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Valle Esmeralda', validMapData)) as userMaps.UserMapResponse; + expect(created.mapNum).toBeGreaterThanOrEqual(mapValidation.USER_MAP_START); + expect(created.mapNum).toBeLessThanOrEqual(mapValidation.USER_MAP_END); + expect(mapValidation.isUserMapNumber(created.mapNum)).toBe(true); + expect(mapValidation.isOfficialMapNumber(created.mapNum)).toBe(false); + }); + + it('rejects attempts to allocate or overwrite official world maps (1-500)', async () => { + const attemptOfficial = await userMaps.createMap( + OWNER_ID, + 'Hack Oficial Ullathorpe', + validMapData, + 1, // Official Ullathorpe map + ); + expect('error' in attemptOfficial).toBe(true); + if ('error' in attemptOfficial) { + expect(attemptOfficial.error).toContain('rango reservado'); + } + }); + + it('rejects portals and tile exits pointing to the official world (world isolation)', () => { + const exitToOfficial: mapValidation.UserMapData = { + ...validMapData, + terrain: [ + { x: 5, y: 5, tileExit: { map: 1, x: 50, y: 50 } }, // Porting to Ullathorpe! + ], + }; + const check = mapValidation.validateWorldIsolation(exitToOfficial); + expect(check.ok).toBe(false); + expect(check.errors[0]).toContain('apunta al mapa oficial'); + }); + }); + + // ── 2. Issue #24: Economy Isolation ───────────────────────────────────────── + describe('Economy Isolation', () => { + it('prevents placement of currency / gold piles and prohibited items', () => { + // Gold pile placement attempt + const goldMapData: mapValidation.UserMapData = { + ...validMapData, + specials: [{ x: 10, y: 10, entityId: 12 }], // Item 12 = Gold currency + }; + const resGold = mapValidation.validateEconomyIsolation(goldMapData); + expect(resGold.ok).toBe(false); + expect(resGold.errors[0]).toContain('aislamiento de economía'); + + // Direct gold value on special + const goldValueData: mapValidation.UserMapData = { + ...validMapData, + specials: [{ x: 10, y: 10, gold: 50000 }], + }; + const resVal = mapValidation.validateEconomyIsolation(goldValueData); + expect(resVal.ok).toBe(false); + expect(resVal.errors[0]).toContain('pilas de oro directas'); + }); + + it('prevents NPCs from granting gold or dropping prohibited economy items', () => { + const exploitativeNpc: mapValidation.UserMapData = { + ...validMapData, + npcs: [ + { + x: 15, + y: 15, + name: 'Dragon del Oro', + gold: 10000, + drop: [{ item: 12, cant: 500 }], + }, + ], + }; + const check = mapValidation.validateEconomyIsolation(exploitativeNpc); + expect(check.ok).toBe(false); + expect(check.errors.some((e) => e.includes('oro'))).toBe(true); + }); + }); + + // ── 3. Issue #24: Ownership & Permissions ───────────────────────────────── + describe('Ownership & Unauthorized Edit Protection', () => { + it('allows only the owner to edit their map and blocks unauthorized users', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Mi Mapa Privado', validMapData)) as userMaps.UserMapResponse; + + // Owner can update + const ownerUpdate = await userMaps.updateMapDraft(created.id, OWNER_ID, { + name: 'Mi Mapa Privado Actualizado', + }); + expect(ownerUpdate).not.toBeNull(); + if (ownerUpdate && 'name' in ownerUpdate) { + expect(ownerUpdate.name).toBe('Mi Mapa Privado Actualizado'); + } + + // Intruder cannot update + const intruderUpdate = await userMaps.updateMapDraft(created.id, OTHER_USER_ID, { + name: 'Mapa Hackeado', + }); + expect(intruderUpdate).not.toBeNull(); + if (intruderUpdate && 'error' in intruderUpdate) { + expect(intruderUpdate.error).toContain('No autorizado: sólo el dueño'); + } + }); + + it('blocks unauthorized users from proposing or deleting another user map', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Mapa de Prueba', validMapData)) as userMaps.UserMapResponse; + + const intruderProp = await userMaps.proposeMap(created.id, OTHER_USER_ID); + expect(intruderProp.ok).toBe(false); + expect(intruderProp.error).toContain('No autorizado: sólo el dueño'); + + const intruderDel = await userMaps.deleteMap(created.id, OTHER_USER_ID); + expect(intruderDel.ok).toBe(false); + expect(intruderDel.error).toContain('No autorizado: sólo el dueño'); + }); + + it('prevents players from viewing draft maps by map number until published', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Mapa Secreto', validMapData)) as userMaps.UserMapResponse; + + // Player cannot load by number while in draft + const playerView = await userMaps.getMapByNumber(created.mapNum, PLAYER_ID, false); + expect(playerView).toBeNull(); + + // Owner can view their own draft map by number + const ownerView = await userMaps.getMapByNumber(created.mapNum, OWNER_ID, false); + expect(ownerView).not.toBeNull(); + expect(ownerView?.mapNum).toBe(created.mapNum); + }); + }); + + // ── 4. Issue #24: Quotas & Storage Limits ───────────────────────────────── + describe('Quotas & Limit Enforcement', () => { + it('returns clear error when account exceeds max active maps quota', async () => { + // Fill 5 maps + for (let i = 1; i <= 5; i++) { + await userMaps.createMap(OWNER_ID, `Mapa ${i}`, validMapData); + } + + // 6th map should fail with quota error + const sixth = await userMaps.createMap(OWNER_ID, 'Mapa Extra', validMapData); + expect('error' in sixth).toBe(true); + if ('error' in sixth) { + expect(sixth.error).toContain('Alcanzaste el límite de cuota'); + } + }); + + it('enforces entity quotas and storage byte size limits', async () => { + const heavyData: mapValidation.UserMapData = { + meta: { name: 'Gigante' }, + npcs: Array.from({ length: 25 }, (_, i) => ({ x: i + 1, y: 1 })), // default quota is 20 + }; + + const res = await userMaps.createMap(OWNER_ID, 'Mapa Exceso NPCs', heavyData); + expect('error' in res).toBe(true); + if ('error' in res) { + expect(res.error).toContain('supera la cuota permitida'); + } + }); + }); + + // ── 5. Issue #25: Moderation Flow & Automated Pre-Filtering ─────────────── + describe('Moderation Lifecycle Flow', () => { + it('runs pre-filtering checks, claims, approves and allows community reporting', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Isla del Sol', validMapData)) as userMaps.UserMapResponse; + + // Propose + const propRes = await userMaps.proposeMap(created.id, OWNER_ID); + expect(propRes.ok).toBe(true); + expect(propRes.map?.state).toBe('proposed'); + + // Mod claims and approves + await userMaps.claimForReview(created.id, MOD_ID); + const appRes = await userMaps.approveMap(created.id, MOD_ID); + expect(appRes.ok).toBe(true); + expect(appRes.map?.state).toBe('published'); + + // Public can now view it by map_num + const publicMap = await userMaps.getMapByNumber(created.mapNum, PLAYER_ID, false); + expect(publicMap).not.toBeNull(); + expect(publicMap?.state).toBe('published'); + + // Reporting sends it back to in_review + const rep = await userMaps.reportMap(created.id, PLAYER_ID, 'Gráficos inapropiados'); + expect(rep.ok).toBe(true); + const inReview = await userMaps.getMapById(created.id, MOD_ID, true); + expect(inReview?.state).toBe('in_review'); + }); + }); +}); diff --git a/database/migrations/002_user_map_moderation.sql b/database/migrations/002_user_map_moderation.sql new file mode 100644 index 00000000..d3662a9c --- /dev/null +++ b/database/migrations/002_user_map_moderation.sql @@ -0,0 +1,63 @@ +-- Migration: User Map Moderation System (Issue #25) +-- Implements: draft -> proposed -> in_review -> published | rejected + +CREATE TYPE user_map_state AS ENUM ( + 'draft', + 'proposed', + 'in_review', + 'published', + 'rejected', + 'archived' +); + +CREATE TABLE user_maps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name VARCHAR(64) NOT NULL, + map_num INTEGER UNIQUE, + map_data JSONB NOT NULL DEFAULT '{}', + state user_map_state NOT NULL DEFAULT 'draft', + rejection_reason TEXT, + npc_count INTEGER NOT NULL DEFAULT 0, + obj_count INTEGER NOT NULL DEFAULT 0, + allow_combat BOOLEAN NOT NULL DEFAULT FALSE, + allow_exp BOOLEAN NOT NULL DEFAULT FALSE, + proposed_at TIMESTAMPTZ, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT user_maps_name_length CHECK (char_length(name) >= 3), + CONSTRAINT user_maps_num_range CHECK (map_num IS NULL OR (map_num >= 100000 AND map_num <= 999999)) +); + +CREATE INDEX idx_user_maps_owner ON user_maps(owner_account_id); +CREATE INDEX idx_user_maps_num ON user_maps(map_num); +CREATE INDEX idx_user_maps_state ON user_maps(state); +CREATE INDEX idx_user_maps_published ON user_maps(published_at DESC) WHERE state = 'published'; + +CREATE TABLE user_map_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + map_id UUID NOT NULL REFERENCES user_maps(id) ON DELETE CASCADE, + reporter_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT user_map_reports_unique UNIQUE (map_id, reporter_account_id) +); + +CREATE TABLE user_map_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + map_id UUID NOT NULL REFERENCES user_maps(id) ON DELETE CASCADE, + reviewer_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + action VARCHAR(16) NOT NULL CHECK (action IN ('approved','rejected','queued','unpublished')), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_map_quotas ( + account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, + max_maps INTEGER NOT NULL DEFAULT 5, + max_npcs_per_map INTEGER NOT NULL DEFAULT 20, + max_objs_per_map INTEGER NOT NULL DEFAULT 50, + max_storage_bytes INTEGER NOT NULL DEFAULT 5242880, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/frontend/utils/gameLoader.ts b/frontend/utils/gameLoader.ts index f26b355d..7dffb3a0 100644 --- a/frontend/utils/gameLoader.ts +++ b/frontend/utils/gameLoader.ts @@ -55,6 +55,25 @@ const DYNAMIC_INSTANCE_MAP_START = 30_000; const DYNAMIC_INSTANCE_MAP_STRIDE = 50; const CHALLENGE_INSTANCE_MAP_START = 2_000; const CHALLENGE_INSTANCE_BASE_MAP_ID = 506; + +/** + * Convención de rangos de ID de mapa (Issue #24): + * - 1 a ~500: Mundo oficial + * - 500 a 599: Mapas locales estáticos + * - 2.000 a 29.999: Retos + * - 30.000 a 99.999: Instancias dinámicas + * - 100.000 a 999.999: Mapas de usuario (aislado estructuralmente) + */ +export const USER_MAP_START = 100_000; +export const USER_MAP_END = 999_999; + +export function isUserMap(mapNumber: number): boolean { + return mapNumber >= USER_MAP_START && mapNumber <= USER_MAP_END; +} + +export function isOfficialWorldMap(mapNumber: number): boolean { + return mapNumber >= 1 && mapNumber < 500; +} const MAP_ASSET_VERSIONS: Partial> = { 166: "1.0", 286: "1.2", @@ -699,6 +718,68 @@ export async function loadMapData(mapNumber: number): Promise { return remappedData; } + if (isUserMap(mapNumber)) { + const userMapPayload = await fetchJsonWithFallback<{ + id: string; + mapNum: number; + name: string; + mapData?: { + terrain?: Array<{ + x: number; + y: number; + blocked?: boolean; + layer?: number; + grhIndex?: number | null; + }>; + meta?: { + width?: number; + height?: number; + }; + }; + }>( + `${getApiBaseUrl()}/maps/user/by-number/${mapNumber}`, + `${getApiBaseUrl()}/maps/user/by-number/${mapNumber}`, + `user map ${mapNumber}`, + { preferLocal: false }, + ); + + const width = userMapPayload.mapData?.meta?.width ?? 100; + const height = userMapPayload.mapData?.meta?.height ?? 100; + const userMapData: MapData = { + [String(mapNumber)]: {}, + }; + const mapEntry = userMapData[String(mapNumber)]; + + for (let y = 1; y <= height; y++) { + mapEntry[String(y)] = {}; + for (let x = 1; x <= width; x++) { + mapEntry[String(y)][String(x)] = { + blocked: 0, + graphics: { "1": 1 }, + }; + } + } + + if (Array.isArray(userMapPayload.mapData?.terrain)) { + for (const tile of userMapPayload.mapData.terrain) { + const row = mapEntry[String(tile.y)]; + if (!row) continue; + const t = row[String(tile.x)]; + if (!t) continue; + if (tile.blocked !== undefined) { + t.blocked = tile.blocked ? 1 : 0; + } + if (tile.grhIndex != null && tile.layer != null) { + t.graphics = t.graphics ?? {}; + t.graphics[String(tile.layer)] = tile.grhIndex; + } + } + } + + mapValueCache.set(mapNumber, userMapData); + return userMapData; + } + const assetMapNumber = dynamicBaseMapNumber ? dynamicBaseMapNumber : mapNumber >= 1000