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

70 lines
2.4 KiB
JavaScript

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