src/bot/minecraftNarration.test.ts

import { test } from "bun:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path";

import { Store } from "../store/store.ts"; import { rmTempDir } from "../testHelpers.ts"; import { createMinecraftNarrationState, maybePostMinecraftNarration } from "./minecraftNarration.ts"; import type { MinecraftGameEvent } from "../agents/minecraft/types.ts";

function mcEvent(event: MinecraftGameEvent): MinecraftGameEvent { return event; }

async function withTempStore(run: (store: Store) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clanker-minecraft-narration-test-")); const dbPath = path.join(dir, "clanker.db"); const store = new Store(dbPath); store.init();

try { await run(store); } finally { store.close(); await rmTempDir(dir); } }

function buildRuntime({ store, guildId, channelId, llmGenerate, onSend }: { store: Store; guildId: string; channelId: string; llmGenerate: (payload: Record<string, unknown>) => Promise<Record<string, unknown>>; onSend?: (payload: Record<string, unknown>) => void; }) { const channel = { id: channelId, guildId, name: "survival", async sendTyping() { return true; }, async send(payload: Record<string, unknown>) { onSend?.(payload); return { id: sent-${Date.now()}, createdTimestamp: Date.now(), guildId, channelId }; } };

return { appConfig: { env: "test" }, store, llm: { generate: llmGenerate }, memory: {}, client: { user: { id: "bot-1" }, guilds: { cache: { get() { return undefined; } } }, channels: { cache: { get(id: string) { return id === channelId ? channel : undefined; } } } }, botUserId: "bot-1", canSendMessage() { return true; }, canTalkNow() { return true; }, getSimulatedTypingDelayMs() { return 0; }, markSpoke() {}, composeMessageContentForHistory(_message: unknown, baseText = "") { return baseText; } } as Parameters[0]; }

test("maybePostMinecraftNarration posts a Discord narration message for significant events", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; const llmCalls: Array<Record<string, unknown>> = []; const sentPayloads: Array<Record<string, unknown>> = [];

store.patchSettings({
  permissions: {
    replies: {
      maxMessagesPerHour: 100
    }
  },
  agentStack: {
    runtimeConfig: {
      minecraft: {
        narration: {
          eagerness: 100,
          minSecondsBetweenPosts: 0
        }
      }
    }
  }
});
store.recordMessage({
  messageId: "msg-1",
  createdAt: Date.now() - 1_000,
  guildId,
  channelId,
  authorId: "user-1",
  authorName: "alice",
  isBot: false,
  content: "how's the cave run going",
  referencedMessageId: null
});

const runtime = buildRuntime({
  store,
  guildId,
  channelId,
  onSend(payload) {
    sentPayloads.push(payload);
  },
  async llmGenerate(payload) {
    llmCalls.push(payload);
    return {
      text: JSON.stringify({
        skip: false,
        text: "i just ate shit and died in the cave lmao",
        reason: "death is worth surfacing"
      }),
      toolCalls: [],
      rawContent: null,
      provider: "test",
      model: "test-model",
      usage: {
        inputTokens: 0,
        outputTokens: 0
      },
      costUsd: 0.01
    };
  }
});

const posted = await maybePostMinecraftNarration(runtime, {
  guildId,
  channelId,
  ownerUserId: "user-1",
  scopeKey: "guild-1:channel-1",
  source: "reply_session",
  serverLabel: "Survival SMP",
  events: [mcEvent({ type: "death", timestamp: "2026-04-04T12:00:00.000Z", summary: "death" })],
  state: createMinecraftNarrationState()
});

assert.equal(posted, true);
assert.equal(llmCalls.length, 1);
assert.equal(sentPayloads.length, 1);
assert.equal(sentPayloads[0]?.content, "i just ate shit and died in the cave lmao");
assert.match(String(llmCalls[0]?.userPrompt || ""), /how's the cave run going/i);

const postLog = store.getRecentActions(10, { kinds: ["minecraft_narration_post"] })[0];
assert.equal(postLog?.content, "i just ate shit and died in the cave lmao");

}); });

