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:
@@ -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