diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95b11597..d7b35edd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 8fd58c95..6900748c 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -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"; @@ -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; + +/** + * 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; + +/** + * 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 { + 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; + 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 ?? {}; + 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, + 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(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, + }; +} diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..1eaa5dca 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -96,15 +96,20 @@ import { upsertGameBalance, } from "./repositories/gameBalance"; import { + checkIsolatedRegions, clearTile, discardDrafts, getGraphicContent, getMapStatus, listGraphics, listMapOverrides, + paintRectangle, + paintRectangleSchema, paintTiles, paintTilesSchema, publishMap, + queryRegion, + queryRegionSchema, revertMap, uploadGraphic, } from "./repositories/worldBuilder"; @@ -866,13 +871,23 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { return; } - response.json( - await paintTiles( - mapNum, - parsed.data.tiles, - authorized.session.account._id, - ), + const result = await paintTiles( + mapNum, + parsed.data.tiles, + authorized.session.account._id, ); + + const hasBlockedChange = parsed.data.tiles.some( + (tile) => tile.blocked === true || tile.blocked === false, + ); + + if (hasBlockedChange) { + const isolation = await checkIsolatedRegions(mapNum); + response.json({ ...result, isolatedRegions: isolation.isolated }); + return; + } + + response.json(result); } catch (error) { const message = error instanceof Error ? error.message : "Unexpected error"; @@ -909,6 +924,118 @@ app.delete( }, ); +/** Pinta un rectangulo de tiles como borrador en una sola operacion atomica. */ +app.post( + "/admin/game-data/maps/:mapNum/tiles/paint-rectangle", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const parsed = paintRectangleSchema.safeParse(request.body); + + if (!parsed.success) { + response + .status(400) + .json({ error: JSON.stringify(parsed.error.issues) }); + return; + } + + const result = await paintRectangle( + mapNum, + parsed.data, + authorized.session.account._id, + ); + + const hasBlockedChange = + parsed.data.blocked === true || parsed.data.blocked === false; + + if (hasBlockedChange) { + const isolation = await checkIsolatedRegions(mapNum); + response.json({ ...result, isolatedRegions: isolation.isolated }); + return; + } + + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** Devuelve los tiles (overrides) dentro de un rectangulo del mapa. */ +app.get( + "/admin/game-data/maps/:mapNum/tiles/region", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const parsed = queryRegionSchema.safeParse(request.query); + + if (!parsed.success) { + response + .status(400) + .json({ error: JSON.stringify(parsed.error.issues) }); + return; + } + + const overrides = await queryRegion(mapNum, parsed.data); + response.json({ mapNum, overrides }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** + * Verifica si hay regiones caminables aisladas en un mapa. + * + * Carga el mapa base, aplica los overrides publicados y ejecuta BFS para + * detectar casillas caminables inalcanzables desde una semilla. + */ +app.post( + "/admin/game-data/maps/:mapNum/tiles/blocked-check", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const result = await checkIsolatedRegions(mapNum); + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + /** * Devuelve los tiles modificados de un mapa. El cliente carga el mapa base * desde el archivo estatico y aplica estos cambios encima, asi no hay que diff --git a/api/src/tests/map-tiles.integration.test.ts b/api/src/tests/map-tiles.integration.test.ts new file mode 100644 index 00000000..e72b66da --- /dev/null +++ b/api/src/tests/map-tiles.integration.test.ts @@ -0,0 +1,492 @@ +import assert from "node:assert/strict"; +import { beforeAll, afterEach, test } from "vitest"; +import pool from "../db"; +import { + ensureApiReady, + registerAccount, + requestJson, +} from "./helpers/api"; + +const ADMIN_EMAIL = "admin@test.local"; +const ADMIN_PASSWORD = "AdminTest123"; +const ADMIN_PROXY_TOKEN = "test-admin-proxy-token"; + +let adminSessionToken: string; + +async function adminRequest( + path: string, + init?: RequestInit, +): Promise<{ status: number; ok: boolean; data: T }> { + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${adminSessionToken}`); + headers.set("x-game-data-admin-token", ADMIN_PROXY_TOKEN); + if (!headers.has("Content-Type") && init?.method !== "GET") { + headers.set("Content-Type", "application/json"); + } + return requestJson(path, { ...init, headers }); +} + +async function cleanupMap(mapNum: number): Promise { + await pool.query( + `DELETE FROM game_map_tile_overrides WHERE map_num = $1`, + [mapNum], + ); +} + +beforeAll(async () => { + await ensureApiReady(); + + try { + const session = await registerAccount( + "Admin Tiles", + ADMIN_EMAIL, + ADMIN_PASSWORD, + ); + adminSessionToken = session.sessionToken; + } catch { + const loginResponse = await requestJson< + { sessionToken?: string; error?: string } + >("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identifier: ADMIN_EMAIL, + password: ADMIN_PASSWORD, + }), + }); + if (loginResponse.ok && loginResponse.data.sessionToken) { + adminSessionToken = loginResponse.data.sessionToken; + } else { + throw new Error( + "Could not authenticate as admin for tile tests", + ); + } + } +}); + +afterEach(async () => { + await cleanupMap(1); +}); + +const TEST_MAP = 1; + +test("paint a single tile and verify it persists", async () => { + const paintResponse = await adminRequest<{ + applied?: number; + error?: string; + }>(`/admin/game-data/maps/${TEST_MAP}/tiles`, { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 10, y: 10, layer: 1, grhIndex: 5500 }], + }), + }); + + assert.equal(paintResponse.ok, true, JSON.stringify(paintResponse.data)); + assert.equal(paintResponse.data.applied, 1); + + const overrides = await adminRequest<{ + overrides?: Array<{ + x: number; + y: number; + layer: number; + grhIndex: number | null; + }>; + }>(`/maps/${TEST_MAP}/overrides`); + + assert.equal(overrides.ok, true); + const tile = overrides.data.overrides?.find( + (o) => o.x === 10 && o.y === 10 && o.layer === 1, + ); + assert.ok(tile, "Painted tile should appear in overrides"); + assert.equal(tile.grhIndex, 5500); +}); + +test("paint a 20x20 rectangle atomically", async () => { + const response = await adminRequest<{ + applied?: number; + error?: string; + }>(`/admin/game-data/maps/${TEST_MAP}/tiles/paint-rectangle`, { + method: "POST", + body: JSON.stringify({ + startX: 1, + startY: 1, + endX: 20, + endY: 20, + layer: 1, + grhIndex: 5500, + }), + }); + + assert.equal(response.ok, true, JSON.stringify(response.data)); + assert.equal(response.data.applied, 400); + + const overrides = await adminRequest<{ + overrides?: Array<{ x: number; y: number }>; + }>(`/maps/${TEST_MAP}/overrides`); + + assert.equal(overrides.ok, true); + assert.equal(overrides.data.overrides?.length, 400); +}); + +test("out-of-range coordinates return 400 without corrupting data", async () => { + const beforeResponse = await adminRequest<{ applied?: number }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 50, y: 50, layer: 1, grhIndex: 5500 }], + }), + }, + ); + assert.equal(beforeResponse.ok, true); + + const invalidResponses = await Promise.all([ + adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 0, y: 50, layer: 1, grhIndex: 5500 }], + }), + }, + ), + adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 101, y: 50, layer: 1, grhIndex: 5500 }], + }), + }, + ), + adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 50, y: 0, layer: 1, grhIndex: 5500 }], + }), + }, + ), + adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 50, y: 101, layer: 1, grhIndex: 5500 }], + }), + }, + ), + ]); + + for (const res of invalidResponses) { + assert.equal( + res.status, + 400, + `Expected 400 for invalid coords, got ${res.status}`, + ); + } + + const afterOverrides = await adminRequest<{ + overrides?: Array<{ x: number; y: number; layer: number }>; + }>(`/maps/${TEST_MAP}/overrides`); + const originalTile = afterOverrides.data.overrides?.find( + (o) => o.x === 50 && o.y === 50 && o.layer === 1, + ); + assert.ok( + originalTile, + "Original tile should still exist after failed operations", + ); +}); + +test("invalid graphic index returns 400 without corrupting data", async () => { + const invalidGraphic = 1000001; + const response = await adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 5, y: 5, layer: 1, grhIndex: invalidGraphic }], + }), + }, + ); + + assert.equal(response.status, 400); + assert.ok(response.data.error?.includes(String(invalidGraphic))); + + const overrides = await adminRequest<{ + overrides?: Array<{ x: number; y: number; layer: number }>; + }>(`/maps/${TEST_MAP}/overrides`); + const badTile = overrides.data.overrides?.find( + (o) => o.x === 5 && o.y === 5 && o.layer === 1, + ); + assert.equal( + badTile, + undefined, + "Invalid tile should not exist after failed operation", + ); +}); + +test("blocked flag is persisted on tile", async () => { + const response = await adminRequest<{ + applied?: number; + error?: string; + }>(`/admin/game-data/maps/${TEST_MAP}/tiles`, { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 25, y: 25, layer: 1, blocked: true }], + }), + }); + + assert.equal(response.ok, true, JSON.stringify(response.data)); + + const regionResponse = await adminRequest<{ + overrides?: Array<{ + x: number; + y: number; + blocked: boolean | null; + }>; + }>( + `/admin/game-data/maps/${TEST_MAP}/tiles/region?startX=25&startY=25&endX=25&endY=25`, + ); + + assert.equal(regionResponse.ok, true); + const tile = regionResponse.data.overrides?.find( + (o) => o.x === 25 && o.y === 25, + ); + assert.ok(tile, "Blocked tile should exist"); + assert.equal(tile.blocked, true); +}); + +test("failed operation leaves map unchanged (rollback on invalid graphic in batch)", async () => { + const invalidGraphic = 1000099; + + await adminRequest<{ applied?: number }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [{ x: 70, y: 70, layer: 1, grhIndex: 5500 }], + }), + }, + ); + + const response = await adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [ + { x: 71, y: 71, layer: 1, grhIndex: 5500 }, + { x: 72, y: 72, layer: 1, grhIndex: invalidGraphic }, + ], + }), + }, + ); + + assert.equal(response.status, 400); + + const overrides = await adminRequest<{ + overrides?: Array<{ x: number; y: number; layer: number }>; + }>(`/maps/${TEST_MAP}/overrides`); + + const tile71 = overrides.data.overrides?.find( + (o) => o.x === 71 && o.y === 71 && o.layer === 1, + ); + assert.equal( + tile71, + undefined, + "Tile 71 should not exist — batch rolled back", + ); + + const tile70 = overrides.data.overrides?.find( + (o) => o.x === 70 && o.y === 70 && o.layer === 1, + ); + assert.ok( + tile70, + "Tile 70 from the previous valid paint should still exist", + ); +}); + +test("region query returns only tiles within bounds", async () => { + await adminRequest<{ applied?: number }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ + tiles: [ + { x: 30, y: 30, layer: 1, grhIndex: 5500 }, + { x: 35, y: 35, layer: 1, grhIndex: 5501 }, + { x: 60, y: 60, layer: 1, grhIndex: 5502 }, + ], + }), + }, + ); + + const response = await adminRequest<{ + overrides?: Array<{ x: number; y: number }>; + }>( + `/admin/game-data/maps/${TEST_MAP}/tiles/region?startX=28&startY=28&endX=36&endY=36`, + ); + + assert.equal(response.ok, true, JSON.stringify(response.data)); + const xs = response.data.overrides?.map((o) => o.x) ?? []; + assert.ok(xs.includes(30), "Should include tile at x=30"); + assert.ok(xs.includes(35), "Should include tile at x=35"); + assert.ok( + !xs.includes(60), + "Should not include tile at x=60 (out of region)", + ); +}); + +test("paint-rectangle with blocked=true returns isolatedRegions field", async () => { + const response = await adminRequest<{ + applied?: number; + isolatedRegions?: boolean; + error?: string; + }>(`/admin/game-data/maps/${TEST_MAP}/tiles/paint-rectangle`, { + method: "POST", + body: JSON.stringify({ + startX: 45, + startY: 45, + endX: 55, + endY: 55, + layer: 1, + blocked: true, + }), + }); + + assert.equal(response.ok, true, JSON.stringify(response.data)); + assert.equal(typeof response.data.isolatedRegions, "boolean"); +}); + +test("blocked-check returns valid result on clean map", async () => { + const response = await adminRequest<{ + isolated?: boolean; + unreachableCount?: number; + }>(`/admin/game-data/maps/${TEST_MAP}/tiles/blocked-check`, { + method: "POST", + }); + + assert.equal(response.ok, true, JSON.stringify(response.data)); + assert.equal(typeof response.data.isolated, "boolean"); + assert.equal(typeof response.data.unreachableCount, "number"); +}); + +test("blocked-check detects isolated region after blocking a ring", async () => { + const ringResponse1 = await adminRequest<{ applied?: number }>( + `/admin/game-data/maps/${TEST_MAP}/tiles/paint-rectangle`, + { + method: "POST", + body: JSON.stringify({ + startX: 45, + startY: 45, + endX: 55, + endY: 45, + layer: 1, + blocked: true, + }), + }, + ); + assert.equal(ringResponse1.ok, true); + + const ringResponse2 = await adminRequest<{ applied?: number }>( + `/admin/game-data/maps/${TEST_MAP}/tiles/paint-rectangle`, + { + method: "POST", + body: JSON.stringify({ + startX: 45, + startY: 55, + endX: 55, + endY: 55, + layer: 1, + blocked: true, + }), + }, + ); + assert.equal(ringResponse2.ok, true); + + const ringResponse3 = await adminRequest<{ applied?: number }>( + `/admin/game-data/maps/${TEST_MAP}/tiles/paint-rectangle`, + { + method: "POST", + body: JSON.stringify({ + startX: 45, + startY: 45, + endX: 45, + endY: 55, + layer: 1, + blocked: true, + }), + }, + ); + assert.equal(ringResponse3.ok, true); + + const ringResponse4 = await adminRequest<{ applied?: number }>( + `/admin/game-data/maps/${TEST_MAP}/tiles/paint-rectangle`, + { + method: "POST", + body: JSON.stringify({ + startX: 55, + startY: 45, + endX: 55, + endY: 55, + layer: 1, + blocked: true, + }), + }, + ); + assert.equal(ringResponse4.ok, true); + + const response = await adminRequest<{ + isolated?: boolean; + unreachableCount?: number; + }>(`/admin/game-data/maps/${TEST_MAP}/tiles/blocked-check`, { + method: "POST", + }); + + assert.equal(response.ok, true, JSON.stringify(response.data)); + assert.equal(response.data.isolated, true); + assert.ok( + (response.data.unreachableCount ?? 0) > 0, + "Should have unreachable tiles inside the ring", + ); +}); + +test("tile array size limit rejects 501 tiles", async () => { + const tiles = Array.from({ length: 501 }, (_, i) => ({ + x: (i % 100) + 1, + y: Math.floor(i / 100) + 1, + layer: 1, + grhIndex: 5500, + })); + + const response = await adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles`, + { + method: "PUT", + body: JSON.stringify({ tiles }), + }, + ); + + assert.equal(response.status, 400); +}); + +test("rectangle size limit rejects >500 tiles", async () => { + const response = await adminRequest<{ error?: string }>( + `/admin/game-data/maps/${TEST_MAP}/tiles/paint-rectangle`, + { + method: "POST", + body: JSON.stringify({ + startX: 1, + startY: 1, + endX: 25, + endY: 25, + layer: 1, + grhIndex: 5500, + }), + }, + ); + + assert.equal(response.status, 400); +});