Files
canalhandia/src/main/java/dev/marcospaulo/canalhandia/ChatLog.java
T
marcos 0726ce3794 Give the AI a personality, chat awareness and live server state
The AI could only see three static facts about the server, so it answered
"quem tá online?" and "tá chovendo?" by insisting it had no access — true of
the model, but not of the plugin, which has all of it on hand. It also had no
tone: correct answers delivered like a manual, on a server whose whole point
is people ribbing each other.

Persona: five tones (zoeiro, amigao, seco, aldeao, neutro), switchable live
with /ia personalidade <nome>. Personality is expressed only as extra system
instructions and changes how the model talks, never what it may do. Every
persona — including the blank one — carries Persona.GUARD, which restates the
no-commands/no-server-access limits inside the persona's own frame, so a
roleplay instruction cannot read as licence to claim powers the plugin does
not grant it. The guard also forbids inventing stats, which matters now that
real numbers are being fed in. The teasing personas each state where the line
is; a test asserts every one of them does.

ChatLog: a 50-line in-memory ring of public chat, the last few lines handed to
the model so a follow-up like "quem tá reclamando aí?" has a referent. Written
from the chat event (off the main thread) and read from /ia, so it is
synchronised; a concurrency test hammers it from eight threads, because an
unsynchronised deque here would throw ConcurrentModification into a player's
answer. Recorded at MONITOR priority so what is stored is what the room saw —
a zoacao swap included — and cancelled messages are never stored. Nothing
touches disk.

ServerState: who is online with their platform, dimension, time of day,
weather, and the asker's coordinates, health, hunger and XP. Captured on the
main thread before the async call — every field reads the Bukkit world API,
which is not safe off it — and only the formatted string crosses the thread
boundary. Formatting is pure and tested, including the negative-tick case a
raw modulo would drop through every band.

Styling: Java players get a hover card with the original question and the
active persona, plus a click that pre-fills "/ia " for a follow-up.
suggestCommand, not runCommand: nothing executes without the player pressing
enter. Bedrock renders neither hover nor click, so it keeps the plain line,
built through broadcastPerPlatform like every other interactive message here.

preflight.sh: a read-only pre-restart harness. It verifies the jar opens, that
plugin.yml declares all 16 commands, that the staged jar's hash matches the
local build and is owned 1000:0, that the live config parses as YAML and
carries the keys this deploy depends on, and that a rollback jar and config
backup both exist. It never restarts anything. A missing YAML parser reports
as "not checked" rather than "invalid" — a harness that cries wolf gets
ignored.

176 tests, up from 101.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 22:49:02 +00:00

121 lines
3.9 KiB
Java

package dev.marcospaulo.canalhandia;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
/**
* A tiny rolling window of what was just said in public chat, so the AI can
* follow along instead of answering every question in a vacuum. "Quem tá
* falando merda aí?" only makes sense with the last few lines in hand.
*
* <p>Deliberately small and forgetful, like {@link Conversations}: this is
* ambient context for one answer, not a transcript. Nothing is written to disk,
* and a restart starts it empty.
*
* <p><b>Thread-safe.</b> Writes come from the chat event, which Paper fires off
* the main thread, while reads come from {@link Ai}'s snapshot on the main
* thread. Both go through {@link #lines}'s own monitor; nothing slow happens
* under the lock.
*
* <p>Messages are stored <em>after</em> any {@code zoacao} replacement, so the
* AI sees what the room saw rather than what was typed.
*/
final class ChatLog {
/**
* A hard ceiling on retained lines regardless of what the config asks for.
* The window handed to the model is capped separately and is normally much
* smaller; this only bounds memory if someone sets an absurd value.
*/
static final int MAX_RETAINED = 50;
/** Longest single message kept. Longer ones are cut, so one paste cannot
* dominate the whole context window. */
static final int MAX_MESSAGE_CHARS = 200;
record Line(String player, String message) {
}
private final Deque<Line> lines = new ArrayDeque<>();
/**
* Records one public chat message. Blank messages and blank names are
* ignored rather than stored as empty lines the model would have to parse.
*/
void add(String player, String message) {
if (player == null || player.isBlank() || message == null || message.isBlank()) {
return;
}
String text = message.strip();
if (text.length() > MAX_MESSAGE_CHARS) {
text = text.substring(0, MAX_MESSAGE_CHARS) + "";
}
synchronized (lines) {
lines.addLast(new Line(player.strip(), text));
while (lines.size() > MAX_RETAINED) {
lines.removeFirst();
}
}
}
/**
* The most recent {@code max} lines, oldest first — reading order, which is
* how the model should see a conversation.
*
* <p>A non-positive {@code max} returns an empty list, so turning the
* feature off in config costs nothing here.
*/
List<Line> recent(int max) {
if (max <= 0) {
return List.of();
}
synchronized (lines) {
int skip = Math.max(0, lines.size() - max);
List<Line> out = new ArrayList<>(Math.min(max, lines.size()));
int i = 0;
for (Line line : lines) {
if (i++ >= skip) {
out.add(line);
}
}
return out;
}
}
/**
* The recent window as one pt-BR block for a system message, or {@code null}
* when there is nothing to say. Pure formatting given the lines, so the
* shape of what reaches the model is testable without a server.
*/
static String format(List<Line> recent) {
if (recent == null || recent.isEmpty()) {
return null;
}
StringBuilder out = new StringBuilder();
for (Line line : recent) {
out.append(line.player()).append(": ").append(line.message()).append('\n');
}
return out.toString().strip();
}
/** Convenience: {@link #format} over {@link #recent}. */
String formatRecent(int max) {
return format(recent(max));
}
/** How many lines are currently held. For tests and {@code /canalhandia status}. */
int size() {
synchronized (lines) {
return lines.size();
}
}
void clear() {
synchronized (lines) {
lines.clear();
}
}
}