Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ jobs:
TOKEN_AUTH: test-token-secret
NODE_ENV: test
CORS_ORIGIN: "http://localhost:3000"
GAME_DATA_ADMIN_EMAIL: admin@test.local
GAME_DATA_ADMIN_ACCOUNT_ID: test-admin-account-id
GAME_DATA_ADMIN_PROXY_TOKEN: test-admin-proxy-token
run: |
pnpm exec tsx src/server.ts &
for i in {1..30}; do
Expand Down
277 changes: 277 additions & 0 deletions api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { z } from "zod";
import pool from "../db";
import { validatePngUpload } from "../lib/pngValidation";
Expand Down Expand Up @@ -417,3 +419,278 @@ export async function clearTile(

return (result.rowCount ?? 0) > 0;
}

// ─── Rectangle paint ──────────────────────────────────────────────────────────

const rawPaintRectangleSchema = z.object({
startX: z.coerce.number().int().min(1).max(MAP_SIZE),
startY: z.coerce.number().int().min(1).max(MAP_SIZE),
endX: z.coerce.number().int().min(1).max(MAP_SIZE),
endY: z.coerce.number().int().min(1).max(MAP_SIZE),
layer: z.coerce.number().int().min(1).max(4),
grhIndex: z.coerce.number().int().nonnegative().nullable().optional(),
blocked: z.boolean().nullable().optional(),
});

export const paintRectangleSchema = rawPaintRectangleSchema.refine(
(data) => {
const minX = Math.min(data.startX, data.endX);
const maxX = Math.max(data.startX, data.endX);
const minY = Math.min(data.startY, data.endY);
const maxY = Math.max(data.startY, data.endY);
const width = maxX - minX + 1;
const height = maxY - minY + 1;
return width * height <= 500;
},
{
message:
"El rectangulo no puede superar 500 tiles (ancho x alto <= 500).",
},
);

export type PaintRectangleInput = z.infer<typeof rawPaintRectangleSchema>;

/**
* Pinta un rectangulo de tiles como BORRADOR.
*
* Expande el rectangulo a tiles individuales y delega en paintTiles(),
* que ya maneja la atomicidad y la validacion de graficos.
*/
export async function paintRectangle(
mapNum: number,
input: PaintRectangleInput,
accountId: string,
): Promise<{ applied: number }> {
const minX = Math.min(input.startX, input.endX);
const maxX = Math.max(input.startX, input.endX);
const minY = Math.min(input.startY, input.endY);
const maxY = Math.max(input.startY, input.endY);

const tiles: TilePaint[] = [];

for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
tiles.push({
x,
y,
layer: input.layer,
grhIndex: input.grhIndex,
blocked: input.blocked,
});
}
}

return paintTiles(mapNum, tiles, accountId);
}

// ─── Region query ─────────────────────────────────────────────────────────────

export const queryRegionSchema = z.object({
startX: z.coerce.number().int().min(1).max(MAP_SIZE),
startY: z.coerce.number().int().min(1).max(MAP_SIZE),
endX: z.coerce.number().int().min(1).max(MAP_SIZE),
endY: z.coerce.number().int().min(1).max(MAP_SIZE),
});

export type QueryRegionInput = z.infer<typeof queryRegionSchema>;

/**
* Devuelve los overrides de tiles en un rectangulo del mapa.
*
* Incluye borradores y publicados. Para la misma coordenada, el borrador tiene
* prioridad sobre lo publicado (DISTINCT ON con orden de status).
*/
export async function queryRegion(
mapNum: number,
input: QueryRegionInput,
): Promise<MapTileOverride[]> {
const minX = Math.min(input.startX, input.endX);
const maxX = Math.max(input.startX, input.endX);
const minY = Math.min(input.startY, input.endY);
const maxY = Math.max(input.startY, input.endY);

const result = await pool.query<{
x: number;
y: number;
layer: number;
grh_index: number | null;
blocked: boolean | null;
status: string;
}>(
`SELECT DISTINCT ON (x, y, layer) x, y, layer, grh_index, blocked, status
FROM game_map_tile_overrides
WHERE map_num = $1
AND x BETWEEN $2 AND $3
AND y BETWEEN $4 AND $5
ORDER BY x, y, layer, status ASC`,
[mapNum, minX, maxX, minY, maxY],
);

return result.rows.map((row) => ({
x: row.x,
y: row.y,
layer: row.layer,
grhIndex: row.grh_index,
blocked: row.blocked,
status: row.status as "draft" | "published",
}));
}

// ─── Isolated region detection ────────────────────────────────────────────────

type TerrainJson = {
id?: number;
width?: number;
height?: number;
palette?: Record<string, { graphics?: unknown; blocked?: boolean }>;
rows?: number[][];
};

const MAPAS_SOURCE_DIR = path.join(__dirname, "../mapas_source");

