2 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5 20795b053d Консоль не вешает вкладку под потоком логов сборки
CI / test (push) Successful in 24s
Release / release (push) Successful in 31s
Загрузка мира со сборкой модов печатает десятки тысяч строк: каждое
исключение мода — стек на два десятка строк. Консоль рисовала их по одной
и после каждой прокручивалась вниз, заставляя браузер пересчитывать
раскладку всего списка. Десять тысяч строк блокировали вкладку на 29
секунд, и запуск сервера выглядел как зависшая панель.

Строки копятся и выводятся пачкой раз в кадр: те же десять тысяч теперь
занимают 14 мс. Узлы сверх предела списка не создаются вовсе, а на скрытой
вкладке очередь ограничена, чтобы не расти в памяти.

Заодно вывод игрового сервера больше не гоняется через словарь переводов:
он английский, в словаре менеджера его нет, а перебор шаблонов регулярками
на таком потоке стоит дорого.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:57:11 +03:00
mrleo1nidandClaude Opus 5 6d27fd049d Панель видит моды Build 42 и названия больших сборок
CI / test (push) Successful in 23s
Release / release (push) Successful in 29s
В Build 42 mod.info лежит не в корне мода, а в папке версии: common, 42,
42.16. Сканер смотрел только в корень, и такие моды выглядели нескачанными
с неизвестным Mod ID — включить их через панель было нельзя. На реальной
мастерской находилось 237 модов из 399.

