| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import { describe, expect, it } from "vitest";
- import { createEngine, sceneSchema } from "../src/index.js";
- const TIME_YAML = [
- "id: default",
- "dayLengthTicks: 480",
- "seasons:",
- " - { name: spring, dayCount: 28 }",
- "start: { day: 1, season: spring }",
- "",
- ].join("\n");
- const FARM_YAML = [
- "id: farm",
- "width: 5",
- "height: 5",
- 'tilesetRef: "tiles"',
- "spawn: { x: 0, y: 0 }",
- "layers:",
- " - name: ground",
- " data:",
- " - [grass, grass, grass, grass, grass]",
- " - [grass, grass, grass, grass, grass]",
- " - [grass, grass, grass, grass, water]",
- " - [grass, grass, grass, grass, water]",
- " - [grass, grass, grass, grass, grass]",
- "",
- ].join("\n");
- const SHED_YAML = [
- "id: shed",
- "name: Shed",
- "footprint: { w: 2, h: 2 }",
- "buildable: any",
- "blocksMovement: true",
- "",
- ].join("\n");
- const FENCE_YAML = [
- "id: fence",
- "name: Fence",
- "footprint: { w: 1, h: 1 }",
- "buildable: { tiles: [grass] }",
- "blocksMovement: true",
- "",
- ].join("\n");
- const CHICKEN_YAML = [
- "id: chicken",
- "stages:",
- " - { name: chick, days: 2, feedRequired: false }",
- " - { name: grown, days: 2, feedRequired: true }",
- "produces:",
- " - { itemId: egg, qty: 1 }",
- "",
- ].join("\n");
- interface Setup {
- engine: ReturnType<typeof createEngine>;
- close: () => Promise<void>;
- }
- async function boot(sceneYamls: Record<string, string> = {}): Promise<Setup> {
- const tmp = await mkdtemp(join(tmpdir(), "scene-"));
- await mkdir(join(tmp, "maps"), { recursive: true });
- await mkdir(join(tmp, "buildings"), { recursive: true });
- await mkdir(join(tmp, "animals"), { recursive: true });
- await writeFile(join(tmp, "time.yaml"), TIME_YAML);
- await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
- await writeFile(join(tmp, "buildings", "shed.yaml"), SHED_YAML);
- await writeFile(join(tmp, "buildings", "fence.yaml"), FENCE_YAML);
- await writeFile(join(tmp, "animals", "chicken.yaml"), CHICKEN_YAML);
- if (Object.keys(sceneYamls).length > 0) {
- await mkdir(join(tmp, "scenes"), { recursive: true });
- for (const [id, yaml] of Object.entries(sceneYamls)) {
- await writeFile(join(tmp, "scenes", `${id}.yaml`), yaml);
- }
- }
- const engine = createEngine({ contentDir: tmp });
- try {
- await engine.start();
- } catch (err) {
- await rm(tmp, { recursive: true, force: true });
- throw err;
- }
- return {
- engine,
- close: async () => {
- await engine.stop();
- await rm(tmp, { recursive: true, force: true });
- },
- };
- }
- function sceneYaml(id: string, entities: string[]): string {
- return [`id: ${id}`, "entities:", ...entities.map((entity) => ` - ${entity}`), ""].join("\n");
- }
- describe("sceneSchema", () => {
- it("accepts a valid building/npc/animal mix and an empty entity list", () => {
- expect(
- sceneSchema.safeParse({
- id: "farm-start",
- entities: [
- { type: "building", buildingId: "shed", x: 0, y: 0 },
- { type: "npc", npcId: "mayor", x: 1, y: 1, dialogue: "welcome", label: "Mayor" },
- { type: "animal", animalId: "chicken", x: 2, y: 2 },
- ],
- }).success,
- ).toBe(true);
- expect(sceneSchema.safeParse({ id: "empty", entities: [] }).success).toBe(true);
- });
- it("rejects discriminated-union misuse and unknown entity types", () => {
- const npcWithBuildingId = sceneSchema.safeParse({
- id: "s",
- entities: [{ type: "npc", buildingId: "shed", x: 0, y: 0 }],
- });
- expect(npcWithBuildingId.success).toBe(false);
- const unknownType = sceneSchema.safeParse({
- id: "s",
- entities: [{ type: "plot", x: 0, y: 0 }],
- });
- expect(unknownType.success).toBe(false);
- const missingType = sceneSchema.safeParse({
- id: "s",
- entities: [{ buildingId: "shed", x: 0, y: 0 }],
- });
- expect(missingType.success).toBe(false);
- });
- it("rejects negative coords, non-integer coords, and unknown keys", () => {
- expect(
- sceneSchema.safeParse({ id: "s", entities: [{ type: "building", buildingId: "shed", x: -1, y: 0 }] }).success,
- ).toBe(false);
- expect(
- sceneSchema.safeParse({ id: "s", entities: [{ type: "npc", npcId: "m", x: 0, y: 1.5 }] }).success,
- ).toBe(false);
- expect(
- sceneSchema.safeParse({ id: "s", entities: [{ type: "animal", animalId: "c", x: 0, y: 0, extra: 1 }] }).success,
- ).toBe(false);
- });
- it("rejects a missing id and non-array entities", () => {
- expect(sceneSchema.safeParse({ entities: [] }).success).toBe(false);
- expect(sceneSchema.safeParse({ id: "s", entities: "nope" }).success).toBe(false);
- });
- });
- describe("scene seed instantiation", () => {
- it("instantiates a building via PlacementSystem semantics with the runtime entity id", async () => {
- const { engine, close } = await boot({
- "farm-start": sceneYaml("farm-start", [
- "{ type: building, buildingId: shed, x: 0, y: 0 }",
- "{ type: building, buildingId: fence, x: 3, y: 3 }",
- ]),
- });
- try {
- const shed = engine.world.get("building:shed:0:0");
- expect(shed).toMatchObject({
- id: "building:shed:0:0",
- type: "building",
- buildingId: "shed",
- x: 0,
- y: 0,
- w: 2,
- h: 2,
- blocksMovement: true,
- });
- expect(engine.world.get("building:fence:3:3")).toMatchObject({ type: "building", buildingId: "fence" });
- } finally {
- await close();
- }
- });
- it("fails boot loudly when two scene buildings overlap", async () => {
- await expect(
- boot({
- "farm-start": sceneYaml("farm-start", [
- "{ type: building, buildingId: shed, x: 0, y: 0 }",
- "{ type: building, buildingId: fence, x: 1, y: 1 }",
- ]),
- }),
- ).rejects.toThrow(/overlaps existing building/);
- });
- it("fails boot loudly when a scene building is not buildable on its tile", async () => {
- await expect(
- boot({
- "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: fence, x: 4, y: 2 }"]),
- }),
- ).rejects.toThrow(/not buildable for fence/);
- });
- it("fails boot loudly when a scene references an unknown building def", async () => {
- await expect(
- boot({
- "farm-start": sceneYaml("farm-start", ["{ type: building, buildingId: barn, x: 0, y: 0 }"]),
- }),
- ).rejects.toThrow(/unknown building: barn/);
- });
- it("creates an npc entity with optional dialogue and label fields", async () => {
- const { engine, close } = await boot({
- "farm-start": sceneYaml("farm-start", [
- "{ type: npc, npcId: mayor, x: 1, y: 1, dialogue: welcome, label: Mayor }",
- "{ type: npc, npcId: villager, x: 3, y: 1 }",
- ]),
- });
- try {
- expect(engine.world.get("mayor")).toMatchObject({
- id: "mayor",
- type: "npc",
- x: 1,
- y: 1,
- dialogue: "welcome",
- label: "Mayor",
- });
- expect(engine.world.get("villager")).toEqual({ id: "villager", type: "npc", x: 3, y: 1 });
- } finally {
- await close();
- }
- });
- it("fails boot loudly on a duplicate npc entity id", async () => {
- await expect(
- boot({
- "farm-start": sceneYaml("farm-start", [
- "{ type: npc, npcId: mayor, x: 1, y: 1 }",
- "{ type: npc, npcId: mayor, x: 2, y: 2 }",
- ]),
- }),
- ).rejects.toThrow(/duplicate npc entity id: mayor/);
- });
- it("spawns animals via GrowthSystem.spawnAnimal with the runtime entity id", async () => {
- const { engine, close } = await boot({
- "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: chicken, x: 2, y: 2 }"]),
- });
- try {
- expect(engine.world.get("animal:chicken:1")).toMatchObject({
- id: "animal:chicken:1",
- type: "animal",
- animalId: "chicken",
- x: 2,
- y: 2,
- stageIndex: 0,
- daysInStage: 0,
- fed: false,
- });
- } finally {
- await close();
- }
- });
- it("fails boot loudly when a scene references an unknown animal def", async () => {
- await expect(
- boot({
- "farm-start": sceneYaml("farm-start", ["{ type: animal, animalId: cow, x: 2, y: 2 }"]),
- }),
- ).rejects.toThrow(/unknown animal def: cow/);
- });
- it("boots unchanged when zero scenes are registered", async () => {
- const { engine, close } = await boot();
- try {
- expect(engine.registry.list("scene")).toEqual([]);
- expect(engine.world.query({ type: "building" })).toHaveLength(0);
- expect(engine.world.query({ type: "npc" })).toHaveLength(0);
- expect(engine.world.query({ type: "animal" })).toHaveLength(0);
- expect(engine.world.get("player")).toMatchObject({ id: "player", type: "player", x: 0, y: 0 });
- } finally {
- await close();
- }
- });
- });
|