| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291 |
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
- import { existsSync } from "node:fs";
- import { tmpdir } from "node:os";
- import { dirname, join } from "node:path";
- import { describe, expect, it } from "vitest";
- import { createEngine } from "@nengine/core";
- import type { ContentBundle } from "@nengine/core";
- import { compileBundle } from "@nengine/pack";
- import { createPlaytestSession, saveAllFlushes } from "../src/renderer/playtest-session.js";
- const MOD_ID = "playtest-mod";
- const MOD_CODE = [
- "export default {",
- " manifest: { id: \"playtest-mod\", version: \"0.1.0\" },",
- " hooks: {",
- " onDayStart(ctx) {",
- " const { world } = ctx;",
- " let stats = world.get(\"mod:playtest-stats\");",
- " if (!stats) {",
- " stats = world.create({ id: \"mod:playtest-stats\", type: \"mod-state\", days: 0 });",
- " }",
- " stats.days += 1;",
- " },",
- " },",
- "};",
- "",
- ].join("\n");
- const FIXTURE_FILES: Record<string, string> = {
- "content/time.yaml": [
- "id: default",
- "dayLengthTicks: 480",
- "seasons:",
- " - { name: spring, dayCount: 14 }",
- "start: { day: 1, season: spring }",
- "",
- ].join("\n"),
- "content/weather.yaml": [
- "id: default",
- "defaults:",
- " sunny: { name: Sunny }",
- "seasons:",
- " - season: spring",
- " weathers:",
- " - { id: sunny, weight: 100 }",
- "",
- ].join("\n"),
- "content/interact.yaml": [
- "id: default",
- "order:",
- " - harvest",
- " - water",
- " - sow",
- " - till",
- "",
- ].join("\n"),
- "content/maps/farm.yaml": [
- "# Playtest fixture map: grass with a water pond; spawn at (2,2).",
- "id: farm",
- "width: 12",
- "height: 12",
- "tilesetRef: farm-tiles",
- "spawn: { x: 2, y: 2 }",
- "walkable:",
- " water: false",
- "layers:",
- " - name: ground",
- " data:",
- mapRows(),
- "",
- ].join("\n"),
- "content/items/hoe.yaml": [
- "id: hoe",
- "name: Hoe",
- "kind: tool",
- "stackable: false",
- "price: 100",
- "use: tills a soil tile",
- "",
- ].join("\n"),
- "content/items/carrot-seed.yaml": [
- "id: carrot-seed",
- "name: Carrot Seed",
- "kind: seed",
- "stackable: true",
- "price: 20",
- "use: plant in tilled soil",
- "",
- ].join("\n"),
- "content/items/carrot.yaml": [
- "id: carrot",
- "name: Carrot",
- "kind: material",
- "stackable: true",
- "price: 35",
- "use: harvested from a carrot plant",
- "",
- ].join("\n"),
- "content/items/egg.yaml": [
- "id: egg",
- "name: Egg",
- "kind: material",
- "stackable: true",
- "price: 25",
- "use: laid by a chicken",
- "",
- ].join("\n"),
- "content/crops/carrot.yaml": [
- "id: carrot",
- "seedItem: carrot-seed",
- "stages:",
- " - { name: sprout, days: 1, waterRequired: true }",
- " - { name: mature, days: 1, waterRequired: false }",
- "seasons:",
- " - spring",
- "drops:",
- " - { itemId: carrot, qty: 1 }",
- "",
- ].join("\n"),
- "content/buildings/shed.yaml": [
- "id: shed",
- "name: Shed",
- "footprint: { w: 2, h: 2 }",
- "buildable: any",
- "blocksMovement: true",
- "",
- ].join("\n"),
- "content/animals/chicken.yaml": [
- "id: chicken",
- "stages:",
- " - { name: chick, days: 1, feedRequired: true }",
- "produces:",
- " - { itemId: egg, qty: 1 }",
- "",
- ].join("\n"),
- "content/scenes/farm.yaml": [
- "id: farm",
- "entities:",
- " - { type: building, buildingId: shed, x: 4, y: 4 }",
- " - { type: npc, npcId: mayor, x: 6, y: 2, label: Mayor }",
- " - { type: animal, animalId: chicken, x: 8, y: 8 }",
- "",
- ].join("\n"),
- "mods/playtest-mod/index.mjs": MOD_CODE,
- };
- /** A 12×12 map: a 2×2 water pond at the top-left, grass elsewhere. */
- function mapRows(): string {
- const rows: string[] = [];
- for (let y = 0; y < 12; y++) {
- const row: string[] = [];
- for (let x = 0; x < 12; x++) {
- row.push(x < 2 && y < 2 ? "water" : "grass");
- }
- rows.push(` - [${row.join(", ")}]`);
- }
- return rows.join("\n");
- }
- async function fixtureDir(): Promise<string> {
- const dir = await mkdtemp(join(tmpdir(), "editor-playtest-"));
- for (const [rel, content] of Object.entries(FIXTURE_FILES)) {
- const target = join(dir, rel);
- await mkdir(dirname(target), { recursive: true });
- await writeFile(target, content, "utf8");
- }
- return dir;
- }
- async function rmRecursive(dir: string): Promise<void> {
- await rm(dir, { recursive: true, force: true });
- }
- describe("playtest bundle boot (E5 fixture)", () => {
- it("compiles a project with a scene + mod and boots in bundle mode with the scene seeded", async () => {
- const dir = await fixtureDir();
- try {
- const bundle = await compileBundle(join(dir, "content"), join(dir, "mods"));
- expect(bundle.mods?.[MOD_ID]).toBeDefined();
- expect(typeof bundle.mods?.[MOD_ID]?.code).toBe("string");
- expect(bundle.content["building"]).toBeDefined();
- expect(bundle.content["animal"]).toBeDefined();
- expect(bundle.content["scene"]).toBeDefined();
- const engine = createEngine({ bundle });
- await engine.start();
- try {
- const mapEntity = engine.world.query({ type: "map" })[0];
- expect(mapEntity?.id).toBe("farm");
- expect(engine.world.get("player")).toMatchObject({ id: "player", x: 2, y: 2 });
- const shed = engine.world.query({ type: "building" })[0];
- expect(shed?.id).toBe("building:shed:4:4");
- expect(shed?.x).toBe(4);
- expect(shed?.y).toBe(4);
- expect(shed?.w).toBe(2);
- expect(shed?.h).toBe(2);
- const mayor = engine.world.get("mayor");
- expect(mayor?.type).toBe("npc");
- expect(mayor?.label).toBe("Mayor");
- const chicken = engine.world.query({ type: "animal" })[0];
- expect(chicken?.id).toBe("animal:chicken:1");
- expect(chicken?.x).toBe(8);
- expect(chicken?.y).toBe(8);
- expect(engine.mods).toHaveLength(1);
- expect(engine.mods[0]?.id).toBe(MOD_ID);
- expect(engine.mods[0]?.loaded).toBe(true);
- expect(engine.world.get("mod:playtest-stats")).toBeUndefined();
- engine.tick(480);
- expect(engine.world.get("mod:playtest-stats")?.days).toBe(1);
- } finally {
- await engine.stop();
- }
- } finally {
- await rmRecursive(dir);
- }
- });
- it("save-all flush content lands in the compiled bundle (see what ships)", async () => {
- const dir = await fixtureDir();
- try {
- const modified = FIXTURE_FILES["content/maps/farm.yaml"] as string;
- const result = await saveAllFlushes(
- [{ path: "content/maps/farm.yaml", content: modified.replace("spawn: { x: 2, y: 2 }", "spawn: { x: 5, y: 5 }") }],
- (path, content) => writeFile(join(dir, path), content, "utf8"),
- );
- expect(result.failures).toEqual([]);
- expect(result.flushed).toEqual(["content/maps/farm.yaml"]);
- const bundle = await compileBundle(join(dir, "content"));
- const engine = createEngine({ bundle });
- await engine.start();
- try {
- expect(engine.world.get("player")?.x).toBe(5);
- expect(engine.world.get("player")?.y).toBe(5);
- } finally {
- await engine.stop();
- }
- } finally {
- await rmRecursive(dir);
- }
- });
- it("leaves bundle.mods undefined when the project has no mods dir (main handler parity)", async () => {
- const dir = await fixtureDir();
- try {
- await rm(join(dir, "mods"), { recursive: true, force: true });
- const modsDir = join(dir, "mods");
- const bundle = await compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined);
- expect(bundle.mods).toBeUndefined();
- } finally {
- await rmRecursive(dir);
- }
- });
- it("the full playtest session orchestrates save-all → compile → boot and tears down", async () => {
- const dir = await fixtureDir();
- try {
- let bootedBundle: ContentBundle | undefined;
- let tornDown = false;
- const session = createPlaytestSession({
- collectFlushes: () => [],
- write: async (path, content) => writeFile(join(dir, path), content, "utf8"),
- compile: async () => {
- const modsDir = join(dir, "mods");
- return compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined);
- },
- boot: async (bundle) => {
- bootedBundle = bundle;
- },
- teardown: async () => {
- tornDown = true;
- },
- });
- const result = await session.start();
- expect(result.ok).toBe(true);
- expect(bootedBundle?.content["scene"]).toBeDefined();
- expect(bootedBundle?.mods?.[MOD_ID]).toBeDefined();
- await session.stop();
- expect(tornDown).toBe(true);
- expect(session.phase).toBe("idle");
- } finally {
- await rmRecursive(dir);
- }
- });
- });
|