test("maybePostMinecraftNarration honors model skip and starts a per-channel min-gap cooldown", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; let llmCallCount = 0;

store.patchSettings({
  permissions: {
    replies: {
      maxMessagesPerHour: 100
    }
  },
  agentStack: {
    runtimeConfig: {
      minecraft: {
        narration: {
          eagerness: 100,
          minSecondsBetweenPosts: 120
        }
      }
    }
  }
});

const runtime = buildRuntime({
  store,
  guildId,
  channelId,
  async llmGenerate() {
    llmCallCount += 1;
    return {
      text: JSON.stringify({
        skip: true,
        text: "[SKIP]",
        reason: "not worth posting to Discord"
      }),
      toolCalls: [],
      rawContent: null,
      provider: "test",
      model: "test-model",
      usage: {
        inputTokens: 0,
        outputTokens: 0
      },
      costUsd: 0.01
    };
  }
});
const state = createMinecraftNarrationState();

const first = await maybePostMinecraftNarration(runtime, {
  guildId,
  channelId,
  ownerUserId: "user-1",
  scopeKey: "guild-1:channel-1",
  source: "reply_session",
  serverLabel: "Survival SMP",
  events: [mcEvent({ type: "player_join", timestamp: "2026-04-04T12:00:00.000Z", summary: "player joined: Alice", playerName: "Alice" })],
  state
});
const second = await maybePostMinecraftNarration(runtime, {
  guildId,
  channelId,
  ownerUserId: "user-1",
  scopeKey: "guild-1:channel-1",
  source: "reply_session",
  serverLabel: "Survival SMP",
  events: [mcEvent({ type: "player_leave", timestamp: "2026-04-04T12:00:05.000Z", summary: "player left: Alice", playerName: "Alice" })],
  state
});

assert.equal(first, false);
assert.equal(second, false);
assert.equal(llmCallCount, 1);
assert.equal(store.getRecentActions(10, { kinds: ["minecraft_narration_skip"] }).length, 1);
assert.equal(
  store.getRecentActions(10, { kinds: ["minecraft_narration_blocked"] }).some((entry) => entry.content === "min_gap_active"),
  true
);

}); });

test("maybePostMinecraftNarration surfaces in-game chat history into the narration prompt", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; const llmCalls: Array<Record<string, unknown>> = [];

store.patchSettings({
  permissions: {
    replies: {
      maxMessagesPerHour: 100
    }
  },
  agentStack: {
    runtimeConfig: {
      minecraft: {
        narration: {
          eagerness: 100,
          minSecondsBetweenPosts: 0
        }
      }
    }
  }
});

const runtime = buildRuntime({
  store,
  guildId,
  channelId,
  async llmGenerate(payload) {
    llmCalls.push(payload);
    return {
      text: JSON.stringify({
        skip: true,
        text: "[SKIP]",
        reason: "chat already handled it"
      }),
      toolCalls: [],
      rawContent: null,
      provider: "test",
      model: "test-model",
      usage: {
        inputTokens: 0,
        outputTokens: 0
      },
      costUsd: 0.01
    };
  }
});

const posted = await maybePostMinecraftNarration(runtime, {
  guildId,
  channelId,
  ownerUserId: "user-1",
  scopeKey: "guild-1:channel-1",
  source: "reply_session",
  serverLabel: "Survival SMP",
  events: [mcEvent({ type: "player_join", timestamp: "2026-04-04T12:00:00.000Z", summary: "player joined: Alice", playerName: "Alice" })],
  chatHistory: [
    {
      sender: "Alice",
      text: "yo clanky where u at",
      timestamp: "2026-04-04T11:59:55.000Z",
      isBot: false
    },
    {
      sender: "Clanky",
      text: "coming down the hill now",
      timestamp: "2026-04-04T11:59:58.000Z",
      isBot: true
    }
  ],
  state: createMinecraftNarrationState()
});

assert.equal(posted, false);
assert.equal(llmCalls.length, 1);
const userPrompt = String(llmCalls[0]?.userPrompt || "");
assert.match(userPrompt, /=== RECENT IN-GAME CHAT ===/);
assert.match(userPrompt, /<Alice> yo clanky where u at/);
assert.match(userPrompt, /coming down the hill now/);

}); });

