From 678c2d82a71a6d9be3ea698c37c51eb4b5ba79ac Mon Sep 17 00:00:00 2001 From: Rodrigoue9 Date: Wed, 19 Aug 2026 12:56:12 -0300 Subject: [PATCH 1/2] feat(world-builder): stage 2 map objects and structures API (#90) --- api/src/repositories/mapObjects.ts | 127 +++++++++++++++++++++++++++++ api/src/server.ts | 89 ++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 api/src/repositories/mapObjects.ts diff --git a/api/src/repositories/mapObjects.ts b/api/src/repositories/mapObjects.ts new file mode 100644 index 00000000..ce7acc24 --- /dev/null +++ b/api/src/repositories/mapObjects.ts @@ -0,0 +1,127 @@ +import pool from "../db"; + +export const MAP_SIZE = 100; + +export type MapObjectState = "placed" | "structure" | "door_open" | "door_closed" | "sign"; + +export type MapObjectRecord = { + id: number; + mapId: number; + x: number; + y: number; + objIndex: number; + amount: number; + state: MapObjectState; + createdBy: string | null; + createdAt: string; +}; + +export type StructureTileInput = { + x: number; + y: number; + objIndex: number; + amount?: number; + state?: MapObjectState; +}; + +function validateCoordinates(x: number, y: number): void { + if (x < 1 || x > MAP_SIZE || y < 1 || y > MAP_SIZE) { + throw new Error(`Coordenadas fuera de rango (1-${MAP_SIZE}): (${x}, ${y})`); + } +} + +export async function placeObject( + mapId: number, + x: number, + y: number, + objIndex: number, + amount: number = 1, + createdBy: string | null = null, + state: MapObjectState = "placed" +): Promise { + validateCoordinates(x, y); + if (objIndex <= 0) { + throw new Error("objIndex debe ser mayor a 0"); + } + + const res = await pool.query( + `INSERT INTO game_map_objects (map_id, x, y, obj_index, amount, state, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`, + [mapId, x, y, objIndex, amount, state, createdBy] + ); + return res.rows[0]; +} + +export async function moveObject(id: number, newX: number, newY: number): Promise { + validateCoordinates(newX, newY); + const res = await pool.query( + `UPDATE game_map_objects + SET x = $1, y = $2 + WHERE id = $3 + RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`, + [newX, newY, id] + ); + return res.rows[0] || null; +} + +export async function removeObject(id: number): Promise { + const res = await pool.query(`DELETE FROM game_map_objects WHERE id = $1`, [id]); + return (res.rowCount ?? 0) > 0; +} + +export async function getMapObjects(mapId: number): Promise { + const res = await pool.query( + `SELECT id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt" + FROM game_map_objects + WHERE map_id = $1 + ORDER BY id ASC`, + [mapId] + ); + return res.rows; +} + +export async function setObjectState(id: number, state: MapObjectState): Promise { + const res = await pool.query( + `UPDATE game_map_objects + SET state = $1 + WHERE id = $2 + RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`, + [state, id] + ); + return res.rows[0] || null; +} + +export async function placeStructure( + mapId: number, + tiles: StructureTileInput[], + createdBy: string | null = null +): Promise { + if (!tiles.length) return []; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const results: MapObjectRecord[] = []; + for (const tile of tiles) { + validateCoordinates(tile.x, tile.y); + if (tile.objIndex <= 0) { + throw new Error("objIndex debe ser mayor a 0"); + } + const res = await client.query( + `INSERT INTO game_map_objects (map_id, x, y, obj_index, amount, state, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`, + [mapId, tile.x, tile.y, tile.objIndex, tile.amount ?? 1, tile.state ?? "structure", createdBy] + ); + results.push(res.rows[0]); + } + await client.query("COMMIT"); + return results; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..9c210bfb 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -2891,4 +2891,93 @@ app.get("/user-online-stats", async (request, response) => { } }); +app.get("/internal/map-objects/:mapId", requireAuth, async (request, response) => { + try { + const mapId = Number(request.params.mapId); + const { getMapObjects } = await import("./repositories/mapObjects"); + const result = await getMapObjects(mapId); + response.json(result); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.post("/internal/map-objects", requireAuth, async (request, response) => { + try { + const { mapId, x, y, objIndex, amount, createdBy, state } = request.body; + const { placeObject } = await import("./repositories/mapObjects"); + const result = await placeObject(mapId, x, y, objIndex, amount, createdBy, state); + response.status(201).json(result); + } catch (error) { + response.status(400).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.put("/internal/map-objects/:id/move", requireAuth, async (request, response) => { + try { + const id = Number(request.params.id); + const { newX, newY } = request.body; + const { moveObject } = await import("./repositories/mapObjects"); + const result = await moveObject(id, newX, newY); + if (!result) { + return response.status(404).json({ error: "Objeto no encontrado" }); + } + response.json(result); + } catch (error) { + response.status(400).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.delete("/internal/map-objects/:id", requireAuth, async (request, response) => { + try { + const id = Number(request.params.id); + const { removeObject } = await import("./repositories/mapObjects"); + const ok = await removeObject(id); + if (!ok) { + return response.status(404).json({ error: "Objeto no encontrado" }); + } + response.json({ success: true }); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.put("/internal/map-objects/:id/state", requireAuth, async (request, response) => { + try { + const id = Number(request.params.id); + const { state } = request.body; + const { setObjectState } = await import("./repositories/mapObjects"); + const result = await setObjectState(id, state); + if (!result) { + return response.status(404).json({ error: "Objeto no encontrado" }); + } + response.json(result); + } catch (error) { + response.status(400).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.post("/internal/map-objects/structure", requireAuth, async (request, response) => { + try { + const { mapId, tiles, createdBy } = request.body; + const { placeStructure } = await import("./repositories/mapObjects"); + const result = await placeStructure(mapId, tiles, createdBy); + response.status(201).json(result); + } catch (error) { + response.status(400).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + void start(); From 0f411093a573677a1391223741762fb71fd7eeef Mon Sep 17 00:00:00 2001 From: Rodrigoue9 Date: Wed, 19 Aug 2026 14:03:03 -0300 Subject: [PATCH 2/2] fix(world-builder): add game_map_objects schema and strict input validation (#90) --- api/schema.sql | 15 ++++++++++ api/src/repositories/mapObjects.ts | 46 ++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/api/schema.sql b/api/schema.sql index c5103206..798e88e5 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -627,3 +627,18 @@ CREATE INDEX IF NOT EXISTS idx_game_map_tile_overrides_map ON game_map_tile_overrides(map_num, status); CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at ON game_uploaded_graphics(created_at DESC); + +CREATE TABLE IF NOT EXISTS game_map_objects ( + id SERIAL PRIMARY KEY, + map_id INTEGER NOT NULL CHECK (map_id > 0), + x INTEGER NOT NULL CHECK (x BETWEEN 1 AND 100), + y INTEGER NOT NULL CHECK (y BETWEEN 1 AND 100), + obj_index INTEGER NOT NULL CHECK (obj_index > 0), + amount INTEGER NOT NULL DEFAULT 1 CHECK (amount > 0), + state VARCHAR(50) NOT NULL DEFAULT 'default' CHECK (state IN ('default', 'door_open', 'door_closed', 'locked', 'destroyed')), + created_by UUID REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_game_map_objects_map_coords + ON game_map_objects(map_id, x, y); diff --git a/api/src/repositories/mapObjects.ts b/api/src/repositories/mapObjects.ts index ce7acc24..47fe8cba 100644 --- a/api/src/repositories/mapObjects.ts +++ b/api/src/repositories/mapObjects.ts @@ -24,8 +24,22 @@ export type StructureTileInput = { state?: MapObjectState; }; -function validateCoordinates(x: number, y: number): void { - if (x < 1 || x > MAP_SIZE || y < 1 || y > MAP_SIZE) { +export const ALLOWED_MAP_OBJECT_STATES: MapObjectState[] = [ + "placed", + "structure", + "door_open", + "door_closed", + "sign", +]; + +export function validateState(state: unknown): asserts state is MapObjectState { + if (typeof state !== "string" || !ALLOWED_MAP_OBJECT_STATES.includes(state as MapObjectState)) { + throw new Error(`Estado no valido. Permitidos: ${ALLOWED_MAP_OBJECT_STATES.join(", ")}`); + } +} + +function validateCoordinates(x: unknown, y: unknown): void { + if (!Number.isInteger(x) || !Number.isInteger(y) || (x as number) < 1 || (x as number) > MAP_SIZE || (y as number) < 1 || (y as number) > MAP_SIZE) { throw new Error(`Coordenadas fuera de rango (1-${MAP_SIZE}): (${x}, ${y})`); } } @@ -39,10 +53,17 @@ export async function placeObject( createdBy: string | null = null, state: MapObjectState = "placed" ): Promise { + if (!Number.isInteger(mapId) || mapId < 1) { + throw new Error("mapId debe ser un entero positivo"); + } validateCoordinates(x, y); - if (objIndex <= 0) { + if (!Number.isInteger(objIndex) || objIndex <= 0) { throw new Error("objIndex debe ser mayor a 0"); } + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error("amount debe ser mayor a 0"); + } + validateState(state); const res = await pool.query( `INSERT INTO game_map_objects (map_id, x, y, obj_index, amount, state, created_by) @@ -82,6 +103,10 @@ export async function getMapObjects(mapId: number): Promise { } export async function setObjectState(id: number, state: MapObjectState): Promise { + if (!Number.isInteger(id) || id < 1) { + throw new Error("id debe ser un entero positivo"); + } + validateState(state); const res = await pool.query( `UPDATE game_map_objects SET state = $1 @@ -97,7 +122,10 @@ export async function placeStructure( tiles: StructureTileInput[], createdBy: string | null = null ): Promise { - if (!tiles.length) return []; + if (!Number.isInteger(mapId) || mapId < 1) { + throw new Error("mapId debe ser un entero positivo"); + } + if (!Array.isArray(tiles) || !tiles.length) return []; const client = await pool.connect(); try { @@ -105,14 +133,20 @@ export async function placeStructure( const results: MapObjectRecord[] = []; for (const tile of tiles) { validateCoordinates(tile.x, tile.y); - if (tile.objIndex <= 0) { + if (!Number.isInteger(tile.objIndex) || tile.objIndex <= 0) { throw new Error("objIndex debe ser mayor a 0"); } + const tileAmount = tile.amount ?? 1; + if (!Number.isInteger(tileAmount) || tileAmount <= 0) { + throw new Error("amount debe ser mayor a 0"); + } + const tileState = tile.state ?? "structure"; + validateState(tileState); const res = await client.query( `INSERT INTO game_map_objects (map_id, x, y, obj_index, amount, state, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`, - [mapId, tile.x, tile.y, tile.objIndex, tile.amount ?? 1, tile.state ?? "structure", createdBy] + [mapId, tile.x, tile.y, tile.objIndex, tileAmount, tileState, createdBy] ); results.push(res.rows[0]); }