Названия из мастерской фронтенд запрашивал одним списком, а эндпоинт не
принимает больше двухсот идентификаторов за раз. У сборки на 284 пакета
запрос падал целиком, ошибка молча проглатывалась, и вместо названий
оставались одни номера. Теперь запрос идёт партиями, а полная неудача
больше не остаётся незамеченной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:38:23 +03:00
7 changed files with 231 additions and 23 deletions
+4
View File
@@ -280,6 +280,10 @@ Mod ID, который нужен серверу, лежит внутри сам
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
```
В Build 42 `mod.info` лежит не в корне мода, а в папке версии — `common` для
общей части, `42` и `42.16` под конкретные сборки игры. Панель смотрит и туда,
выбирая самую свежую версию; корневой `mod.info` (раскладка Build 41) главнее.
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
+14 -4
View File
@@ -85,9 +85,15 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
})
}
// translatable отсеивает вывод самого игрового сервера: он на английском и в
// словаре менеджера его нет. Перебирать ради него все шаблоны накладно —
// загрузка мира со сборкой модов печатает десятки тысяч строк.
func translatable(stream string) bool {
return stream == "manager" || stream == "task"
}
// translateLines переводит строки консоли на язык читающего. Буфер один на
// всех, поэтому перевод делается на выходе, у каждого подключения свой. Вывод
// самого игрового сервера через словарь проходит без изменений: он английский.
// всех, поэтому перевод делается на выходе, у каждого подключения свой.
func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine {
lang := i18n.FromRequest(r)
if lang == i18n.Default {
@@ -95,7 +101,9 @@ func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLin
}
out := make([]pzserver.LogLine, len(lines))
for i, line := range lines {
line.Text = i18n.T(lang, line.Text)
if translatable(line.Stream) {
line.Text = i18n.T(lang, line.Text)
}
out[i] = line
}
return out
@@ -149,7 +157,9 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
line.Text = i18n.T(lang, line.Text)
if translatable(line.Stream) {
line.Text = i18n.T(lang, line.Text)
}
if !writeSSE(w, line) {
return
}
+18
View File
@@ -0,0 +1,18 @@
package api
import "testing"
// Вывод игрового сервера идёт мимо словаря: он английский, а перебор шаблонов
// на десятках тысяч строк загрузки мира стоит дорого.
func TestTranslatableOnlyManagerStreams(t *testing.T) {
for _, stream := range []string{"manager", "task"} {
if !translatable(stream) {
t.Errorf("строки потока %q должны переводиться", stream)
}
}
for _, stream := range []string{"stdout", "stderr", ""} {
if translatable(stream) {
t.Errorf("строки потока %q переводить незачем", stream)
}
}
}
+54 -1
View File
@@ -91,7 +91,11 @@ func readModsDir(dir, workshopID string) []InstalledMod {
continue
}
modDir := filepath.Join(dir, entry.Name())
mod, ok := readModInfo(filepath.Join(modDir, "mod.info"))
info, found := modInfoPath(modDir)
if !found {
continue
}
mod, ok := readModInfo(info)
if !ok {
continue
}
@@ -105,6 +109,55 @@ func readModsDir(dir, workshopID string) []InstalledMod {
return out
}
// modInfoPath ищет mod.info внутри папки мода.
//
// В Build 41 он лежал в корне, а в Build 42 мод раскладывают по папкам версий:
// common — общая часть, 42 и 42.16 — под конкретные сборки игры. Если искать
// только в корне, мод из мастерской выглядит нескачанным, его Mod ID остаётся
// неизвестным и включить его через панель нельзя.
func modInfoPath(modDir string) (string, bool) {
if root := filepath.Join(modDir, "mod.info"); fileExists(root) {
return root, true
}
entries, err := os.ReadDir(modDir)
if err != nil {
return "", false
}
var versions []string
common := ""
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if !fileExists(filepath.Join(modDir, entry.Name(), "mod.info")) {
continue
}
if strings.EqualFold(entry.Name(), "common") {
common = entry.Name()
continue
}
versions = append(versions, entry.Name())
}
// Сначала самая свежая версия: «42.16» важнее «42», а «common» —
// последняя, это общая часть на случай, когда версии своего mod.info не
// завели. Обратный порядок строк для номеров версий работает и здесь.
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
if common != "" {
versions = append(versions, common)
}
if len(versions) == 0 {
return "", false
}
return filepath.Join(modDir, versions[0], "mod.info"), true
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
// комментариями. Нас интересуют id, name и description.
func readModInfo(path string) (InstalledMod, bool) {
+60
View File
@@ -75,3 +75,63 @@ func writeModInfo(t *testing.T, dir, content string) {
t.Fatal(err)
}
}
// В Build 42 mod.info лежит не в корне мода, а в папке версии. Мод из такой
// раскладки должен находиться, иначе панель считает его нескачанным.
func TestScanInstalledModsBuild42Layout(t *testing.T) {
root := t.TempDir()
content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID)
writeModInfo(t, filepath.Join(content, "111", "mods", "Wipes", "common"),
"name=Cleaning Wipes\nid=dustinguished_cleaning_wipes\n")
// Мод с несколькими версиями: берём самую свежую, а не common.
mod := filepath.Join(content, "222", "mods", "NewMusicG90s")
writeModInfo(t, filepath.Join(mod, "common"), "name=Mixtape (common)\nid=NewMusicG90s\n")
writeModInfo(t, filepath.Join(mod, "42"), "name=Mixtape (42)\nid=NewMusicG90s\n")
writeModInfo(t, filepath.Join(mod, "42.16"), "name=Mixtape (42.16)\nid=NewMusicG90s\n")
mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, "")
byID := make(map[string]InstalledMod, len(mods))
for _, m := range mods {
byID[m.ModID] = m
}
if len(byID) != 2 {
t.Fatalf("найдено %d модов, ожидалось 2: %v", len(byID), byID)
}
if got := byID["dustinguished_cleaning_wipes"]; got.Name != "Cleaning Wipes" || got.WorkshopID != "111" {
t.Errorf("мод из common разобран неверно: %+v", got)
}
if got := byID["NewMusicG90s"]; got.Name != "Mixtape (42.16)" {
t.Errorf("взята не самая свежая версия: %+v", got)
}
// Dir указывает на папку мода, а не на папку версии: так его удобнее найти.
if got := byID["NewMusicG90s"]; filepath.Base(got.Dir) != "NewMusicG90s" {
t.Errorf("Dir указывает не на папку мода: %+v", got)
}
}
// Корневой mod.info главнее версий: так раскладывают моды Build 41.
func TestModInfoPathPrefersRoot(t *testing.T) {
mod := t.TempDir()
writeModInfo(t, mod, "name=Root\nid=Root\n")
writeModInfo(t, filepath.Join(mod, "42"), "name=Version\nid=Version\n")
path, ok := modInfoPath(mod)
if !ok {
t.Fatal("mod.info не найден")
}
if filepath.Dir(path) != mod {
t.Errorf("взят mod.info из папки версии: %s", path)
}
}
func TestModInfoPathWithoutModInfo(t *testing.T) {
mod := t.TempDir()
if err := os.MkdirAll(filepath.Join(mod, "media"), 0o755); err != nil {
t.Fatal(err)
}
if _, ok := modInfoPath(mod); ok {
t.Error("mod.info найден там, где его нет")
}
}
+79 -18
View File
@@ -436,8 +436,8 @@ function handleConsoleMeta(meta) {
lastSeq = 0;
if (!restarted) return;
$("#console-output").innerHTML = "";
pushConsoleLine("manager", t("console.managerRestarted"));
clearConsoleScreen();
queueConsoleLine("manager", t("console.managerRestarted"));
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
// нового запуска нет.
openConsoleStream();
@@ -451,30 +451,68 @@ function closeConsoleStream() {
function appendConsoleLine(line) {
if (line.seq <= lastSeq) return;
lastSeq = line.seq;
pushConsoleLine(line.stream, line.text);
queueConsoleLine(line.stream, line.text);
}
// pushConsoleLine рисует строку на экране. Отдельно от нумерации: свои
// сообщения панель показывает без номера из буфера менеджера.
function pushConsoleLine(stream, text) {
// Загрузка мира со сборкой модов печатает десятки тысяч строк за минуты:
// каждое исключение — стек на два десятка строк. Рисовать их по одной нельзя:
// прокрутка вниз после каждой строки заставляет браузер пересчитывать раскладку
// всего списка, и вкладка встаёт колом на десятки секунд. Поэтому строки
// копятся и выводятся пачкой раз в кадр.
let consoleQueue = [];
let consoleFrame = 0;
// queueConsoleLine ставит строку в очередь на отрисовку. Отдельно от нумерации:
// свои сообщения панель показывает без номера из буфера менеджера.
function queueConsoleLine(stream, text) {
consoleQueue.push({ stream, text });
// На скрытой вкладке кадры не идут, и очередь росла бы без предела: держим
// столько, сколько всё равно поместится на экране.
if (consoleQueue.length > MAX_CONSOLE_LINES * 2) {
consoleQueue = consoleQueue.slice(-MAX_CONSOLE_LINES);
}
if (!consoleFrame) consoleFrame = requestAnimationFrame(flushConsoleQueue);
}
function flushConsoleQueue() {
consoleFrame = 0;
const pending = consoleQueue;
consoleQueue = [];
if (!pending.length) return;
const output = $("#console-output");
const filter = $("#console-filter").value.trim().toLowerCase();
// Из потопа сохранится всё равно только хвост — остальные узлы незачем даже
// создавать.
const from = Math.max(0, pending.length - MAX_CONSOLE_LINES);
const el = document.createElement("div");
el.className = `line-${stream}`;
el.textContent = text;
el.dataset.text = text.toLowerCase();
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
output.appendChild(el);
const batch = document.createDocumentFragment();
for (let i = from; i < pending.length; i++) {
const { stream, text } = pending[i];
const el = document.createElement("div");
el.className = `line-${stream}`;
el.textContent = text;
el.dataset.text = text.toLowerCase();
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
batch.appendChild(el);
}
output.appendChild(batch);
while (output.childElementCount > MAX_CONSOLE_LINES) {
output.removeChild(output.firstElementChild);
}
// Прокрутка — один раз на пачку: именно она и стоит дорого.
if ($("#console-autoscroll").checked) {
output.scrollTop = output.scrollHeight;
}
}
// clearConsoleScreen убирает и то, что уже нарисовано, и то, что ждёт кадра.
function clearConsoleScreen() {
consoleQueue = [];
$("#console-output").innerHTML = "";
}
$("#console-filter").addEventListener("input", (event) => {
const filter = event.target.value.trim().toLowerCase();
Array.from($("#console-output").children).forEach((line) => {
@@ -484,7 +522,7 @@ $("#console-filter").addEventListener("input", (event) => {
$("#btn-console-clear").addEventListener("click", () => {
// Чистим только экран: история остаётся в буфере менеджера.
$("#console-output").innerHTML = "";
clearConsoleScreen();
});
// История отправленных команд: та же логика, что и в обычном терминале.
@@ -1267,13 +1305,36 @@ function renderMods() {
.join("") || `<li class="muted">${t("mods.empty")}</li>`;
}
// LOOKUP_BATCH — сколько идентификаторов уходит в Steam за раз. Сервер не
// принимает больше двух сотен, а у крупной сборки их втрое больше: одним
// запросом список названий не пришёл бы вовсе.
const LOOKUP_BATCH = 100;
async function lookupWorkshopTitles() {
if (!mods.workshop.length) return;
const data = await post("/api/mods/lookup", { ids: mods.workshop });
(data.items || []).forEach((item) => {
mods.details[item.id] = item;
});
renderMods();
let failure = null;
let received = 0;
for (let from = 0; from < mods.workshop.length; from += LOOKUP_BATCH) {
const ids = mods.workshop.slice(from, from + LOOKUP_BATCH);
try {
const data = await post("/api/mods/lookup", { ids });
(data.items || []).forEach((item) => {
mods.details[item.id] = item;
received++;
});
// Рисуем по мере ответов: у сотен модов ожидание заметно.
renderMods();
} catch (error) {
failure = error;
}
}
// Без интернета названий не будет, и это не ошибка панели — но молчать тоже
// нельзя: иначе непонятно, почему вместо названий одни номера.
if (failure && received === 0) {
toast(t("mods.titlesFailed", { reason: failure.message }), "err");
}
}
// moveEnabled двигает мод в списке загрузки на delta позиций.
+2
View File
@@ -225,6 +225,7 @@ const STRINGS = {
"mods.mapsTitle": "Карты (Map)",
"mods.mapsPlaceholder": "Например: Muldraugh, KY",
"mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.",
"mods.titlesFailed": "Названия модов не получены из мастерской: {reason}",
"mods.caseTitle": "Регистр имён файлов модов",
"mods.caseHint": "Игра приводит путь к файлам анимаций в нижний регистр целиком, включая имя папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог ошибки AnimNode.Parse. Кнопка кладёт рядом строчные ссылки — файлы модов при этом не меняются. Запускайте после каждой докачки модов.",
"mods.caseApply": "Разложить строчные ссылки",
@@ -575,6 +576,7 @@ const STRINGS = {
"mods.mapsTitle": "Maps (Map)",
"mods.mapsPlaceholder": "For example: Muldraugh, KY",
"mods.mapsHint": "One map per line. The mod map goes first, the base map last.",
"mods.titlesFailed": "Could not get the mod titles from the workshop: {reason}",
"mods.caseTitle": "Mod file name case",
"mods.caseHint": "The game lowercases the whole path to animation files, the mod folder name included. On Linux those files are not found and the server floods the log with AnimNode.Parse errors. The button puts lowercase symlinks next to them — mod files themselves are left alone. Run it after every mod download.",
"mods.caseApply": "Create lowercase links",