test("maybePostMinecraftNarration dedups progression milestones per session (first diamond fires, second is filtered)", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; let llmCallCount = 0;

store.patchSettings({
  permissions: {
    replies: {
      maxMessagesPerHour: 100
    }
  },
  agentStack: {
    runtimeConfig: {
      minecraft: {
        narration: {
          eagerness: 100,
          minSecondsBetweenPosts: 0
        }
      }
    }
  }
});

const runtime = buildRuntime({
  store,
  guildId,
  channelId,
  async llmGenerate() {
    llmCallCount += 1;
    return {
      text: JSON.stringify({
        skip: true,
        text: "[SKIP]",
        reason: "not worth posting"
      }),
      toolCalls: [],
      rawContent: null,
      provider: "test",
      model: "test-model",
      usage: {
        inputTokens: 0,
        outputTokens: 0
      },
      costUsd: 0.01
    };
  }
});
const state = createMinecraftNarrationState();

const first = await maybePostMinecraftNarration(runtime, {
  guildId,
  channelId,
  ownerUserId: "user-1",
  scopeKey: "guild-1:channel-1",
  source: "reply_session",
  serverLabel: "Survival SMP",
  events: [mcEvent({ type: "item_pickup", timestamp: "2026-04-04T12:00:00.000Z", summary: "collected 1 block(s) of diamond_ore", itemName: "diamond_ore", count: 1 })],
  state
});
const second = await maybePostMinecraftNarration(runtime, {
  guildId,
  channelId,
  ownerUserId: "user-1",
  scopeKey: "guild-1:channel-1",
  source: "reply_session",
  serverLabel: "Survival SMP",
  events: [mcEvent({ type: "item_pickup", timestamp: "2026-04-04T12:01:00.000Z", summary: "collected 1 block(s) of diamond_ore", itemName: "diamond_ore", count: 1 })],
  state
});

// First call: milestone passes filter, LLM invoked (and returns [SKIP]).
// Milestone is consumed even on skip — this is the "once per session"
// progression semantic. Second identical event must be filtered OUT
// categorically, with zero additional LLM calls.
assert.equal(first, false);
assert.equal(second, false);
assert.equal(llmCallCount, 1);
const filteredEntries = store
  .getRecentActions(10, { kinds: ["minecraft_narration_filtered"] })
  .filter((entry) => entry.content === "no_significant_events");
assert.equal(filteredEntries.length, 1);

}); });

test("maybePostMinecraftNarration filters routine Minecraft events before invoking the LLM", async () => { await withTempStore(async (store) => { const guildId = "guild-1"; const channelId = "channel-1"; let llmCallCount = 0;

store.patchSettings({
  agentStack: {
    runtimeConfig: {
      minecraft: {
        narration: {
          eagerness: 100,
          minSecondsBetweenPosts: 0
        }
      }
    }
  }
});

const runtime = buildRuntime({
  store,
  guildId,
  channelId,
  async llmGenerate() {
    llmCallCount += 1;
    return {
      text: "",
      toolCalls: [],
      rawContent: null,
      provider: "test",
      model: "test-model",
      usage: {
        inputTokens: 0,
        outputTokens: 0
      }
    };
  }
});

const posted = await maybePostMinecraftNarration(runtime, {
  guildId,
  channelId,
  ownerUserId: "user-1",
  scopeKey: "guild-1:channel-1",
  source: "reply_session",
  serverLabel: "Survival SMP",
  events: [
    mcEvent({ type: "navigation", timestamp: "2026-04-04T12:00:00.000Z", summary: "pathfinding to 10,64,10 (range=1)", x: 10, y: 64, z: 10, range: 1 }),
    mcEvent({ type: "chat", timestamp: "2026-04-04T12:00:01.000Z", summary: "sent chat: ok heading over", sender: "ClankyBuddy", message: "ok heading over", isBot: true })
  ],
  state: createMinecraftNarrationState()
});

assert.equal(posted, false);
assert.equal(llmCallCount, 0);
assert.equal(
  store.getRecentActions(10, { kinds: ["minecraft_narration_filtered"] }).some((entry) => entry.content === "no_significant_events"),
  true
);

}); });