i18n: per-player EN/PT via Adventure GlobalTranslator (#1)

Foundation + commands module of the i18n spec.

- I18n registry/loader + Lang.tr facade + reloadI18n
- PT source-of-truth bundle + EN translation
- CanalhandiaCommand player-facing strings migrated; admin-tuning/help/enum-labels deferred
- I18nTest: parity + per-locale render + pt_BR fallback; 316/316 green

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 15:53:27 +00:00
parent c1a6b9730f
commit dafd96a4b6
63 changed files with 9075 additions and 177 deletions
@@ -171,6 +171,88 @@ final class MiniMax {
return content.getAsString();
}
/** Runs a tool the model asked for and returns its result text. */
@FunctionalInterface
interface ToolExecutor {
String run(String name, String argumentsJson);
}
/**
* Answers with tools: the model may call the given tools, whose results are
* fed back until it produces a final answer or {@code maxCalls} rounds pass.
*
* <p>The last round is deliberately sent tool-free, so a model that keeps
* asking for tools instead of answering is still forced to produce prose
* rather than looping forever. Every failure returns null, like {@link
* #answer}, so the caller cannot tell a broken loop from "no answer".
*/
String answerWithTools(String key, String model, List<Turn> initial, JsonArray tools,
ToolExecutor executor, int maxTokens, double temperature, int maxCalls) {
JsonArray messages = new JsonArray();
for (Turn turn : initial) {
JsonObject object = new JsonObject();
object.addProperty("role", turn.role());
object.addProperty("content", turn.content());
messages.add(object);
}
for (int round = 0; round <= maxCalls; round++) {
boolean lastRound = round == maxCalls;
JsonObject body = new JsonObject();
body.addProperty("model", model);
body.add("messages", messages);
body.addProperty("max_tokens", maxTokens);
body.addProperty("temperature", temperature);
if (!lastRound) {
body.add("tools", tools);
body.addProperty("tool_choice", "auto");
}
JsonObject message = message(post(key, body));
if (message == null) {
return null;
}
JsonElement calls = message.get("tool_calls");
boolean hasCalls = calls != null && calls.isJsonArray() && !calls.getAsJsonArray().isEmpty();
if (lastRound || !hasCalls) {
JsonElement content = message.get("content");
if (content != null && content.isJsonPrimitive() && !content.getAsString().isBlank()) {
return content.getAsString();
}
if (lastRound) {
warn.accept("IA: sem resposta após " + maxCalls + " rodadas de ferramenta.");
}
return null;
}
// Append the assistant turn (carrying its tool_calls) verbatim, then
// one tool result per call. Some servers reject a null content on an
// assistant turn, so an empty string stands in.
JsonObject assistant = message.deepCopy();
if (!assistant.has("content") || assistant.get("content").isJsonNull()) {
assistant.addProperty("content", "");
}
messages.add(assistant);
for (JsonElement element : calls.getAsJsonArray()) {
JsonObject call = element.getAsJsonObject();
String id = call.has("id") ? call.get("id").getAsString() : "";
JsonObject function = call.getAsJsonObject("function");
String name = function.get("name").getAsString();
String arguments = function.has("arguments")
? function.get("arguments").getAsString() : "{}";
String result;
try {
result = executor.run(name, arguments);
} catch (RuntimeException e) {
result = "erro ao executar " + name + ": " + e.getMessage();
}
JsonObject toolMessage = new JsonObject();
toolMessage.addProperty("role", "tool");
toolMessage.addProperty("tool_call_id", id);
toolMessage.addProperty("content", result == null ? "sem resultado." : result);
messages.add(toolMessage);
}
}
return null;
}
private JsonObject base(String model, List<Turn> messages, int maxTokens, double temperature) {
JsonArray array = new JsonArray();
for (Turn msg : messages) {