| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import { describe, expect, it } from "vitest";
- import {
- actionSchema,
- animationSchema,
- ContentLoader,
- ContentRegistry,
- World,
- type AnimationDef,
- type BehaviorRuntimeCtx,
- } from "../src/index.js";
- import { runActions } from "../src/systems/behavior-runtime.js";
- const VALID_DEF: AnimationDef = {
- id: "player",
- initial: "idle",
- states: [
- { id: "idle", spriteRef: "player", loop: true },
- { id: "walk", spriteRef: "player-walk", fps: 8, loop: true },
- ],
- transitions: [
- { from: "idle", to: "walk", on: "move" },
- { from: "walk", to: "idle", on: "idle" },
- ],
- };
- describe("animation content kind", () => {
- it("accepts a valid def with states, optional fps/loop, and optional transitions", () => {
- const result = animationSchema.safeParse(VALID_DEF);
- expect(result.success).toBe(true);
- expect(result.success && result.data.states[1]).toEqual({ id: "walk", spriteRef: "player-walk", fps: 8, loop: true });
- });
- it("accepts a minimal def without transitions", () => {
- const minimal = animationSchema.safeParse({ id: "player", initial: "idle", states: [{ id: "idle", spriteRef: "player" }] });
- expect(minimal.success).toBe(true);
- });
- it("rejects a bad initial state (not a declared state)", () => {
- const result = animationSchema.safeParse({ id: "player", initial: "nope", states: [{ id: "idle", spriteRef: "player" }] });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(result.error.issues.some((issue) => issue.path.join(".") === "initial" && /must be a declared state/.test(issue.message))).toBe(true);
- }
- });
- it("rejects duplicate state ids", () => {
- const result = animationSchema.safeParse({
- id: "player",
- initial: "idle",
- states: [
- { id: "idle", spriteRef: "player" },
- { id: "idle", spriteRef: "player-walk" },
- ],
- });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(result.error.issues.some((issue) => issue.path.join(".") === "states" && /duplicate state id/.test(issue.message))).toBe(true);
- }
- });
- it("rejects transitions referencing undeclared states", () => {
- const result = animationSchema.safeParse({
- id: "player",
- initial: "idle",
- states: [{ id: "idle", spriteRef: "player" }],
- transitions: [{ from: "idle", to: "dance", on: "x" }],
- });
- expect(result.success).toBe(false);
- if (!result.success) {
- expect(result.error.issues.some((issue) => issue.path.join(".") === "transitions.0.to" && /must be a declared state/.test(issue.message))).toBe(true);
- }
- });
- it("rejects non-positive fps and strict unknown keys", () => {
- expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s", fps: 0 }] }).success).toBe(false);
- expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s", extra: true }] }).success).toBe(false);
- expect(animationSchema.safeParse({ id: "x", initial: "a", states: [{ id: "a", spriteRef: "s" }], extra: true }).success).toBe(false);
- });
- it("loads and validates animation YAML from content/animations/", async () => {
- const tmp = await mkdtemp(join(tmpdir(), "farm-animation-"));
- try {
- await mkdir(join(tmp, "animations"), { recursive: true });
- await writeFile(join(tmp, "animations", "player.yaml"), [
- "id: player",
- "initial: idle",
- "states:",
- " - { id: idle, spriteRef: player, loop: true }",
- " - { id: walk, spriteRef: player-walk, fps: 8 }",
- "",
- ].join("\n"));
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.load(tmp);
- expect(report.ok).toBe(true);
- expect(registry.ids("animation")).toEqual(["player"]);
- expect(registry.get<AnimationDef>("animation", "player")).toEqual({
- id: "player",
- initial: "idle",
- states: [
- { id: "idle", spriteRef: "player", loop: true },
- { id: "walk", spriteRef: "player-walk", fps: 8 },
- ],
- });
- } finally {
- await rm(tmp, { recursive: true, force: true });
- }
- });
- it("rejects a loader-level invalid animation file without aborting siblings", async () => {
- const tmp = await mkdtemp(join(tmpdir(), "farm-animation-bad-"));
- try {
- await mkdir(join(tmp, "animations"), { recursive: true });
- await writeFile(join(tmp, "animations", "bad.yaml"), "id: bad\ninitial: nope\nstates: [{ id: idle, spriteRef: player }]\n");
- const registry = new ContentRegistry();
- const loader = new ContentLoader(registry);
- const report = await loader.load(tmp);
- expect(report.ok).toBe(false);
- const bad = report.items.find((i) => i.id === "bad");
- expect(bad?.status).toBe("error");
- expect(registry.get("animation", "bad")).toBeUndefined();
- } finally {
- await rm(tmp, { recursive: true, force: true });
- }
- });
- });
- describe("set-state null clears an entity's anim field (D38)", () => {
- it("accepts null as a set-state value in the action schema", () => {
- const parsed = actionSchema.safeParse({ action: "set-state", path: "player.anim", value: null });
- expect(parsed.success).toBe(true);
- });
- it("runtime set-state writes null, clearing a previously-set anim string", () => {
- const world = new World();
- world.create({ id: "player", type: "player", x: 0, y: 0, anim: "dance" });
- const ctx: BehaviorRuntimeCtx = { world };
- runActions(ctx, [{ action: "set-state", path: "player.anim", value: null }]);
- expect(world.get("player")).toMatchObject({ anim: null });
- runActions(ctx, [{ action: "set-state", path: "player.anim", value: "dance" }]);
- expect(world.get("player")).toMatchObject({ anim: "dance" });
- runActions(ctx, [{ action: "set-state", path: "player.anim", value: null }]);
- expect(world.get("player")).toMatchObject({ anim: null });
- });
- });
|