diff --git a/api/schema.sql b/api/schema.sql index d0008678..8d681220 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -650,5 +650,10 @@ CREATE TABLE IF NOT EXISTS game_map_tile_entities ( CREATE INDEX IF NOT EXISTS idx_game_map_tile_entities_map ON game_map_tile_entities(map_num, status); + +-- #8 NPC movement pattern (+ future entity extras). Safe if #9 already added meta. +ALTER TABLE game_map_tile_entities + ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}'::jsonb; + CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at ON game_uploaded_graphics(created_at DESC); diff --git a/api/src/lib/mapNpcPlacement.ts b/api/src/lib/mapNpcPlacement.ts new file mode 100644 index 00000000..ca026cfe --- /dev/null +++ b/api/src/lib/mapNpcPlacement.ts @@ -0,0 +1,183 @@ +/** + * Pure helpers for Etapa 2 NPC place / move / remove (#8). + * DB-free so acceptance rules are unit-testable without Postgres. + */ + +export const MAP_SIZE = 100; + +/** Soft cap so construction mode cannot flood a map with creatures. */ +export const MAX_NPCS_PER_MAP = 50; + +export type Point = { x: number; y: number }; + +export type NpcPlacementErrorCode = + | "out_of_bounds" + | "invalid_npc_index" + | "invalid_movement" + | "tile_blocked" + | "tile_occupied" + | "map_npc_limit" + | "npc_not_found" + | "same_tile_move"; + +export class NpcPlacementValidationError extends Error { + readonly code: NpcPlacementErrorCode; + + constructor(code: NpcPlacementErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export function assertInBounds(x: number, y: number, label = "tile"): void { + if ( + !Number.isInteger(x) || + !Number.isInteger(y) || + x < 1 || + y < 1 || + x > MAP_SIZE || + y > MAP_SIZE + ) { + throw new NpcPlacementValidationError( + "out_of_bounds", + `${label} (${x},${y}) fuera del mapa 1..${MAP_SIZE}.`, + ); + } +} + +export function assertValidNpcIndex(npcIndex: number): void { + if (!Number.isInteger(npcIndex) || npcIndex <= 0) { + throw new NpcPlacementValidationError( + "invalid_npc_index", + `npcIndex inexistente o invalido: ${String(npcIndex)}.`, + ); + } +} + +/** Movement is optional; when present it must be a non-negative integer. */ +export function normalizeMovement(movement: number | undefined): number | undefined { + if (movement === undefined) { + return undefined; + } + if (!Number.isInteger(movement) || movement < 0) { + throw new NpcPlacementValidationError( + "invalid_movement", + `movement invalido: ${String(movement)}.`, + ); + } + return movement; +} + +export function assertUnderMapNpcLimit(currentCount: number, adding = 1): void { + if (!Number.isInteger(currentCount) || currentCount < 0) { + throw new NpcPlacementValidationError( + "map_npc_limit", + `Conteo de NPCs invalido: ${String(currentCount)}.`, + ); + } + if (currentCount + adding > MAX_NPCS_PER_MAP) { + throw new NpcPlacementValidationError( + "map_npc_limit", + `El mapa ya tiene ${currentCount} NPCs; maximo ${MAX_NPCS_PER_MAP}.`, + ); + } +} + +/** + * Self-move (from === to) is a no-op success — maintainer note on #8. + * Distinct destination is returned for callers to run in one TX. + */ +export function planNpcMove( + from: Point, + to: Point, +): { from: Point; to: Point; noop: boolean } { + assertInBounds(from.x, from.y, "origen"); + assertInBounds(to.x, to.y, "destino"); + if (from.x === to.x && from.y === to.y) { + return { from, to, noop: true }; + } + return { from, to, noop: false }; +} + +/** + * Resolve whether (x,y) is blocked given baseline terrain + optional override. + * Override `blocked === null/undefined` means "fall back to terrain". + */ +export function resolveTileBlocked(options: { + x: number; + y: number; + width: number; + height: number; + /** Palette id at rows[y-1][x-1]. */ + paletteId: number | null | undefined; + /** palette[id].blocked */ + paletteBlocked: boolean | undefined; + /** Draft/published override for collision (layer-1 style). */ + overrideBlocked?: boolean | null; +}): boolean { + const { x, y, width, height } = options; + if ( + !Number.isInteger(x) || + !Number.isInteger(y) || + x < 1 || + y < 1 || + x > width || + y > height + ) { + return true; + } + if (options.overrideBlocked === true) { + return true; + } + if (options.overrideBlocked === false) { + return false; + } + return Boolean(options.paletteBlocked); +} + +export function assertTileNotBlocked(blocked: boolean, x: number, y: number): void { + if (blocked) { + throw new NpcPlacementValidationError( + "tile_blocked", + `Tile (${x},${y}) esta bloqueado; no se puede colocar un NPC ahi.`, + ); + } +} + +export function assertTileFree(occupied: boolean, x: number, y: number): void { + if (occupied) { + throw new NpcPlacementValidationError( + "tile_occupied", + `Ya hay un NPC en (${x},${y}).`, + ); + } +} + +export type TerrainSnapshot = { + width: number; + height: number; + /** rows[row][col] = palette id (1-based coords → rows[y-1][x-1]) */ + rows: number[][]; + paletteBlocked: Map; +}; + +export function isTerrainTileBlocked( + terrain: TerrainSnapshot, + x: number, + y: number, + overrideBlocked?: boolean | null, +): boolean { + const row = terrain.rows[y - 1]; + const paletteId = row ? row[x - 1] : null; + const paletteBlocked = + paletteId == null ? true : terrain.paletteBlocked.get(paletteId); + return resolveTileBlocked({ + x, + y, + width: terrain.width, + height: terrain.height, + paletteId, + paletteBlocked, + overrideBlocked, + }); +} diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 4402a924..544db3f8 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -25,6 +25,7 @@ export type TileEntityPlacement = { y: number; kind: TileEntityKind; entityId: number; + meta?: Record; }; export type UploadedGraphic = { @@ -422,11 +423,11 @@ export async function listMapTileEntities( includeDrafts = false, ): Promise { const query = includeDrafts - ? `SELECT DISTINCT ON (x, y, kind) x, y, kind, entity_id, status + ? `SELECT DISTINCT ON (x, y, kind) x, y, kind, entity_id, meta, status FROM game_map_tile_entities WHERE map_num = $1 ORDER BY x, y, kind, status ASC` - : `SELECT x, y, kind, entity_id, status + : `SELECT x, y, kind, entity_id, meta, status FROM game_map_tile_entities WHERE map_num = $1 AND status = 'published' ORDER BY y, x, kind`; @@ -436,6 +437,7 @@ export async function listMapTileEntities( y: number; kind: string; entity_id: number; + meta: Record | null; status: string; }>(query, [mapNum]); @@ -444,6 +446,7 @@ export async function listMapTileEntities( y: row.y, kind: row.kind as TileEntityKind, entityId: row.entity_id, + meta: (row.meta as Record | null) ?? {}, status: row.status as "draft" | "published", })); } @@ -479,12 +482,13 @@ export async function publishMap( const entitiesResult = await client.query( `INSERT INTO game_map_tile_entities - (map_num, x, y, kind, entity_id, status, updated_by_account_id, updated_at) - SELECT map_num, x, y, kind, entity_id, 'published', $2, NOW() + (map_num, x, y, kind, entity_id, meta, status, updated_by_account_id, updated_at) + SELECT map_num, x, y, kind, entity_id, meta, 'published', $2, NOW() FROM game_map_tile_entities WHERE map_num = $1 AND status = 'draft' ON CONFLICT (map_num, x, y, kind, status) DO UPDATE SET entity_id = EXCLUDED.entity_id, + meta = EXCLUDED.meta, updated_by_account_id = EXCLUDED.updated_by_account_id, updated_at = NOW()`, [mapNum, accountId], diff --git a/api/src/repositories/worldBuilderNpcs.ts b/api/src/repositories/worldBuilderNpcs.ts new file mode 100644 index 00000000..1509105c --- /dev/null +++ b/api/src/repositories/worldBuilderNpcs.ts @@ -0,0 +1,346 @@ +/** + * Etapa 2 (#8): place / move / remove / list map NPCs on worldBuilder drafts. + * Validates catalog, blocked terrain, stacking, and MAX_NPCS_PER_MAP. + */ + +import { existsSync } from "fs"; +import fs from "fs/promises"; +import path from "path"; +import { z } from "zod"; +import pool from "../db"; +import { + MAP_SIZE, + MAX_NPCS_PER_MAP, + NpcPlacementValidationError, + assertInBounds, + assertTileFree, + assertTileNotBlocked, + assertUnderMapNpcLimit, + assertValidNpcIndex, + isTerrainTileBlocked, + normalizeMovement, + planNpcMove, + type TerrainSnapshot, +} from "../lib/mapNpcPlacement"; + +export const placeNpcSchema = z.object({ + x: z.coerce.number().int().min(1).max(MAP_SIZE), + y: z.coerce.number().int().min(1).max(MAP_SIZE), + npcIndex: z.coerce.number().int().positive(), + movement: z.coerce.number().int().nonnegative().optional(), +}); + +export const moveNpcSchema = z.object({ + fromX: z.coerce.number().int().min(1).max(MAP_SIZE), + fromY: z.coerce.number().int().min(1).max(MAP_SIZE), + toX: z.coerce.number().int().min(1).max(MAP_SIZE), + toY: z.coerce.number().int().min(1).max(MAP_SIZE), +}); + +export type MapNpcEntity = { + x: number; + y: number; + npcIndex: number; + movement?: number; + status: "draft" | "published"; +}; + +function resolveMapsSourceDir(): string { + const candidates = [ + path.resolve(__dirname, ".."), + path.resolve(__dirname, "..", "..", "src"), + ]; + for (const candidate of candidates) { + if (existsSync(path.join(candidate, "mapas_source"))) { + return path.join(candidate, "mapas_source"); + } + } + return path.join(candidates[0], "mapas_source"); +} + +export async function loadTerrainSnapshot(mapNum: number): Promise { + const terrainPath = path.join( + resolveMapsSourceDir(), + `mapa_${mapNum}`, + "terrain.json", + ); + if (!existsSync(terrainPath)) { + throw new Error(`El mapa ${mapNum} no tiene terrain.json fuente.`); + } + const terrain = JSON.parse(await fs.readFile(terrainPath, "utf8")) as { + width?: number; + height?: number; + rows?: number[][]; + palette?: Record; + }; + const width = Number(terrain.width) || MAP_SIZE; + const height = Number(terrain.height) || MAP_SIZE; + const rows = Array.isArray(terrain.rows) ? terrain.rows : []; + const paletteBlocked = new Map(); + for (const [id, entry] of Object.entries(terrain.palette ?? {})) { + const parsedId = Number.parseInt(id, 10); + if (Number.isInteger(parsedId) && parsedId > 0) { + paletteBlocked.set(parsedId, Boolean(entry?.blocked)); + } + } + return { width, height, rows, paletteBlocked }; +} + +type Queryable = { + query: ( + text: string, + params?: unknown[], + ) => Promise<{ rows: any[]; rowCount: number | null }>; +}; + +async function getOverrideBlocked( + client: Queryable, + mapNum: number, + x: number, + y: number, +): Promise { + const result = await client.query( + `SELECT blocked FROM game_map_tile_overrides + WHERE map_num = $1 AND x = $2 AND y = $3 AND layer = 1 + AND blocked IS NOT NULL + ORDER BY CASE status WHEN 'draft' THEN 0 ELSE 1 END + LIMIT 1`, + [mapNum, x, y], + ); + const row = result.rows[0] as { blocked: boolean } | undefined; + return row ? Boolean(row.blocked) : null; +} + +async function assertNpcExists(client: Queryable, npcIndex: number): Promise { + assertValidNpcIndex(npcIndex); + const exists = await client.query( + `SELECT 1 FROM game_npcs WHERE id = $1 LIMIT 1`, + [npcIndex], + ); + if ((exists.rowCount ?? 0) === 0) { + throw new NpcPlacementValidationError( + "invalid_npc_index", + `El NPC ${npcIndex} no existe en el catalogo.`, + ); + } +} + +function metaFromMovement(movement: number | undefined): Record { + return movement === undefined ? {} : { movement }; +} + +function movementFromMeta(meta: unknown): number | undefined { + if (!meta || typeof meta !== "object") return undefined; + const value = (meta as { movement?: unknown }).movement; + return typeof value === "number" && Number.isInteger(value) && value >= 0 + ? value + : undefined; +} + +function toEntity( + x: number, + y: number, + npcIndex: number, + meta: unknown, + status: string, +): MapNpcEntity { + const movement = movementFromMeta(meta); + return movement === undefined + ? { x, y, npcIndex, status: status as "draft" | "published" } + : { x, y, npcIndex, movement, status: status as "draft" | "published" }; +} + +/** Effective NPC placements (draft overlays published). */ +export async function listMapNpcs(mapNum: number): Promise { + const result = await pool.query<{ + x: number; + y: number; + entity_id: number; + meta: Record | null; + status: string; + }>( + `SELECT DISTINCT ON (x, y) x, y, entity_id, meta, status + FROM game_map_tile_entities + WHERE map_num = $1 AND kind = 'npc' + ORDER BY x, y, CASE status WHEN 'draft' THEN 0 ELSE 1 END`, + [mapNum], + ); + return result.rows.map((row) => + toEntity(row.x, row.y, row.entity_id, row.meta, row.status), + ); +} + +async function countEffectiveNpcs(client: Queryable, mapNum: number): Promise { + const result = await client.query( + `SELECT COUNT(*)::int AS n FROM ( + SELECT DISTINCT ON (x, y) x, y + FROM game_map_tile_entities + WHERE map_num = $1 AND kind = 'npc' + ORDER BY x, y, CASE status WHEN 'draft' THEN 0 ELSE 1 END + ) t`, + [mapNum], + ); + return Number(result.rows[0]?.n ?? 0); +} + +async function hasNpcAt( + client: Queryable, + mapNum: number, + x: number, + y: number, +): Promise { + const result = await client.query( + `SELECT 1 FROM game_map_tile_entities + WHERE map_num = $1 AND x = $2 AND y = $3 AND kind = 'npc' + LIMIT 1`, + [mapNum, x, y], + ); + return (result.rowCount ?? 0) > 0; +} + +async function assertPlaceableTile( + client: Queryable, + mapNum: number, + x: number, + y: number, + terrain: TerrainSnapshot, +): Promise { + assertInBounds(x, y); + const overrideBlocked = await getOverrideBlocked(client, mapNum, x, y); + assertTileNotBlocked( + isTerrainTileBlocked(terrain, x, y, overrideBlocked), + x, + y, + ); + assertTileFree(await hasNpcAt(client, mapNum, x, y), x, y); +} + +export async function placeMapNpc( + mapNum: number, + input: z.infer, + accountId: string, +): Promise<{ placed: true; npcIndex: number; movement?: number; count: number }> { + const movement = normalizeMovement(input.movement); + assertInBounds(input.x, input.y); + const terrain = await loadTerrainSnapshot(mapNum); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await assertNpcExists(client, input.npcIndex); + await assertPlaceableTile(client, mapNum, input.x, input.y, terrain); + const count = await countEffectiveNpcs(client, mapNum); + assertUnderMapNpcLimit(count, 1); + await client.query( + `INSERT INTO game_map_tile_entities + (map_num, x, y, kind, entity_id, meta, status, updated_by_account_id, updated_at) + VALUES ($1,$2,$3,'npc',$4,$5::jsonb,'draft',$6,NOW())`, + [ + mapNum, + input.x, + input.y, + input.npcIndex, + JSON.stringify(metaFromMovement(movement)), + accountId, + ], + ); + await client.query("COMMIT"); + return movement === undefined + ? { placed: true, npcIndex: input.npcIndex, count: count + 1 } + : { + placed: true, + npcIndex: input.npcIndex, + movement, + count: count + 1, + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function moveMapNpc( + mapNum: number, + input: z.infer, + accountId: string, +): Promise<{ moved: true; noop?: true; npcIndex: number; movement?: number }> { + const plan = planNpcMove( + { x: input.fromX, y: input.fromY }, + { x: input.toX, y: input.toY }, + ); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const current = await client.query<{ + entity_id: number; + meta: Record | null; + }>( + `SELECT entity_id, meta FROM game_map_tile_entities + WHERE map_num=$1 AND x=$2 AND y=$3 AND kind='npc' AND status='draft' + FOR UPDATE`, + [mapNum, plan.from.x, plan.from.y], + ); + const row = current.rows[0]; + if (!row) { + throw new NpcPlacementValidationError( + "npc_not_found", + `No hay NPC borrador en (${plan.from.x},${plan.from.y}).`, + ); + } + const movement = movementFromMeta(row.meta); + if (plan.noop) { + await client.query("COMMIT"); + return movement === undefined + ? { moved: true, noop: true, npcIndex: row.entity_id } + : { moved: true, noop: true, npcIndex: row.entity_id, movement }; + } + + const terrain = await loadTerrainSnapshot(mapNum); + await assertPlaceableTile(client, mapNum, plan.to.x, plan.to.y, terrain); + + await client.query( + `DELETE FROM game_map_tile_entities + WHERE map_num=$1 AND x=$2 AND y=$3 AND kind='npc' AND status='draft'`, + [mapNum, plan.from.x, plan.from.y], + ); + await client.query( + `INSERT INTO game_map_tile_entities + (map_num, x, y, kind, entity_id, meta, status, updated_by_account_id, updated_at) + VALUES ($1,$2,$3,'npc',$4,$5::jsonb,'draft',$6,NOW())`, + [ + mapNum, + plan.to.x, + plan.to.y, + row.entity_id, + JSON.stringify(metaFromMovement(movement)), + accountId, + ], + ); + await client.query("COMMIT"); + return movement === undefined + ? { moved: true, npcIndex: row.entity_id } + : { moved: true, npcIndex: row.entity_id, movement }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function removeMapNpc( + mapNum: number, + x: number, + y: number, +): Promise<{ removed: boolean }> { + assertInBounds(x, y); + const removed = await pool.query( + `DELETE FROM game_map_tile_entities + WHERE map_num=$1 AND x=$2 AND y=$3 AND kind='npc' AND status='draft'`, + [mapNum, x, y], + ); + return { removed: (removed.rowCount ?? 0) > 0 }; +} + +export { MAX_NPCS_PER_MAP, NpcPlacementValidationError }; diff --git a/api/src/server.ts b/api/src/server.ts index 2b309610..03679231 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -113,6 +113,15 @@ import { tileEntitySchema, uploadGraphic, } from "./repositories/worldBuilder"; +import { + NpcPlacementValidationError, + listMapNpcs, + moveMapNpc, + moveNpcSchema, + placeMapNpc, + placeNpcSchema, + removeMapNpc, +} from "./repositories/worldBuilderNpcs"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -1134,6 +1143,119 @@ app.get( }, ); + +/** #8: list NPCs placed on a map (draft overlays published). */ +app.get("/admin/game-data/maps/:mapNum/npcs", 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; + } + + response.json({ mapNum, npcs: await listMapNpcs(mapNum) }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** #8: place NPC draft with catalog / blocked / stack / limit checks. */ +app.post("/admin/game-data/maps/:mapNum/npcs", 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 = placeNpcSchema.safeParse(request.body); + if (!parsed.success) { + response.status(400).json({ error: JSON.stringify(parsed.error.issues) }); + return; + } + + response.json( + await placeMapNpc(mapNum, parsed.data, authorized.session.account._id), + ); + } catch (error) { + if (error instanceof NpcPlacementValidationError) { + const status = error.code === "invalid_npc_index" ? 404 : 400; + response.status(status).json({ error: error.message, code: error.code }); + return; + } + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** #8: atomic move of a draft NPC (self-tile is a no-op success). */ +app.post("/admin/game-data/maps/:mapNum/npcs/move", 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 = moveNpcSchema.safeParse(request.body); + if (!parsed.success) { + response.status(400).json({ error: JSON.stringify(parsed.error.issues) }); + return; + } + + response.json( + await moveMapNpc(mapNum, parsed.data, authorized.session.account._id), + ); + } catch (error) { + if (error instanceof NpcPlacementValidationError) { + response.status(400).json({ error: error.message, code: error.code }); + return; + } + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** #8: remove a draft NPC from a tile. */ +app.delete("/admin/game-data/maps/:mapNum/npcs/:x/:y", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const x = Number.parseInt(request.params.x ?? "", 10); + const y = Number.parseInt(request.params.y ?? "", 10); + if ( + !Number.isInteger(mapNum) || + mapNum <= 0 || + !Number.isInteger(x) || + !Number.isInteger(y) + ) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + response.json(await removeMapNpc(mapNum, x, y)); + } catch (error) { + if (error instanceof NpcPlacementValidationError) { + response.status(400).json({ error: error.message, code: error.code }); + return; + } + const message = error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + /** Coloca un objeto o un NPC en un tile, como borrador. */ app.put( "/admin/game-data/maps/:mapNum/entities", diff --git a/api/src/tests/mapNpcPlacement.test.ts b/api/src/tests/mapNpcPlacement.test.ts new file mode 100644 index 00000000..0f07293e --- /dev/null +++ b/api/src/tests/mapNpcPlacement.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MAX_NPCS_PER_MAP, + NpcPlacementValidationError, + assertInBounds, + assertTileFree, + assertTileNotBlocked, + assertUnderMapNpcLimit, + assertValidNpcIndex, + isTerrainTileBlocked, + normalizeMovement, + planNpcMove, + resolveTileBlocked, + type TerrainSnapshot, +} from "../lib/mapNpcPlacement"; + +test("assertInBounds rejects non-integers and out of range", () => { + assertInBounds(1, 100); + assert.throws(() => assertInBounds(0, 1), NpcPlacementValidationError); + assert.throws(() => assertInBounds(1, 101), NpcPlacementValidationError); + assert.throws(() => assertInBounds(1.5, 1), NpcPlacementValidationError); +}); + +test("assertValidNpcIndex and normalizeMovement", () => { + assertValidNpcIndex(42); + assert.throws(() => assertValidNpcIndex(0), NpcPlacementValidationError); + assert.equal(normalizeMovement(undefined), undefined); + assert.equal(normalizeMovement(0), 0); + assert.equal(normalizeMovement(3), 3); + assert.throws(() => normalizeMovement(-1), NpcPlacementValidationError); +}); + +test("MAX_NPCS_PER_MAP is a single named constant and enforced", () => { + assert.equal(MAX_NPCS_PER_MAP, 50); + assertUnderMapNpcLimit(49, 1); + assert.throws(() => assertUnderMapNpcLimit(50, 1), (err: unknown) => { + assert.ok(err instanceof NpcPlacementValidationError); + assert.equal(err.code, "map_npc_limit"); + return true; + }); +}); + +test("planNpcMove treats self-tile as noop success (maintainer #8 note)", () => { + assert.deepEqual(planNpcMove({ x: 5, y: 5 }, { x: 5, y: 5 }), { + from: { x: 5, y: 5 }, + to: { x: 5, y: 5 }, + noop: true, + }); + assert.deepEqual(planNpcMove({ x: 5, y: 5 }, { x: 6, y: 5 }), { + from: { x: 5, y: 5 }, + to: { x: 6, y: 5 }, + noop: false, + }); + assert.throws(() => planNpcMove({ x: 0, y: 1 }, { x: 1, y: 1 }), NpcPlacementValidationError); +}); + +test("resolveTileBlocked: override wins, else palette, OOB blocked", () => { + assert.equal( + resolveTileBlocked({ + x: 1, + y: 1, + width: 100, + height: 100, + paletteId: 1, + paletteBlocked: false, + overrideBlocked: true, + }), + true, + ); + assert.equal( + resolveTileBlocked({ + x: 1, + y: 1, + width: 100, + height: 100, + paletteId: 1, + paletteBlocked: true, + overrideBlocked: false, + }), + false, + ); + assert.equal( + resolveTileBlocked({ + x: 1, + y: 1, + width: 100, + height: 100, + paletteId: 1, + paletteBlocked: true, + overrideBlocked: null, + }), + true, + ); + assert.equal( + resolveTileBlocked({ + x: 0, + y: 1, + width: 100, + height: 100, + paletteId: 1, + paletteBlocked: false, + }), + true, + ); +}); + +test("isTerrainTileBlocked reads palette via rows", () => { + const terrain: TerrainSnapshot = { + width: 3, + height: 3, + rows: [ + [1, 2, 1], + [2, 1, 2], + [1, 1, 2], + ], + paletteBlocked: new Map([ + [1, false], + [2, true], + ]), + }; + assert.equal(isTerrainTileBlocked(terrain, 1, 1), false); + assert.equal(isTerrainTileBlocked(terrain, 2, 1), true); + assert.equal(isTerrainTileBlocked(terrain, 2, 1, false), false); + assert.throws(() => assertTileNotBlocked(true, 2, 1), (err: unknown) => { + assert.ok(err instanceof NpcPlacementValidationError); + assert.equal(err.code, "tile_blocked"); + return true; + }); +}); + +test("assertTileFree rejects stacking", () => { + assertTileFree(false, 1, 1); + assert.throws(() => assertTileFree(true, 1, 1), (err: unknown) => { + assert.ok(err instanceof NpcPlacementValidationError); + assert.equal(err.code, "tile_occupied"); + return true; + }); +});