-
Notifications
You must be signed in to change notification settings - Fork 26
feat(world-builder): stage 2 map objects and structures API (#90) #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| 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; | ||
| }; | ||
|
|
||
| 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})`); | ||
| } | ||
| } | ||
|
|
||
| export async function placeObject( | ||
| mapId: number, | ||
| x: number, | ||
| y: number, | ||
| objIndex: number, | ||
| amount: number = 1, | ||
| createdBy: string | null = null, | ||
| state: MapObjectState = "placed" | ||
| ): Promise<MapObjectRecord> { | ||
| if (!Number.isInteger(mapId) || mapId < 1) { | ||
| throw new Error("mapId debe ser un entero positivo"); | ||
| } | ||
| validateCoordinates(x, y); | ||
| 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) | ||
| 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] | ||
| ); | ||
|
Comment on lines
+54
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return res.rows[0]; | ||
|
gitar-bot[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export async function moveObject(id: number, newX: number, newY: number): Promise<MapObjectRecord | null> { | ||
| 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<boolean> { | ||
| 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<MapObjectRecord[]> { | ||
| 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<MapObjectRecord | null> { | ||
| 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 | ||
| 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<MapObjectRecord[]> { | ||
| 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 { | ||
| await client.query("BEGIN"); | ||
| const results: MapObjectRecord[] = []; | ||
| for (const tile of tiles) { | ||
| validateCoordinates(tile.x, tile.y); | ||
| 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, tileAmount, tileState, createdBy] | ||
| ); | ||
| results.push(res.rows[0]); | ||
| } | ||
| await client.query("COMMIT"); | ||
| return results; | ||
| } catch (err) { | ||
| await client.query("ROLLBACK"); | ||
| throw err; | ||
| } finally { | ||
| client.release(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
| }); | ||
| } | ||
| }); | ||
|
Comment on lines
+2907
to
+2918
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚨 Bug: state CHECK constraint disagrees with allowed app states
The DB CHECK on game_map_objects.state permits ('default','door_open','door_closed','locked','destroyed'), but the application's ALLOWED_MAP_OBJECT_STATES / MapObjectState are ('placed','structure','door_open','door_closed','sign'). validateState() will accept 'placed', 'structure', and 'sign' (including the defaults used by placeObject → 'placed' and placeStructure → 'structure'), so the INSERT will violate the CHECK constraint and throw at the DB layer — placeObject and placeStructure fail for every default call. Align the two lists: update the schema CHECK to IN ('placed','structure','door_open','door_closed','sign') and set the column DEFAULT to 'placed' (or 'structure').
Match the schema CHECK/DEFAULT to the application's MapObjectState values.:
Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