movement.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
  2. import { tmpdir } from "node:os";
  3. import { join } from "node:path";
  4. import { describe, expect, it } from "vitest";
  5. import {
  6. createEngine,
  7. type BuildingDef,
  8. type MovementSystem,
  9. type NpcMoveEvent,
  10. type PlacementSystem,
  11. type System,
  12. } from "../src/index.js";
  13. const TIME_YAML = [
  14. "id: default",
  15. "dayLengthTicks: 480",
  16. "seasons:",
  17. " - { name: spring, dayCount: 28 }",
  18. "start: { day: 1, season: spring }",
  19. "",
  20. ].join("\n");
  21. const FARM_YAML = [
  22. "id: farm",
  23. "width: 5",
  24. "height: 5",
  25. 'tilesetRef: "tiles"',
  26. "spawn: { x: 0, y: 0 }",
  27. "layers:",
  28. " - name: ground",
  29. " data:",
  30. " - [grass, grass, water, water, water]",
  31. " - [grass, grass, water, water, water]",
  32. " - [grass, grass, grass, grass, grass]",
  33. " - [grass, grass, grass, grass, grass]",
  34. " - [grass, grass, grass, grass, grass]",
  35. "",
  36. ].join("\n");
  37. const SHED: BuildingDef = {
  38. id: "shed",
  39. footprint: { w: 2, h: 2 },
  40. buildable: "map",
  41. blocksMovement: true,
  42. };
  43. const PATH: BuildingDef = {
  44. id: "path",
  45. footprint: { w: 1, h: 1 },
  46. buildable: "any",
  47. blocksMovement: false,
  48. };
  49. interface Setup {
  50. engine: ReturnType<typeof createEngine>;
  51. close: () => Promise<void>;
  52. }
  53. async function setup(spy?: System): Promise<Setup> {
  54. const tmp = await mkdtemp(join(tmpdir(), "farm-movement-"));
  55. await mkdir(join(tmp, "maps"), { recursive: true });
  56. await writeFile(join(tmp, "time.yaml"), TIME_YAML);
  57. await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
  58. const systems: System[] = [];
  59. if (spy) systems.push(spy);
  60. const engine = createEngine({ contentDir: tmp, systems });
  61. await engine.start();
  62. engine.registry.register("building", SHED);
  63. engine.registry.register("building", PATH);
  64. return {
  65. engine,
  66. close: async () => {
  67. await engine.stop();
  68. await rm(tmp, { recursive: true, force: true });
  69. },
  70. };
  71. }
  72. function movementOf(engine: ReturnType<typeof createEngine>): MovementSystem {
  73. const movement = engine.systems.get<MovementSystem>("movement");
  74. if (!movement) throw new Error("movement system not booted");
  75. return movement;
  76. }
  77. function spawnNpc(engine: ReturnType<typeof createEngine>, id: string, x: number, y: number): void {
  78. engine.world.create({ id, type: "npc", x, y });
  79. }
  80. function eventSpy(): { system: System; seen: { event: string; payload: unknown }[] } {
  81. const seen: { event: string; payload: unknown }[] = [];
  82. const system: System = {
  83. id: "spy",
  84. init() {},
  85. on(event, payload) {
  86. seen.push({ event, payload });
  87. },
  88. };
  89. return { system, seen };
  90. }
  91. describe("MovementSystem.moveTo", () => {
  92. it("moves an npc tile-to-tile and emits npc-move with from/to", async () => {
  93. const { system: spy, seen } = eventSpy();
  94. const { engine, close } = await setup(spy);
  95. try {
  96. spawnNpc(engine, "villager", 0, 0);
  97. const result = movementOf(engine).moveTo("villager", 2, 2);
  98. expect(result).toEqual({ npcId: "villager", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } });
  99. const npc = engine.world.get("villager") as { x: number; y: number };
  100. expect(npc.x).toBe(2);
  101. expect(npc.y).toBe(2);
  102. const moves = seen.filter((entry) => entry.event === "npc-move").map((entry) => entry.payload as NpcMoveEvent);
  103. expect(moves).toEqual([{ npcId: "villager", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }]);
  104. } finally {
  105. await close();
  106. }
  107. });
  108. it("throws on an unknown or non-npc entity", async () => {
  109. const { engine, close } = await setup();
  110. try {
  111. expect(() => movementOf(engine).moveTo("ghost", 0, 0)).toThrow(/unknown npc: ghost/);
  112. engine.world.create({ id: "player", type: "player", x: 0, y: 0 });
  113. expect(() => movementOf(engine).moveTo("player", 1, 1)).toThrow(/unknown npc: player/);
  114. } finally {
  115. await close();
  116. }
  117. });
  118. it("throws when the target is out of map bounds", async () => {
  119. const { engine, close } = await setup();
  120. try {
  121. spawnNpc(engine, "villager", 0, 0);
  122. expect(() => movementOf(engine).moveTo("villager", 5, 0)).toThrow(/target out of bounds: \(5,0\)/);
  123. expect(() => movementOf(engine).moveTo("villager", 0, 5)).toThrow(/target out of bounds: \(0,5\)/);
  124. } finally {
  125. await close();
  126. }
  127. });
  128. it("throws on an unwalkable tile (the water convention)", async () => {
  129. const { engine, close } = await setup();
  130. try {
  131. spawnNpc(engine, "villager", 0, 0);
  132. expect(() => movementOf(engine).moveTo("villager", 2, 0)).toThrow(/tile \(2,0\) is not walkable/);
  133. const npc = engine.world.get("villager") as { x: number; y: number };
  134. expect(npc.x).toBe(0);
  135. } finally {
  136. await close();
  137. }
  138. });
  139. it("throws when the target is blocked by a blocksMovement building", async () => {
  140. const { engine, close } = await setup();
  141. try {
  142. spawnNpc(engine, "villager", 3, 3);
  143. const placement = engine.systems.get<PlacementSystem>("placement");
  144. if (!placement) throw new Error("placement system not booted");
  145. placement.place("shed", 0, 0);
  146. expect(() => movementOf(engine).moveTo("villager", 0, 0)).toThrow(/blocked by building building:shed:0:0 at \(0,0\)/);
  147. expect(() => movementOf(engine).moveTo("villager", 1, 1)).toThrow(/blocked by building/);
  148. } finally {
  149. await close();
  150. }
  151. });
  152. it("allows movement onto a non-blocking building and onto another npc", async () => {
  153. const { engine, close } = await setup();
  154. try {
  155. spawnNpc(engine, "leah", 4, 4);
  156. spawnNpc(engine, "sam", 0, 0);
  157. const placement = engine.systems.get<PlacementSystem>("placement");
  158. if (!placement) throw new Error("placement system not booted");
  159. placement.place("path", 2, 2);
  160. expect(movementOf(engine).moveTo("leah", 2, 2)).toMatchObject({ to: { x: 2, y: 2 } });
  161. expect(movementOf(engine).moveTo("leah", 0, 0)).toMatchObject({ to: { x: 0, y: 0 } });
  162. } finally {
  163. await close();
  164. }
  165. });
  166. it("rejects bad coords", async () => {
  167. const { engine, close } = await setup();
  168. try {
  169. spawnNpc(engine, "villager", 0, 0);
  170. expect(() => movementOf(engine).moveTo("villager", -1, 0)).toThrow(/non-negative integers/);
  171. expect(() => movementOf(engine).moveTo("villager", 0, 1.5)).toThrow(/non-negative integers/);
  172. } finally {
  173. await close();
  174. }
  175. });
  176. });
  177. describe("MovementSystem.findPath (A* entity API)", () => {
  178. it("returns an A* path from the npc position to a reachable tile, including start and goal", async () => {
  179. const { engine, close } = await setup();
  180. try {
  181. spawnNpc(engine, "villager", 0, 0);
  182. const path = movementOf(engine).findPath("villager", { x: 2, y: 2 });
  183. expect(path).toEqual([
  184. { x: 0, y: 0 },
  185. { x: 1, y: 0 },
  186. { x: 1, y: 1 },
  187. { x: 1, y: 2 },
  188. { x: 2, y: 2 },
  189. ]);
  190. const npc = engine.world.get("villager") as { x: number; y: number };
  191. expect(npc.x).toBe(0);
  192. expect(npc.y).toBe(0);
  193. } finally {
  194. await close();
  195. }
  196. });
  197. it("returns null when the goal is not walkable (water fallback)", async () => {
  198. const { engine, close } = await setup();
  199. try {
  200. spawnNpc(engine, "villager", 0, 0);
  201. expect(movementOf(engine).findPath("villager", { x: 2, y: 0 })).toBeNull();
  202. } finally {
  203. await close();
  204. }
  205. });
  206. it("returns null when the goal is blocked by a blocksMovement building", async () => {
  207. const { engine, close } = await setup();
  208. try {
  209. spawnNpc(engine, "villager", 4, 4);
  210. const placement = engine.systems.get<PlacementSystem>("placement");
  211. if (!placement) throw new Error("placement system not booted");
  212. placement.place("shed", 0, 0);
  213. expect(movementOf(engine).findPath("villager", { x: 0, y: 0 })).toBeNull();
  214. } finally {
  215. await close();
  216. }
  217. });
  218. it("returns null when the goal is occupied by another grid entity", async () => {
  219. const { engine, close } = await setup();
  220. try {
  221. spawnNpc(engine, "leah", 0, 0);
  222. spawnNpc(engine, "sam", 1, 0);
  223. expect(movementOf(engine).findPath("leah", { x: 1, y: 0 })).toBeNull();
  224. expect(movementOf(engine).findPath("leah", { x: 2, y: 2 })).not.toBeNull();
  225. } finally {
  226. await close();
  227. }
  228. });
  229. it("returns null for an unreachable goal and validates the npc and coords", async () => {
  230. const { engine, close } = await setup();
  231. try {
  232. spawnNpc(engine, "villager", 0, 0);
  233. expect(() => movementOf(engine).findPath("ghost", { x: 1, y: 1 })).toThrow(/unknown npc: ghost/);
  234. expect(() => movementOf(engine).findPath("villager", { x: -1, y: 0 })).toThrow(/non-negative integers/);
  235. expect(movementOf(engine).findPath("villager", { x: 5, y: 0 })).toBeNull();
  236. } finally {
  237. await close();
  238. }
  239. });
  240. it("respects a map walkable declaration over the UNWALKABLE_TILES fallback", async () => {
  241. const { engine, close } = await setup();
  242. try {
  243. engine.registry.register("map", {
  244. id: "farm",
  245. width: 5,
  246. height: 5,
  247. tilesetRef: "tiles",
  248. spawn: { x: 0, y: 0 },
  249. layers: [
  250. { name: "ground", data: [
  251. ["grass", "grass", "water", "grass", "grass"],
  252. ["grass", "grass", "water", "grass", "grass"],
  253. ["grass", "grass", "water", "grass", "grass"],
  254. ["grass", "grass", "water", "grass", "grass"],
  255. ["grass", "grass", "grass", "grass", "grass"],
  256. ] },
  257. ],
  258. walkable: { water: true },
  259. });
  260. spawnNpc(engine, "villager", 0, 0);
  261. const path = movementOf(engine).findPath("villager", { x: 4, y: 0 });
  262. expect(path).not.toBeNull();
  263. expect(path![path!.length - 1]).toEqual({ x: 4, y: 0 });
  264. } finally {
  265. await close();
  266. }
  267. });
  268. });
  269. describe("MovementSystem.teleport", () => {
  270. it("bypasses bounds, walkability, and blocking checks but still emits npc-move", async () => {
  271. const { system: spy, seen } = eventSpy();
  272. const { engine, close } = await setup(spy);
  273. try {
  274. spawnNpc(engine, "villager", 3, 3);
  275. const placement = engine.systems.get<PlacementSystem>("placement");
  276. if (!placement) throw new Error("placement system not booted");
  277. placement.place("shed", 0, 0);
  278. const result = movementOf(engine).teleport("villager", 0, 0);
  279. expect(result).toEqual({ npcId: "villager", from: { x: 3, y: 3 }, to: { x: 0, y: 0 } });
  280. expect(movementOf(engine).teleport("villager", 99, 99)).toMatchObject({ to: { x: 99, y: 99 } });
  281. expect(movementOf(engine).teleport("villager", 2, 0)).toMatchObject({ to: { x: 2, y: 0 } });
  282. const moves = seen.filter((entry) => entry.event === "npc-move");
  283. expect(moves).toHaveLength(3);
  284. } finally {
  285. await close();
  286. }
  287. });
  288. it("still requires a valid npc and non-negative coords", async () => {
  289. const { engine, close } = await setup();
  290. try {
  291. expect(() => movementOf(engine).teleport("ghost", 0, 0)).toThrow(/unknown npc: ghost/);
  292. spawnNpc(engine, "villager", 0, 0);
  293. expect(() => movementOf(engine).teleport("villager", -2, 0)).toThrow(/non-negative integers/);
  294. } finally {
  295. await close();
  296. }
  297. });
  298. });