Files
mayatnikovandClaude Opus 4.7 d9f11b7d99 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>
2026-05-26 19:12:58 +03:00

49 lines
1.9 KiB
JavaScript

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"));
});