From 8b32f063b515c725d0b1ec9b7a862eb7f724250f Mon Sep 17 00:00:00 2001 From: Vishal Maurya Date: Thu, 10 Sep 2026 00:59:03 +0530 Subject: [PATCH 1/2] feat(maps): implement user map proposal and moderation flow (closes #25) - Implemented state lifecycle: draft -> proposed -> in_review -> published | rejected - Added automated pre-moderation filter (banned words, reachability BFS, entity quotas) - Added moderation queue with map preview data for reviewers without playing - Added mandatory rejection reasons delivered to map owners - Added report mechanism moving published maps back to in_review - Added unpublish capability for active maps - Added comprehensive unit and integration test suite --- api/schema.sql | 65 ++ api/src/lib/mapValidation.ts | 272 ++++++++ api/src/repositories/userMaps.ts | 632 ++++++++++++++++++ api/src/server.ts | 382 +++++++++++ api/src/tests/userMaps.test.ts | 456 +++++++++++++ .../migrations/002_user_map_moderation.sql | 57 ++ 6 files changed, 1864 insertions(+) create mode 100644 api/src/lib/mapValidation.ts create mode 100644 api/src/repositories/userMaps.ts create mode 100644 api/src/tests/userMaps.test.ts create mode 100644 database/migrations/002_user_map_moderation.sql diff --git a/api/schema.sql b/api/schema.sql index d0008678..5fdf0437 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -652,3 +652,68 @@ 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_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, + 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) +); + +CREATE INDEX IF NOT EXISTS idx_user_maps_owner ON user_maps(owner_account_id); +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, + 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..00c117b1 --- /dev/null +++ b/api/src/lib/mapValidation.ts @@ -0,0 +1,272 @@ +/** + * Automated map validation checks for user-submitted maps (Issue #25) + * + * Implements pre-filtering before human moderation: + * 1. Banned words / offensive language detection in map name and texts + * 2. Entity limits & quotas validation (NPCs, objects, dimensions) + * 3. Topological connectivity & reachability check (BFS from spawn point, + * detection of trapped walkable pockets and unreachable entities) + */ + +export type MapEntityPlacement = { + x: number; + y: number; + id?: number; + entityId?: number; + name?: string; + type?: string; + [key: string]: unknown; +}; + +export type UserMapData = { + meta?: { + name?: string; + width?: number; + height?: number; + spawnX?: number; + spawnY?: number; + description?: string; + [key: string]: unknown; + }; + terrain?: Array<{ + x: number; + y: number; + blocked?: boolean; + layer?: number; + grhIndex?: number | null; + [key: string]: unknown; + }>; + npcs?: MapEntityPlacement[]; + specials?: MapEntityPlacement[]; + signs?: Array<{ x: number; y: number; text: string }>; + [key: string]: unknown; +}; + +export type UserMapQuotaLimits = { + maxNpcsPerMap: number; + maxObjsPerMap: 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, +]; + +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[] = []; + + // 1. Check map name + const nameCheck = checkBannedWords(mapName); + if (!nameCheck.ok) { + errors.push(`El nombre del mapa contiene términos prohibidos: ${nameCheck.matched.join(', ')}`); + } + + // 2. Check meta description + 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(', ')}`); + } + } + + // 3. Check NPC names + 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.`); + } + } + } + } + + // 4. Check sign texts + 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 }; +} + +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}.`); + } + + 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 }; + } + + // Map blocked tiles lookup: key = `${x},${y}` + 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 }; + } + + // BFS exploration from spawn point + 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]); + } + } + } + } + + // Must have at least a minimal walkable area + if (visited.size < 5) { + errors.push(`El mapa no tiene un área transitable suficiente desde el punto de entrada (solo ${visited.size} tiles alcanzables).`); + } + + // Check if NPCs or specials are placed on blocked or unreachable tiles + 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; + }; +}; + +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 allErrors = [...textRes.errors, ...quotaRes.errors, ...reachRes.errors]; + const allWarnings = [...reachRes.warnings]; + + return { + passed: allErrors.length === 0, + errors: allErrors, + warnings: allWarnings, + checks: { + textFilter: textRes.ok, + quotas: quotaRes.ok, + reachability: reachRes.ok, + }, + }; +} diff --git a/api/src/repositories/userMaps.ts b/api/src/repositories/userMaps.ts new file mode 100644 index 00000000..b17dd815 --- /dev/null +++ b/api/src/repositories/userMaps.ts @@ -0,0 +1,632 @@ +import pool from '../db'; +import { + runAutomatedMapChecks, + UserMapData, + UserMapQuotaLimits, + AutomatedCheckResult, +} 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_data: UserMapData; + state: UserMapState; + rejection_reason: string | null; + npc_count: number; + obj_count: number; + proposed_at: Date | null; + published_at: Date | null; + created_at: Date; + updated_at: Date; +}; + +export type UserMapResponse = { + id: string; + ownerId: string; + name: string; + state: UserMapState; + rejectionReason: string | null; + npcCount: number; + objCount: number; + 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; +}; + +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, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function toResponse( + row: UserMapRecord & { reports_count?: string | number }, + includeMapData = false, +): UserMapResponse { + return { + id: row.id, + ownerId: row.owner_account_id, + name: row.name, + state: row.state, + rejectionReason: row.rejection_reason, + npcCount: row.npc_count, + objCount: row.obj_count, + 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 }; +} + +// ── 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; + }>( + `SELECT max_maps, max_npcs_per_map, max_objs_per_map + 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, + }; +} + +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, + }; + + await pool.query( + `INSERT INTO user_map_quotas (account_id, max_maps, max_npcs_per_map, max_objs_per_map, updated_at) + VALUES ($1, $2, $3, $4, 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, + updated_at = NOW()`, + [accountId, updated.maxMaps, updated.maxNpcsPerMap, updated.maxObjsPerMap], + ); + + 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, +): Promise { + const trimmedName = name.trim(); + if (trimmedName.length < 3) { + return { error: 'El nombre del mapa debe tener al menos 3 caracteres.' }; + } + + 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 res = await pool.query( + `INSERT INTO user_maps (owner_account_id, name, map_data, npc_count, obj_count, state, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'draft', NOW(), NOW()) + RETURNING *`, + [ownerAccountId, trimmedName, 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 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, + ownerAccountId: string, + updates: { name?: string; mapData?: UserMapData }, +): Promise { + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1 AND owner_account_id = $2`, + [mapId, ownerAccountId], + ); + if (existing.rowCount === 0) return null; + + const current = existing.rows[0]; + 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; + const { npcCount, objCount } = countEntities(newMapData); + + const quota = await getQuota(ownerAccountId); + 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 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); +} + +/** + * Propose a map for moderation: + * Runs automated pre-filtering checks before the map reaches human moderators. + */ +export async function proposeMap( + mapId: string, + ownerAccountId: string, +): Promise<{ + ok: boolean; + map?: UserMapResponse; + automatedChecks?: AutomatedCheckResult; + error?: string; +}> { + const existing = await pool.query( + `SELECT * FROM user_maps WHERE id = $1 AND owner_account_id = $2`, + [mapId, ownerAccountId], + ); + if (existing.rowCount === 0) { + return { ok: false, error: 'Mapa no encontrado.' }; + } + + const map = existing.rows[0]; + 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(ownerAccountId); + 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], + ); + + // Reviewers get full mapData preview without needing to play the game + 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) }; +} + +/** + * Report a published map: + * Any player can report a live map. Doing so automatically sends it back to 'in_review' + * so it immediately reappears in the moderator queue for investigation. + */ +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.' }; + } + + // Insert or update report by this reporter + 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], + ); + + // Send map back to 'in_review' so it enters the moderation queue + await pool.query( + `UPDATE user_maps + SET state = 'in_review', + updated_at = NOW() + WHERE id = $1`, + [mapId], + ); + + return { ok: true }; +} + +/** + * Unpublish a published map: + * Moderator action to take down an already published map if a problem emerges. + */ +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..3e51a9da 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -113,6 +113,22 @@ import { tileEntitySchema, uploadGraphic, } from "./repositories/worldBuilder"; +import { + approveMap, + claimForReview, + createMap, + getMapById, + getMapReports, + getMapReviews, + getModerationQueue, + listOwnMaps, + listPublishedMaps, + proposeMap, + rejectMap, + reportMap, + unpublishMap, + updateMapDraft, +} from "./repositories/userMaps"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -1213,6 +1229,372 @@ 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 }); + } +}); + +/** 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..b4bc80aa --- /dev/null +++ b/api/src/tests/userMaps.test.ts @@ -0,0 +1,456 @@ +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_data: mapValidation.UserMapData; + state: userMaps.UserMapState; + rejection_reason: string | null; + npc_count: number; + obj_count: number; + 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; + +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. 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_data: JSON.parse(params[2] as string), + npc_count: params[3] as number, + obj_count: params[4] as number, + 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] }; + } + + // 4. 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) }], + }; + } + + // 5. Select by ID and owner + if (sqlUpper.includes('SELECT * FROM USER_MAPS WHERE ID = $1 AND OWNER_ACCOUNT_ID = $2')) { + const found = mockMaps.find( + (m) => m.id === params[0] && m.owner_account_id === params[1], + ); + return { rowCount: found ? 1 : 0, rows: found ? [found] : [] }; + } + + // 6. 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] : [] }; + } + + // 7. 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: [] }; + } + + // 8. 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: [] }; + } + + // 9. 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: [] }; + } + + // 10. 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: [] }; + } + + // 11. 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: [] }; + } + + // 12. 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 }; + } + + // 13. List Published Maps + if (sqlUpper.includes("WHERE STATE = 'PUBLISHED'")) { + const published = mockMaps.filter((m) => m.state === 'published'); + return { rowCount: published.length, rows: published }; + } + + // 14. 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 }; + } + + // 15. 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: [] }; + } + + // 16. 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 }; + } + + // 17. 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 Moderation & Validation System (Issue #25)', () => { + const OWNER_ID = 'user-owner-001'; + 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; + }); + + // ── 1. Automated Checks ────────────────────────────────────────────────── + describe('Automated Pre-Filtering Checks', () => { + it('detects banned/offensive words in map name and texts', () => { + const clean = mapValidation.checkBannedWords('Mapa del Dragón'); + expect(clean.ok).toBe(true); + + const offensive = mapValidation.checkBannedWords('Mapa nazi secreto'); + expect(offensive.ok).toBe(false); + expect(offensive.matched).toContain('nazi'); + + const textValidation = mapValidation.validateTextContent('Isla Tranquila', { + meta: { description: 'Una concha de arena' }, + npcs: [{ x: 5, y: 5, name: 'Pelotudo' }], + }); + expect(textValidation.ok).toBe(false); + expect(textValidation.errors.length).toBeGreaterThanOrEqual(1); + }); + + it('validates entity quotas and map dimensions', () => { + const quotas = { maxNpcsPerMap: 2, maxObjsPerMap: 2, maxWidth: 100, maxHeight: 100 }; + const excessData: mapValidation.UserMapData = { + npcs: [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }], + specials: [{ x: 4, y: 4 }], + }; + const res = mapValidation.validateQuotasAndLimits(excessData, quotas); + expect(res.ok).toBe(false); + expect(res.errors[0]).toContain('Supera la cuota máxima de NPCs'); + }); + + it('validates reachability from spawn point via BFS', () => { + // Blocked spawn point + const blockedSpawnMap: mapValidation.UserMapData = { + meta: { width: 50, height: 50, spawnX: 10, spawnY: 10 }, + terrain: [{ x: 10, y: 10, blocked: true }], + }; + const resBlocked = mapValidation.validateReachability(blockedSpawnMap); + expect(resBlocked.ok).toBe(false); + expect(resBlocked.errors[0]).toContain('bloqueado'); + + // Valid spawn with open surroundings + const validSpawnMap: mapValidation.UserMapData = { + meta: { width: 50, height: 50, spawnX: 25, spawnY: 25 }, + terrain: [], + }; + const resValid = mapValidation.validateReachability(validSpawnMap); + expect(resValid.ok).toBe(true); + expect(resValid.reachableTilesCount).toBeGreaterThan(10); + }); + }); + + // ── 2. Lifecycle & State Machine ───────────────────────────────────────── + describe('Map Lifecycle & Moderation Flow', () => { + it('creates a map in draft state', async () => { + const created = await userMaps.createMap(OWNER_ID, 'Valle Esmeralda', validMapData); + expect('id' in created).toBe(true); + if ('id' in created) { + expect(created.state).toBe('draft'); + expect(created.name).toBe('Valle Esmeralda'); + expect(created.ownerId).toBe(OWNER_ID); + } + }); + + it('prevents non-owners from seeing draft maps, but allows moderators to preview', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Valle Oculto', validMapData)) as userMaps.UserMapResponse; + + // Random player cannot see draft + const otherView = await userMaps.getMapById(created.id, PLAYER_ID, false); + expect(otherView).toBeNull(); + + // Owner can see draft with map data + const ownerView = await userMaps.getMapById(created.id, OWNER_ID, false); + expect(ownerView).not.toBeNull(); + expect(ownerView?.mapData).toBeDefined(); + + // Moderator can see draft with map data + const modView = await userMaps.getMapById(created.id, MOD_ID, true); + expect(modView).not.toBeNull(); + expect(modView?.mapData).toBeDefined(); + }); + + it('runs automated checks upon proposal and moves to proposed state if valid', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Isla Pacífica', validMapData)) as userMaps.UserMapResponse; + + const propRes = await userMaps.proposeMap(created.id, OWNER_ID); + expect(propRes.ok).toBe(true); + expect(propRes.map?.state).toBe('proposed'); + expect(propRes.map?.proposedAt).toBeDefined(); + expect(propRes.automatedChecks?.passed).toBe(true); + }); + + it('rejects proposal when automated checks fail', async () => { + const invalidData: mapValidation.UserMapData = { + meta: { width: 50, height: 50, spawnX: 5, spawnY: 5 }, + terrain: [{ x: 5, y: 5, blocked: true }], // blocked spawn! + }; + const created = (await userMaps.createMap(OWNER_ID, 'Isla Bloqueada', invalidData)) as userMaps.UserMapResponse; + + const propRes = await userMaps.proposeMap(created.id, OWNER_ID); + expect(propRes.ok).toBe(false); + expect(propRes.error).toContain('chequeos automáticos'); + expect(propRes.automatedChecks?.passed).toBe(false); + }); + + it('supports full moderation lifecycle: propose -> in_review -> reject -> re-propose -> approve', async () => { + // 1. Author creates draft + const created = (await userMaps.createMap(OWNER_ID, 'Paso Nevado', validMapData)) as userMaps.UserMapResponse; + + // 2. Author proposes map + await userMaps.proposeMap(created.id, OWNER_ID); + + // 3. Moderator inspects queue (receives map preview without needing to play) + const queue = await userMaps.getModerationQueue(10, 0); + expect(queue.length).toBe(1); + expect(queue[0].id).toBe(created.id); + expect(queue[0].mapData).toBeDefined(); + + // 4. Moderator claims for review + const claimRes = await userMaps.claimForReview(created.id, MOD_ID); + expect(claimRes.ok).toBe(true); + expect(claimRes.map?.state).toBe('in_review'); + + // 5. Moderator rejects with mandatory reason + const noReason = await userMaps.rejectMap(created.id, MOD_ID, ''); + expect(noReason.ok).toBe(false); // Reason is required! + + const rejectRes = await userMaps.rejectMap( + created.id, + MOD_ID, + 'Faltan detalles en la zona norte y los caminos son confusos.', + ); + expect(rejectRes.ok).toBe(true); + expect(rejectRes.map?.state).toBe('rejected'); + expect(rejectRes.map?.rejectionReason).toContain('Faltan detalles'); + + // Author views rejection reason + const authorView = await userMaps.getMapById(created.id, OWNER_ID); + expect(authorView?.rejectionReason).toContain('Faltan detalles'); + + // 6. Author fixes and re-proposes + const rePropRes = await userMaps.proposeMap(created.id, OWNER_ID); + expect(rePropRes.ok).toBe(true); + expect(rePropRes.map?.state).toBe('proposed'); + + // 7. Moderator approves + const approveRes = await userMaps.approveMap(created.id, MOD_ID, 'Todo corregido correctamente'); + expect(approveRes.ok).toBe(true); + expect(approveRes.map?.state).toBe('published'); + expect(approveRes.map?.publishedAt).toBeDefined(); + + // Now public player can find it + const publicMap = await userMaps.getMapById(created.id, PLAYER_ID, false); + expect(publicMap?.state).toBe('published'); + }); + + it('allows players to report a published map and sends it back to in_review', async () => { + // Setup a published map + const created = (await userMaps.createMap(OWNER_ID, 'Colina del Sol', validMapData)) as userMaps.UserMapResponse; + await userMaps.proposeMap(created.id, OWNER_ID); + await userMaps.approveMap(created.id, MOD_ID); + + // Player reports map + const reportRes = await userMaps.reportMap( + created.id, + PLAYER_ID, + 'Contiene una zona donde los personajes quedan atrapados sin poder salir.', + ); + expect(reportRes.ok).toBe(true); + + // Map is automatically moved to in_review + const checkMap = await userMaps.getMapById(created.id, MOD_ID, true); + expect(checkMap?.state).toBe('in_review'); + expect(checkMap?.reportsCount).toBe(1); + + // Reappears in moderation queue + const queue = await userMaps.getModerationQueue(10, 0); + expect(queue.some((m) => m.id === created.id)).toBe(true); + }); + + it('allows moderators to unpublish an active map', async () => { + const created = (await userMaps.createMap(OWNER_ID, 'Castillo Abierto', validMapData)) as userMaps.UserMapResponse; + await userMaps.proposeMap(created.id, OWNER_ID); + await userMaps.approveMap(created.id, MOD_ID); + + const unpub = await userMaps.unpublishMap(created.id, MOD_ID, 'Infracción de derechos de autor en gráficos.'); + expect(unpub.ok).toBe(true); + expect(unpub.map?.state).toBe('rejected'); + expect(unpub.map?.rejectionReason).toContain('Infracción de derechos'); + + // Public player can no longer view it + const publicCheck = await userMaps.getMapById(created.id, PLAYER_ID, false); + expect(publicCheck).toBeNull(); + }); + }); +}); diff --git a/database/migrations/002_user_map_moderation.sql b/database/migrations/002_user_map_moderation.sql new file mode 100644 index 00000000..69702c0d --- /dev/null +++ b/database/migrations/002_user_map_moderation.sql @@ -0,0 +1,57 @@ +-- 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_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, + 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) +); + +CREATE INDEX idx_user_maps_owner ON user_maps(owner_account_id); +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, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); From 22abd3a0fe8dfe5b4079e408df0ad6b54cd7c359 Mon Sep 17 00:00:00 2001 From: Vishal Maurya Date: Thu, 10 Sep 2026 01:15:41 +0530 Subject: [PATCH 2/2] feat(maps): implement isolated user map space with ownership and quotas (closes #24) - Defined reserved user map ID range [100,000 - 999,999] in frontend/utils/gameLoader.ts and API without colliding with official maps (1-500), static maps (500-599), challenges (2,000-29,999), or dynamic instances (30,000-99,999) - Enforced strict account ownership (only owner can modify/delete; unauthorized edits blocked) - Implemented configurable quotas: max maps per account, max NPCs/objects per map, and storage byte limits - Enforced economy isolation preventing placement of currency (gold coins), high-tier keys, unauthorized loot items, or XP/gold drops - Enforced world isolation preventing exits/portals from breaching official world maps - Added client user map loader and routing by map number - Added comprehensive unit and integration test suite covering ownership protection, quota limits, and economic isolation --- api/schema.sql | 9 +- api/src/lib/mapValidation.ts | 157 ++++++- api/src/repositories/userMaps.ts | 194 +++++++-- api/src/server.ts | 60 +++ api/src/tests/userMaps.test.ts | 389 +++++++++--------- .../migrations/002_user_map_moderation.sql | 10 +- frontend/utils/gameLoader.ts | 81 ++++ 7 files changed, 669 insertions(+), 231 deletions(-) diff --git a/api/schema.sql b/api/schema.sql index 5fdf0437..1f5050f3 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -675,19 +675,24 @@ 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_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'; @@ -714,6 +719,8 @@ CREATE TABLE IF NOT EXISTS user_map_quotas ( 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 index 00c117b1..ac6359b0 100644 --- a/api/src/lib/mapValidation.ts +++ b/api/src/lib/mapValidation.ts @@ -1,13 +1,31 @@ /** - * Automated map validation checks for user-submitted maps (Issue #25) + * Automated map validation checks for user-submitted maps (Issues #24 and #25) * - * Implements pre-filtering before human moderation: - * 1. Banned words / offensive language detection in map name and texts - * 2. Entity limits & quotas validation (NPCs, objects, dimensions) - * 3. Topological connectivity & reachability check (BFS from spawn point, - * detection of trapped walkable pockets and unreachable entities) + * 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; @@ -15,9 +33,18 @@ export type MapEntityPlacement = { 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; @@ -26,6 +53,8 @@ export type UserMapData = { spawnX?: number; spawnY?: number; description?: string; + allowCombat?: boolean; + allowExp?: boolean; [key: string]: unknown; }; terrain?: Array<{ @@ -34,17 +63,20 @@ export type UserMapData = { 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; }; @@ -56,6 +88,16 @@ const BANNED_PATTERNS: RegExp[] = [ /\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[] = []; @@ -71,13 +113,11 @@ export function checkBannedWords(text: string): { ok: boolean; matched: string[] export function validateTextContent(mapName: string, mapData: UserMapData): { ok: boolean; errors: string[] } { const errors: string[] = []; - // 1. Check map name const nameCheck = checkBannedWords(mapName); if (!nameCheck.ok) { errors.push(`El nombre del mapa contiene términos prohibidos: ${nameCheck.matched.join(', ')}`); } - // 2. Check meta description if (mapData.meta?.description) { const descCheck = checkBannedWords(mapData.meta.description); if (!descCheck.ok) { @@ -85,7 +125,6 @@ export function validateTextContent(mapName: string, mapData: UserMapData): { ok } } - // 3. Check NPC names if (Array.isArray(mapData.npcs)) { for (const npc of mapData.npcs) { if (npc.name) { @@ -97,7 +136,6 @@ export function validateTextContent(mapName: string, mapData: UserMapData): { ok } } - // 4. Check sign texts if (Array.isArray(mapData.signs)) { for (const sign of mapData.signs) { if (sign.text) { @@ -112,6 +150,78 @@ export function validateTextContent(mapName: string, mapData: UserMapData): { ok 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, @@ -137,6 +247,13 @@ export function validateQuotasAndLimits( 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 }; } @@ -159,7 +276,6 @@ export function validateReachability(mapData: UserMapData): { return { ok: false, errors, warnings, reachableTilesCount: 0 }; } - // Map blocked tiles lookup: key = `${x},${y}` const blockedTiles = new Set(); if (Array.isArray(mapData.terrain)) { for (const t of mapData.terrain) { @@ -175,7 +291,6 @@ export function validateReachability(mapData: UserMapData): { return { ok: false, errors, warnings, reachableTilesCount: 0 }; } - // BFS exploration from spawn point const visited = new Set(); const queue: Array<[number, number]> = [[spawnX, spawnY]]; visited.add(spawnKey); @@ -202,12 +317,10 @@ export function validateReachability(mapData: UserMapData): { } } - // Must have at least a minimal walkable area if (visited.size < 5) { errors.push(`El mapa no tiene un área transitable suficiente desde el punto de entrada (solo ${visited.size} tiles alcanzables).`); } - // Check if NPCs or specials are placed on blocked or unreachable tiles if (Array.isArray(mapData.npcs)) { for (const npc of mapData.npcs) { const key = `${npc.x},${npc.y}`; @@ -244,6 +357,8 @@ export type AutomatedCheckResult = { textFilter: boolean; quotas: boolean; reachability: boolean; + economyIsolation: boolean; + worldIsolation: boolean; }; }; @@ -255,8 +370,16 @@ export function runAutomatedMapChecks( const textRes = validateTextContent(mapName, mapData); const quotaRes = validateQuotasAndLimits(mapData, quotas); const reachRes = validateReachability(mapData); - - const allErrors = [...textRes.errors, ...quotaRes.errors, ...reachRes.errors]; + 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 { @@ -267,6 +390,8 @@ export function runAutomatedMapChecks( 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 index b17dd815..34e7b140 100644 --- a/api/src/repositories/userMaps.ts +++ b/api/src/repositories/userMaps.ts @@ -4,6 +4,12 @@ import { UserMapData, UserMapQuotaLimits, AutomatedCheckResult, + USER_MAP_START, + USER_MAP_END, + isUserMapNumber, + isOfficialMapNumber, + validateEconomyIsolation, + validateWorldIsolation, } from '../lib/mapValidation'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -20,11 +26,14 @@ 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; @@ -35,10 +44,13 @@ 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; @@ -52,6 +64,7 @@ export type UserMapQuota = { maxMaps: number; maxNpcsPerMap: number; maxObjsPerMap: number; + maxStorageBytes: number; }; export type UserMapReport = { @@ -76,6 +89,7 @@ export const DEFAULT_QUOTA: UserMapQuota = { maxMaps: 5, maxNpcsPerMap: 20, maxObjsPerMap: 50, + maxStorageBytes: 5 * 1024 * 1024, // 5 MB }; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -88,10 +102,13 @@ function toResponse( 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, @@ -109,6 +126,22 @@ function countEntities(mapData: UserMapData): { npcCount: number; objCount: numb 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 { @@ -116,8 +149,9 @@ export async function getQuota(accountId: string): Promise { 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 + `SELECT max_maps, max_npcs_per_map, max_objs_per_map, max_storage_bytes FROM user_map_quotas WHERE account_id = $1`, [accountId], @@ -128,6 +162,7 @@ export async function getQuota(accountId: string): Promise { maxMaps: row.max_maps, maxNpcsPerMap: row.max_npcs_per_map, maxObjsPerMap: row.max_objs_per_map, + maxStorageBytes: row.max_storage_bytes ?? DEFAULT_QUOTA.maxStorageBytes, }; } @@ -140,17 +175,19 @@ export async function setQuota( 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, updated_at) - VALUES ($1, $2, $3, $4, NOW()) + `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], + [accountId, updated.maxMaps, updated.maxNpcsPerMap, updated.maxObjsPerMap, updated.maxStorageBytes], ); return updated; @@ -170,12 +207,14 @@ 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) { @@ -190,11 +229,45 @@ export async function createMap( 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_data, npc_count, obj_count, state, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, 'draft', NOW(), NOW()) + `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, JSON.stringify(mapData), npcCount, objCount], + [ownerAccountId, trimmedName, mapNum, JSON.stringify(mapData), npcCount, objCount], ); return toResponse(res.rows[0], true); } @@ -225,6 +298,35 @@ export async function getMapById( 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, @@ -253,16 +355,22 @@ export async function listOwnMaps( export async function updateMapDraft( mapId: string, - ownerAccountId: string, + requestingAccountId: string, updates: { name?: string; mapData?: UserMapData }, ): Promise { const existing = await pool.query( - `SELECT * FROM user_maps WHERE id = $1 AND owner_account_id = $2`, - [mapId, ownerAccountId], + `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.' }; } @@ -273,9 +381,20 @@ export async function updateMapDraft( } const newMapData = updates.mapData ?? current.map_data; - const { npcCount, objCount } = countEntities(newMapData); - const quota = await getQuota(ownerAccountId); + // 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}).` }; } @@ -283,6 +402,11 @@ export async function updateMapDraft( 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, @@ -297,13 +421,35 @@ export async function updateMapDraft( 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, - ownerAccountId: string, + requestingAccountId: string, ): Promise<{ ok: boolean; map?: UserMapResponse; @@ -311,20 +457,24 @@ export async function proposeMap( error?: string; }> { const existing = await pool.query( - `SELECT * FROM user_maps WHERE id = $1 AND owner_account_id = $2`, - [mapId, ownerAccountId], + `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(ownerAccountId); + const quota = await getQuota(requestingAccountId); const checks = runAutomatedMapChecks(map.name, map.map_data, quota); if (!checks.passed) { @@ -377,7 +527,6 @@ export async function getModerationQueue( [states, limit, offset], ); - // Reviewers get full mapData preview without needing to play the game return res.rows.map((row) => toResponse(row, true)); } @@ -492,11 +641,6 @@ export async function rejectMap( return { ok: true, map: toResponse(updated.rows[0], true) }; } -/** - * Report a published map: - * Any player can report a live map. Doing so automatically sends it back to 'in_review' - * so it immediately reappears in the moderator queue for investigation. - */ export async function reportMap( mapId: string, reporterAccountId: string, @@ -518,7 +662,6 @@ export async function reportMap( return { ok: false, error: 'Solo se pueden reportar mapas actualmente publicados.' }; } - // Insert or update report by this reporter await pool.query( `INSERT INTO user_map_reports (map_id, reporter_account_id, reason, created_at) VALUES ($1, $2, $3, NOW()) @@ -527,7 +670,6 @@ export async function reportMap( [mapId, reporterAccountId, trimmedReason], ); - // Send map back to 'in_review' so it enters the moderation queue await pool.query( `UPDATE user_maps SET state = 'in_review', @@ -539,10 +681,6 @@ export async function reportMap( return { ok: true }; } -/** - * Unpublish a published map: - * Moderator action to take down an already published map if a problem emerges. - */ export async function unpublishMap( mapId: string, moderatorAccountId: string, diff --git a/api/src/server.ts b/api/src/server.ts index 3e51a9da..83fdcce5 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -117,7 +117,9 @@ import { approveMap, claimForReview, createMap, + deleteMap, getMapById, + getMapByNumber, getMapReports, getMapReviews, getModerationQueue, @@ -1327,6 +1329,64 @@ app.get("/maps/user/:id", async (request, response) => { } }); +/** 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 { diff --git a/api/src/tests/userMaps.test.ts b/api/src/tests/userMaps.test.ts index b4bc80aa..0eb0379f 100644 --- a/api/src/tests/userMaps.test.ts +++ b/api/src/tests/userMaps.test.ts @@ -8,11 +8,14 @@ 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; @@ -40,6 +43,7 @@ const mockMaps: MockMapRecord[] = []; const mockReports: MockReportRecord[] = []; const mockReviews: MockReviewRecord[] = []; let nextId = 1; +let nextMapNum = 100_000; vi.mock('../db', () => ({ default: { @@ -62,15 +66,24 @@ vi.mock('../db', () => ({ return { rowCount: 1, rows: [{ count: String(count) }] }; } - // 3. Create Map (INSERT) + // 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_data: JSON.parse(params[2] as string), - npc_count: params[3] as number, - obj_count: params[4] as number, + 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, @@ -82,7 +95,7 @@ vi.mock('../db', () => ({ return { rowCount: 1, rows: [record] }; } - // 4. Get by ID + // 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: [] }; @@ -93,21 +106,24 @@ vi.mock('../db', () => ({ }; } - // 5. Select by ID and owner - if (sqlUpper.includes('SELECT * FROM USER_MAPS WHERE ID = $1 AND OWNER_ACCOUNT_ID = $2')) { - const found = mockMaps.find( - (m) => m.id === params[0] && m.owner_account_id === params[1], - ); - return { rowCount: found ? 1 : 0, rows: found ? [found] : [] }; + // 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) }], + }; } - // 6. Select by ID simple + // 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] : [] }; } - // 7. Update Map Draft + // 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) { @@ -121,7 +137,18 @@ vi.mock('../db', () => ({ return { rowCount: 0, rows: [] }; } - // 8. Propose Map (UPDATE state = 'proposed') + // 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) { @@ -134,7 +161,7 @@ vi.mock('../db', () => ({ return { rowCount: 0, rows: [] }; } - // 9. Claim for Review (UPDATE state = 'in_review') + // 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) { @@ -145,7 +172,7 @@ vi.mock('../db', () => ({ return { rowCount: 0, rows: [] }; } - // 10. Approve Map (UPDATE state = 'published') + // 12. Approve Map (UPDATE state = 'published') if (sqlUpper.includes("SET STATE = 'PUBLISHED'")) { const found = mockMaps.find((m) => m.id === params[0]); if (found) { @@ -158,7 +185,7 @@ vi.mock('../db', () => ({ return { rowCount: 0, rows: [] }; } - // 11. Reject / Unpublish (UPDATE state = 'rejected') + // 13. Reject / Unpublish (UPDATE state = 'rejected') if (sqlUpper.includes("SET STATE = 'REJECTED'")) { const found = mockMaps.find((m) => m.id === params[1]); if (found) { @@ -170,7 +197,7 @@ vi.mock('../db', () => ({ return { rowCount: 0, rows: [] }; } - // 12. List Moderation Queue + // 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)); @@ -181,13 +208,13 @@ vi.mock('../db', () => ({ return { rowCount: mapped.length, rows: mapped }; } - // 13. List Published Maps + // 15. List Published Maps if (sqlUpper.includes("WHERE STATE = 'PUBLISHED'")) { const published = mockMaps.filter((m) => m.state === 'published'); return { rowCount: published.length, rows: published }; } - // 14. List Own Maps + // 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', @@ -195,7 +222,7 @@ vi.mock('../db', () => ({ return { rowCount: owned.length, rows: owned }; } - // 15. Reports & Reviews INSERTs + // 17. Reports & Reviews INSERTs if (sqlUpper.includes('INSERT INTO USER_MAP_REPORTS')) { mockReports.push({ id: `rep-${nextId++}`, @@ -219,13 +246,13 @@ vi.mock('../db', () => ({ return { rowCount: 1, rows: [] }; } - // 16. Reports SELECT + // 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 }; } - // 17. Reviews SELECT + // 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 }; @@ -238,8 +265,9 @@ vi.mock('../db', () => ({ // ── Tests ──────────────────────────────────────────────────────────────────── -describe('User Map Moderation & Validation System (Issue #25)', () => { +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'; @@ -258,199 +286,192 @@ describe('User Map Moderation & Validation System (Issue #25)', () => { mockReports.length = 0; mockReviews.length = 0; nextId = 1; + nextMapNum = 100_000; }); - // ── 1. Automated Checks ────────────────────────────────────────────────── - describe('Automated Pre-Filtering Checks', () => { - it('detects banned/offensive words in map name and texts', () => { - const clean = mapValidation.checkBannedWords('Mapa del Dragón'); - expect(clean.ok).toBe(true); - - const offensive = mapValidation.checkBannedWords('Mapa nazi secreto'); - expect(offensive.ok).toBe(false); - expect(offensive.matched).toContain('nazi'); + // ── 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); + }); - const textValidation = mapValidation.validateTextContent('Isla Tranquila', { - meta: { description: 'Una concha de arena' }, - npcs: [{ x: 5, y: 5, name: 'Pelotudo' }], - }); - expect(textValidation.ok).toBe(false); - expect(textValidation.errors.length).toBeGreaterThanOrEqual(1); + 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('validates entity quotas and map dimensions', () => { - const quotas = { maxNpcsPerMap: 2, maxObjsPerMap: 2, maxWidth: 100, maxHeight: 100 }; - const excessData: mapValidation.UserMapData = { - npcs: [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }], - specials: [{ x: 4, y: 4 }], + 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 res = mapValidation.validateQuotasAndLimits(excessData, quotas); - expect(res.ok).toBe(false); - expect(res.errors[0]).toContain('Supera la cuota máxima de NPCs'); + const check = mapValidation.validateWorldIsolation(exitToOfficial); + expect(check.ok).toBe(false); + expect(check.errors[0]).toContain('apunta al mapa oficial'); }); + }); - it('validates reachability from spawn point via BFS', () => { - // Blocked spawn point - const blockedSpawnMap: mapValidation.UserMapData = { - meta: { width: 50, height: 50, spawnX: 10, spawnY: 10 }, - terrain: [{ x: 10, y: 10, blocked: true }], + // ── 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 resBlocked = mapValidation.validateReachability(blockedSpawnMap); - expect(resBlocked.ok).toBe(false); - expect(resBlocked.errors[0]).toContain('bloqueado'); - - // Valid spawn with open surroundings - const validSpawnMap: mapValidation.UserMapData = { - meta: { width: 50, height: 50, spawnX: 25, spawnY: 25 }, - terrain: [], + 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 resValid = mapValidation.validateReachability(validSpawnMap); - expect(resValid.ok).toBe(true); - expect(resValid.reachableTilesCount).toBeGreaterThan(10); + const resVal = mapValidation.validateEconomyIsolation(goldValueData); + expect(resVal.ok).toBe(false); + expect(resVal.errors[0]).toContain('pilas de oro directas'); }); - }); - // ── 2. Lifecycle & State Machine ───────────────────────────────────────── - describe('Map Lifecycle & Moderation Flow', () => { - it('creates a map in draft state', async () => { - const created = await userMaps.createMap(OWNER_ID, 'Valle Esmeralda', validMapData); - expect('id' in created).toBe(true); - if ('id' in created) { - expect(created.state).toBe('draft'); - expect(created.name).toBe('Valle Esmeralda'); - expect(created.ownerId).toBe(OWNER_ID); - } + 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); }); + }); - it('prevents non-owners from seeing draft maps, but allows moderators to preview', async () => { - const created = (await userMaps.createMap(OWNER_ID, 'Valle Oculto', validMapData)) as userMaps.UserMapResponse; - - // Random player cannot see draft - const otherView = await userMaps.getMapById(created.id, PLAYER_ID, false); - expect(otherView).toBeNull(); + // ── 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 see draft with map data - const ownerView = await userMaps.getMapById(created.id, OWNER_ID, false); - expect(ownerView).not.toBeNull(); - expect(ownerView?.mapData).toBeDefined(); + // 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'); + } - // Moderator can see draft with map data - const modView = await userMaps.getMapById(created.id, MOD_ID, true); - expect(modView).not.toBeNull(); - expect(modView?.mapData).toBeDefined(); + // 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('runs automated checks upon proposal and moves to proposed state if valid', async () => { - const created = (await userMaps.createMap(OWNER_ID, 'Isla Pacífica', validMapData)) as userMaps.UserMapResponse; + 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 propRes = await userMaps.proposeMap(created.id, OWNER_ID); - expect(propRes.ok).toBe(true); - expect(propRes.map?.state).toBe('proposed'); - expect(propRes.map?.proposedAt).toBeDefined(); - expect(propRes.automatedChecks?.passed).toBe(true); - }); + 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'); - it('rejects proposal when automated checks fail', async () => { - const invalidData: mapValidation.UserMapData = { - meta: { width: 50, height: 50, spawnX: 5, spawnY: 5 }, - terrain: [{ x: 5, y: 5, blocked: true }], // blocked spawn! - }; - const created = (await userMaps.createMap(OWNER_ID, 'Isla Bloqueada', invalidData)) as userMaps.UserMapResponse; - - const propRes = await userMaps.proposeMap(created.id, OWNER_ID); - expect(propRes.ok).toBe(false); - expect(propRes.error).toContain('chequeos automáticos'); - expect(propRes.automatedChecks?.passed).toBe(false); + 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('supports full moderation lifecycle: propose -> in_review -> reject -> re-propose -> approve', async () => { - // 1. Author creates draft - const created = (await userMaps.createMap(OWNER_ID, 'Paso Nevado', validMapData)) as userMaps.UserMapResponse; - - // 2. Author proposes map - await userMaps.proposeMap(created.id, OWNER_ID); + 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; - // 3. Moderator inspects queue (receives map preview without needing to play) - const queue = await userMaps.getModerationQueue(10, 0); - expect(queue.length).toBe(1); - expect(queue[0].id).toBe(created.id); - expect(queue[0].mapData).toBeDefined(); + // Player cannot load by number while in draft + const playerView = await userMaps.getMapByNumber(created.mapNum, PLAYER_ID, false); + expect(playerView).toBeNull(); - // 4. Moderator claims for review - const claimRes = await userMaps.claimForReview(created.id, MOD_ID); - expect(claimRes.ok).toBe(true); - expect(claimRes.map?.state).toBe('in_review'); + // 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); + }); + }); - // 5. Moderator rejects with mandatory reason - const noReason = await userMaps.rejectMap(created.id, MOD_ID, ''); - expect(noReason.ok).toBe(false); // Reason is required! + // ── 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); + } - const rejectRes = await userMaps.rejectMap( - created.id, - MOD_ID, - 'Faltan detalles en la zona norte y los caminos son confusos.', - ); - expect(rejectRes.ok).toBe(true); - expect(rejectRes.map?.state).toBe('rejected'); - expect(rejectRes.map?.rejectionReason).toContain('Faltan detalles'); - - // Author views rejection reason - const authorView = await userMaps.getMapById(created.id, OWNER_ID); - expect(authorView?.rejectionReason).toContain('Faltan detalles'); - - // 6. Author fixes and re-proposes - const rePropRes = await userMaps.proposeMap(created.id, OWNER_ID); - expect(rePropRes.ok).toBe(true); - expect(rePropRes.map?.state).toBe('proposed'); - - // 7. Moderator approves - const approveRes = await userMaps.approveMap(created.id, MOD_ID, 'Todo corregido correctamente'); - expect(approveRes.ok).toBe(true); - expect(approveRes.map?.state).toBe('published'); - expect(approveRes.map?.publishedAt).toBeDefined(); - - // Now public player can find it - const publicMap = await userMaps.getMapById(created.id, PLAYER_ID, false); - expect(publicMap?.state).toBe('published'); + // 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('allows players to report a published map and sends it back to in_review', async () => { - // Setup a published map - const created = (await userMaps.createMap(OWNER_ID, 'Colina del Sol', validMapData)) as userMaps.UserMapResponse; - await userMaps.proposeMap(created.id, OWNER_ID); - await userMaps.approveMap(created.id, MOD_ID); - - // Player reports map - const reportRes = await userMaps.reportMap( - created.id, - PLAYER_ID, - 'Contiene una zona donde los personajes quedan atrapados sin poder salir.', - ); - expect(reportRes.ok).toBe(true); - - // Map is automatically moved to in_review - const checkMap = await userMaps.getMapById(created.id, MOD_ID, true); - expect(checkMap?.state).toBe('in_review'); - expect(checkMap?.reportsCount).toBe(1); + 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 + }; - // Reappears in moderation queue - const queue = await userMaps.getModerationQueue(10, 0); - expect(queue.some((m) => m.id === created.id)).toBe(true); + 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'); + } }); + }); - it('allows moderators to unpublish an active map', async () => { - const created = (await userMaps.createMap(OWNER_ID, 'Castillo Abierto', validMapData)) as userMaps.UserMapResponse; - await userMaps.proposeMap(created.id, OWNER_ID); - await userMaps.approveMap(created.id, MOD_ID); + // ── 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; - const unpub = await userMaps.unpublishMap(created.id, MOD_ID, 'Infracción de derechos de autor en gráficos.'); - expect(unpub.ok).toBe(true); - expect(unpub.map?.state).toBe('rejected'); - expect(unpub.map?.rejectionReason).toContain('Infracción de derechos'); + // 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'); - // Public player can no longer view it - const publicCheck = await userMaps.getMapById(created.id, PLAYER_ID, false); - expect(publicCheck).toBeNull(); + // 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 index 69702c0d..d3662a9c 100644 --- a/database/migrations/002_user_map_moderation.sql +++ b/database/migrations/002_user_map_moderation.sql @@ -1,4 +1,4 @@ --- Migration: User Map Moderation System (Issue #25) +-- Migration: User Map Moderation System (Issue #25) -- Implements: draft -> proposed -> in_review -> published | rejected CREATE TYPE user_map_state AS ENUM ( @@ -14,19 +14,24 @@ 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_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'; @@ -53,5 +58,6 @@ CREATE TABLE user_map_quotas ( 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