import type { MinecraftChatMessage } from "../agents/minecraft/minecraftBrain.ts"; import type { MinecraftGameEvent } from "../agents/minecraft/types.ts"; import { safeJsonParseFromString } from "../normalization/valueParsers.ts"; import { buildMinecraftNarrationPrompt, buildSystemPrompt } from "../prompts/index.ts"; import { applyOrchestratorOverrideSettings, getBotName, getMinecraftNarrationSettings, getReplyPermissions, getResolvedMinecraftBrainBinding } from "../settings/agentStack.ts"; import { sanitizeBotText, sleep } from "../utils.ts"; import { normalizeSkipSentinel, splitDiscordMessage } from "./botHelpers.ts"; import type { BotContext } from "./botContext.ts";
const MINECRAFT_NARRATION_JSON_SCHEMA = { "skip": false, "text": "message text or [SKIP]", "reason": "short reason or null" };
const MINECRAFT_NARRATION_SIGNAL_LIMIT = 6; const MINECRAFT_NARRATION_CONTEXT_MESSAGE_LIMIT = 10; const MINECRAFT_NARRATION_MIN_GAP_ACTION_KINDS = [ "minecraft_narration_post", "minecraft_narration_skip" ] as const; const CORE_PROGRESS_BLOCKS = new Set([ "diamond_ore", "deepslate_diamond_ore", "ancient_debris", "obsidian" ]); const WIDE_PROGRESS_BLOCKS = new Set([ ...CORE_PROGRESS_BLOCKS, "emerald_ore", "deepslate_emerald_ore" ]);
type StoredMessageLike = { author_name?: string; content?: string; is_bot?: boolean | number; };
type MinecraftNarrationChannelLike = { id: string; guildId?: string; name?: string; send?: (payload: unknown) => Promise<{ id: string; createdTimestamp: number; guildId: string; channelId: string; }>; sendTyping?: () => Promise; };
type MinecraftNarrationClientLike = BotContext["client"] & { user?: { id?: string; } | null; channels: { cache: { get: (id: string) => MinecraftNarrationChannelLike | undefined; }; }; };
export type MinecraftNarrationRuntime = BotContext & { readonly client: MinecraftNarrationClientLike; canSendMessage: (maxPerHour: number) => boolean; canTalkNow: (settings: Record<string, unknown>) => boolean; getSimulatedTypingDelayMs: (minMs: number, jitterMs: number) => number; markSpoke: () => void; composeMessageContentForHistory: (message: unknown, baseText?: string) => string; };
export type MinecraftNarrationState = { seenMilestones: Set; };
export type MinecraftNarrationSignal = { category: "death" | "server" | "combat" | "player" | "progress"; key: string; event: MinecraftGameEvent; summary: string; minEagerness: number; };
type MaybePostMinecraftNarrationOptions = { guildId: string | null; channelId: string | null; ownerUserId?: string | null; scopeKey?: string | null; source?: string | null; serverLabel?: string | null; events: MinecraftGameEvent[]; /**
- Recent in-game chat snapshot from the session when these events fired.
- Passed through to the narration prompt as a labeled, separate section
- alongside Discord context. Mirrors Phase 2.3's label-and-keep-separate
- cross-surface design so the model can tell which surface each message
- came from. */ chatHistory?: MinecraftChatMessage[]; state: MinecraftNarrationState; };
function isSendableChannel( channel: MinecraftNarrationChannelLike | null | undefined ): channel is MinecraftNarrationChannelLike { return Boolean(channel) && typeof channel.send === "function" && typeof channel.sendTyping === "function"; }
function buildNarrationSettings(settings: Record<string, unknown>) { const binding = getResolvedMinecraftBrainBinding(settings); return applyOrchestratorOverrideSettings(settings, { provider: binding.provider, model: binding.model, temperature: binding.temperature, maxOutputTokens: Math.min(600, Math.max(120, Number(binding.maxOutputTokens) || 300)), reasoningEffort: binding.reasoningEffort }); }
function recordProgressMilestone( state: MinecraftNarrationState, blockName: string, eagerness: number ): MinecraftNarrationSignal | null { const normalizedBlockName = String(blockName || "").trim().toLowerCase(); if (!normalizedBlockName) return null;
const isCore = CORE_PROGRESS_BLOCKS.has(normalizedBlockName); const isWide = WIDE_PROGRESS_BLOCKS.has(normalizedBlockName); if (!isCore && !isWide) return null;
const minEagerness = isCore ? 30 : 75; if (eagerness < minEagerness) return null;
const key = progress:${normalizedBlockName};
if (state.seenMilestones.has(key)) return null;
state.seenMilestones.add(key);
return {
category: "progress",
key,
event: {
type: "item_pickup",
timestamp: new Date().toISOString(),
summary: `collected block milestone: ${normalizedBlockName}`,
itemName: normalizedBlockName,
count: 1
},
summary: `first notable progression item this session: ${normalizedBlockName}`,
minEagerness
};
}
export function createMinecraftNarrationState(): MinecraftNarrationState { return { seenMilestones: new Set() }; }
export function selectSignificantMinecraftEvents({ events, eagerness, state }: { events: MinecraftGameEvent[]; eagerness: number; state: MinecraftNarrationState; }): MinecraftNarrationSignal[] { const normalizedEagerness = Math.max(0, Math.min(100, Number(eagerness) || 0)); if (normalizedEagerness <= 0) return [];
const selected: MinecraftNarrationSignal[] = []; const selectedKeys = new Set(); let connectSignal: MinecraftNarrationSignal | null = null;
const pushSignal = (signal: MinecraftNarrationSignal | null) => { if (!signal) return; if (normalizedEagerness < signal.minEagerness) return; if (selectedKeys.has(signal.key)) return; selectedKeys.add(signal.key); selected.push(signal); };
for (const event of Array.isArray(events) ? events : []) { if (!event || typeof event !== "object") continue;
switch (event.type) {
case "chat":
continue;
case "server":
if (event.serverEvent === "spawned_as") {
connectSignal = {
category: "server",
key: `server_join:${event.summary.toLowerCase()}`,
event,
summary: event.summary,
minEagerness: 25
};
continue;
}
if (event.serverEvent === "logged_in" || event.serverEvent === "spawn") {
if (!connectSignal) {
connectSignal = {
category: "server",
key: `server_join:${event.serverEvent}`,
event,
summary: event.summary,
minEagerness: 25
};
}
continue;
}
pushSignal({
category: "server",
key: `server_state:${event.serverEvent}:${event.summary.toLowerCase()}`,
event,
summary: event.summary,
minEagerness: 1
});
continue;
case "death":
pushSignal({
category: "death",
key: `death:${event.timestamp}`,
event,
summary: "you died in Minecraft",
minEagerness: 1
});
continue;
case "combat":
pushSignal({
category: "combat",
key: `combat:${event.combatKind}:${event.target.toLowerCase()}`,
event,
summary: event.summary,
minEagerness: 15
});
continue;
case "player_join":
case "player_leave":
pushSignal({
category: "player",
key: `player:${event.type}:${event.playerName.toLowerCase()}`,
event,
summary: event.summary,
minEagerness: 15
});
continue;
case "item_pickup":
pushSignal(recordProgressMilestone(state, event.itemName, normalizedEagerness));
continue;
case "block_break":
pushSignal(recordProgressMilestone(state, event.blockName, normalizedEagerness));
continue;
default:
continue;
}
}
if (connectSignal) { pushSignal(connectSignal); }
return selected.slice(0, MINECRAFT_NARRATION_SIGNAL_LIMIT); }
function getLastNarrationActionAt( runtime: MinecraftNarrationRuntime, guildId: string, channelId: string ): number { const rows = runtime.store.getRecentActions(20, { guildId, kinds: [...MINECRAFT_NARRATION_MIN_GAP_ACTION_KINDS] }); return rows .filter((row) => String(row?.channel_id || "").trim() === channelId) .map((row) => Date.parse(String(row?.created_at || ""))) .filter((value) => Number.isFinite(value)) .reduce((latest, value) => Math.max(latest, value), 0); }
export async function maybePostMinecraftNarration( runtime: MinecraftNarrationRuntime, { guildId, channelId, ownerUserId = null, scopeKey = null, source = null, serverLabel = null, events, chatHistory = [], state }: MaybePostMinecraftNarrationOptions ): Promise { const normalizedGuildId = String(guildId || "").trim(); const normalizedChannelId = String(channelId || "").trim(); if (!normalizedGuildId || !normalizedChannelId || !Array.isArray(events) || events.length === 0) { return false; }
const settings = runtime.store.getSettings(); const permissions = getReplyPermissions(settings); const narration = getMinecraftNarrationSettings(settings); const normalizedEagerness = Math.max(0, Math.min(100, Number(narration.eagerness) || 0)); if (normalizedEagerness <= 0) return false;
const significantEvents = selectSignificantMinecraftEvents({ events, eagerness: normalizedEagerness, state }); if (significantEvents.length <= 0) { runtime.store.logAction({ kind: "minecraft_narration_filtered", guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, content: "no_significant_events", metadata: { scopeKey, source, eventCount: events.length, events } }); return false; }
runtime.store.logAction({ kind: "minecraft_narration_candidate", guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, content: significantEvents.map((event) => event.summary).join("; "), metadata: { scopeKey, source, serverLabel, eventCount: significantEvents.length, categories: significantEvents.map((event) => event.category), events: significantEvents.map((event) => event.event) } });
if (!runtime.canSendMessage(permissions.maxMessagesPerHour)) { runtime.store.logAction({ kind: "minecraft_narration_blocked", guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, content: "hourly_message_cap", metadata: { scopeKey, source } }); return false; }
if (!runtime.canTalkNow(settings)) { runtime.store.logAction({ kind: "minecraft_narration_blocked", guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, content: "message_cooldown_active", metadata: { scopeKey, source } }); return false; }
const minGapMs = Math.max(0, Number(narration.minSecondsBetweenPosts) || 0) * 1000; if (minGapMs > 0) { const lastActionAt = getLastNarrationActionAt(runtime, normalizedGuildId, normalizedChannelId); if (lastActionAt && Date.now() - lastActionAt < minGapMs) { runtime.store.logAction({ kind: "minecraft_narration_blocked", guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, content: "min_gap_active", metadata: { scopeKey, source, minSecondsBetweenPosts: Number(narration.minSecondsBetweenPosts) || 0, lastActionAt: new Date(lastActionAt).toISOString() } }); return false; } }
const channel = runtime.client.channels.cache.get(normalizedChannelId); if (!isSendableChannel(channel)) { runtime.store.logAction({ kind: "bot_error", guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, content: "minecraft_narration_channel_unavailable", metadata: { scopeKey, source } }); return false; }
const recentMessages = runtime.store.getRecentMessages( normalizedChannelId, MINECRAFT_NARRATION_CONTEXT_MESSAGE_LIMIT ) as StoredMessageLike[]; const botName = getBotName(settings); const userPrompt = buildMinecraftNarrationPrompt({ botName, channelName: String(channel?.name || "channel").trim() || "channel", serverLabel, narrationEagerness: normalizedEagerness, recentMessages, recentMcChat: Array.isArray(chatHistory) ? chatHistory : [], botUsername: botName, significantEvents }); const generation = await runtime.llm.generate({ settings: buildNarrationSettings(settings), systemPrompt: buildSystemPrompt(settings), userPrompt, jsonSchema: MINECRAFT_NARRATION_JSON_SCHEMA, trace: { guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, source: "minecraft_narration" } }); const parsed = safeJsonParseFromString(generation.text, null) as { skip?: unknown; text?: unknown; reason?: unknown; } | null; const reason = parsed?.reason == null ? null : String(parsed.reason).trim() || null; const text = sanitizeBotText(normalizeSkipSentinel(String(parsed?.text || "")), 1800); const skip = parsed?.skip === true || !text || text === "[SKIP]";
if (skip) { runtime.store.logAction({ kind: "minecraft_narration_skip", guildId: normalizedGuildId, channelId: normalizedChannelId, userId: ownerUserId, content: reason || "skip", metadata: { scopeKey, source, parseOk: Boolean(parsed), reason, events: significantEvents.map((event) => ({ category: event.category, summary: event.summary, event: event.event })), llm: { provider: generation.provider, model: generation.model, usage: generation.usage, costUsd: generation.costUsd } } }); return false; }
await channel.sendTyping(); await sleep(runtime.getSimulatedTypingDelayMs(350, 900)); const chunks = splitDiscordMessage(text); const sent = await channel.send({ content: chunks[0] }); for (let index = 1; index < chunks.length; index += 1) { await channel.send({ content: chunks[index] }); }
runtime.markSpoke(); runtime.store.recordMessage({ messageId: sent.id, createdAt: sent.createdTimestamp, guildId: sent.guildId, channelId: sent.channelId, authorId: runtime.client.user?.id || "unknown", authorName: getBotName(settings), isBot: true, content: runtime.composeMessageContentForHistory(sent, text), referencedMessageId: null }); runtime.store.logAction({ kind: "minecraft_narration_post", guildId: sent.guildId, channelId: sent.channelId, messageId: sent.id, userId: ownerUserId, content: text, metadata: { scopeKey, source, serverLabel, reason, events: significantEvents.map((event) => ({ category: event.category, summary: event.summary, event: event.event })), llm: { provider: generation.provider, model: generation.model, usage: generation.usage, costUsd: generation.costUsd } } }); return true; }
