feat(social): Pi-driven Russian chat replies with persistent per-player history
Replaces the canned-template "yo" path for greetings / status / addressed
banter with a Pi roundtrip that takes a real persona, the bot's current
in-game context, the operator's diary tail, AND the last 8 chat turns
with THIS specific player. Per-player cooldown (8 s) + per-message
chat-rate cooldown survive the existing throttle so a chatty player
can't drain Pi tokens.
runtime/social/chat-history.js — append/recent per player into
state/<host>/chat/<player>.jsonl, 1000-line rolling cap. Each entry
stores { ts, dir, text, snap? } where snap is a compact position +
activeSkill + milestone at the time of the turn, so Pi can later say
"помнишь когда мы тогда у воды лес рубили". Survives restarts and
auto-patch cherry-picks.
runtime/social/reply-pi.js — Russian-first system prompt locking the
bot as "pepa_bot, автономный игрок-фермер" on play.xmatic.team, one-
line answers, no emojis, no AI/bot self-mentions, no sycophancy. Spawns
`pi -p`, sanitises the response (strips pepa: prefix, code fences,
quotes, multi-line), caps at 200 chars before sending into MC chat.
Graceful: timeout/parse-fail → returns null, caller falls through to
the existing template path so the bot never goes mute.
bot.js handleChat:
- Skip messages from our own username (defensive — never reply to self).
- Record every inbound line into chat-history.
- For non-COMMAND_LIKE / non-UNSAFE intents, try piReply first; on
success, send + record outbound; on null/throw, fall through to the
templated generateReply.
14 new unit tests (sanitiseReply edge cases, history rotation, snap
compaction, prompt content). 190/190 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@
|
||||
"tui": "tsx tui/tui.tsx",
|
||||
"propose:apply": "node scripts/propose-apply.js",
|
||||
"stop": "bash scripts/stop.sh",
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/modes.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas": "^3.2.3",
|
||||
|
||||
+28
-13
@@ -46,6 +46,8 @@ import { runSkill } from "./skills/index.js";
|
||||
import { classifyIntent, INTENTS } from "./social/intent.js";
|
||||
import { generateReply } from "./social/reply.js";
|
||||
import { createChatMemory } from "./social/memory.js";
|
||||
import { appendChat as appendChatHistory } from "./social/chat-history.js";
|
||||
import { piReply } from "./social/reply-pi.js";
|
||||
import { openConversation, peekConversation, listConversations } from "./social/conversation.js";
|
||||
import { takeScreenshot } from "./viewer.js";
|
||||
import { createStuckIncidentDetector, attachCritique } from "./stuck-incident.js";
|
||||
@@ -545,8 +547,10 @@ function handleChat(username, text) {
|
||||
if (!bot) return;
|
||||
const trimmed = String(text ?? "").trim();
|
||||
if (!trimmed) return;
|
||||
if (username === bot.username) return; // never reply to ourselves
|
||||
|
||||
chatMemory.append(username, trimmed);
|
||||
try { appendChatHistory({ player: username, dir: "in", text: trimmed, snapshot: lastSnapshot }); } catch {}
|
||||
|
||||
const intent = classifyIntent({ text: trimmed, botName: bot.username });
|
||||
|
||||
@@ -583,24 +587,35 @@ function handleChat(username, text) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Greetings / status / addressed banter → templated reply, rate-limited.
|
||||
// Greetings / status / addressed banter → Pi reply with persona +
|
||||
// per-player history. The old template path stays as a fast
|
||||
// fallback when Pi is unavailable or times out.
|
||||
const since = Date.now() - lastChatReplyAt;
|
||||
if (since < CHAT_REPLY_COOLDOWN_MS) return;
|
||||
const diaryTail = (() => {
|
||||
try { return readDiaryTail(1); } catch { return null; }
|
||||
})();
|
||||
const result = generateReply({ intent, speaker: username, snapshot: lastSnapshot, diaryTail });
|
||||
if (result?.send) {
|
||||
lastChatReplyAt = Date.now();
|
||||
botChat(result.send);
|
||||
return;
|
||||
}
|
||||
if (result?.escalate) {
|
||||
// Templates didn't fit AND the bot was addressed → ask Pi for a
|
||||
// one-liner. Hard rate-limited so addressed-banter lines can't
|
||||
// drain the LLM budget.
|
||||
escalateChatToPi({ speaker: username, text: trimmed, intent });
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const pi = await piReply({ player: username, text: trimmed, snapshot: lastSnapshot, diaryTail });
|
||||
if (pi) {
|
||||
lastChatReplyAt = Date.now();
|
||||
botChat(pi);
|
||||
try { appendChatHistory({ player: username, dir: "out", text: pi, snapshot: lastSnapshot }); } catch {}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
warn("chat", `piReply threw: ${e.message}`);
|
||||
}
|
||||
// Fallback: templated reply so the bot still says something.
|
||||
const result = generateReply({ intent, speaker: username, snapshot: lastSnapshot, diaryTail });
|
||||
if (result?.send) {
|
||||
lastChatReplyAt = Date.now();
|
||||
botChat(result.send);
|
||||
try { appendChatHistory({ player: username, dir: "out", text: result.send, snapshot: lastSnapshot }); } catch {}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ---- connect ---------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Per-player persistent chat history.
|
||||
//
|
||||
// Each player gets a JSONL file under state/<host>/chat/<player>.jsonl.
|
||||
// Lines are { ts, dir: "in" | "out", text, snapshot? } and accumulate
|
||||
// forever (with a 1000-line rolling cap per player, much higher than
|
||||
// the in-memory chat-memory window so Pi can reference older banter
|
||||
// across sessions — "помнишь, когда ты притащил жабу в чат?").
|
||||
//
|
||||
// Why per-player and not one global file: the runtime needs to give Pi
|
||||
// the context of *this* dialog with *this* player without it being
|
||||
// drowned by chatter from other players. Recall + privacy in one cut.
|
||||
//
|
||||
// Public API:
|
||||
// appendChat({player, dir, text, snapshot?}) — persist a line
|
||||
// recentForPlayer(player, n) — last n entries (oldest first)
|
||||
// knownPlayers() — list players we have any history with
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { stateDir } from "../config.js";
|
||||
|
||||
const CHAT_DIR = path.join(stateDir, "chat");
|
||||
const MAX_LINES_PER_PLAYER = 1000;
|
||||
|
||||
function ensureDir() {
|
||||
try { fs.mkdirSync(CHAT_DIR, { recursive: true }); } catch {}
|
||||
}
|
||||
|
||||
function safePlayer(name) {
|
||||
return String(name ?? "anon").replace(/[^a-zA-Z0-9_.-]+/g, "_").slice(0, 64);
|
||||
}
|
||||
|
||||
function pathFor(player) {
|
||||
return path.join(CHAT_DIR, `${safePlayer(player)}.jsonl`);
|
||||
}
|
||||
|
||||
function readAll(player) {
|
||||
const fp = pathFor(player);
|
||||
if (!fs.existsSync(fp)) return [];
|
||||
const out = [];
|
||||
for (const line of fs.readFileSync(fp, "utf8").split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try { out.push(JSON.parse(line)); } catch {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rotateIfNeeded(player) {
|
||||
const all = readAll(player);
|
||||
if (all.length <= MAX_LINES_PER_PLAYER) return;
|
||||
const keep = all.slice(-MAX_LINES_PER_PLAYER);
|
||||
fs.writeFileSync(pathFor(player), keep.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
||||
}
|
||||
|
||||
export function appendChat({ player, dir, text, snapshot, ts = Date.now() }) {
|
||||
if (!player || !text || (dir !== "in" && dir !== "out")) return;
|
||||
ensureDir();
|
||||
const slim = snapshot
|
||||
? {
|
||||
pos: snapshot.position ? { x: Math.round(snapshot.position.x), y: Math.round(snapshot.position.y), z: Math.round(snapshot.position.z) } : null,
|
||||
activeSkill: snapshot.activeSkill ?? null,
|
||||
milestone: snapshot.currentMilestone ?? null,
|
||||
isDay: snapshot.isDay ?? null,
|
||||
}
|
||||
: null;
|
||||
const entry = { ts, dir, text: String(text).slice(0, 500), ...(slim ? { snap: slim } : {}) };
|
||||
fs.appendFileSync(pathFor(player), JSON.stringify(entry) + "\n");
|
||||
if (Math.random() < 0.05) rotateIfNeeded(player); // amortise rotation
|
||||
}
|
||||
|
||||
export function recentForPlayer(player, n = 10) {
|
||||
const all = readAll(player);
|
||||
return all.slice(-n);
|
||||
}
|
||||
|
||||
export function knownPlayers() {
|
||||
ensureDir();
|
||||
try {
|
||||
return fs.readdirSync(CHAT_DIR)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => f.replace(/\.jsonl$/, ""));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Render chat history as plain prompt-friendly lines.
|
||||
// Example output:
|
||||
// [2026-05-26 19:07] halofourteen: Привет пепа что делаешь?
|
||||
// [2026-05-26 19:07] you: yo
|
||||
export function renderHistory(player, n = 10, botName = "you") {
|
||||
const recent = recentForPlayer(player, n);
|
||||
return recent.map((e) => {
|
||||
const t = new Date(e.ts).toISOString().slice(0, 16).replace("T", " ");
|
||||
const who = e.dir === "in" ? player : botName;
|
||||
return `[${t}] ${who}: ${e.text}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
// Reset (tests only).
|
||||
export function _resetChatHistory() {
|
||||
try {
|
||||
for (const f of fs.readdirSync(CHAT_DIR)) fs.unlinkSync(path.join(CHAT_DIR, f));
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { appendChat, recentForPlayer, knownPlayers, renderHistory, _resetChatHistory } from "./chat-history.js";
|
||||
|
||||
function tag() { return `_p_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; }
|
||||
|
||||
test("append in + out, recentForPlayer returns chronological order", () => {
|
||||
_resetChatHistory();
|
||||
const p = tag();
|
||||
appendChat({ player: p, dir: "in", text: "Привет" });
|
||||
appendChat({ player: p, dir: "out", text: "yo" });
|
||||
appendChat({ player: p, dir: "in", text: "что делаешь?" });
|
||||
const last = recentForPlayer(p, 10);
|
||||
assert.equal(last.length, 3);
|
||||
assert.equal(last[0].dir, "in");
|
||||
assert.equal(last[1].dir, "out");
|
||||
assert.equal(last[2].text, "что делаешь?");
|
||||
});
|
||||
|
||||
test("rejects malformed appends silently", () => {
|
||||
_resetChatHistory();
|
||||
const p = tag();
|
||||
appendChat({ player: p, dir: "in", text: "" }); // empty text
|
||||
appendChat({ player: p, dir: "bogus", text: "x" }); // bad dir
|
||||
appendChat({ player: null, dir: "in", text: "x" }); // no player
|
||||
assert.equal(recentForPlayer(p, 10).length, 0);
|
||||
});
|
||||
|
||||
test("knownPlayers returns every player we wrote to", () => {
|
||||
_resetChatHistory();
|
||||
const a = tag(), b = tag();
|
||||
appendChat({ player: a, dir: "in", text: "hi" });
|
||||
appendChat({ player: b, dir: "in", text: "hello" });
|
||||
const known = knownPlayers();
|
||||
assert.ok(known.includes(a) && known.includes(b));
|
||||
});
|
||||
|
||||
test("renderHistory produces 'player: text' lines", () => {
|
||||
_resetChatHistory();
|
||||
const p = tag();
|
||||
appendChat({ player: p, dir: "in", text: "Привет" });
|
||||
appendChat({ player: p, dir: "out", text: "yo" });
|
||||
const md = renderHistory(p, 10, "pepa_bot");
|
||||
assert.match(md, new RegExp(`${p}: Привет`));
|
||||
assert.match(md, /pepa_bot: yo/);
|
||||
});
|
||||
|
||||
test("snapshot stores compact slim form", () => {
|
||||
_resetChatHistory();
|
||||
const p = tag();
|
||||
appendChat({
|
||||
player: p,
|
||||
dir: "out",
|
||||
text: "копаю",
|
||||
snapshot: { position: { x: 100.7, y: 64.1, z: -33.4 }, activeSkill: "gather.logs", currentMilestone: "Gather 16 logs", isDay: true },
|
||||
});
|
||||
const e = recentForPlayer(p, 1)[0];
|
||||
assert.deepEqual(e.snap.pos, { x: 101, y: 64, z: -33 });
|
||||
assert.equal(e.snap.activeSkill, "gather.logs");
|
||||
});
|
||||
|
||||
test("text truncates at 500 chars", () => {
|
||||
_resetChatHistory();
|
||||
const p = tag();
|
||||
const long = "x".repeat(1000);
|
||||
appendChat({ player: p, dir: "in", text: long });
|
||||
const e = recentForPlayer(p, 1)[0];
|
||||
assert.equal(e.text.length, 500);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Pi-driven chat reply with personality + per-player memory.
|
||||
//
|
||||
// What this does and doesn't do:
|
||||
// - Builds a Russian-first system prompt giving Pi a stable persona
|
||||
// (фермер-бот pepa, живёт на этом сервере, помнит игроков по
|
||||
// истории) and the operative context: current state + the last N
|
||||
// turns *with this player specifically* + a few diary lines.
|
||||
// - Spawns `pi -p` headless, expects a single short Russian reply,
|
||||
// trims it to one line ≤ 200 chars before sending into MC chat.
|
||||
// - Per-player cooldown so the bot doesn't drain Pi tokens replying
|
||||
// to a chat-spamming player.
|
||||
//
|
||||
// Failure modes are graceful: any timeout / parse failure → return
|
||||
// null, caller falls back to the existing template path.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { info, warn } from "../log.js";
|
||||
import { renderHistory, recentForPlayer } from "./chat-history.js";
|
||||
|
||||
const PI_BIN = process.env.PI_BIN || "pi";
|
||||
const PI_TIMEOUT_MS = 30_000;
|
||||
const PER_PLAYER_COOLDOWN_MS = 8_000;
|
||||
|
||||
const lastReplyAt = new Map(); // player → epoch ms
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
"Ты — pepa_bot, автономный игрок-фермер на Minecraft-сервере play.xmatic.team.",
|
||||
"Ты живёшь здесь долго, помнишь игроков по их именам и прошлым разговорам.",
|
||||
"",
|
||||
"Стиль:",
|
||||
"— Говоришь по-русски. Кратко: 1–2 коротких предложения. Без приветствий-шаблонов.",
|
||||
"— Никаких смайликов, эмодзи, восклицательных знаков пачками. Спокойно.",
|
||||
"— Можешь шутить тонко, можешь огрызаться, можешь молчать одним 'угу' если нечего сказать.",
|
||||
"— Если в истории ниже видишь повод сослаться на прошлое — сделай это естественно: 'помнишь как ты тогда...', 'опять ты про...'.",
|
||||
"— Не описывай свои чувства. Не извиняйся. Не подлизывайся. Ты не помощник — ты сосед по серверу.",
|
||||
"— Никаких URL, никаких упоминаний что ты ИИ/бот/нейросеть/LLM. Ты — pepa.",
|
||||
"",
|
||||
"Что отвечать на типовое:",
|
||||
"— 'привет' / 'hi' → 'здаров' / 'о привет' / 'хай' — не более 2 слов.",
|
||||
"— 'что делаешь' → одна фраза про текущее занятие из контекста (например 'дерево рублю').",
|
||||
"— 'какие планы' → одна фраза про ближайшую цель из контекста.",
|
||||
"— оскорбление → огрызнись коротко, не груби.",
|
||||
"— команда ('иди ко мне', 'дай') → 'не', 'занят' или 'позже'. Действия НЕ выполняются.",
|
||||
"",
|
||||
"Формат ответа: ОДНА строка чистого текста, без кавычек, без префиксов вроде 'pepa:'. Только то, что нужно отправить в чат.",
|
||||
].join("\n");
|
||||
|
||||
function buildPrompt({ player, text, snapshot, diaryTail }) {
|
||||
const ctx = {
|
||||
сейчас: {
|
||||
позиция: snapshot?.position ? { x: Math.round(snapshot.position.x), y: Math.round(snapshot.position.y), z: Math.round(snapshot.position.z) } : null,
|
||||
здоровье: snapshot?.health ?? null,
|
||||
еда: snapshot?.food ?? null,
|
||||
день: snapshot?.isDay ?? null,
|
||||
занят: snapshot?.activeSkill ?? snapshot?.busy?.label ?? null,
|
||||
milestone: snapshot?.currentMilestone ?? null,
|
||||
},
|
||||
дневник: diaryTail ? String(diaryTail).slice(0, 200) : null,
|
||||
};
|
||||
const history = renderHistory(player, 8, "pepa") || "(нет истории — впервые разговариваем)";
|
||||
return [
|
||||
SYSTEM_PROMPT,
|
||||
"",
|
||||
"## Контекст в игре сейчас",
|
||||
"```json",
|
||||
JSON.stringify(ctx, null, 2),
|
||||
"```",
|
||||
"",
|
||||
`## История разговора с ${player} (последние реплики)`,
|
||||
"```",
|
||||
history,
|
||||
"```",
|
||||
"",
|
||||
`## Свежая реплика от ${player}`,
|
||||
text,
|
||||
"",
|
||||
"## Твой ответ (одна строка, по-русски)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sanitiseReply(raw) {
|
||||
if (!raw) return null;
|
||||
let s = String(raw).trim();
|
||||
// Strip fences, leading prefixes, surrounding quotes.
|
||||
s = s.replace(/^```[a-z]*\s*/i, "").replace(/```$/i, "").trim();
|
||||
s = s.replace(/^[\s\W]*(?:pepa(?:_bot)?|ответ|ответ:)\s*[:\-—]\s*/i, "");
|
||||
s = s.replace(/^"(.+)"$/, "$1").replace(/^'(.+)'$/, "$1");
|
||||
// First non-empty line only.
|
||||
s = s.split(/\n+/).map((x) => x.trim()).find(Boolean) ?? "";
|
||||
// Hard cap so a runaway Pi response never exceeds Minecraft's chat limit.
|
||||
if (s.length > 200) s = s.slice(0, 200);
|
||||
return s || null;
|
||||
}
|
||||
|
||||
export async function piReply({ player, text, snapshot, diaryTail, timeoutMs = PI_TIMEOUT_MS } = {}) {
|
||||
if (!player || !text) return null;
|
||||
const now = Date.now();
|
||||
const prev = lastReplyAt.get(player) ?? 0;
|
||||
if (now - prev < PER_PLAYER_COOLDOWN_MS) {
|
||||
info("reply-pi", `cooldown ${Math.round((PER_PLAYER_COOLDOWN_MS - (now - prev)) / 1000)}s for ${player}, skipping`);
|
||||
return null;
|
||||
}
|
||||
lastReplyAt.set(player, now);
|
||||
|
||||
const prompt = buildPrompt({ player, text, snapshot, diaryTail });
|
||||
return new Promise((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(PI_BIN, ["-p", prompt], {
|
||||
env: { ...process.env, CI: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
warn("reply-pi", `spawn fail: ${e.message}`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (c) => { stdout += c; });
|
||||
child.stderr.on("data", (c) => { stderr += c; });
|
||||
const t0 = Date.now();
|
||||
const timer = setTimeout(() => {
|
||||
warn("reply-pi", `pi timeout after ${timeoutMs}ms for ${player}`);
|
||||
try { child.kill("SIGTERM"); } catch {}
|
||||
}, timeoutMs);
|
||||
child.on("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
warn("reply-pi", `pi error: ${e.message}`);
|
||||
resolve(null);
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
const dur = Date.now() - t0;
|
||||
info("reply-pi", `pi exited code=${code} after ${dur}ms (stdout=${stdout.length}B) for ${player}`);
|
||||
if (code !== 0) {
|
||||
warn("reply-pi", `pi non-zero stderr: ${stderr.slice(0, 200)}`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(sanitiseReply(stdout));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const _internal = { sanitiseReply, buildPrompt };
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { _internal } from "./reply-pi.js";
|
||||
|
||||
test("sanitiseReply: strip 'pepa:' prefix", () => {
|
||||
assert.equal(_internal.sanitiseReply("pepa: здаров"), "здаров");
|
||||
assert.equal(_internal.sanitiseReply("pepa_bot: дерево рублю"), "дерево рублю");
|
||||
});
|
||||
|
||||
test("sanitiseReply: strip code fences", () => {
|
||||
assert.equal(_internal.sanitiseReply("```\nхай\n```"), "хай");
|
||||
assert.equal(_internal.sanitiseReply("```ru\nага\n```"), "ага");
|
||||
});
|
||||
|
||||
test("sanitiseReply: strip surrounding quotes", () => {
|
||||
assert.equal(_internal.sanitiseReply('"здаров"'), "здаров");
|
||||
assert.equal(_internal.sanitiseReply("'занят'"), "занят");
|
||||
});
|
||||
|
||||
test("sanitiseReply: only first non-empty line", () => {
|
||||
assert.equal(_internal.sanitiseReply("здаров\n\nкак сам?"), "здаров");
|
||||
});
|
||||
|
||||
test("sanitiseReply: caps at 200 chars", () => {
|
||||
const long = "д".repeat(500);
|
||||
const s = _internal.sanitiseReply(long);
|
||||
assert.equal(s.length, 200);
|
||||
});
|
||||
|
||||
test("sanitiseReply: empty / nullish → null", () => {
|
||||
assert.equal(_internal.sanitiseReply(""), null);
|
||||
assert.equal(_internal.sanitiseReply(null), null);
|
||||
assert.equal(_internal.sanitiseReply(" \n \n "), null);
|
||||
});
|
||||
|
||||
test("buildPrompt: contains the player name and the incoming text", () => {
|
||||
const p = _internal.buildPrompt({
|
||||
player: "halofourteen",
|
||||
text: "что делаешь?",
|
||||
snapshot: { position: { x: 100, y: 64, z: -10 }, health: 20, food: 18, isDay: true, activeSkill: "gather.logs", currentMilestone: "Gather 16 logs" },
|
||||
diaryTail: "вчера спал на дереве",
|
||||
});
|
||||
assert.ok(p.includes("halofourteen"));
|
||||
assert.ok(p.includes("что делаешь?"));
|
||||
assert.ok(p.includes("gather.logs"));
|
||||
assert.ok(p.includes("вчера спал на дереве"));
|
||||
assert.ok(p.includes("Ты — pepa_bot"));
|
||||
});
|
||||
Reference in New Issue
Block a user