| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591 |
- import { resolve } from "node:path";
- import { describe, expect, it } from "vitest";
- import { createEngine, type IntentSystem, type InventorySystem, type PlantingSystem } from "@nengine/core";
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
- import { DEFAULT_DIRS, resolveDirs } from "../src/index.js";
- import { createNengineMcpServer, createGateway } from "../src/server.js";
- const repoRoot = resolve(process.cwd(), "../..");
- const sampleGameDir = resolve(repoRoot, "examples", "sample-game");
- const contentDir = resolve(sampleGameDir, "content");
- const modsDir = resolve(sampleGameDir, "mods");
- interface Harness {
- engine: ReturnType<typeof createEngine>;
- client: Client;
- close: () => Promise<void>;
- }
- async function setup(): Promise<Harness> {
- const dirs = resolveDirs({ argv: ["--content-dir", contentDir, "--mods-dir", modsDir], env: {}, cwd: repoRoot });
- const engine = createEngine(dirs);
- await engine.start();
- const gateway = createGateway(engine);
- const server = createNengineMcpServer(engine, gateway);
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
- await server.connect(serverTransport);
- const client = new Client({ name: "sample-game-test", version: "0.0.1" });
- await client.connect(clientTransport);
- return {
- engine,
- client,
- close: async () => {
- await client.close();
- await server.close();
- await engine.stop();
- },
- };
- }
- function textOf(result: { content?: { type: string; text?: string }[] }): string {
- return result.content?.find((block) => block.type === "text")?.text ?? "";
- }
- function jsonOf(text: string): Record<string, any> {
- return JSON.parse(text) as Record<string, any>;
- }
- async function callTool(client: Client, name: string, arguments_: Record<string, unknown>): Promise<Record<string, any>> {
- const result = await client.callTool({ name, arguments: arguments_ });
- if (result.isError) {
- throw new Error(`tool ${name} failed: ${textOf(result)}`);
- }
- return jsonOf(textOf(result));
- }
- describe("apps/mcp-server resolveDirs (parameterized entry)", () => {
- it("defaults to the monorepo content/mods when nothing is provided", () => {
- expect(resolveDirs({ argv: [], env: {}, cwd: repoRoot })).toEqual(DEFAULT_DIRS);
- });
- it("resolves CLI paths relative to cwd", () => {
- const dirs = resolveDirs({
- argv: ["--content-dir", "examples/sample-game/content", "--mods-dir", "examples/sample-game/mods"],
- env: {},
- cwd: repoRoot,
- });
- expect(dirs.contentDir).toBe(contentDir);
- expect(dirs.modsDir).toBe(modsDir);
- });
- it("reads env vars and lets CLI win over env, env win over default", () => {
- const envDirs = resolveDirs({
- argv: [],
- env: { NENGINE_CONTENT_DIR: "env-content", NENGINE_MODS_DIR: "env-mods" },
- cwd: repoRoot,
- });
- expect(envDirs.contentDir).toBe(resolve(repoRoot, "env-content"));
- expect(envDirs.modsDir).toBe(resolve(repoRoot, "env-mods"));
- const cliWins = resolveDirs({
- argv: ["--content-dir", "cli-content"],
- env: { NENGINE_CONTENT_DIR: "env-content" },
- cwd: repoRoot,
- });
- expect(cliWins.contentDir).toBe(resolve(repoRoot, "cli-content"));
- expect(cliWins.modsDir).toBe(DEFAULT_DIRS.modsDir);
- });
- it("leaves absolute CLI paths untouched", () => {
- const isWindows = process.platform === "win32";
- const absArg = isWindows ? "C:\\abs\\content" : "/abs/content";
- const dirs = resolveDirs({ argv: ["--content-dir", absArg], env: {}, cwd: repoRoot });
- expect(dirs.contentDir).toBe(absArg);
- });
- it("resolves the optional ws port via CLI --ws-port / env NENGINE_WS_PORT / default", () => {
- const viaCli = resolveDirs({ argv: ["--ws-port", "8787"], env: {}, cwd: repoRoot });
- expect(viaCli.wsPort).toBe(8787);
- const viaEnv = resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
- expect(viaEnv.wsPort).toBe(9000);
- const cliWins = resolveDirs({ argv: ["--ws-port", "8787"], env: { NENGINE_WS_PORT: "9000" }, cwd: repoRoot });
- expect(cliWins.wsPort).toBe(8787);
- const unset = resolveDirs({ argv: [], env: {}, cwd: repoRoot });
- expect(unset.wsPort).toBeUndefined();
- expect(resolveDirs({ argv: [], env: { NENGINE_WS_PORT: "" }, cwd: repoRoot }).wsPort).toBeUndefined();
- });
- it("rejects invalid ws port values", () => {
- for (const bad of ["abc", "0", "-1", "8787.5", "70000"]) {
- expect(() => resolveDirs({ argv: ["--ws-port", bad], env: {}, cwd: repoRoot })).toThrow(/invalid ws port/);
- }
- });
- });
- describe("examples/sample-game end-to-end through the parameterized entry", () => {
- it("loads every content kind and server.info reflects the sample game", async () => {
- const { client, close } = await setup();
- try {
- const info = await callTool(client, "server.info", {});
- expect(info.content).toEqual({
- animal: ["chicken", "cow"],
- animation: ["player"],
- behavior: ["starter-gift"],
- building: ["barn", "coop", "fence", "shop"],
- crop: ["carrot", "parsnip", "pumpkin", "turnip"],
- dialogue: ["mayor", "mia"],
- interact: ["default"],
- map: ["farm"],
- time: ["default"],
- item: [
- "baked-egg",
- "cake",
- "carrot-seed",
- "carrot-soup",
- "carrot",
- "cherry-jam",
- "egg",
- "festival-cherry-petal",
- "festival-firework-ticket",
- "festival-harvest-charm",
- "festival-snow-crystal",
- "fish-stew",
- "fish",
- "grand-feast",
- "harvest-pie",
- "hoe",
- "milk",
- "oat-milk",
- "parsnip-seed",
- "parsnip-soup",
- "parsnip",
- "pumpkin-pie",
- "pumpkin-seed",
- "pumpkin",
- "snow-cone",
- "summer-pudding",
- "turnip-seed",
- "turnip-soup",
- "turnip",
- "watering-can",
- ],
- particle: ["dust", "rain", "sparkle", "wind"],
- recipe: [
- "baked-egg",
- "cake",
- "carrot-soup",
- "cherry-jam",
- "fish-stew",
- "grand-feast",
- "harvest-pie",
- "oat-milk",
- "parsnip-soup",
- "pumpkin-pie",
- "snow-cone",
- "summer-pudding",
- "turnip-soup",
- ],
- shader: ["rain-tint", "sparkle-glow"],
- shop: ["general-store"],
- sound: ["bgm-autumn", "bgm-spring", "bgm-summer", "bgm-victory", "bgm-winter", "coin", "harvest", "till", "water"],
- sprite: ["barn", "carrot-grown", "carrot-mature", "carrot-sprout", "chicken-chick", "chicken-grown", "chicken-laying", "coop", "cow-calf", "cow-grown", "cow-milking", "fence", "parsnip-grown", "parsnip-mature", "parsnip-sprout", "player-walk", "player", "pumpkin-grown", "pumpkin-mature", "pumpkin-sprout", "shop", "turnip-grown", "turnip-mature", "turnip-sprout"],
- statemachine: ["carrot-quest", "chicken-egg", "cow-milk", "daily-stamina", "festival-autumn", "festival-spring", "festival-summer", "festival-winter", "fishing-treat", "mayor-routine", "mayor-talk-quest", "restoration-quest", "starter-quest"],
- weather: ["default"],
- });
- expect(info.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
- const map = await callTool(client, "map.get", { mapId: "farm" });
- expect(map.id).toBe("farm");
- expect(map.width).toBe(20);
- expect(map.height).toBe(16);
- } finally {
- await close();
- }
- });
- it("loads the sample-game binary assets into the engine asset table (D31)", async () => {
- const { engine, client, close } = await setup();
- try {
- expect(engine.assets.size).toBe(21);
- expect(engine.assets.get("grass-1")).toMatchObject({ kind: "image", mime: "image/png" });
- expect(engine.assets.get("water-1")).toMatchObject({ kind: "image", mime: "image/png" });
- expect(engine.assets.get("tilled-1")).toMatchObject({ kind: "image", mime: "image/png" });
- expect(engine.assets.get("plant-sprout")).toMatchObject({ kind: "image" });
- expect(engine.assets.get("coop-1")).toMatchObject({ kind: "image" });
- expect(engine.assets.get("fence-1")).toMatchObject({ kind: "image" });
- for (const id of ["click-1", "confirm-1", "error-1", "drop-1", "pluck-1"]) {
- expect(engine.assets.get(id)).toMatchObject({ kind: "audio", mime: "audio/ogg" });
- }
- const info = await callTool(client, "server.info", {});
- // sound catalog includes core sfx plus seasonal/victory BGMs added by the sample festival template.
- expect(info.content.sound).toEqual(expect.arrayContaining(["coin", "harvest", "till", "water"]));
- expect((info.content.sound as string[]).filter((id) => id.startsWith("bgm-")).sort()).toEqual([
- "bgm-autumn",
- "bgm-spring",
- "bgm-summer",
- "bgm-victory",
- "bgm-winter",
- ]);
- } finally {
- await close();
- }
- });
- it("plays the full gameplay arc: gift, grow, harvest, sell, buy, build, chicken, egg, quest, dialogue", async () => {
- const { engine, client, close } = await setup();
- try {
- const inv = engine.systems.get<InventorySystem>("inventory");
- const planting = engine.systems.get<PlantingSystem>("planting");
- if (!inv || !planting) throw new Error("systems not booted");
- const player = () => engine.world.get("player");
- expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [] });
- expect(inv.items()).toEqual({});
- const day2 = await callTool(client, "time.advance", { ticks: 480 });
- expect(day2).toEqual({ day: 2, season: "spring", tickInDay: 0 });
- expect(inv.items()).toEqual({ "parsnip-seed": 3, "carrot-seed": 2 });
- expect(player()?.flag).toEqual({ "starter-gift": "given" });
- expect(await callTool(client, "npc.list", {})).toEqual({ npcs: [{ id: "npc:mayor", x: 6, y: 5 }] });
- expect(engine.world.get("mod:sample-stats")).toMatchObject({ type: "mod-state", greetings: 1 });
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
- machineId: "starter-quest",
- state: "active",
- started: true,
- });
- const weather = await callTool(client, "weather.get", {});
- expect(["sunny", "rain", "wind", "storm"]).toContain(weather.weatherId);
- const plots: [number, number][] = [
- [6, 4],
- [7, 4],
- [8, 4],
- ];
- for (const [x, y] of plots) {
- planting.till(x, y);
- planting.sow(x, y, "parsnip-seed");
- await ensureWatered(engine, client, x, y);
- }
- await callTool(client, "time.tick", { ticks: 480 });
- await callTool(client, "time.tick", { ticks: 480 });
- for (const [x, y] of plots) {
- await ensureWatered(engine, client, x, y);
- }
- await callTool(client, "time.tick", { ticks: 480 });
- await callTool(client, "time.tick", { ticks: 480 });
- for (const [x, y] of plots) {
- expect(engine.world.get(`plot:${x}:${y}`)).toMatchObject({ stageIndex: 2 });
- }
- for (const [x, y] of plots) {
- planting.harvest(x, y);
- }
- expect(inv.count("parsnip")).toBe(3);
- expect(inv.count("parsnip-seed")).toBe(2);
- expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "starter-quest" })).toEqual({
- machineId: "starter-quest",
- state: "complete",
- started: true,
- });
- expect(
- await callTool(client, "shop.sell", { shopId: "general-store", itemId: "parsnip", qty: 3 }),
- ).toEqual({ shopId: "general-store", itemId: "parsnip", qty: 3, total: 90, gold: 90 });
- expect(
- await callTool(client, "shop.buy", { shopId: "general-store", itemId: "carrot-seed", qty: 1 }),
- ).toEqual({ shopId: "general-store", itemId: "carrot-seed", qty: 1, total: 25, gold: 65 });
- expect(await callTool(client, "placement.place", { buildingId: "coop", x: 10, y: 4 })).toMatchObject({
- id: "building:coop:10:4",
- type: "building",
- buildingId: "coop",
- x: 10,
- y: 4,
- w: 3,
- h: 3,
- blocksMovement: true,
- });
- expect(await callTool(client, "placement.place", { buildingId: "fence", x: 9, y: 8 })).toMatchObject({
- id: "building:fence:9:8",
- });
- expect(await callTool(client, "animal.spawn", { animalId: "chicken", x: 9, y: 3 })).toMatchObject({
- id: "animal:chicken:1",
- type: "animal",
- animalId: "chicken",
- stageIndex: 0,
- });
- expect(await callTool(client, "animal.stage", { id: "animal:chicken:1", stageIndex: 2 })).toMatchObject({
- stageIndex: 2,
- stage: "laying",
- });
- expect(await callTool(client, "animal.feed", { id: "animal:chicken:1" })).toEqual({
- id: "animal:chicken:1",
- animalId: "chicken",
- fed: true,
- });
- await callTool(client, "time.tick", { ticks: 480 });
- expect((await callTool(client, "item.query_inventory", {})).inventory.egg).toBe(1);
- expect(engine.world.get("mod:sample-stats")).toMatchObject({ greetings: 6 });
- expect(await callTool(client, "npc.get", { npcId: "npc:mayor" })).toMatchObject({
- id: "npc:mayor",
- type: "npc",
- name: "Mayor",
- x: 6,
- y: 6,
- });
- const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
- expect(started.nodeId).toBe("greeting");
- expect(started.choices.map((choice: { text: string }) => choice.text)).toEqual([
- "Any farming tips?",
- "Tell me about chickens.",
- "Got anything for me?",
- "I am just passing through.",
- ]);
- const rewarded = await callTool(client, "dialogue.choose", { index: 2 });
- expect(rewarded.nodeId).toBe("quest-reward");
- expect(inv.count("egg")).toBe(3);
- const farewell = await callTool(client, "dialogue.choose", { index: 0 });
- expect(farewell.nodeId).toBe("farewell");
- expect(farewell.choices).toEqual([]);
- } finally {
- await close();
- }
- });
- });
- describe("examples/sample-game Tier 3 content (D29): quest chain, stamina/skills, fishing minigame", () => {
- it("chains the quests: starter-quest complete → carrot-quest active → harvest carrots → complete + reward", async () => {
- const { engine, client, close } = await setup();
- try {
- const inv = engine.systems.get<InventorySystem>("inventory");
- if (!inv) throw new Error("inventory system not booted");
- const player = () => engine.world.get("player");
- await callTool(client, "time.advance", { ticks: 480 }); // day 2: starter gift + starter-quest active
- await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2);
- expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
- await callTool(client, "time.tick", { ticks: 480 }); // next day-start activates carrot-quest
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
- machineId: "carrot-quest",
- state: "active",
- started: true,
- });
- const carrotSeedBefore = inv.count("carrot-seed");
- await growAndHarvest(engine, client, [[6, 4]], "carrot-seed", 2, 3);
- expect(player()?.questProgress).toEqual({ "starter-quest": "complete", "carrot-quest": "complete" });
- expect(inv.count("carrot")).toBe(1);
- expect(inv.count("carrot-seed")).toBe(carrotSeedBefore + 1); // -1 sown, +2 quest reward
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "carrot-quest" })).toEqual({
- machineId: "carrot-quest",
- state: "complete",
- started: true,
- });
- } finally {
- await close();
- }
- });
- it("completes mayor-talk-quest by finishing the mayor dialogue and gates a new dialogue branch", async () => {
- const { engine, client, close } = await setup();
- try {
- const inv = engine.systems.get<InventorySystem>("inventory");
- if (!inv) throw new Error("inventory system not booted");
- const player = () => engine.world.get("player");
- await callTool(client, "time.advance", { ticks: 480 });
- await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2); // starter-quest complete
- await callTool(client, "time.tick", { ticks: 480 }); // carrot-quest active
- await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3); // carrot-quest complete
- await callTool(client, "time.tick", { ticks: 480 }); // mayor-talk-quest active
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
- machineId: "mayor-talk-quest",
- state: "active",
- started: true,
- });
- const eggBefore = inv.count("egg");
- const started = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
- expect(started.choices.map((choice: { text: string }) => choice.text)).not.toContain("Tell me about fishing.");
- await callTool(client, "dialogue.choose", { index: started.choices.length - 1 }); // farewell → dialogue-end
- expect(player()?.questProgress).toEqual({
- "starter-quest": "complete",
- "carrot-quest": "complete",
- "mayor-talk-quest": "complete",
- });
- expect(inv.count("egg")).toBe(eggBefore + 1);
- expect(player()?.gold).toBe(100);
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "mayor-talk-quest" })).toEqual({
- machineId: "mayor-talk-quest",
- state: "complete",
- started: true,
- });
- const again = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
- expect(again.choices.map((choice: { text: string }) => choice.text)).toContain("Tell me about fishing.");
- } finally {
- await close();
- }
- });
- it("fishes at the pond via IntentSystem: fish gained, stamina drained, skill incremented, mod event + treat reward", async () => {
- const { engine, client, close } = await setup();
- try {
- const inv = engine.systems.get<InventorySystem>("inventory");
- const intent = engine.systems.get<IntentSystem>("intent");
- if (!inv || !intent) throw new Error("systems not booted");
- const player = () => engine.world.get("player");
- const caught: unknown[] = [];
- engine.events.on("mod:fish-caught", (payload) => caught.push(payload));
- const tired: unknown[] = [];
- engine.events.on("mod:fish-too-tired", (payload) => tired.push(payload));
- expect(player()).toMatchObject({ stamina: 100, skills: {} });
- await callTool(client, "time.advance", { ticks: 480 }); // day 2: daily-stamina machine restores to ready
- expect(player()?.stamina).toBe(100);
- expect(intent.handle("move", { tile: { x: 3, y: 3 } })).toMatchObject({ ok: true });
- for (let i = 0; i < 3; i++) {
- expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({
- action: "interact",
- tile: { x: 3, y: 2 },
- ok: true,
- });
- }
- expect(inv.count("fish")).toBe(3);
- expect(player()).toMatchObject({ stamina: 85, skills: { fishing: 3 } });
- expect(caught).toHaveLength(3);
- expect(caught[0]).toMatchObject({ fish: "fish", size: 1, x: 3, y: 2, stamina: 95 });
- expect(caught[2]).toMatchObject({ fish: "fish", size: 3, catches: 3, stamina: 85 });
- expect(inv.count("egg")).toBe(1); // every third catch: fishing-treat machine matches mod:fish-caught
- expect(engine.world.get("plot:3:2")).toBeUndefined(); // the Mod normalizes the default till-on-water
- (player() as { stamina: number }).stamina = 3;
- expect(intent.handle("interact", { tile: { x: 3, y: 2 } })).toMatchObject({ ok: true });
- expect(inv.count("fish")).toBe(3);
- expect(player()).toMatchObject({ stamina: 3, skills: { fishing: 3 } });
- expect(tired).toHaveLength(1);
- expect(tired[0]).toMatchObject({ x: 3, y: 2, stamina: 3 });
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "daily-stamina" })).toEqual({
- machineId: "daily-stamina",
- state: "low",
- started: true,
- });
- } finally {
- await close();
- }
- });
- });
- describe("examples/sample-game Tier 4 content: restoration arc + victory", () => {
- it("unlocks Mia, completes restoration-quest at 500 gold, and gates the victory dialogues", async () => {
- const { engine, client, close } = await setup();
- try {
- const inv = engine.systems.get<InventorySystem>("inventory");
- if (!inv) throw new Error("inventory system not booted");
- const player = () => engine.world.get("player");
- // Starter arc -> starter-quest complete.
- await callTool(client, "time.advance", { ticks: 480 });
- await growAndHarvest(engine, client, [[6, 4], [7, 4], [8, 4]], "parsnip-seed", 2, 2);
- expect(player()?.questProgress).toEqual({ "starter-quest": "complete" });
- // carrot-quest -> complete.
- await callTool(client, "time.tick", { ticks: 480 });
- await growAndHarvest(engine, client, [[6, 4], [7, 4]], "carrot-seed", 2, 3);
- expect(player()?.questProgress?.["carrot-quest"]).toBe("complete");
- // mayor-talk-quest: activate + finish the mayor dialogue (+100 gold, +1 egg).
- await callTool(client, "time.tick", { ticks: 480 });
- const mayor = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
- await callTool(client, "dialogue.choose", { index: mayor.choices.length - 1 }); // farewell -> dialogue-end
- expect(player()?.questProgress?.["mayor-talk-quest"]).toBe("complete");
- expect(player()?.gold).toBe(100);
- // First day-start after mayor-talk-quest: restoration-quest activates and Mia spawns.
- await callTool(client, "time.tick", { ticks: 480 });
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "restoration-quest" })).toEqual({
- machineId: "restoration-quest",
- state: "active",
- started: true,
- });
- const npcs = await callTool(client, "npc.list", {});
- expect(npcs.npcs.map((npc: { id: string }) => npc.id)).toContain("npc:mia");
- // Reach the 500-gold restoration goal by selling pumpkins (a new autumn crop).
- await callTool(client, "item.give", { itemId: "pumpkin", qty: 12 });
- const sold = await callTool(client, "shop.sell", { shopId: "general-store", itemId: "pumpkin", qty: 12 });
- expect(sold.gold).toBe(640); // 100 + 12 * 45
- // Next day-start: restoration-quest completes -> flag.victory + 200 gold bonus + 3 eggs.
- const eggBefore = inv.count("egg");
- await callTool(client, "time.tick", { ticks: 480 });
- expect(await callTool(client, "behavior.statemachine.status", { machineId: "restoration-quest" })).toEqual({
- machineId: "restoration-quest",
- state: "complete",
- started: true,
- });
- expect(player()?.flag?.victory).toBe(true);
- // Expected base: 100 (start) + 12 * 45 (pumpkin sale) + 200 (restoration-quest bonus) = 840.
- // On or after Spring day 15, festival-spring additionally awards +150 gold for a total of 990.
- expect([840, 990]).toContain(player()?.gold);
- expect(inv.count("egg")).toBe(eggBefore + 3);
- // The Mayor's and Mia's victory branches are now gated in.
- const mayorAgain = await callTool(client, "dialogue.start", { dialogueId: "mayor" });
- expect(mayorAgain.choices.map((choice: { text: string }) => choice.text)).toContain("The farm is restored!");
- const mia = await callTool(client, "dialogue.start", { dialogueId: "mia" });
- expect(mia.choices.map((choice: { text: string }) => choice.text)).toContain("The farm is thriving now!");
- } finally {
- await close();
- }
- });
- });
- async function ensureWatered(
- engine: ReturnType<typeof createEngine>,
- client: Client,
- x: number,
- y: number,
- ): Promise<void> {
- const plot = engine.world.get(`plot:${x}:${y}`) as { watered?: boolean } | undefined;
- if (!plot || plot.watered) return;
- await callTool(client, "crop.water", { x, y });
- }
- async function growAndHarvest(
- engine: ReturnType<typeof createEngine>,
- client: Client,
- plots: [number, number][],
- seedItemId: string,
- sproutDays: number,
- grownDays: number,
- ): Promise<void> {
- const planting = engine.systems.get<PlantingSystem>("planting");
- if (!planting) throw new Error("planting system not booted");
- for (const [x, y] of plots) {
- planting.till(x, y);
- planting.sow(x, y, seedItemId);
- await ensureWatered(engine, client, x, y);
- }
- await callTool(client, "time.tick", { ticks: 480 * sproutDays });
- for (const [x, y] of plots) {
- await ensureWatered(engine, client, x, y);
- }
- await callTool(client, "time.tick", { ticks: 480 * grownDays });
- for (const [x, y] of plots) {
- planting.harvest(x, y);
- }
- }
|