function loadBaseBlockedTiles(mapNum: number): boolean[][] | null {
const terrainPath = path.join(
MAPAS_SOURCE_DIR,
`mapa_${mapNum}`,
"terrain.json",
);

if (!fs.existsSync(terrainPath)) {
return null;
}

const terrain = JSON.parse(
fs.readFileSync(terrainPath, "utf8"),
) as TerrainJson;
const palette = terrain.palette ?? {};
Comment on lines +551 to +565

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Performance: BFS uses array.shift() and synchronous file I/O on request path

checkIsolatedRegions runs on every blocked-flag edit (PUT tiles and paint-rectangle). It uses queue.shift() which is O(n) per dequeue, making the BFS O(n^2) on up to 10,000 cells, and loadBaseBlockedTiles calls fs.existsSync/fs.readFileSync + JSON.parse synchronously, blocking the event loop for the whole file read on each such request. Both are functionally fine at 100x100 but avoidable: use an index-based queue head pointer (or a deque) instead of shift(), and use fs.promises.readFile to avoid blocking. Optionally cache parsed terrain per map since terrain.json is static.

Was this helpful? React with 👍 / 👎

const rows = terrain.rows ?? [];
const height = Math.max(1, Math.min(100, Number(terrain.height) || 100));
const width = Math.max(1, Math.min(100, Number(terrain.width) || 100));

const grid: boolean[][] = [];

for (let y = 0; y < height; y++) {
grid[y] = [];
const row = Array.isArray(rows[y]) ? rows[y]! : [];

for (let x = 0; x < width; x++) {
const paletteId = Number(row[x]) || 0;
const paletteTile =
paletteId > 0 ? palette[String(paletteId)] : undefined;
grid[y][x] = Boolean(paletteTile?.blocked);
}
}

return grid;
}

/**
* Detecta regiones caminables que quedaron aisladas despues de una edicion.
*
* Carga el mapa base desde terrain.json, aplica los overrides publicados,
* y ejecuta BFS desde la primera casilla caminable encontrada. Si hay
* casillas caminables inalcanzables, devuelve true.
*/
export async function checkIsolatedRegions(
mapNum: number,
): Promise<{ isolated: boolean; unreachableCount: number }> {
const grid = loadBaseBlockedTiles(mapNum);

if (!grid) {
return { isolated: false, unreachableCount: 0 };
}

const height = grid.length;
const width = grid[0]!.length;

const overrides = await queryRegion(mapNum, {
startX: 1,
startY: 1,
Comment on lines +594 to +608

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Isolation check reports false positives on naturally-disconnected maps

checkIsolatedRegions seeds a BFS from the first walkable tile and reports isolated=true whenever total walkable tiles exceed those reachable from that single seed. But base maps already contain multiple disconnected walkable regions — running this on the clean, unedited mapa_1/terrain.json yields 15 walkable components, 6118 walkable tiles, and unreachableCount=368 with isolated=true. So every PUT/paint-rectangle edit that sets blocked will return isolatedRegions=true regardless of whether the edit actually fractured the map, defeating the feature's purpose and misleading editors. Test #9 only asserts the return type, not that a clean map yields isolated=false, so this slips through. Fix by comparing connectivity against a baseline computed on the pre-edit grid (e.g. count connected components before vs. after, or only report regions newly isolated by this edit) rather than comparing a single seed's reach to the global walkable total.

Was this helpful? React with 👍 / 👎

endX: MAP_SIZE,
endY: MAP_SIZE,
});

for (const override of overrides) {
const oy = override.y - 1;
const ox = override.x - 1;

if (oy >= 0 && oy < height && ox >= 0 && ox < width) {
if (override.blocked === true) {
grid[oy]![ox] = true;
} else if (override.blocked === false) {
grid[oy]![ox] = false;
}
}
}

let seedX = -1;
let seedY = -1;

for (let y = 0; y < height && seedX === -1; y++) {
for (let x = 0; x < width && seedX === -1; x++) {
if (!grid[y]![x]) {
seedX = x;
seedY = y;
}
}
}

if (seedX === -1) {
return { isolated: false, unreachableCount: 0 };
}

const visited = Array.from({ length: height }, () =>
new Array<boolean>(width).fill(false),
);
const queue: Array<[number, number]> = [[seedX, seedY]];
visited[seedY]![seedX] = true;
let reachable = 0;

const directions: [number, number][] = [
[0, -1],
[0, 1],
[-1, 0],
[1, 0],
];

while (queue.length > 0) {
const current = queue.shift()!;
const cx = current[0]!;
const cy = current[1]!;
reachable += 1;

for (const dir of directions) {
const nx = cx + dir[0]!;
const ny = cy + dir[1]!;

if (
nx >= 0 &&
nx < width &&
ny >= 0 &&
ny < height &&
!visited[ny]![nx] &&
!grid[ny]![nx]
) {
visited[ny]![nx] = true;
queue.push([nx, ny]);
}
}
}

let totalWalkable = 0;

for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!grid[y]![x]) {
totalWalkable += 1;
}
}
}

const unreachableCount = totalWalkable - reachable;

return {
isolated: unreachableCount > 0,
unreachableCount,
};
}
Loading