From 9a224bc070530a4691648f96790417c812e9941a Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Sat, 5 Sep 2026 21:14:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D1=83=D1=81=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B8=20=D0=B0=D0=BD=D0=B3=D0=BB=D0=B8=D0=B9=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B9=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=B8=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Язык выбирается в шапке панели, а до первого выбора берётся из настроек браузера. Переведены интерфейс, подписи и описания всех параметров конфигов, ошибки API и служебные сообщения менеджера в консоли. Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на выходе из API по словарю, где ключом служит русская фраза из кода, — вывод самого игрового сервера через него проходит без изменений. Co-Authored-By: Claude Opus 5 --- README.md | 27 +- internal/api/handlers_auth.go | 20 +- internal/api/handlers_config.go | 25 +- internal/api/handlers_mods.go | 15 +- internal/api/handlers_profiles.go | 26 +- internal/api/handlers_server.go | 48 +- internal/api/handlers_tasks.go | 26 +- internal/api/handlers_update.go | 8 +- internal/api/server.go | 20 +- internal/i18n/catalog.go | 190 +++ internal/i18n/i18n.go | 152 +++ internal/i18n/i18n_test.go | 104 ++ web/i18n_test.go | 178 +++ web/static/app.js | 926 ++++----------- web/static/hints-en.json | 1806 +++++++++++++++++++++++++++++ web/static/hints-ru.json | 1806 +++++++++++++++++++++++++++++ web/static/i18n.js | 672 +++++++++++ web/static/index.html | 206 ++-- web/static/style.css | 19 + 19 files changed, 5417 insertions(+), 857 deletions(-) create mode 100644 internal/i18n/catalog.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 web/i18n_test.go create mode 100644 web/static/hints-en.json create mode 100644 web/static/hints-ru.json create mode 100644 web/static/i18n.js diff --git a/README.md b/README.md index 57757eb..c437c13 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с ротацией, скачиванием и восстановлением (перед восстановлением автоматически снимается страховочная копия). +- **Два языка** — русский и английский, с выбором в шапке и автоопределением + по языку браузера. - **Установка и обновление** сервера через SteamCMD прямо из панели. - **Обновление самой панели** — кнопкой из интерфейса или одной командой в терминале. @@ -162,6 +164,18 @@ sudo /opt/pzmanager/install.sh --uninstall --purge curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash -s -- --uninstall --purge ``` +## Язык интерфейса + +Панель говорит по-русски и по-английски. Язык выбирается списком в шапке (и на +экране входа, до того как вошли), а до первого выбора берётся из настроек +браузера: русский — русским, всем остальным — английский. Выбор запоминается в +браузере, поэтому у каждого, кто заходит в панель, он свой. + +Переводится всё, что показывает панель: интерфейс, подписи и описания всех +параметров `servertest.ini` и `SandboxVars.lua`, ошибки и служебные сообщения +менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он +приходит от Project Zomboid. + ## Моды Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что @@ -201,9 +215,10 @@ Mod ID, который нужен серверу, лежит внутри сам совпадение. Ключи, которых панель не знает (в новой версии игры их всегда добавляют), не теряются — они собираются в раздел «Прочее». -Описания параметров панель берёт из самого конфига: игра пишет их -комментариями на языке сервера. Полный текст виден при наведении, начало — -прямо под полем. +Подписи и описания у панели свои, на языке интерфейса: игра пишет свои +комментарии на языке сервера, а на Linux он почти всегда английский. Расшифровку +конкретных значений (что означает 1, 2, 3) панель добавляет из самого конфига. +Полный текст виден при наведении, начало — прямо под полем. Правка меняет ровно ту строку, которую поправили: комментарии, порядок и переводы строк файла сохраняются, а рядом остаётся копия прежней версии с @@ -445,6 +460,12 @@ make run # локальный запуск с конфигом в ./dev Веб-интерфейс — обычные HTML, CSS и JS в `web/static`, без сборщика; при `go build` они вшиваются в бинарник через `embed`. +Переводы лежат рядом: строки интерфейса — в `web/static/i18n.js`, подписи +параметров конфигов — в `web/static/hints-ru.json` и `hints-en.json` +(загружается только нужный язык), сообщения самого менеджера — в +`internal/i18n/catalog.go`, где ключом служит русская фраза из кода. Тесты в +`web` и `internal/i18n` следят, чтобы словари не разъезжались. + ## Лицензия [MIT](LICENSE) — делайте с кодом что угодно, но без каких-либо гарантий. diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index abe7e87..bd5d403 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -34,7 +34,7 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { // handleSetup создаёт первого администратора. Доступен, только пока учёток нет. func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { if !s.users.IsEmpty() { - writeError(w, http.StatusConflict, "панель уже настроена") + writeError(w, r, http.StatusConflict, "панель уже настроена") return } var creds credentials @@ -44,16 +44,16 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { // Токен печатается в журнал менеджера при старте: без него первым // администратором стал бы любой, кто успел открыть страницу. if s.setupToken == "" || subtle.ConstantTimeCompare([]byte(creds.Token), []byte(s.setupToken)) != 1 { - writeError(w, http.StatusForbidden, "неверный код первичной настройки") + writeError(w, r, http.StatusForbidden, "неверный код первичной настройки") return } if err := s.users.AddUser(creds.Login, creds.Password); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } id, err := s.users.Login(creds.Login, creds.Password) if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } // Администратор создан — код больше не нужен и не должен лежать на диске. @@ -72,10 +72,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { id, err := s.users.Login(creds.Login, creds.Password) if err != nil { if errors.Is(err, auth.ErrBadCredentials) { - writeError(w, http.StatusUnauthorized, err.Error()) + writeError(w, r, http.StatusUnauthorized, err.Error()) return } - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.setSessionCookie(w, id, sessionTTL) @@ -100,7 +100,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } if err := s.users.AddUser(creds.Login, creds.Password); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, map[string]any{"login": creds.Login}) @@ -110,11 +110,11 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { login := r.PathValue("login") current, _ := s.users.SessionFromRequest(r) if login == current { - writeError(w, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли") + writeError(w, r, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли") return } if err := s.users.DeleteUser(login); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"deleted": login}) @@ -128,7 +128,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { return } if err := s.users.SetPassword(r.PathValue("login"), body.Password); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index 17cc471..5acd3c2 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -7,6 +7,7 @@ import ( "time" "github.com/mrleo1nid/pzmanager/internal/config" + "github.com/mrleo1nid/pzmanager/internal/i18n" "github.com/mrleo1nid/pzmanager/internal/pzconfig" ) @@ -19,12 +20,12 @@ func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "exists": false, "path": path, - "hint": "Файл создаётся при первом запуске сервера", + "hint": i18n.T(i18n.FromRequest(r), "Файл создаётся при первом запуске сервера"), }) return } if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ @@ -50,14 +51,14 @@ func (s *Server) handlePutServerConfig(w http.ResponseWriter, r *http.Request) { path := s.cfg.ServerConfigPath() ini, err := pzconfig.LoadINI(path) if err != nil { - writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error()) + writeError(w, r, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error()) return } for key, value := range body.Values { ini.Set(key, value) } if err := pzconfig.SaveINI(path, ini); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.sup.AppendLog("manager", "Конфиг сервера сохранён (изменений: "+strconv.Itoa(len(body.Values))+"). Изменения вступят в силу после перезапуска.") @@ -71,12 +72,12 @@ func (s *Server) handleGetSandbox(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "exists": false, "path": path, - "hint": "Файл создаётся при первом запуске сервера", + "hint": i18n.T(i18n.FromRequest(r), "Файл создаётся при первом запуске сервера"), }) return } if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ @@ -96,17 +97,17 @@ func (s *Server) handlePutSandbox(w http.ResponseWriter, r *http.Request) { path := s.cfg.SandboxConfigPath() sb, err := pzconfig.LoadSandbox(path) if err != nil { - writeError(w, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error()) + writeError(w, r, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error()) return } for key, value := range body.Values { if err := sb.Set(key, value); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } } if err := pzconfig.SaveSandbox(path, sb); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.sup.AppendLog("manager", "Настройки песочницы сохранены. Часть параметров применяется только к новому миру.") @@ -177,16 +178,16 @@ func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request) c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute) if err := c.Save(); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } if err := c.EnsureDirs(); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.sup.AppendLog("manager", "Настройки менеджера сохранены") writeJSON(w, http.StatusOK, map[string]any{ "saved": true, - "note": "Адрес прослушивания применится после перезапуска pzmanager", + "note": i18n.T(i18n.FromRequest(r), "Адрес прослушивания применится после перезапуска pzmanager"), }) } diff --git a/internal/api/handlers_mods.go b/internal/api/handlers_mods.go index 067928c..1f8fae5 100644 --- a/internal/api/handlers_mods.go +++ b/internal/api/handlers_mods.go @@ -6,6 +6,7 @@ import ( "path/filepath" "regexp" + "github.com/mrleo1nid/pzmanager/internal/i18n" "github.com/mrleo1nid/pzmanager/internal/pzconfig" ) @@ -24,12 +25,12 @@ func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "exists": false, "path": path, - "hint": "Файл конфигурации появится после первого запуска сервера", + "hint": i18n.T(i18n.FromRequest(r), "Файл конфигурации появится после первого запуска сервера"), }) return } if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } @@ -67,7 +68,7 @@ func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) { path := s.cfg.ServerConfigPath() ini, err := pzconfig.LoadINI(path) if err != nil { - writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error()) + writeError(w, r, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error()) return } ini.SetMods(body.Mods, body.WorkshopItems) @@ -75,7 +76,7 @@ func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) { ini.Set(pzconfig.KeyMap, pzconfig.JoinList(body.Map)) } if err := pzconfig.SaveINI(path, ini); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.sup.AppendLog("manager", "Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.") @@ -95,7 +96,7 @@ func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) { return } if len(body.IDs) > 200 { - writeError(w, http.StatusBadRequest, "слишком много идентификаторов за один запрос") + writeError(w, r, http.StatusBadRequest, "слишком много идентификаторов за один запрос") return } items := s.workshop.Details(r.Context(), body.IDs) @@ -114,13 +115,13 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request) match := idFromInput.FindString(body.ID) if match == "" { - writeError(w, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию") + writeError(w, r, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию") return } items, err := s.workshop.Collection(r.Context(), match) if err != nil { - writeError(w, http.StatusBadGateway, err.Error()) + writeError(w, r, http.StatusBadGateway, err.Error()) return } // Названия подтягиваем сразу: иначе панель показала бы десяток голых diff --git a/internal/api/handlers_profiles.go b/internal/api/handlers_profiles.go index 5877462..f5ee542 100644 --- a/internal/api/handlers_profiles.go +++ b/internal/api/handlers_profiles.go @@ -84,7 +84,7 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) { return } if err := validateProfileMemory(dto); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } @@ -97,7 +97,7 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) { AdminPassword: dto.AdminPassword, } if err := s.cfg.AddProfile(profile); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } @@ -106,13 +106,13 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) { // Профиль уже добавлен в память — откатываем, чтобы не остался // наполовину настроенный. _ = s.cfg.DeleteProfile(profile.ID) - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } } if err := s.cfg.Save(); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.sup.AppendLog("manager", fmt.Sprintf("Создан профиль %q", profile.Name())) @@ -152,7 +152,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") current, ok := s.cfg.Profile(id) if !ok { - writeError(w, http.StatusNotFound, "профиль не найден") + writeError(w, r, http.StatusNotFound, "профиль не найден") return } @@ -161,7 +161,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { return } if err := validateProfileMemory(dto); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } @@ -180,11 +180,11 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { } if err := s.cfg.UpdateProfile(id, updated); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } if err := s.cfg.Save(); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } @@ -198,11 +198,11 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.cfg.DeleteProfile(id); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } if err := s.cfg.Save(); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.sup.AppendLog("manager", fmt.Sprintf( @@ -215,16 +215,16 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { // сервере нельзя: профили делят игровые порты и файлы запуска. func (s *Server) handleActivateProfile(w http.ResponseWriter, r *http.Request) { if s.sup.State() != pzserver.StateStopped { - writeError(w, http.StatusConflict, "сначала остановите игровой сервер") + writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер") return } id := r.PathValue("id") if err := s.cfg.SetActive(id); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } if err := s.cfg.Save(); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name())) diff --git a/internal/api/handlers_server.go b/internal/api/handlers_server.go index 46b1b4b..54510b6 100644 --- a/internal/api/handlers_server.go +++ b/internal/api/handlers_server.go @@ -8,19 +8,26 @@ import ( "strconv" "time" + "github.com/mrleo1nid/pzmanager/internal/i18n" "github.com/mrleo1nid/pzmanager/internal/pzserver" ) func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "server": s.sup.Status(), - "task": s.jobs.Status(), - }) + lang := i18n.FromRequest(r) + status := s.sup.Status() + status.LastExit = i18n.T(lang, status.LastExit) + + // Имя задачи придумано менеджером и висит в панели, пока задача идёт. + task := s.jobs.Status() + task.Name = i18n.T(lang, task.Name) + task.Error = i18n.T(lang, task.Error) + + writeJSON(w, http.StatusOK, map[string]any{"server": status, "task": task}) } func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { if err := s.sup.Start(); err != nil { - writeOpError(w, err) + writeOpError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{"state": s.sup.State()}) @@ -31,7 +38,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { // видит прогресс по состоянию и логам. func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { if s.sup.State() == pzserver.StateStopped { - writeOpError(w, pzserver.ErrNotRunning) + writeOpError(w, r, pzserver.ErrNotRunning) return } go func() { @@ -44,7 +51,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) { if !s.sup.Installed() { - writeOpError(w, pzserver.ErrNotInstalled) + writeOpError(w, r, pzserver.ErrNotInstalled) return } go func() { @@ -63,7 +70,7 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) { return } if err := s.sup.Send(body.Command); err != nil { - writeOpError(w, err) + writeOpError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{"sent": body.Command}) @@ -72,7 +79,23 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) { // handleLogs отдаёт историю консоли начиная с указанного номера строки. func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) - writeJSON(w, http.StatusOK, map[string]any{"lines": s.sup.Lines(since)}) + writeJSON(w, http.StatusOK, map[string]any{"lines": translateLines(r, s.sup.Lines(since))}) +} + +// translateLines переводит строки консоли на язык читающего. Буфер один на +// всех, поэтому перевод делается на выходе, у каждого подключения свой. Вывод +// самого игрового сервера через словарь проходит без изменений: он английский. +func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine { + lang := i18n.FromRequest(r) + if lang == i18n.Default { + return lines + } + out := make([]pzserver.LogLine, len(lines)) + for i, line := range lines { + line.Text = i18n.T(lang, line.Text) + out[i] = line + } + return out } // handleLogStream — живая консоль через Server-Sent Events. SSE выбран вместо @@ -80,7 +103,7 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { - writeError(w, http.StatusInternalServerError, "потоковая отдача не поддерживается") + writeError(w, r, http.StatusInternalServerError, "потоковая отдача не поддерживается") return } @@ -94,9 +117,11 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { lines, unsubscribe := s.sup.Subscribe() defer unsubscribe() + lang := i18n.FromRequest(r) + // Сначала отдаём хвост истории, чтобы вкладка не открывалась пустой. since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) - for _, line := range s.sup.Lines(since) { + for _, line := range translateLines(r, s.sup.Lines(since)) { if !writeSSE(w, line) { return } @@ -115,6 +140,7 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { if !ok { return } + line.Text = i18n.T(lang, line.Text) if !writeSSE(w, line) { return } diff --git a/internal/api/handlers_tasks.go b/internal/api/handlers_tasks.go index 4bd59f5..59d8e34 100644 --- a/internal/api/handlers_tasks.go +++ b/internal/api/handlers_tasks.go @@ -19,7 +19,7 @@ func (s *Server) handleTaskStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) { if !s.jobs.Busy() { - writeError(w, http.StatusConflict, "нет активной задачи") + writeError(w, r, http.StatusConflict, "нет активной задачи") return } s.jobs.Cancel() @@ -31,7 +31,7 @@ func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) { // JVM, и сервер упадёт непредсказуемо. func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { if s.sup.State() != pzserver.StateStopped { - writeError(w, http.StatusConflict, "сначала остановите игровой сервер") + writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер") return } @@ -46,7 +46,7 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { return nil }) if err != nil { - writeOpError(w, err) + writeOpError(w, r, err) return } writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) @@ -55,7 +55,7 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) { archives, err := s.backups.List() if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } // Фильтр по профилю необязателен: панель показывает архивы всех профилей, @@ -88,7 +88,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) { body.Profile = s.cfg.Active } if _, ok := s.cfg.Profile(body.Profile); !ok { - writeError(w, http.StatusBadRequest, "профиль не найден") + writeError(w, r, http.StatusBadRequest, "профиль не найден") return } // Живой сервер держит мир в памяти, и снимок чужого профиля тут ни при @@ -119,7 +119,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) { return err }) if err != nil { - writeOpError(w, err) + writeOpError(w, r, err) return } writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) @@ -127,7 +127,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) { if err := s.backups.Delete(r.PathValue("name")); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")}) @@ -137,18 +137,18 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") path, err := s.backups.Path(name) if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } f, err := os.Open(path) if err != nil { - writeError(w, http.StatusNotFound, "архив не найден") + writeError(w, r, http.StatusNotFound, "архив не найден") return } defer f.Close() info, err := f.Stat() if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) return } @@ -162,12 +162,12 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { // своим состоянием при следующем сохранении. func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { if s.sup.State() != pzserver.StateStopped { - writeError(w, http.StatusConflict, "сначала остановите игровой сервер") + writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер") return } name := r.PathValue("name") if _, err := s.backups.Path(name); err != nil { - writeError(w, http.StatusBadRequest, err.Error()) + writeError(w, r, http.StatusBadRequest, err.Error()) return } // Страховочную копию снимаем с того профиля, поверх которого ляжет архив; @@ -188,7 +188,7 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { return s.backups.Restore(ctx, log, name) }) if err != nil { - writeOpError(w, err) + writeOpError(w, r, err) return } writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName}) diff --git a/internal/api/handlers_update.go b/internal/api/handlers_update.go index e890f3d..55f8f67 100644 --- a/internal/api/handlers_update.go +++ b/internal/api/handlers_update.go @@ -26,7 +26,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) { } if !s.updater.CanApply(r.Context()) { - writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error()) + writeError(w, r, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error()) return } @@ -40,7 +40,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) { return s.updater.Apply(ctx, log, body.Version) }) if err != nil { - writeOpError(w, err) + writeOpError(w, r, err) return } writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) @@ -49,7 +49,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) { // handleRestartManager перезапускает саму панель. func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) { if !s.updater.CanApply(r.Context()) { - writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error()) + writeError(w, r, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error()) return } @@ -60,7 +60,7 @@ func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) { return s.updater.Restart(ctx, log) }) if err != nil { - writeOpError(w, err) + writeOpError(w, r, err) return } writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) diff --git a/internal/api/server.go b/internal/api/server.go index 2565ea6..a30e450 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -13,6 +13,7 @@ import ( "github.com/mrleo1nid/pzmanager/internal/auth" "github.com/mrleo1nid/pzmanager/internal/backup" "github.com/mrleo1nid/pzmanager/internal/config" + "github.com/mrleo1nid/pzmanager/internal/i18n" "github.com/mrleo1nid/pzmanager/internal/job" "github.com/mrleo1nid/pzmanager/internal/pzconfig" "github.com/mrleo1nid/pzmanager/internal/pzserver" @@ -175,7 +176,7 @@ func (s *Server) withCommonHeaders(next http.Handler) http.Handler { if r.Method != http.MethodGet && r.Method != http.MethodHead && strings.HasPrefix(r.URL.Path, "/api/") && r.Header.Get(csrfHeader) == "" { - writeError(w, http.StatusForbidden, "запрос без заголовка "+csrfHeader) + writeError(w, r, http.StatusForbidden, "запрос без заголовка "+csrfHeader) return } next.ServeHTTP(w, r) @@ -186,7 +187,7 @@ func (s *Server) withCommonHeaders(next http.Handler) http.Handler { func (s *Server) requireAuth(next http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, ok := s.users.SessionFromRequest(r); !ok { - writeError(w, http.StatusUnauthorized, "требуется вход") + writeError(w, r, http.StatusUnauthorized, "требуется вход") return } next(w, r) @@ -228,20 +229,21 @@ func writeJSON(w http.ResponseWriter, status int, v any) { } // writeError отправляет ошибку в том же формате, что и остальные ответы. -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) +// Текст переводится на язык панели: в коде сообщения написаны по-русски. +func writeError(w http.ResponseWriter, r *http.Request, status int, message string) { + writeJSON(w, status, map[string]string{"error": i18n.T(i18n.FromRequest(r), message)}) } // writeOpError переводит ошибку операции в подходящий HTTP-код. -func writeOpError(w http.ResponseWriter, err error) { +func writeOpError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning), errors.Is(err, job.ErrBusy): - writeError(w, http.StatusConflict, err.Error()) + writeError(w, r, http.StatusConflict, err.Error()) case errors.Is(err, pzserver.ErrNotInstalled): - writeError(w, http.StatusPreconditionFailed, err.Error()) + writeError(w, r, http.StatusPreconditionFailed, err.Error()) default: - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, r, http.StatusInternalServerError, err.Error()) } } @@ -251,7 +253,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { - writeError(w, http.StatusBadRequest, "некорректный JSON: "+err.Error()) + writeError(w, r, http.StatusBadRequest, "некорректный JSON: "+err.Error()) return false } return true diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go new file mode 100644 index 0000000..efababc --- /dev/null +++ b/internal/i18n/catalog.go @@ -0,0 +1,190 @@ +package i18n + +// Словарь сообщений менеджера. Ключ — русская фраза ровно в том виде, в каком +// она собирается в коде; значение — английский перевод. +// +// В formats оба текста записаны как форматные строки: %s отмечает место, куда +// при сборке сообщения подставилось значение. Порядок значений в переводе +// должен совпадать с оригиналом. + +var messages = map[string]string{ + // --- Вход и учётные записи --- + "панель уже настроена": "the panel is already set up", + "неверный код первичной настройки": "wrong setup code", + "неверный логин или пароль": "wrong login or password", + "требуется вход": "sign-in required", + "логин не может быть пустым": "the login cannot be empty", + "пароль короче 8 символов": "the password is shorter than 8 characters", + "пароли не совпадают": "the passwords do not match", + "пользователь не найден": "user not found", + "пользователь уже существует": "the user already exists", + "нельзя удалить учётную запись, под которой вы вошли": "you cannot delete the account you are signed in with", + + // --- Сервер и задачи --- + "сервер не запущен": "the server is not running", + "сервер уже запущен": "the server is already running", + "сначала остановите игровой сервер": "stop the game server first", + "серверные файлы не найдены — сначала выполните установку": "server files not found — install the server first", + "нет активной задачи": "no active task", + "уже выполняется другая задача": "another task is already running", + "пустая команда": "empty command", + "потоковая отдача не поддерживается": "streaming is not supported", + "штатное завершение": "clean shutdown", + + // --- Профили --- + "профиль не найден": "profile not found", + "нельзя удалить активный профиль — сначала переключитесь на другой": "the active profile cannot be deleted — switch to another one first", + "нельзя удалить единственный профиль": "the only profile cannot be deleted", + "путь к конфигу неизвестен": "the config path is unknown", + + // --- Бэкапы и обновление --- + "архив не найден": "archive not found", + "обновление из панели недоступно: нет pzmanager-admin или правила sudo": "updating from the panel is unavailable: pzmanager-admin or the sudo rule is missing", + "длительность должна быть строкой вида 90s или числом секунд": "the duration must be a string like 90s or a number of seconds", + + // --- Моды --- + "слишком много идентификаторов за один запрос": "too many identifiers in one request", + "не похоже на идентификатор или ссылку на коллекцию": "that does not look like a collection ID or link", + + // --- Подсказки и пометки в ответах API --- + "Файл создаётся при первом запуске сервера": "The file is created on the first server start", + "Файл конфигурации появится после первого запуска сервера": "The configuration file appears after the first server start", + "Адрес прослушивания применится после перезапуска pzmanager": "The listen address applies after a pzmanager restart", + + // --- Имена задач --- + "Установка/обновление сервера через SteamCMD": "Installing or updating the server via SteamCMD", + "Обновление панели": "Panel update", + "Перезапуск панели": "Panel restart", + + // --- Сообщения в консоли панели --- + "Настройки менеджера сохранены": "Manager settings saved", + "Настройки песочницы сохранены. Часть параметров применяется только к новому миру.": "Sandbox settings saved. Some of them only affect a freshly created world.", + "Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.": "Mod list updated. The server will download the missing mods on its next start.", + "Профиль сохранён": "Profile saved", + "Профиль сохранён. Память и аргументы запуска применятся после перезапуска сервера.": "Profile saved. Memory and launch arguments apply after a server restart.", + "Останавливаю сервер перед бэкапом": "Stopping the server before the backup", + "Делаю страховочный бэкап текущего мира": "Taking a safety backup of the current world", + "Поднимаю сервер обратно": "Bringing the server back up", + "Плановый бэкап пропущен: выполняется другая задача": "Scheduled backup skipped: another task is running", + "Мир ещё не создан — архивирую только конфиги": "The world does not exist yet — archiving the configs only", + "Останавливаю сервер командой quit...": "Stopping the server with the quit command...", + "Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM", + "Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL", + "Автоперезапуск отменён": "Automatic restart cancelled", + "Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.", + "Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart", + "Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f", + "Перезапускаю панель": "Restarting the panel", + "обновлением": "the update", + "перезапуском": "the restart", +} + +var formats = [][2]string{ + // --- Ошибки API --- + {"запрос без заголовка %s", "request without the %s header"}, + {"некорректный JSON: %s", "malformed JSON: %s"}, + {"конфиг сервера недоступен: %s", "the server config is unavailable: %s"}, + {"настройки песочницы недоступны: %s", "the sandbox settings are unavailable: %s"}, + + // --- Профили --- + {"профиль %s не найден", "profile %s not found"}, + {"профиль %s уже существует", "profile %s already exists"}, + {"профиль-источник %s не найден", "source profile %s not found"}, + {"идентификатор %s недопустим: разрешены латинские буквы, цифры, дефис и подчёркивание (до 32 символов)", + "identifier %s is not allowed: use Latin letters, digits, hyphen and underscore (up to 32 characters)"}, + {"у профиля %s ещё нет файлов настроек — сначала запустите его хотя бы раз", + "profile %s has no config files yet — start it at least once first"}, + {"скопировать настройки: %s", "copy the settings: %s"}, + + // --- Память JVM --- + {"объём памяти должен выглядеть как 4g или 8192m, получено %s", + "the memory size must look like 4g or 8192m, got %s"}, + {"не разобран объём памяти %s", "could not parse the memory size %s"}, + {"начальный размер памяти (%s) больше максимального (%s)", + "the initial memory size (%s) is larger than the maximum (%s)"}, + {"начальный размер кучи (%s) больше максимального (%s)", + "the initial heap size (%s) is larger than the maximum (%s)"}, + + // --- Бэкапы --- + {"недопустимое имя архива: %s", "invalid archive name: %s"}, + {"небезопасный путь в архиве: %s", "unsafe path inside the archive: %s"}, + {"абсолютный путь в архиве: %s", "absolute path inside the archive: %s"}, + {"архив повреждён: %s", "the archive is damaged: %s"}, + {"страховочный бэкап не создан, восстановление отменено: %s", + "the safety backup failed, the restore was cancelled: %s"}, + + // --- Steam и моды --- + {"Steam недоступен: %s", "Steam is unavailable: %s"}, + {"Steam ответил %s", "Steam answered %s"}, + {"ответ Steam не разобран: %s", "the Steam answer could not be parsed: %s"}, + {"коллекция %s не найдена", "collection %s not found"}, + {"коллекция %s недоступна — проверьте ссылку и что она открыта всем", + "collection %s is unavailable — check the link and that it is public"}, + {"в коллекции %s нет модов", "collection %s has no mods"}, + + // --- Конфиги --- + {"параметр %s не найден в SandboxVars", "setting %s not found in SandboxVars"}, + {"недопустимое значение для %s", "invalid value for %s"}, + {"не удалось разобрать строку параметра %s", "could not parse the line of setting %s"}, + {"прочитать конфиг %s: %s", "read the config %s: %s"}, + {"создать конфиг %s: %s", "create the config %s: %s"}, + {"обновить конфиг %s: %s", "update the config %s: %s"}, + {"разобрать конфиг %s: %s", "parse the config %s: %s"}, + {"прочитать %s: %s", "read %s: %s"}, + {"записать %s: %s", "write %s: %s"}, + {"создать %s: %s", "create %s: %s"}, + {"разобрать %s: %s", "parse %s: %s"}, + {"запустить %s: %s", "start %s: %s"}, + {"запись в консоль: %s", "write to the console: %s"}, + {"не разобрана длительность %s: %s", "could not parse the duration %s: %s"}, + {"скачать SteamCMD: %s", "download SteamCMD: %s"}, + + // --- Имена задач --- + {"Резервное копирование мира: %s", "World backup: %s"}, + {"Плановое резервное копирование: %s", "Scheduled world backup: %s"}, + {"Восстановление из архива %s", "Restore from the archive %s"}, + + // --- Сообщения в консоли панели --- + {"Конфиг сервера сохранён (изменений: %s). Изменения вступят в силу после перезапуска.", + "Server config saved (changes: %s). They take effect after a restart."}, + {"Создан профиль %s", "Profile %s created"}, + {"Настройки профиля %s скопированы в %s", "The settings of profile %s were copied to %s"}, + {"Активный профиль: %s", "Active profile: %s"}, + {"Профиль %s удалён из панели. Мир и конфиги остались на диске: %s", + "Profile %s removed from the panel. Its world and configs stayed on disk: %s"}, + {"Запуск профиля %s: %s %s (pid %s)", "Starting profile %s: %s %s (pid %s)"}, + {"Запускаю: %s", "Running: %s"}, + {"Сервер остановлен: %s", "Server stopped: %s"}, + {"Автоперезапуск через %s (можно отменить кнопкой «Остановить»)", + "Automatic restart in %s (the Stop button cancels it)"}, + {"Автоперезапуск не удался: %s", "Automatic restart failed: %s"}, + {"Сервер падает сразу после запуска %s раза подряд — автоперезапуск остановлен. ", + "The server has crashed right after start %s times in a row — automatic restart is off. "}, + {"Не удалось выставить границы памяти JVM: %s", "Could not set the JVM memory limits: %s"}, + {"Не удалось выставить объём памяти JVM: %s", "Could not set the JVM memory size: %s"}, + {"Память JVM: от %s до %s", "JVM memory: from %s to %s"}, + {"Лимит памяти JVM выставлен: %s", "JVM memory limit set: %s"}, + {"Не удалось отправить quit: %s", "Could not send quit: %s"}, + {"SIGTERM не доставлен: %s", "SIGTERM was not delivered: %s"}, + {"Остановка не удалась: %s", "Stopping failed: %s"}, + {"Остановить сервер не удалось: %s", "The server could not be stopped: %s"}, + {"Перезапуск не удался: %s", "Restart failed: %s"}, + {"Не удалось запустить сервер: %s", "Could not start the server: %s"}, + {"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"}, + {"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"}, + + // --- Бэкапы: ход работы --- + {"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"}, + {"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"}, + {"Восстанавливаю %s в %s", "Restoring %s into %s"}, + {"Восстановлено файлов: %s", "Files restored: %s"}, + {"Удалён старый архив %s", "Old archive %s deleted"}, + {"Ротация не удалась: %s", "Rotation failed: %s"}, + {"Пропускаю %s: %s", "Skipping %s: %s"}, + {"Не прочитан %s: %s", "Not read %s: %s"}, + + // --- SteamCMD --- + {"SteamCMD найден: %s", "SteamCMD found: %s"}, + {"SteamCMD не найден, скачиваю в %s", "SteamCMD not found, downloading into %s"}, + {"SteamCMD установлен: %s", "SteamCMD installed: %s"}, +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..29a7052 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,152 @@ +// Package i18n переводит сообщения менеджера на язык веб-панели. +// +// Исходный язык кода — русский, он же служит ключом перевода: словарь связывает +// русскую фразу с английской, а незнакомая фраза остаётся как есть. Благодаря +// этому через перевод можно безопасно пропускать и вывод игрового сервера — он +// английский и в словаре не встретится. +package i18n + +import ( + "fmt" + "net/http" + "regexp" + "sort" + "strings" +) + +// Lang — язык сообщений панели. +type Lang string + +const ( + RU Lang = "ru" + EN Lang = "en" + // Default — язык, на котором сообщения написаны в коде. + Default = RU +) + +// CookieName — кука, которой панель сообщает выбранный человеком язык. +const CookieName = "pzm_lang" + +// Parse разбирает код языка; для незнакомого возвращает язык по умолчанию. +func Parse(value string) (Lang, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "ru": + return RU, true + case "en": + return EN, true + } + return Default, false +} + +// FromRequest определяет язык ответа. Выбор человека (кука, которую ставит +// панель) важнее списка языков браузера: на сервер могут заходить с чужой +// машины, а язык панели человек выбирает сам. +func FromRequest(r *http.Request) Lang { + if r == nil { + return Default + } + if cookie, err := r.Cookie(CookieName); err == nil { + if lang, ok := Parse(cookie.Value); ok { + return lang + } + } + return fromAcceptLanguage(r.Header.Get("Accept-Language")) +} + +// fromAcceptLanguage берёт первый понятный язык из заголовка. Веса (q=) не +// разбираем: браузеры и так перечисляют языки по убыванию предпочтения. +func fromAcceptLanguage(header string) Lang { + for _, part := range strings.Split(header, ",") { + tag := strings.TrimSpace(part) + if i := strings.IndexByte(tag, ';'); i >= 0 { + tag = tag[:i] + } + if i := strings.IndexByte(tag, '-'); i >= 0 { + tag = tag[:i] + } + if lang, ok := Parse(tag); ok { + return lang + } + } + return Default +} + +// T переводит сообщение на указанный язык. Неизвестное сообщение возвращается +// без изменений: потерять текст хуже, чем показать его на чужом языке. +func T(lang Lang, message string) string { + if lang == Default || lang == "" || message == "" { + return message + } + return translate(message, 0) +} + +// maxDepth ограничивает разбор вложенных ошибок: «прочитать X: создать Y: ...». +const maxDepth = 4 + +func translate(message string, depth int) string { + if text, ok := messages[message]; ok { + return text + } + if depth >= maxDepth { + return message + } + for _, p := range patterns { + match := p.re.FindStringSubmatch(message) + if match == nil { + continue + } + // Подставленные значения переводим тоже: в них попадает вложенная + // ошибка целиком, и она может быть знакома словарю. + args := make([]any, 0, len(match)-1) + for _, group := range match[1:] { + args = append(args, translate(group, depth+1)) + } + return fmt.Sprintf(p.en, args...) + } + return message +} + +// pattern — пара форматов «русский → английский». Значения, подставленные в +// сообщение при его сборке, вылавливаются группами регулярного выражения. +type pattern struct { + re *regexp.Regexp + en string + // weight — длина неизменяемой части. Чем она больше, тем конкретнее + // шаблон, и тем раньше его стоит примерить. + weight int +} + +var patterns []pattern + +func init() { + patterns = make([]pattern, 0, len(formats)) + for _, f := range formats { + patterns = append(patterns, compile(f[0], f[1])) + } + sort.SliceStable(patterns, func(i, j int) bool { + return patterns[i].weight > patterns[j].weight + }) +} + +func compile(ru, en string) pattern { + parts := strings.Split(ru, "%s") + weight := 0 + for _, part := range parts { + weight += len(part) + } + + var body strings.Builder + body.WriteString(regexp.QuoteMeta(parts[0])) + for i := 1; i < len(parts); i++ { + // Последняя группа жадная: в неё попадает вложенная ошибка целиком, + // вместе со своими двоеточиями. + group := "(.+?)" + if i == len(parts)-1 && parts[i] == "" { + group = "(.+)" + } + body.WriteString(group) + body.WriteString(regexp.QuoteMeta(parts[i])) + } + // (?s) — сообщения бывают многострочными. + return pattern{re: regexp.MustCompile("(?s)^" + body.String() + "$"), en: en, weight: weight} +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..00ff27b --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,104 @@ +package i18n + +import ( + "net/http" + "strings" + "testing" +) + +func TestTKnownMessage(t *testing.T) { + got := T(EN, "сервер не запущен") + if got != "the server is not running" { + t.Fatalf("T(EN, ...) = %q", got) + } + if got := T(RU, "сервер не запущен"); got != "сервер не запущен" { + t.Fatalf("русский язык не должен трогать сообщение: %q", got) + } +} + +// Вывод игрового сервера проходит через перевод как есть: словарь его не знает. +func TestTKeepsUnknown(t *testing.T) { + line := "LOG : General f:1 Zombies spawned: 1000" + if got := T(EN, line); got != line { + t.Fatalf("незнакомая строка изменена: %q", got) + } +} + +func TestTFormat(t *testing.T) { + got := T(EN, `профиль "modded" не найден`) + if got != `profile "modded" not found` { + t.Fatalf("T = %q", got) + } +} + +// Вложенная ошибка переводится вместе с обёрткой, а более конкретный шаблон +// («прочитать конфиг») должен выиграть у общего («прочитать»). +func TestTNestedError(t *testing.T) { + msg := "конфиг сервера недоступен: прочитать конфиг /srv/servertest.ini: open: no such file" + want := "the server config is unavailable: read the config /srv/servertest.ini: open: no such file" + if got := T(EN, msg); got != want { + t.Fatalf("T = %q, ожидалось %q", got, want) + } +} + +func TestTTaskName(t *testing.T) { + if got := T(EN, "Резервное копирование мира: modded"); got != "World backup: modded" { + t.Fatalf("T = %q", got) + } +} + +// В переводе должно быть столько же подстановок, сколько в оригинале, иначе +// Sprintf выведет %!s(MISSING) прямо в панель. +func TestCatalogPlaceholders(t *testing.T) { + for _, f := range formats { + ru := strings.Count(f[0], "%s") + en := strings.Count(f[1], "%s") + if ru == 0 { + t.Errorf("шаблон без подстановки: %q", f[0]) + } + if ru != en { + t.Errorf("%q: подстановок %d, в переводе %d", f[0], ru, en) + } + if strings.ContainsAny(strings.ReplaceAll(f[0], "%s", ""), "%") { + t.Errorf("в шаблоне остался другой глагол форматирования: %q", f[0]) + } + } + for ru, en := range messages { + if strings.Contains(ru, "%") || strings.Contains(en, "%") { + t.Errorf("сообщение с подстановкой должно лежать в formats: %q", ru) + } + } +} + +func TestFromRequest(t *testing.T) { + cases := []struct { + name string + cookie string + header string + want Lang + }{ + {"по умолчанию", "", "", Default}, + {"выбор человека", "en", "ru-RU,ru;q=0.9", EN}, + {"мусор в куке", "klingon", "en-US,en;q=0.9", EN}, + {"язык браузера", "", "en-GB,en;q=0.9", EN}, + {"неизвестный язык браузера", "", "de-DE,de;q=0.9", Default}, + {"первый понятный из списка", "", "de-DE;q=0.9,en;q=0.8", EN}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r, err := http.NewRequest(http.MethodGet, "/api/status", nil) + if err != nil { + t.Fatal(err) + } + if c.cookie != "" { + r.AddCookie(&http.Cookie{Name: CookieName, Value: c.cookie}) + } + if c.header != "" { + r.Header.Set("Accept-Language", c.header) + } + if got := FromRequest(r); got != c.want { + t.Fatalf("FromRequest = %q, ожидалось %q", got, c.want) + } + }) + } +} diff --git a/web/i18n_test.go b/web/i18n_test.go new file mode 100644 index 0000000..d0aeeb3 --- /dev/null +++ b/web/i18n_test.go @@ -0,0 +1,178 @@ +package web + +import ( + "encoding/json" + "os" + "regexp" + "sort" + "strings" + "testing" +) + +// Переводы легко разъезжаются: строку добавили в один язык и забыли про другой. +// Тесты ниже сверяют словари между собой и с разметкой. + +func TestUIStringsCoverBothLanguages(t *testing.T) { + tables := parseStrings(t) + ru, en := tables["ru"], tables["en"] + if len(ru) == 0 || len(en) == 0 { + t.Fatalf("словари не разобраны: ru=%d en=%d", len(ru), len(en)) + } + for key := range ru { + if _, ok := en[key]; !ok { + t.Errorf("нет английского перевода: %s", key) + } + } + for key := range en { + if _, ok := ru[key]; !ok { + t.Errorf("нет русского перевода: %s", key) + } + } +} + +// Подстановки вида {count} должны совпадать: иначе в одном языке значение +// просто не появится. +func TestUIStringsPlaceholders(t *testing.T) { + tables := parseStrings(t) + placeholder := regexp.MustCompile(`\{(\w+)\}`) + + for key, ruText := range tables["ru"] { + enText, ok := tables["en"][key] + if !ok { + continue + } + if got, want := names(placeholder, enText), names(placeholder, ruText); !equal(got, want) { + t.Errorf("%s: подстановки ru=%v, en=%v", key, want, got) + } + } +} + +// Каждый ключ из разметки должен существовать в словаре, иначе на странице +// вместо подписи окажется сам ключ. +func TestMarkupKeysExist(t *testing.T) { + markup, err := os.ReadFile("static/index.html") + if err != nil { + t.Fatal(err) + } + tables := parseStrings(t) + attr := regexp.MustCompile(`data-i18n(?:-html|-placeholder|-title)?="([^"]+)"`) + + for _, match := range attr.FindAllStringSubmatch(string(markup), -1) { + if _, ok := tables["ru"][match[1]]; !ok { + t.Errorf("ключ из index.html отсутствует в словаре: %s", match[1]) + } + } + + script, err := os.ReadFile("static/app.js") + if err != nil { + t.Fatal(err) + } + // Ключи, собранные из кусков (`state.${state}`), сюда не попадают — у них + // в коде есть запасной вариант на случай отсутствия перевода. + call := regexp.MustCompile(`\bt\("([^"]+)"`) + found := 0 + for _, match := range call.FindAllStringSubmatch(string(script), -1) { + found++ + if _, ok := tables["ru"][match[1]]; !ok { + t.Errorf("ключ из app.js отсутствует в словаре: %s", match[1]) + } + } + if found == 0 { + t.Error("в app.js не найдено ни одного обращения к словарю") + } +} + +// Подписи параметров конфигов лежат отдельными файлами: в них должны быть одни +// и те же ключи, и у каждого — подпись и описание. +func TestParamHintsMatch(t *testing.T) { + ru := readHints(t, "static/hints-ru.json") + en := readHints(t, "static/hints-en.json") + + for _, section := range []string{"config", "sandbox"} { + if len(ru[section]) == 0 { + t.Fatalf("раздел %s пуст", section) + } + if len(ru[section]) != len(en[section]) { + t.Errorf("%s: ru=%d, en=%d параметров", section, len(ru[section]), len(en[section])) + } + for key, value := range ru[section] { + translated, ok := en[section][key] + if !ok { + t.Errorf("%s: нет английской подписи для %s", section, key) + continue + } + for _, pair := range [][]string{value, translated} { + if len(pair) != 2 || strings.TrimSpace(pair[0]) == "" || strings.TrimSpace(pair[1]) == "" { + t.Errorf("%s.%s: ожидались подпись и описание, получено %v", section, key, pair) + } + } + } + } +} + +func readHints(t *testing.T, path string) map[string]map[string][]string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var hints map[string]map[string][]string + if err := json.Unmarshal(data, &hints); err != nil { + t.Fatalf("%s: %v", path, err) + } + return hints +} + +// parseStrings вытаскивает словари из i18n.js. Разбирать JavaScript целиком +// незачем: строки записаны построчно, ключ в кавычках и двоеточие. +func parseStrings(t *testing.T) map[string]map[string]string { + t.Helper() + source, err := os.ReadFile("static/i18n.js") + if err != nil { + t.Fatal(err) + } + + tables := map[string]map[string]string{} + entry := regexp.MustCompile(`^\s{4}"([^"]+)": "(.*)",$`) + current := "" + for _, line := range strings.Split(string(source), "\n") { + switch strings.TrimSpace(strings.TrimSuffix(line, "\r")) { + case "ru: {": + current = "ru" + tables[current] = map[string]string{} + continue + case "en: {": + current = "en" + tables[current] = map[string]string{} + continue + } + if current == "" { + continue + } + if match := entry.FindStringSubmatch(strings.TrimSuffix(line, "\r")); match != nil { + tables[current][match[1]] = match[2] + } + } + return tables +} + +func names(re *regexp.Regexp, text string) []string { + found := []string{} + for _, match := range re.FindAllStringSubmatch(text, -1) { + found = append(found, match[1]) + } + sort.Strings(found) + return found +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/web/static/app.js b/web/static/app.js index 47fb335..eab9242 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -22,13 +22,13 @@ async function api(method, path, body) { const response = await fetch(path, options); if (response.status === 401) { showAuth(false); - throw new Error("Сессия истекла, войдите заново"); + throw new Error(t("auth.expired")); } const text = await response.text(); const data = text ? JSON.parse(text) : {}; if (!response.ok) { - throw new Error(data.error || `Ошибка ${response.status}`); + throw new Error(data.error || t("error.http", { status: response.status })); } return data; } @@ -58,7 +58,7 @@ const fail = (error) => toast(error.message || String(error), "err"); function formatBytes(bytes) { if (!bytes) return "—"; - const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"]; + const units = [t("unit.b"), t("unit.kb"), t("unit.mb"), t("unit.gb"), t("unit.tb")]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { @@ -74,14 +74,14 @@ function formatUptime(seconds) { const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const parts = []; - if (days) parts.push(`${days} д`); - if (hours) parts.push(`${hours} ч`); - parts.push(`${minutes} мин`); - return "Работает: " + parts.join(" "); + if (days) parts.push(t("unit.days", { value: days })); + if (hours) parts.push(t("unit.hours", { value: hours })); + parts.push(t("unit.minutes", { value: minutes })); + return t("overview.uptime", { value: parts.join(" ") }); } function formatDate(unixSeconds) { - return new Date(unixSeconds * 1000).toLocaleString("ru-RU"); + return new Date(unixSeconds * 1000).toLocaleString(t("locale.tag")); } // Экранирование не нужно там, где мы пишем в textContent, но пригодится для @@ -92,13 +92,13 @@ function escapeHTML(value) { return div.innerHTML; } -const STATE_LABELS = { - stopped: "Остановлен", - restarting: "Ждёт перезапуска", - starting: "Запускается", - running: "Работает", - stopping: "Останавливается", -}; +// Состояние приходит с сервера строкой: подпись ищем в словаре, а незнакомое +// показываем как есть — так новое состояние не превратится в пустоту. +function stateLabel(state) { + const key = `state.${state}`; + const label = t(key); + return label === key ? state : label; +} // --------------------------------------------------------------------------- // Вход и первичная настройка @@ -111,10 +111,8 @@ function showAuth(needSetup) { $("#app").classList.add("hidden"); $("#auth-screen").classList.remove("hidden"); $("#token-field").hidden = !needSetup; - $("#auth-subtitle").textContent = needSetup - ? "Создайте учётную запись администратора" - : "Управление сервером Project Zomboid"; - $("#auth-submit").textContent = needSetup ? "Создать администратора" : "Войти"; + $("#auth-subtitle").textContent = t(needSetup ? "auth.subtitleSetup" : "auth.subtitle"); + $("#auth-submit").textContent = t(needSetup ? "auth.createAdmin" : "auth.signIn"); $("#auth-form").querySelector("[name=password]").autocomplete = needSetup ? "new-password" : "current-password"; @@ -223,7 +221,7 @@ async function refreshStatus() { try { data = await get("/api/status"); } catch (error) { - $("#status-text").textContent = "Нет связи с менеджером"; + $("#status-text").textContent = t("error.offline"); $("#status-dot").className = "dot"; return; } @@ -235,23 +233,32 @@ async function refreshStatus() { // отдать JVM, чтобы не выбирать объём вслепую. let hostStats = {}; +// Что показывают кнопки, зависит от состояния сервера, и сравнивать их подписи +// с текстом было бы неверно: он меняется вместе с языком. +let restartPending = false; +let serverInstalled = false; + function renderStatus(status) { hostStats = status.host || {}; - let label = STATE_LABELS[status.state] || status.state; + let label = stateLabel(status.state); // Пока идёт обратный отсчёт, показываем, сколько осталось: иначе непонятно, // происходит ли вообще что-нибудь. if (status.state === "restarting" && status.restart_in_sec > 0) { - label = `Перезапуск через ${status.restart_in_sec} с`; + label = t("state.restartIn", { sec: status.restart_in_sec }); } $("#status-text").textContent = label; $("#status-dot").className = `dot ${status.state}`; $("#ov-state").textContent = label; - $("#ov-profile").textContent = status.profile_title ? `Профиль: ${status.profile_title}` : ""; + $("#ov-profile").textContent = status.profile_title + ? t("overview.profile", { title: status.profile_title }) + : ""; $("#ov-uptime").textContent = formatUptime(status.uptime_sec); $("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : ""; $("#ov-lastexit").textContent = - status.state === "stopped" && status.last_exit ? `Последнее завершение: ${status.last_exit}` : ""; + status.state === "stopped" && status.last_exit + ? t("overview.lastExit", { reason: status.last_exit }) + : ""; const players = status.players || []; $("#ov-players-count").textContent = players.length; @@ -263,35 +270,37 @@ function renderStatus(status) { const host = status.host || {}; $("#ov-mem").textContent = host.mem_total_bytes - ? `${formatBytes(host.mem_available_bytes)} из ${formatBytes(host.mem_total_bytes)}` + ? t("overview.memoryOf", { + free: formatBytes(host.mem_available_bytes), + total: formatBytes(host.mem_total_bytes), + }) : "—"; $("#ov-load").textContent = host.cpu_count - ? `${(host.load1 || 0).toFixed(2)} на ${host.cpu_count} ядер` + ? t("overview.loadCores", { load: (host.load1 || 0).toFixed(2), count: host.cpu_count }) : (host.load1 || 0).toFixed(2); $("#ov-disk").textContent = host.disk_total_bytes - ? `${formatBytes(host.disk_free_bytes)} из ${formatBytes(host.disk_total_bytes)}` + ? t("overview.memoryOf", { + free: formatBytes(host.disk_free_bytes), + total: formatBytes(host.disk_total_bytes), + }) : "—"; // Кнопка называет то, что произойдёт: на чистой машине это установка, на // готовой — обновление. - $("#install-state").textContent = status.installed - ? "Серверные файлы установлены." - : "Серверные файлы не найдены — сервер ещё не установлен."; - $("#btn-install").textContent = status.installed - ? "Обновить сервер через SteamCMD" - : "Установить сервер через SteamCMD"; - $("#install-hint").textContent = status.installed - ? "Сервер должен быть остановлен. Обновление занимает несколько минут — прогресс виден во вкладке «Консоль»." - : "Займёт несколько минут и около 3 ГБ на диске — прогресс виден во вкладке «Консоль»."; + serverInstalled = Boolean(status.installed); + $("#install-state").textContent = t(serverInstalled ? "install.installed" : "install.missing"); + $("#btn-install").textContent = t(serverInstalled ? "install.update" : "install.install"); + $("#install-hint").textContent = t(serverInstalled ? "install.hintUpdate" : "install.hintInstall"); const running = status.state !== "stopped"; const waitingRestart = status.state === "restarting"; + restartPending = waitingRestart; // В ожидании автоподъёма обе кнопки осмысленны: «Запустить» поднимает // сервер сразу, «Остановить» отменяет перезапуск. $("#btn-start").disabled = (running && !waitingRestart) || !status.installed; $("#btn-stop").disabled = !running; - $("#btn-stop").textContent = waitingRestart ? "Отменить перезапуск" : "Остановить"; + $("#btn-stop").textContent = t(waitingRestart ? "btn.cancelRestart" : "btn.stop"); $("#btn-restart").disabled = !status.installed; $("#btn-install").disabled = running; @@ -304,17 +313,13 @@ function renderStatus(status) { const consoleReady = status.state === "running" || status.state === "starting"; consoleInput.disabled = !consoleReady; consoleButton.disabled = !consoleReady; - consoleInput.placeholder = consoleReady - ? "Команда сервера, например: players, save, quit" - : "Сервер остановлен — консоль принимает команды только на работающем"; + consoleInput.placeholder = t(consoleReady ? "console.placeholder" : "console.placeholderOffline"); // Профили делят игровые порты и файл запуска, поэтому переключать их можно // только на остановленном сервере. const picker = $("#profile-select"); picker.disabled = running && !waitingRestart; - picker.title = running - ? "Остановите сервер, чтобы переключить профиль" - : "Профиль сервера: свой мир, конфиг и моды"; + picker.title = t(running ? "profiles.pickerLocked" : "profiles.pickerTitle"); // Активный профиль мог смениться в другой вкладке браузера. if (!running && status.profile && picker.value !== status.profile) { picker.value = status.profile; @@ -333,7 +338,7 @@ function renderTask(task) { if (task.error) { toast(`${task.name}: ${task.error}`, "err"); } else { - ok(`${task.name} — готово`); + ok(t("task.done", { name: task.name })); loadTab(currentTab); } } @@ -352,21 +357,18 @@ async function serverAction(path, confirmText) { $("#btn-start").addEventListener("click", () => serverAction("/api/server/start")); $("#btn-stop").addEventListener("click", () => { // Отмена перезапуска ничего не рушит, спрашивать незачем. - const waiting = $("#btn-stop").textContent === "Отменить перезапуск"; - serverAction("/api/server/stop", waiting ? "" : "Остановить игровой сервер? Игроки будут отключены."); + serverAction("/api/server/stop", restartPending ? "" : t("confirm.stop")); }); $("#btn-restart").addEventListener("click", () => - serverAction("/api/server/restart", "Перезапустить сервер? Игроки будут отключены.")); + serverAction("/api/server/restart", t("confirm.restart"))); $("#btn-install").addEventListener("click", async () => { // Тот же вопрос, что и на кнопке: «установить» и «обновить» — разные вещи // по ощущениям, хотя команда SteamCMD одна. - const installed = $("#install-state").textContent.startsWith("Серверные файлы установлены"); - const action = installed ? "Обновить серверные файлы" : "Установить сервер"; - if (!confirm(`${action} через SteamCMD?`)) return; + if (!confirm(t(serverInstalled ? "install.confirmUpdate" : "install.confirmInstall"))) return; try { await post("/api/install"); - ok("Задача запущена — следите за вкладкой «Консоль»"); + ok(t("install.started")); refreshStatus(); } catch (error) { fail(error); @@ -476,166 +478,23 @@ $("#console-command").addEventListener("keydown", (event) => { // Конфиг сервера // --------------------------------------------------------------------------- -// Подписи и описания параметров servertest.ini. Игра пишет свои комментарии -// на языке сервера, и на Linux они почти всегда английские, поэтому панель -// держит собственные: ключ -> [подпись, описание]. Чего здесь нет, панель -// покажет с описанием из самого конфига. -const CONFIG_HINTS = { - PublicName: ["Название сервера", "Показывается в списке серверов игры и Steam."], - PublicDescription: ["Описание сервера", "Текст под названием в списке серверов. Перенос строки — ."], - Public: ["Показывать в списке серверов", "Сервер со Steam-авторизацией виден в браузере Steam в любом случае."], - Open: ["Пускать без белого списка", "Если выключено, учётные записи создаёт администратор вручную."], - MaxPlayers: ["Максимум игроков", "Администраторы не считаются. Выше 32 игроков возможны просадки."], - ServerWelcomeMessage: ["Приветственное сообщение", "Показывается в чате сразу после входа. Поддерживает и цвета ."], - ResetID: ["Идентификатор сброса", "Меняется при мягком сбросе мира: клиенты с другим значением создают нового персонажа."], - ServerPlayerID: ["Идентификатор персонажей", "По нему игра отличает персонажей этого сервера от чужих."], - Seed: ["Зерно генерации мира", "Чтобы сменить, впишите новое значение и удалите map_worldgen.bin в папке мира."], - server_browser_announced_ip: ["Объявляемый IP", "Нужен, когда у машины несколько адресов и в список серверов должен попасть определённый."], - Password: ["Пароль сервера", "Пусто — вход без пароля."], - MaxAccountsPerUser: ["Учёток на игрока", "Сколько персонажей может завести один Steam-аккаунт. 0 — без ограничения."], - DenyLoginOnOverloadedServer: ["Не пускать при перегрузке", "Отказывать во входе, когда сервер не справляется с нагрузкой."], - DropOffWhiteListAfterDeath: ["Убирать из белого списка после смерти", "Погибший игрок теряет доступ и должен получить его заново."], - LoginQueueEnabled: ["Очередь на вход", "Лишние игроки ждут освободившегося места вместо отказа."], - LoginQueueConnectTimeout: ["Таймаут очереди, сек", "Сколько ждать подключения игрока, дошедшего до начала очереди."], - AllowCoop: ["Разрешить разделённый экран", "Несколько игроков с одного клиента."], - AllowNonAsciiUsername: ["Ники не только латиницей", "Разрешить кириллицу и другие не-ASCII символы в никах."], - SteamScoreboard: ["Показывать Steam-имена", "Отображать имена и аватары Steam в списке игроков."], - SteamVAC: ["Защита Steam VAC", "Включает античит-защиту Valve."], - DoLuaChecksum: ["Сверять контрольные суммы Lua", "Отключать игроков с изменёнными Lua-файлами."], - DefaultPort: ["Игровой порт (UDP)", "По умолчанию 16261. Должен быть открыт на firewall."], - UDPPort: ["Второй игровой порт", "По умолчанию 16262. Тоже должен быть открыт."], - RCONPort: ["Порт RCON", "Порт для внешнего управления консолью сервера."], - RCONPassword: ["Пароль RCON", "Пусто — RCON выключен. Панели он не нужен: она пишет прямо в консоль."], - PingLimit: ["Предел пинга, мс", "Игроков с задержкой выше выкидывает. Ниже 100 ставить не стоит."], - UPnP: ["Пробрасывать порты через UPnP", "Автоматически открыть порты на роутере, если тот поддерживает UPnP."], - MaxPacketsPerSecond: ["Пакетов в секунду", "Ограничение сетевого потока на игрока."], - ClientCommandFilter: ["Фильтр команд клиента", "Список команд, которые не пишутся в журнал."], - MultiplayerStatisticsPeriod: ["Период сбора статистики", "Как часто писать сетевую статистику. 0 — не собирать."], - PVP: ["Разрешить бой между игроками", "Игроки могут ранить и убивать друг друга."], - PVPLogToolChat: ["Писать PVP в чат админов", "Сообщать администраторам о PVP-стычках в чате."], - PVPLogToolFile: ["Писать PVP в файл", "Вести журнал PVP-стычек на диске."], - SafetySystem: ["Система безопасности", "Урон игроку проходит, только когда оба сняли защиту."], - ShowSafety: ["Показывать значок защиты", "Иконка над головой показывает, снята ли защита."], - SafetyToggleTimer: ["Задержка переключения, сек", "Сколько секунд занимает снятие или включение защиты."], - SafetyCooldownTimer: ["Откат переключения, сек", "Через сколько секунд защиту можно переключить снова."], - SafetyDisconnectDelay: ["Задержка выхода в PVP, сек", "Сколько игрок остаётся в мире после отключения во время боя."], - PVPMeleeDamageModifier: ["Урон в ближнем бою, %", "Множитель урона игрок-игрок для оружия ближнего боя."], - PVPFirearmDamageModifier: ["Урон от огнестрела, %", "Множитель урона игрок-игрок для огнестрельного оружия."], - PVPMeleeWhileHitReaction: ["Удары во время реакции на попадание", "Разрешить бить игрока, пока тот отшатывается от предыдущего удара."], - KnockedDownAllowed: ["Разрешить сбивать с ног", "Игроки могут сбивать друг друга с ног."], - SneakModeHideFromOtherPlayers: ["Скрытность прячет от игроков", "В режиме скрытности игрока не видят другие игроки."], - PlayerBumpPlayer: ["Толкать других игроков", "Столкновения игроков между собой."], - UsePhysicsHitReaction: ["Физическая реакция на удар", "Включает физику отдачи при попадании."], - CarEngineAttractionModifier: ["Шум двигателя для зомби", "Насколько сильно двигатель привлекает зомби."], - Map: ["Карты", "Список карт через точку с запятой. Первой идёт карта мода, последней — базовая."], - SpawnPoint: ["Точка появления", "Координаты x,y,z. Нули — стандартные точки появления карты."], - SpawnItems: ["Стартовые предметы", "Список предметов, которые получает новый персонаж."], - SaveWorldEveryMinutes: ["Сохранять мир каждые, мин", "0 — сохранять только при выходе игроков и остановке сервера."], - PauseEmpty: ["Пауза без игроков", "Игровое время останавливается, когда на сервере никого нет."], - NoFire: ["Запретить огонь", "Полностью отключает распространение огня."], - AllowDestructionBySledgehammer: ["Разрешить кувалду", "Игроки могут ломать стены и объекты кувалдой."], - SledgehammerOnlyInSafehouse: ["Кувалда только в убежище", "Ломать можно лишь внутри своего убежища."], - PlayerRespawnWithSelf: ["Возрождение у своего трупа", "Разрешить появляться на месте гибели прошлого персонажа."], - PlayerRespawnWithOther: ["Возрождение у товарища", "Разрешить появляться рядом с другим игроком."], - FastForwardMultiplier: ["Ускорение времени", "Во сколько раз ускоряется время при перемотке."], - RemovePlayerCorpsesOnCorpseRemoval: ["Убирать и трупы игроков", "Автоочистка трупов затрагивает и погибших игроков."], - TrashDeleteAll: ["Кнопка «удалить всё»", "Разрешить очищать контейнер целиком одним действием."], - BloodSplatLifespanDays: ["Кровь исчезает через, дней", "0 — пятна крови остаются навсегда."], - ItemNumbersLimitPerContainer: ["Предметов в контейнере", "Ограничение на количество предметов. 0 — без ограничения."], - SafehousePreventsLootRespawn: ["Убежище блокирует респавн лута", "В зданиях-убежищах лут заново не появляется."], - SwitchZombiesOwnershipEachUpdate: ["Передавать зомби каждый такт", "Меняет схему распределения зомби между клиентами."], - UltraSpeedDoesnotAffectToAnimals: ["Перемотка не ускоряет животных", "На максимальной скорости времени животные живут в обычном темпе."], - SleepAllowed: ["Разрешить сон", "Игроки могут спать; на сервере с несколькими игроками время не перематывается."], - SleepNeeded: ["Требовать сон", "Усталость накапливается и требует сна."], - SpeedLimit: ["Ограничение скорости", "Предел скорости транспорта, км/ч."], - BackupsCount: ["Сколько копий хранить", "Резервные копии, которые делает сама игра."], - BackupsOnStart: ["Копия при запуске", "Игра делает бэкап мира при каждом старте сервера."], - BackupsOnVersionChange: ["Копия при смене версии", "Бэкап перед первым запуском на новой версии игры."], - BackupsPeriod: ["Период копий, мин", "0 — периодические бэкапы игры выключены. У панели есть свои."], - Mods: ["Включённые моды", "Список Mod ID в порядке загрузки. Правится на вкладке «Моды»."], - WorkshopItems: ["Загружаемые из мастерской", "Список Workshop ID. Правится на вкладке «Моды»."], - GlobalChat: ["Общий чат", "Сообщения видят все игроки на сервере."], - ChatStreams: ["Доступные каналы чата", "Список каналов: s — сказать, r — рация, a — админ, w — крик и так далее."], - VoiceEnable: ["Голосовой чат", "Разрешить голосовое общение."], - VoiceMinDistance: ["Слышимость вблизи", "Расстояние, на котором голос слышен без затухания."], - VoiceMaxDistance: ["Предел слышимости", "Расстояние, дальше которого голос не слышен."], - Voice3D: ["Объёмный звук голоса", "Голос слышен с направления, откуда говорит игрок."], - ChatMessageCharacterLimit: ["Длина сообщения", "Максимум символов в одном сообщении чата."], - ChatMessageSlowModeTime: ["Пауза между сообщениями, сек", "Защита от флуда: минимальный интервал между сообщениями."], - AnnounceDeath: ["Объявлять о смертях", "Сообщать в чат о гибели игроков."], - AnnounceAnimalDeath: ["Объявлять о гибели животных", "Сообщать в чат, когда погибает домашнее животное."], - BanKickGlobalSound: ["Звук при бане и кике", "Проигрывать звук всем при исключении игрока."], - BadWordListFile: ["Файл со списком брани", "Путь к списку запрещённых слов."], - GoodWordListFile: ["Файл с исключениями", "Слова, которые фильтр пропускает."], - BadWordPolicy: ["Что делать с бранью", "Заменять слово или блокировать сообщение целиком."], - BadWordReplacement: ["Чем заменять", "Текст, на который меняется запрещённое слово."], - DisplayUserName: ["Ники над головами", "Показывать имена игроков в мире."], - ShowFirstAndLastName: ["Имя и фамилия", "Показывать полное имя персонажа вместо ника."], - UsernameDisguises: ["Маскировка ника", "Ник скрывается, когда лицо игрока закрыто маской или шлемом."], - HideDisguisedUserName: ["Прятать ник замаскированных", "У замаскированного игрока имя не показывается вовсе."], - MouseOverToSeeDisplayName: ["Имя по наведению", "Ник виден, только если навести на игрока курсор."], - HidePlayersBehindYou: ["Скрывать игроков за спиной", "Не показывать тех, кто вне поля зрения персонажа."], - MapRemotePlayerVisibility: ["Игроки на карте", "Показывать ли других игроков на внутриигровой карте."], - ShowCoordinates: ["Показывать координаты", "Координаты видны игрокам в интерфейсе."], - DisableScoreboard: ["Отключить таблицу игроков", "Скрыть список игроков сервера."], - HideAdminsInPlayerList: ["Прятать администраторов", "Администраторы не видны в списке игроков."], - PlayerSafehouse: ["Игроки могут ставить убежища", "Разрешить обычным игрокам объявлять здание своим."], - AdminSafehouse: ["Убежища только у админов", "Объявлять убежища могут лишь администраторы."], - SafehouseAllowTrepass: ["Пускать посторонних", "Разрешить входить в чужое убежище."], - SafehouseAllowFire: ["Огонь в убежище", "Разрешить возгорания на территории убежища."], - SafehouseAllowLoot: ["Брать вещи в убежище", "Разрешить посторонним забирать предметы из убежища."], - SafehouseAllowRespawn: ["Возрождение в убежище", "Члены убежища появляются в нём после гибели."], - SafehouseDaySurvivedToClaim: ["Дней до права на убежище", "Сколько дней надо прожить, прежде чем объявлять здание своим."], - SafeHouseRemovalTime: ["Освобождение убежища, ч", "Через сколько часов отсутствия владельца убежище снимается."], - SafehouseAllowNonResidential: ["Нежилые здания под убежище", "Разрешить занимать магазины, склады и прочие нежилые постройки."], - SafehouseDisableDisguises: ["Без маскировки в убежище", "Маскировка не действует на территории убежища."], - MaxSafezoneSize: ["Размер безопасной зоны", "Максимальная площадь убежища в клетках."], - DisableSafehouseWhenOwnerConnected: ["Снимать защиту при владельце", "Пока владелец в игре, защита убежища не действует."], - DisableSafehouseWhenPlayerConnected: ["Снимать защиту при игроках", "Защита не действует, пока внутри есть игроки."], - Faction: ["Разрешить фракции", "Игроки могут создавать группировки."], - FactionDaySurvivedToCreate: ["Дней до создания фракции", "Сколько дней нужно прожить, чтобы основать фракцию."], - FactionPlayersRequiredForTag: ["Игроков для метки фракции", "Сколько участников нужно, чтобы у фракции появился тег."], - War: ["Разрешить войны фракций", "Фракции могут объявлять друг другу войну."], - WarStartDelay: ["Задержка начала войны", "Через сколько времени после объявления начинается война."], - WarDuration: ["Длительность войны", "Сколько длится объявленная война."], - WarSafehouseHitPoints: ["Прочность убежищ в войне", "Сколько урона выдерживают постройки во время войны."], - DisableRadioStaff: ["Скрыть рацию персонала", "Отключить служебный радиоканал."], - DisableRadioAdmin: ["Скрыть рацию админов", "Отключить радиоканал администраторов."], - DisableRadioGM: ["Скрыть рацию гейм-мастеров", "Отключить радиоканал GM."], - DisableRadioOverseer: ["Скрыть рацию наблюдателей", "Отключить радиоканал наблюдателей."], - DisableRadioModerator: ["Скрыть рацию модераторов", "Отключить радиоканал модераторов."], - DisableRadioInvisible: ["Скрыть рацию невидимых", "Отключить радиоканал скрытых персонажей."], - DiscordEnable: ["Связь с Discord", "Пересылать чат сервера в Discord и обратно."], - DiscordToken: ["Токен бота", "Токен Discord-бота, через которого идёт связь."], - DiscordChannel: ["Канал чата", "Название канала для игрового чата."], - DiscordChannelID: ["Идентификатор канала", "Числовой ID канала Discord."], - DiscordChatChannel: ["Канал чата", "Канал, куда пересылается игровой чат."], - DiscordLogChannel: ["Канал журнала", "Канал для служебных сообщений сервера."], - DiscordCommandChannel: ["Канал команд", "Канал, из которого принимаются команды."], - WebhookAddress: ["Адрес вебхука", "Куда отправлять события сервера по HTTP."], - AntiCheatSafety: ["Античит: система защиты", "Проверка попыток обойти защиту от урона."], - AntiCheatSpeed: ["Античит: скорость", "Проверка неестественно быстрого перемещения."], - AntiCheatNoClip: ["Античит: проход сквозь стены", "Проверка перемещения через препятствия."], - AntiCheatHit: ["Античит: попадания", "Проверка ударов с недостижимого расстояния."], - AntiCheatPacketException: ["Античит: битые пакеты", "Реакция на некорректные сетевые пакеты."], - AntiCheatPermission: ["Античит: права", "Проверка попыток выполнить действия без прав."], - AntiCheatXP: ["Античит: опыт", "Проверка неестественного роста навыков."], - AntiCheatSafeHouse: ["Античит: убежища", "Проверка обхода защиты убежищ."], - AntiCheatPlayer: ["Античит: персонаж", "Проверка изменённых характеристик персонажа."], - AntiCheatChecksum: ["Античит: контрольные суммы", "Сверка файлов игры у клиента."], - ClientActionLogs: ["Журнал действий клиента", "Какие действия игроков записывать в журнал."], - PerkLogs: ["Журнал навыков", "Записывать изменения уровней навыков."], - DisableVehicleTowing: ["Запретить буксировку", "Машины нельзя цеплять друг к другу."], - DisableTrailerTowing: ["Запретить прицепы", "Прицепы нельзя цеплять к машинам."], - DisableBurntTowing: ["Не буксировать сгоревшие", "Сгоревший транспорт нельзя буксировать."], +// Подписей и описаний параметров — сотни на каждый язык, и держать их в коде +// панели незачем: они лежат отдельными файлами и нужны только двум вкладкам. +let paramHints = null; - AutoCreateUserInWhiteList: ["Заводить учётки автоматически", "Игрок при первом входе сам создаёт логин и пароль."], - ConstructionPreventsLootRespawn: ["Постройки блокируют респавн лута", "В зданиях, где игроки что-то построили, лут заново не появляется."], - HoursForLootRespawn: ["Респавн лута через, часов", "0 — лут не появляется заново."], - MaxItemsForLootRespawn: ["Предметов при респавне", "Сколько предметов возвращается в контейнер при обновлении лута."], - KickFastPlayers: ["Выкидывать за скорость", "Отключать игроков, движущихся неестественно быстро. Может срабатывать ложно."], - MinutesPerPage: ["Минут на страницу", "Сколько игрового времени уходит на чтение одной страницы книги."], -}; +async function loadHints() { + if (paramHints) return paramHints; + try { + const response = await fetch(`/hints-${LANG}.json`, { credentials: "same-origin" }); + paramHints = response.ok ? await response.json() : {}; + } catch (error) { + // Без словаря панель остаётся рабочей: параметры покажутся по именам ключей. + paramHints = {}; + } + paramHints.config = paramHints.config || {}; + paramHints.sandbox = paramHints.sandbox || {}; + return paramHints; +} const BOOLEAN_VALUES = new Set(["true", "false"]); @@ -644,25 +503,25 @@ const BOOLEAN_VALUES = new Set(["true", "false"]); // версии к версии, и новые ключи не должны пропадать со страницы. const CONFIG_GROUPS = [ { - title: "Основное", + titleKey: "group.config.general", keys: ["PublicName", "PublicDescription", "Public", "Open", "MaxPlayers", "ServerWelcomeMessage", "ResetID", "ServerPlayerID", "Seed", "server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"], }, { - title: "Доступ и учётные записи", + titleKey: "group.config.access", keys: ["Password", "MaxAccountsPerUser", "DenyLoginOnOverloadedServer", "AutoCreateUserInWhiteList", "DropOffWhiteListAfterDeath", "LoginQueueEnabled", "LoginQueueConnectTimeout", "AllowCoop", "AllowNonAsciiUsername", "SteamScoreboard", "SteamVAC", "DoLuaChecksum"], }, { - title: "Сеть и порты", + titleKey: "group.config.network", keys: ["DefaultPort", "UDPPort", "RCONPort", "RCONPassword", "PingLimit", "UPnP", "MaxPacketsPerSecond", "ClientCommandFilter", "MultiplayerStatisticsPeriod"], }, { - title: "PVP и урон", + titleKey: "group.config.pvp", keys: ["PVP", "PVPLogToolChat", "PVPLogToolFile", "SafetySystem", "ShowSafety", "SafetyToggleTimer", "SafetyCooldownTimer", "SafetyDisconnectDelay", "PVPMeleeDamageModifier", "PVPFirearmDamageModifier", "PVPMeleeWhileHitReaction", @@ -670,7 +529,7 @@ const CONFIG_GROUPS = [ "UsePhysicsHitReaction", "CarEngineAttractionModifier"], }, { - title: "Мир и правила", + titleKey: "group.config.world", keys: ["Map", "SpawnPoint", "SpawnItems", "SaveWorldEveryMinutes", "PauseEmpty", "NoFire", "AllowDestructionBySledgehammer", "SledgehammerOnlyInSafehouse", "PlayerRespawnWithSelf", "PlayerRespawnWithOther", "FastForwardMultiplier", @@ -681,32 +540,32 @@ const CONFIG_GROUPS = [ "SleepAllowed", "SleepNeeded", "SpeedLimit"], }, { - title: "Бэкапы самой игры", + titleKey: "group.config.backups", keys: ["BackupsCount", "BackupsOnStart", "BackupsOnVersionChange", "BackupsPeriod"], }, { - title: "Моды", + titleKey: "group.config.mods", keys: ["Mods", "WorkshopItems"], }, { - title: "Чат и голос", + titleKey: "group.config.chat", keys: ["GlobalChat", "ChatStreams", "VoiceEnable", "VoiceMinDistance", "VoiceMaxDistance", "Voice3D", "ChatMessageCharacterLimit", "ChatMessageSlowModeTime", "AnnounceDeath", "AnnounceAnimalDeath", "MinutesPerPage", "BanKickGlobalSound"], }, { - title: "Фильтр брани", + titleKey: "group.config.badwords", keys: ["BadWordListFile", "GoodWordListFile", "BadWordPolicy", "BadWordReplacement"], }, { - title: "Видимость игроков", + titleKey: "group.config.visibility", keys: ["DisplayUserName", "ShowFirstAndLastName", "UsernameDisguises", "HideDisguisedUserName", "MouseOverToSeeDisplayName", "HidePlayersBehindYou", "MapRemotePlayerVisibility", "ShowCoordinates", "DisableScoreboard", "HideAdminsInPlayerList"], }, { - title: "Убежища", + titleKey: "group.config.safehouse", keys: ["PlayerSafehouse", "AdminSafehouse", "SafehouseAllowTrepass", "SafehouseAllowFire", "SafehouseAllowLoot", "SafehouseAllowRespawn", "SafehouseDaySurvivedToClaim", "SafeHouseRemovalTime", "SafehouseAllowNonResidential", "SafehouseDisableDisguises", @@ -714,22 +573,22 @@ const CONFIG_GROUPS = [ "DisableSafehouseWhenPlayerConnected"], }, { - title: "Фракции и войны", + titleKey: "group.config.factions", keys: ["Faction", "FactionDaySurvivedToCreate", "FactionPlayersRequiredForTag", "War", "WarStartDelay", "WarDuration", "WarSafehouseHitPoints"], }, { - title: "Рации", + titleKey: "group.config.radio", keys: ["DisableRadioStaff", "DisableRadioAdmin", "DisableRadioGM", "DisableRadioOverseer", "DisableRadioModerator", "DisableRadioInvisible"], }, { - title: "Discord", + titleKey: "group.config.discord", keys: ["DiscordEnable", "DiscordToken", "DiscordChannel", "DiscordChannelID", "DiscordChatChannel", "DiscordLogChannel", "DiscordCommandChannel", "WebhookAddress"], }, { - title: "Античит и журналы", + titleKey: "group.config.anticheat", keys: ["AntiCheatSafety", "AntiCheatSpeed", "AntiCheatNoClip", "AntiCheatHit", "AntiCheatPacketException", "AntiCheatPermission", "AntiCheatXP", "AntiCheatSafeHouse", "AntiCheatPlayer", "AntiCheatChecksum", "KickFastPlayers", "ClientActionLogs", "PerkLogs"], @@ -737,331 +596,16 @@ const CONFIG_GROUPS = [ prefixes: ["AntiCheatProtectionType"], }, { - title: "Транспорт", + titleKey: "group.config.vehicles", keys: ["DisableVehicleTowing", "DisableTrailerTowing", "DisableBurntTowing"], }, ]; -// Подписи параметров песочницы. Подробное описание игра пишет сама (в Build 42 -// с расшифровкой каждого значения), но на языке сервера — обычно английском, -// поэтому подпись даём свою, а описание берём из файла. -const SANDBOX_HINTS = { - Zombies: ["Количество зомби", "Сколько зомби в мире. Меняет и множитель популяции в расширенных настройках."], - Distribution: ["Распределение зомби", "Где зомби гуще: в городах или равномерно по всей карте."], - ZombiesRespawn: ["Восстановление популяции", "Появляются ли зомби заново в уже зачищенных местах."], - ZombieRespawn: ["Восстановление популяции", "Появляются ли зомби заново в уже зачищенных местах."], - ZombieMigrate: ["Миграция зомби", "Переходят ли зомби между зонами карты, заполняя опустевшие районы."], - ZombieVoronoiNoise: ["Неравномерность расселения", "Насколько неровно зомби рассеяны по карте: выше значение — заметнее скопления и пустоты."], - ZombieAttractionMultiplier: ["Притяжение на шум", "Насколько сильно шум игрока и техники стягивает зомби."], - ZombieHealthImpact: ["Здоровье зомби", "Общая живучесть зомби."], - ZombiePopLootEffect: ["Влияние популяции на лут", "Связывает количество лута с плотностью зомби: где их больше, там богаче добыча."], - MultiHitZombies: ["Удар по нескольким зомби", "Разрешает одним ударом задеть нескольких зомби."], - RearVulnerability: ["Уязвимость со спины", "Насколько игрок уязвимее при нападении со спины."], - SirenEffectsZombies: ["Сирены привлекают зомби", "Реагируют ли зомби на сирены машин и сигнализации."], - RallyGroupSize: ["Размер скопления", "Сколько зомби собирается в бродячую стаю."], - RallyTravelDistance: ["Дальность схода в группу", "С какого расстояния зомби подтягиваются к стае."], - RallyGroupSeparation: ["Расстояние между группами", "Насколько далеко стаи держатся друг от друга."], - RallyGroupRadius: ["Плотность группы", "Насколько плотно зомби идут внутри стаи."], - HoursForCorpseRemoval: ["Трупы исчезают через, часов", "Через сколько часов трупы исчезают сами. 0 — остаются навсегда."], - DecayingCorpseHealthImpact: ["Вред от разлагающихся трупов", "Насколько сильно разлагающиеся трупы вредят здоровью рядом."], - BloodLevel: ["Количество крови", "Сколько крови остаётся на одежде, оружии и полу."], - BloodSplatLifespanDays: ["Кровь исчезает через, дней", "Через сколько дней пятна крови исчезают. 0 — никогда."], - DayLength: ["Длина игрового дня", "Сколько реального времени длятся игровые сутки."], - StartYear: ["Год начала", "Год, с которого начинается партия."], - StartMonth: ["Месяц начала", "Месяц начала: от него зависят погода и урожай."], - StartDay: ["День начала", "День месяца, с которого начинается партия."], - StartTime: ["Время начала", "Время суток на старте."], - DayNightCycle: ["Смена дня и ночи", "Как быстро сменяются день и ночь."], - NightLength: ["Длина ночи", "Какую долю суток занимает ночь."], - NightDarkness: ["Темнота ночью", "Насколько темно ночью."], - TimeSinceApo: ["Времени с начала эпидемии", "Сколько времени прошло с начала эпидемии до старта: влияет на состояние мира и запасы."], - WaterShut: ["Отключение воды", "Когда в водопроводе кончится вода."], - ElecShut: ["Отключение электричества", "Когда отключится электричество."], - WaterShutModifier: ["Дней до отключения воды", "Через сколько дней отключат воду, если выбран точный срок."], - ElecShutModifier: ["Дней до отключения света", "Через сколько дней отключат свет, если выбран точный срок."], - Alarm: ["Частота сигнализаций", "Как часто в домах срабатывает сигнализация, привлекая зомби."], - AlarmDecay: ["Затухание сигнализаций", "Перестают ли сигнализации работать со временем."], - AlarmDecayModifier: ["Скорость затухания сигнализаций", "Как быстро сигнализации выходят из строя."], - LockedHouses: ["Запертые дома", "Как часто дома оказываются запертыми."], - ErosionSpeed: ["Скорость зарастания", "Как быстро мир зарастает травой и растительностью."], - ErosionDays: ["Дней до полного зарастания", "За сколько дней зарастание доходит до предела. 0 — отключено."], - EndRegen: ["Восстановление выносливости", "Как быстро восстанавливается выносливость."], - Helicopter: ["Вертолёт", "Как часто над картой пролетает вертолёт, стягивающий зомби."], - MetaEvent: ["Случайные события", "Частота отдалённых событий: выстрелов, криков, сигнализаций."], - SleepingEvent: ["Ночные события", "Могут ли события происходить, пока персонаж спит."], - AnnotatedMapChance: ["Шанс найти карту с пометками", "Как часто попадаются карты с пометками тайников."], - VehicleStoryChance: ["Частота историй с транспортом", "Как часто встречаются сцены с машинами: аварии, брошенный транспорт."], - ZoneStoryChance: ["Частота историй на местности", "Как часто встречаются сюжетные сцены на местности."], - SurvivorHouseChance: ["Частота домов выживших", "Как часто попадаются дома, обжитые другими выжившими."], - MaximumLootedBuildingRooms: ["Комнат в разграбленном здании", "Сколько комнат в здании может оказаться уже разграблено."], - ClimateCycle: ["Смена времён года", "Насколько выражена смена времён года."], - FogCycle: ["Частота туманов", "Как часто ложится туман."], - Temperature: ["Температура", "Общий температурный фон."], - TemperatureModifier: ["Поправка температуры", "Поправка к температуре: холоднее или теплее обычного."], - Rain: ["Дожди", "Как часто идут дожди."], - RainModifier: ["Поправка дождей", "Поправка к количеству осадков."], - MaxFogIntensity: ["Плотность тумана", "Насколько густым бывает туман."], - MaxRainFxIntensity: ["Сила дождя", "Насколько сильным выглядит дождь."], - EnableSnowOnGround: ["Снег на земле", "Ложится ли снег на землю зимой."], - LootRespawn: ["Обновление лута", "Появляется ли лут заново в уже обысканных контейнерах."], - SeenHoursPreventLootRespawn: ["Часов после осмотра до респавна", "Сколько часов после осмотра контейнер остаётся пустым."], - HoursForLootRespawn: ["Респавн лута через, часов", "Через сколько часов лут возвращается. 0 — не возвращается."], - MaxItemsForLootRespawn: ["Предметов при респавне", "Сколько предметов появляется в контейнере при обновлении."], - ConstructionPreventsLootRespawn: ["Постройки блокируют респавн", "Постройки игроков в здании отменяют обновление лута."], - RollsMultiplier: ["Множитель бросков лута", "Сколько раз игра пытается положить предмет в контейнер."], - FoodLoot: ["Еда", "Как часто попадается еда."], - CannedFoodLoot: ["Консервы", "Как часто попадаются консервы."], - LiteratureLoot: ["Книги и журналы", "Как часто попадаются книги, журналы и газеты."], - SurvivalGearsLoot: ["Снаряжение", "Как часто попадается снаряжение выживальщика."], - MedicalLoot: ["Медикаменты", "Как часто попадаются медикаменты."], - WeaponLoot: ["Оружие", "Как часто попадается оружие ближнего боя."], - RangedWeaponLoot: ["Огнестрельное оружие", "Как часто попадается огнестрельное оружие."], - AmmoLoot: ["Патроны", "Как часто попадаются патроны."], - MechanicsLoot: ["Автозапчасти", "Как часто попадаются автозапчасти и инструменты."], - OtherLoot: ["Прочее", "Как часто попадаются прочие предметы."], - SkillBookLoot: ["Учебники навыков", "Как часто попадаются учебники навыков."], - RecipeResourceLoot: ["Материалы для рецептов", "Как часто попадаются материалы для рецептов."], - RemoveStoryLoot: ["Убирать сюжетный лут", "Убирает лут из сюжетных сцен."], - RemoveZombieLoot: ["Убирать лут с зомби", "Убирает предметы, выпадающие из зомби."], - MaximumLooted: ["Предел разграбленности", "Насколько разграбленным мир становится со временем."], - DaysUntilMaximumLooted: ["Дней до предела разграбленности", "За сколько дней мир доходит до предельной разграбленности."], - RuralLooted: ["Разграбленность сельской местности", "Насколько разграблены отдалённые районы."], - MaximumDiminishedLoot: ["Предел оскудения лута", "Насколько скудеет лут в уже посещённых местах."], - DaysUntilMaximumDiminishedLoot: ["Дней до оскудения лута", "За сколько дней лут скудеет до предела."], - XpMultiplier: ["Множитель опыта", "Во сколько раз быстрее растут навыки."], - XpMultiplierAffectsPassive: ["Опыт и пассивные навыки", "Действует ли множитель опыта на силу и выносливость."], - StatsDecrease: ["Скорость падения характеристик", "Как быстро персонаж голодает, устаёт и хочет пить."], - Endurance: ["Выносливость", "Как быстро тратится выносливость."], - Nutrition: ["Учитывать питание", "Учитывать ли калории и вес персонажа."], - StarterKit: ["Стартовый набор", "Выдавать ли новому персонажу базовый набор вещей."], - InjurySeverity: ["Тяжесть травм", "Насколько тяжёлыми бывают травмы."], - BoneFracture: ["Переломы", "Могут ли ломаться кости."], - CharacterFreePoints: ["Свободных очков персонажа", "Сколько дополнительных очков даётся при создании персонажа."], - ConstructionBonusPoints: ["Бонус к строительству", "Бонус к прочности того, что строит игрок."], - NightVisionEnabled: ["Ночное зрение", "Видит ли персонаж в темноте лучше обычного."], - InjuredHealingModifier: ["Скорость заживления", "Как быстро заживают раны."], - MuscleStrainFactor: ["Растяжение мышц", "Как быстро устают мышцы от тяжёлого оружия."], - DiscomfortFactor: ["Дискомфорт", "Как быстро накапливается дискомфорт от неудобной одежды и ран."], - WoundInfectionFactor: ["Заражение ран", "Насколько вероятно заражение обычной раны — не путать с укусом зомби."], - NegativeTraitsPenalty: ["Штраф отрицательных черт", "Насколько сильно мешают отрицательные черты характера."], - EnablePoisoning: ["Отравления", "Можно ли отравиться испорченной едой и ядовитыми ягодами."], - ClothingDegradation: ["Износ одежды", "Как быстро изнашивается одежда."], - AllClothesUnlocked: ["Вся одежда доступна", "Открывает всю одежду при создании персонажа."], - NoBlackClothes: ["Без чёрной одежды", "Убирает чёрную одежду из игры."], - EasyClimbing: ["Лёгкое перелезание", "Упрощает перелезание через заборы и препятствия."], - AttackBlockMovements: ["Атака блокирует движение", "Нельзя двигаться во время удара."], - MetaKnowledge: ["Знание рецептов заранее", "Знает ли персонаж рецепты, которые ещё не изучил."], - SeeNotLearntRecipe: ["Видеть неизученные рецепты", "Показывать ли неизученные рецепты в списке."], - LiteratureCooldown: ["Перерыв между чтением", "Сколько нужно ждать между чтением одинаковых книг."], - MinutesPerPage: ["Минут на страницу", "Сколько игровых минут уходит на страницу книги."], - LevelForMediaXPCutoff: ["Предел опыта с кассет", "До какого уровня навыка кассеты и диски дают опыт."], - LevelForDismantleXPCutoff: ["Предел опыта с разборки", "До какого уровня разборка предметов даёт опыт."], - FoodRotSpeed: ["Скорость порчи еды", "Как быстро портится еда."], - FridgeFactor: ["Эффективность холодильников", "Насколько холодильник замедляет порчу продуктов."], - MaggotSpawn: ["Появление личинок", "Где заводятся личинки: в еде, на трупах или нигде."], - EnableTaintedWaterText: ["Помечать грязную воду", "Предупреждать ли, что вода непригодна для питья."], - FishAbundance: ["Обилие рыбы", "Сколько рыбы в водоёмах."], - NatureAbundance: ["Обилие даров природы", "Сколько ягод, грибов и прочих даров природы."], - PlantResilience: ["Живучесть растений", "Насколько растения устойчивы к болезням и вредителям."], - PlantAbundance: ["Урожайность", "Насколько щедрый урожай дают грядки."], - Farming: ["Скорость роста", "Как быстро растут посевы."], - FarmingSpeedNew: ["Скорость земледелия", "Как быстро растут посевы."], - FarmingAmountNew: ["Урожай с грядки", "Сколько плодов даёт одна грядка."], - CompostTime: ["Время компостирования", "За сколько времени отходы превращаются в компост."], - KillInsideCrops: ["Губить посевы в помещении", "Погибают ли посевы, высаженные в помещении."], - PlantGrowingSeasons: ["Сезонность посевов", "Учитывать ли время года при посадке."], - PlaceDirtAboveground: ["Грядки на любом уровне", "Можно ли делать грядки не на земле — на верхних этажах."], - ClayLakeChance: ["Глина у озёр", "Как часто у озёр попадается глина."], - ClayRiverChance: ["Глина у рек", "Как часто у рек попадается глина."], - DaysForRottenFoodRemoval: ["Дней до удаления гнили", "Через сколько дней испорченная еда исчезает сама. -1 — никогда."], - AidPackage: ["Сброс гуманитарной помощи", "Как часто сбрасывают гуманитарную помощь."], - AnimalStatsModifier: ["Характеристики животных", "Общий множитель характеристик домашних животных."], - AnimalMetaStatsModifier: ["Дикие животные", "Насколько многочисленны и живучи дикие животные."], - AnimalPregnancyTime: ["Срок беременности", "Сколько длится беременность у животных."], - AnimalAgeModifier: ["Скорость взросления", "Как быстро животные взрослеют."], - AnimalMilkIncModifier: ["Надой молока", "Сколько молока дают животные."], - AnimalWoolIncModifier: ["Настриг шерсти", "Сколько шерсти дают животные."], - AnimalRanchChance: ["Частота ферм", "Как часто на карте попадаются фермы с животными."], - AnimalGrassRegrowTime: ["Отрастание травы", "Как быстро отрастает трава на выпасе."], - AnimalMetaPredator: ["Хищники", "Сколько в мире хищников."], - AnimalMatingSeason: ["Сезон спаривания", "Ограничен ли приплод временем года."], - AnimalEggHatch: ["Вылупление яиц", "Как часто из яиц вылупляются птенцы."], - AnimalSoundAttractZombies: ["Животные привлекают зомби", "Привлекают ли зомби звуки животных."], - AnimalTrackChance: ["Частота следов", "Как часто попадаются следы животных."], - AnimalPathChance: ["Частота троп", "Как часто попадаются звериные тропы."], - MaximumRatIndex: ["Предел числа крыс", "Насколько сильно расплодятся крысы."], - DaysUntilMaximumRatIndex: ["Дней до предела крыс", "За сколько дней крысы доходят до предела."], - CarSpawnRate: ["Частота машин", "Сколько машин на дорогах и парковках."], - ChanceHasGas: ["Шанс бензина в баке", "Насколько вероятно, что в баке найденной машины есть бензин."], - InitialGas: ["Бензина в баке", "Сколько бензина в баках на старте."], - CarGasConsumption: ["Расход топлива", "Насколько прожорлив транспорт."], - LockedCar: ["Запертые машины", "Как часто машины оказываются запертыми."], - CarGeneralCondition: ["Состояние машин", "В каком состоянии находится найденный транспорт."], - CarDamageOnImpact: ["Урон машине при ударе", "Насколько сильно машина страдает при столкновении."], - DamageToPlayerFromHitByACar: ["Урон игроку от наезда", "Сколько урона получает пешеход от наезда."], - TrafficJam: ["Пробки на дорогах", "Как часто дороги перегорожены пробками из брошенных машин."], - CarAlarm: ["Автосигнализации", "Как часто у машин срабатывает сигнализация."], - PlayerDamageFromCrash: ["Урон при аварии", "Сколько урона получает водитель в аварии."], - SirenShutoffHours: ["Сирена смолкает через, часов", "Через сколько часов смолкает сирена полицейской машины."], - RecentlySurvivorVehicles: ["Машины выживших", "Как часто попадаются машины, недавно брошенные выжившими."], - EnableVehicles: ["Транспорт в игре", "Есть ли в игре транспорт вообще."], - VehicleEasyUse: ["Упрощённое управление", "Упрощает управление: машина заводится и едет без возни."], - MassInvasion: ["Массовое нашествие", "Резко увеличивает число зомби, идущих на шум транспорта."], - FuelStationGas: ["Топливо на заправках", "Сколько топлива осталось на заправках."], - FuelStationGasInfinite: ["Бесконечное топливо", "Топливо на заправках не кончается."], - FuelStationGasMin: ["Минимум топлива на заправке", "Наименьший запас топлива на заправке."], - FuelStationGasMax: ["Максимум топлива на заправке", "Наибольший запас топлива на заправке."], - FuelStationGasEmptyChance: ["Шанс пустой заправки", "Насколько вероятно, что заправка окажется пустой."], - FirearmUseDamageChance: ["Износ при стрельбе", "Как быстро оружие изнашивается от стрельбы."], - FirearmNoiseMultiplier: ["Громкость выстрелов", "Насколько далеко слышны выстрелы."], - FirearmJamMultiplier: ["Частота осечек", "Как часто оружие заклинивает."], - FirearmMoodleMultiplier: ["Влияние состояния на меткость", "Насколько усталость, паника и голод портят меткость."], - FirearmWeatherMultiplier: ["Влияние погоды на меткость", "Насколько дождь и туман портят меткость."], - FirearmHeadGearEffect: ["Влияние головного убора", "Насколько головной убор мешает целиться."], - Generator: ["Расход топлива генератора", "Насколько экономно генератор расходует топливо."], - GeneratorSpawning: ["Частота генераторов", "Как часто попадаются генераторы."], - GeneratorFuelConsumption: ["Расход генератора", "Сколько топлива сжигает генератор."], - GeneratorTileRange: ["Радиус питания генератора", "На сколько клеток вокруг генератор даёт электричество."], - GeneratorVerticalPowerRange: ["Этажей питания генератора", "На сколько этажей вверх и вниз достаёт питание."], - AllowExteriorGenerator: ["Генератор на улице", "Можно ли ставить генератор под открытым небом."], - FireSpread: ["Распространение огня", "Распространяется ли огонь на соседние объекты."], - MaximumFireFuelHours: ["Часов горения топлива", "Сколько часов горит топливо в костре."], - LightBulbLifespan: ["Срок службы лампочек", "Как долго служат лампочки. 0 — вечно."], - VERSION: ["Версия набора настроек", "Версия формата настроек. Меняет её сама игра, править вручную не нужно."], - - "FoodLootNew": ["Еда", "Как часто попадается еда. Новая система лута Build 42."], - "CannedFoodLootNew": ["Консервы", "Как часто попадаются консервы."], - "LiteratureLootNew": ["Книги и журналы", "Как часто попадаются книги и журналы."], - "MedicalLootNew": ["Медикаменты", "Как часто попадаются медикаменты."], - "SurvivalGearsLootNew": ["Снаряжение выживальщика", "Как часто попадается снаряжение выживальщика."], - "WeaponLootNew": ["Оружие ближнего боя", "Как часто попадается оружие ближнего боя."], - "RangedWeaponLootNew": ["Огнестрельное оружие", "Как часто попадается огнестрельное оружие."], - "AmmoLootNew": ["Патроны", "Как часто попадаются патроны."], - "MechanicsLootNew": ["Автозапчасти", "Как часто попадаются автозапчасти."], - "OtherLootNew": ["Прочее", "Как часто попадаются прочие предметы."], - "ClothingLootNew": ["Одежда", "Как часто попадается одежда."], - "ContainerLootNew": ["Сумки и контейнеры", "Как часто попадаются сумки, рюкзаки и ящики."], - "KeyLootNew": ["Ключи", "Как часто попадаются ключи."], - "MediaLootNew": ["Кассеты и диски", "Как часто попадаются кассеты, диски и пластинки."], - "MementoLootNew": ["Памятные вещи", "Как часто попадаются памятные вещи и безделушки."], - "CookwareLootNew": ["Кухонная утварь", "Как часто попадается кухонная утварь."], - "MaterialLootNew": ["Стройматериалы", "Как часто попадаются стройматериалы."], - "FarmingLootNew": ["Садовый инвентарь", "Как часто попадаются семена и садовый инвентарь."], - "ToolLootNew": ["Инструменты", "Как часто попадаются инструменты."], - "InsaneLootFactor": ["Множитель безумно редкого", "Множитель для предметов категории «безумно редкие»."], - "ExtremeLootFactor": ["Множитель крайне редкого", "Множитель для крайне редких предметов."], - "RareLootFactor": ["Множитель редкого", "Множитель для редких предметов."], - "NormalLootFactor": ["Множитель обычного", "Множитель для предметов обычной редкости."], - "CommonLootFactor": ["Множитель частого", "Множитель для часто встречающихся предметов."], - "AbundantLootFactor": ["Множитель изобильного", "Множитель для самых обычных предметов."], - "LootItemRemovalList": ["Предметы, убираемые из лута", "Предметы, которых не будет в луте. Список через запятую."], - "WorldItemRemovalList": ["Предметы, убираемые с земли", "Предметы, которые убираются с земли при уборке мира."], - "HoursForWorldItemRemoval": ["Убирать с земли через, часов", "Через сколько часов брошенные предметы исчезают с земли. 0 — не исчезают."], - "ItemRemovalListBlacklistToggle": ["Список как исключение", "Считать список наоборот: убирать всё, кроме перечисленного."], - "Basement.SpawnFrequency": ["Частота подвалов", "Как часто в домах встречаются подвалы."], - "Map.AllowMiniMap": ["Разрешить мини-карту", "Доступна ли игрокам мини-карта в углу экрана."], - "Map.AllowWorldMap": ["Разрешить карту мира", "Доступна ли полноэкранная карта мира."], - "Map.MapAllKnown": ["Карта открыта полностью", "Карта открыта целиком с самого начала."], - "Map.MapNeedsLight": ["Карте нужен свет", "Карту нельзя читать в темноте без источника света."], - "ZombieLore.Speed": ["Скорость", "Как быстро зомби передвигаются: от бегунов до медленно ковыляющих."], - "ZombieLore.SprinterPercentage": ["Доля бегунов", "Какая доля зомби бегает, когда скорость выбрана случайной."], - "ZombieLore.Strength": ["Сила", "Сила зомби: насколько тяжело их отталкивать и сколько урона они наносят."], - "ZombieLore.Toughness": ["Прочность", "Насколько трудно убить зомби."], - "ZombieLore.Transmission": ["Передача заражения", "Как передаётся заражение: через укусы, любые раны или все заражены изначально."], - "ZombieLore.Mortality": ["Время до смерти от укуса", "Сколько живёт заражённый персонаж до смерти."], - "ZombieLore.Reanimate": ["Время до оживления", "Через сколько умерший поднимается зомби."], - "ZombieLore.Cognition": ["Сообразительность", "Насколько зомби сообразительны: умеют ли обходить препятствия и открывать двери."], - "ZombieLore.DoorOpeningPercentage": ["Доля открывающих двери", "Какая доля зомби умеет открывать двери, когда сообразительность случайная."], - "ZombieLore.CrawlUnderVehicle": ["Ползут под машинами", "Могут ли ползающие зомби пролезать под машинами."], - "ZombieLore.Memory": ["Память", "Как долго зомби помнят игрока, потеряв его из виду."], - "ZombieLore.Sight": ["Зрение", "Насколько далеко зомби видят."], - "ZombieLore.Hearing": ["Слух", "Насколько далеко зомби слышат."], - "ZombieLore.SpottedLogic": ["Логика обнаружения", "По каким правилам зомби замечают игрока."], - "ZombieLore.ThumpNoChasing": ["Ломятся, не преследуя", "Ломятся ли зомби в двери и окна, когда никого не преследуют."], - "ZombieLore.ThumpOnConstruction": ["Ломают постройки игроков", "Ломают ли зомби то, что построили игроки."], - "ZombieLore.ActiveOnly": ["Когда активны", "Когда зомби активны: круглые сутки, только днём или только ночью."], - "ZombieLore.TriggerHouseAlarm": ["Включают сигнализацию", "Могут ли зомби включить сигнализацию в доме."], - "ZombieLore.ZombiesDragDown": ["Стаскивают с высоты", "Могут ли зомби стащить игрока с забора или из окна."], - "ZombieLore.ZombiesCrawlersDragDown": ["Ползуны стаскивают", "То же самое для ползающих зомби."], - "ZombieLore.ZombiesFenceLunge": ["Бросаются через забор", "Бросаются ли зомби через низкие заборы."], - "ZombieLore.ZombiesArmorFactor": ["Влияние брони", "Насколько броня зомби гасит урон."], - "ZombieLore.ZombiesMaxDefense": ["Предел защиты", "Предел защиты, который зомби может получить от брони."], - "ZombieLore.ChanceOfAttachedWeapon": ["Шанс оружия в теле", "Шанс найти оружие, застрявшее в теле зомби."], - "ZombieLore.ZombiesFallDamage": ["Урон от падения", "Получают ли зомби урон при падении с высоты."], - "ZombieLore.DisableFakeDead": ["Без притворяющихся мёртвыми", "Убирает зомби, притворяющихся мёртвыми и встающих при приближении."], - "ZombieLore.PlayerSpawnZombieRemoval": ["Радиус очистки при появлении игрока", "Радиус, в котором зомби убираются при появлении игрока в мире."], - "ZombieLore.FenceThumpersRequired": ["Зомби для слома забора", "Сколько зомби должно навалиться, чтобы сломать забор."], - "ZombieLore.FenceDamageMultiplier": ["Урон заборам", "Насколько быстро зомби ломают заборы."], - "ZombieConfig.PopulationMultiplier": ["Множитель популяции", "Общий множитель числа зомби поверх настройки «Количество зомби»."], - "ZombieConfig.PopulationStartMultiplier": ["Популяция в начале", "Сколько зомби в мире в первый день."], - "ZombieConfig.PopulationPeakMultiplier": ["Популяция на пике", "Сколько зомби будет на пике роста популяции."], - "ZombieConfig.PopulationPeakDay": ["День пика популяции", "На какой день популяция достигает пика."], - "ZombieConfig.RespawnHours": ["Часов до респавна", "Через сколько часов зомби возвращаются в зачищенную клетку. 0 — не возвращаются."], - "ZombieConfig.RespawnUnseenHours": ["Часов без наблюдения", "Сколько часов клетка должна быть без игроков, чтобы там снова появились зомби."], - "ZombieConfig.RespawnMultiplier": ["Доля восстановления", "Какая доля от исходной популяции восстанавливается за раз."], - "ZombieConfig.RedistributeHours": ["Часов до перераспределения", "Как часто зомби перераспределяются по карте."], - "ZombieConfig.FollowSoundDistance": ["Дальность реакции на звук", "С какого расстояния зомби идут на услышанный звук."], - "ZombieConfig.RallyGroupSizeVariance": ["Разброс размера групп", "Насколько сильно размер стай отличается от заданного."], - "ZombieConfig.ZombiesCountBeforeDelete": ["Предел зомби до удаления", "Сколько зомби может быть рядом, прежде чем лишние удаляются ради производительности."], - "MultiplierConfig.Global": ["Общий множитель опыта", "Общий множитель опыта для всех навыков сразу."], - "MultiplierConfig.GlobalToggle": ["Применять общий множитель", "Применять общий множитель вместо отдельных значений по навыкам."], - "MultiplierConfig.Fitness": ["Физподготовка", "Во сколько раз быстрее растёт навык «физподготовка»."], - "MultiplierConfig.Strength": ["Сила", "Во сколько раз быстрее растёт навык «сила»."], - "MultiplierConfig.Sprinting": ["Спринт", "Во сколько раз быстрее растёт навык «спринт»."], - "MultiplierConfig.Lightfoot": ["Лёгкая поступь", "Во сколько раз быстрее растёт навык «лёгкая поступь»."], - "MultiplierConfig.Nimble": ["Ловкость", "Во сколько раз быстрее растёт навык «ловкость»."], - "MultiplierConfig.Sneak": ["Скрытность", "Во сколько раз быстрее растёт навык «скрытность»."], - "MultiplierConfig.Axe": ["Топоры", "Во сколько раз быстрее растёт навык «топоры»."], - "MultiplierConfig.Blunt": ["Тяжёлое дробящее", "Во сколько раз быстрее растёт навык «тяжёлое дробящее»."], - "MultiplierConfig.SmallBlunt": ["Лёгкое дробящее", "Во сколько раз быстрее растёт навык «лёгкое дробящее»."], - "MultiplierConfig.LongBlade": ["Длинные клинки", "Во сколько раз быстрее растёт навык «длинные клинки»."], - "MultiplierConfig.SmallBlade": ["Короткие клинки", "Во сколько раз быстрее растёт навык «короткие клинки»."], - "MultiplierConfig.Spear": ["Копья", "Во сколько раз быстрее растёт навык «копья»."], - "MultiplierConfig.Maintenance": ["Уход за оружием", "Во сколько раз быстрее растёт навык «уход за оружием»."], - "MultiplierConfig.Woodwork": ["Столярное дело", "Во сколько раз быстрее растёт навык «столярное дело»."], - "MultiplierConfig.Cooking": ["Готовка", "Во сколько раз быстрее растёт навык «готовка»."], - "MultiplierConfig.Doctor": ["Медицина", "Во сколько раз быстрее растёт навык «медицина»."], - "MultiplierConfig.Electricity": ["Электрика", "Во сколько раз быстрее растёт навык «электрика»."], - "MultiplierConfig.MetalWelding": ["Сварка", "Во сколько раз быстрее растёт навык «сварка»."], - "MultiplierConfig.Mechanics": ["Механика", "Во сколько раз быстрее растёт навык «механика»."], - "MultiplierConfig.Tailoring": ["Портняжное дело", "Во сколько раз быстрее растёт навык «портняжное дело»."], - "MultiplierConfig.Aiming": ["Меткость", "Во сколько раз быстрее растёт навык «меткость»."], - "MultiplierConfig.Reloading": ["Перезарядка", "Во сколько раз быстрее растёт навык «перезарядка»."], - "MultiplierConfig.Fishing": ["Рыбалка", "Во сколько раз быстрее растёт навык «рыбалка»."], - "MultiplierConfig.Trapping": ["Ловушки", "Во сколько раз быстрее растёт навык «ловушки»."], - "MultiplierConfig.PlantScavenging": ["Собирательство", "Во сколько раз быстрее растёт навык «собирательство»."], - "MultiplierConfig.FlintKnapping": ["Обработка камня", "Во сколько раз быстрее растёт навык «обработка камня»."], - "MultiplierConfig.Masonry": ["Каменная кладка", "Во сколько раз быстрее растёт навык «каменная кладка»."], - "MultiplierConfig.Pottery": ["Гончарное дело", "Во сколько раз быстрее растёт навык «гончарное дело»."], - "MultiplierConfig.Carving": ["Резьба", "Во сколько раз быстрее растёт навык «резьба»."], - "MultiplierConfig.Husbandry": ["Животноводство", "Во сколько раз быстрее растёт навык «животноводство»."], - "MultiplierConfig.Tracking": ["Следопытство", "Во сколько раз быстрее растёт навык «следопытство»."], - "MultiplierConfig.Blacksmith": ["Кузнечное дело", "Во сколько раз быстрее растёт навык «кузнечное дело»."], - "MultiplierConfig.Butchering": ["Разделка туш", "Во сколько раз быстрее растёт навык «разделка туш»."], - "MultiplierConfig.Glassmaking": ["Стеклоделие", "Во сколько раз быстрее растёт навык «стеклоделие»."], - "MultiplierConfig.Farming": ["Земледелие", "Во сколько раз быстрее растёт навык «земледелие»."], - - "ZombieConfig.RallyGroupSize": ["Размер скопления", "Сколько зомби собирается в бродячую стаю."], - "ZombieConfig.RallyTravelDistance": ["Дальность схода в группу", "С какого расстояния зомби подтягиваются к стае."], - "ZombieConfig.RallyGroupSeparation": ["Расстояние между группами", "Насколько далеко стаи держатся друг от друга."], - "ZombieConfig.RallyGroupRadius": ["Плотность группы", "Насколько плотно зомби идут внутри стаи."], -}; - -// Вложенные таблицы песочницы становятся разделами, и их имена тоже стоит -// перевести: "MultiplierConfig" ничего не говорит. -const NESTED_TITLES = { - ZombieLore: "Зомби: повадки и характеристики", - ZombieConfig: "Зомби: популяция и респавн", - MultiplierConfig: "Множители опыта навыков", - Map: "Карта", - Basement: "Подвалы", -}; - // Категории верхнеуровневых параметров песочницы. Вложенные (ZombieLore.Speed // и подобные) группируются сами — по имени таблицы, в которой лежат. const SANDBOX_GROUPS = [ { - title: "Зомби", + titleKey: "group.sandbox.zombies", keys: ["Zombies", "Distribution", "ZombiesRespawn", "ZombieRespawn", "ZombieMigrate", "ZombieVoronoiNoise", "ZombieAttractionMultiplier", "ZombieHealthImpact", "ZombiePopLootEffect", "MultiHitZombies", "RearVulnerability", "SirenEffectsZombies", @@ -1070,7 +614,7 @@ const SANDBOX_GROUPS = [ "BloodSplatLifespanDays"], }, { - title: "Мир и время", + titleKey: "group.sandbox.world", keys: ["DayLength", "StartYear", "StartMonth", "StartDay", "StartTime", "DayNightCycle", "NightLength", "NightDarkness", "TimeSinceApo", "WaterShut", "ElecShut", "WaterShutModifier", "ElecShutModifier", "Alarm", "AlarmDecay", "AlarmDecayModifier", @@ -1079,12 +623,12 @@ const SANDBOX_GROUPS = [ "SurvivorHouseChance", "MaximumLootedBuildingRooms"], }, { - title: "Погода", + titleKey: "group.sandbox.weather", keys: ["ClimateCycle", "FogCycle", "Temperature", "TemperatureModifier", "Rain", "RainModifier", "MaxFogIntensity", "MaxRainFxIntensity", "EnableSnowOnGround"], }, { - title: "Лут и предметы", + titleKey: "group.sandbox.loot", keys: ["LootRespawn", "SeenHoursPreventLootRespawn", "HoursForLootRespawn", "MaxItemsForLootRespawn", "ConstructionPreventsLootRespawn", "RollsMultiplier", "LootItemRemovalList", "RemoveStoryLoot", "RemoveZombieLoot", "WorldItemRemovalList", @@ -1098,7 +642,7 @@ const SANDBOX_GROUPS = [ suffixes: ["Loot", "LootNew", "LootFactor"], }, { - title: "Персонаж и навыки", + titleKey: "group.sandbox.character", keys: ["XpMultiplier", "XpMultiplierAffectsPassive", "StatsDecrease", "Endurance", "Nutrition", "StarterKit", "InjurySeverity", "BoneFracture", "CharacterFreePoints", "ConstructionBonusPoints", "NightVisionEnabled", "InjuredHealingModifier", @@ -1109,7 +653,7 @@ const SANDBOX_GROUPS = [ "LevelForDismantleXPCutoff"], }, { - title: "Еда, вода и хозяйство", + titleKey: "group.sandbox.food", keys: ["FoodRotSpeed", "FridgeFactor", "MaggotSpawn", "EnableTaintedWaterText", "FishAbundance", "NatureAbundance", "PlantResilience", "PlantAbundance", "Farming", "FarmingSpeedNew", "FarmingAmountNew", "CompostTime", "KillInsideCrops", @@ -1117,12 +661,12 @@ const SANDBOX_GROUPS = [ "DaysForRottenFoodRemoval", "AidPackage"], }, { - title: "Животные", + titleKey: "group.sandbox.animals", keys: ["MaximumRatIndex", "DaysUntilMaximumRatIndex"], prefixes: ["Animal"], }, { - title: "Транспорт", + titleKey: "group.sandbox.vehicles", keys: ["CarSpawnRate", "ChanceHasGas", "InitialGas", "CarGasConsumption", "LockedCar", "CarGeneralCondition", "CarDamageOnImpact", "DamageToPlayerFromHitByACar", "TrafficJam", "CarAlarm", "PlayerDamageFromCrash", "SirenShutoffHours", "RecentlySurvivorVehicles", @@ -1130,17 +674,17 @@ const SANDBOX_GROUPS = [ prefixes: ["FuelStation"], }, { - title: "Огнестрельное оружие", + titleKey: "group.sandbox.firearms", keys: [], prefixes: ["Firearm"], }, { - title: "Электричество и огонь", + titleKey: "group.sandbox.power", keys: ["AllowExteriorGenerator", "FireSpread", "MaximumFireFuelHours", "LightBulbLifespan"], prefixes: ["Generator"], }, { - title: "Служебное", + titleKey: "group.sandbox.service", keys: ["VERSION"], }, ]; @@ -1159,13 +703,14 @@ async function loadServerConfig() { return; } serverConfigValues = data.values; + const hints = await loadHints(); const descriptions = data.descriptions || {}; const entries = data.keys.map((key) => ({ key, value: data.values[key], description: descriptions[key], })); - renderGroupedFields("#config-fields", entries, CONFIG_GROUPS, CONFIG_HINTS); + renderGroupedFields("#config-fields", entries, CONFIG_GROUPS, hints.config); applyFieldFilter("#config-filter", "#config-fields"); refreshFieldSaveState(SAVE_TARGETS[0]); } @@ -1212,7 +757,7 @@ function groupEntries(entries, groups) { } }); }); - if (items.length) result.push({ title: group.title, items }); + if (items.length) result.push({ id: group.titleKey, title: t(group.titleKey), items }); }); // Вложенные параметры песочницы сами собой образуют разделы: у них путь вида @@ -1233,10 +778,15 @@ function groupEntries(entries, groups) { Array.from(nested.entries()) .sort((a, b) => a[0].localeCompare(b[0])) - .forEach(([table, items]) => - result.push({ title: NESTED_TITLES[table] || table, items })); + .forEach(([table, items]) => { + // Имя таблицы — готовый ключ перевода. Незнакомую показываем как есть: + // песочница пополняется, и новый раздел не должен пропадать. + const key = `group.sandbox.${table}`; + const title = t(key); + result.push({ id: key, title: title === key ? table : title, items }); + }); - if (rest.length) result.push({ title: "Прочее", items: rest }); + if (rest.length) result.push({ id: "group.other", title: t("group.other"), items: rest }); return result; } @@ -1262,8 +812,8 @@ function renderGroupedFields(containerSelector, entries, groups, hints) { groupEntries(entries, groups).forEach((group, index) => { const section = document.createElement("details"); section.className = "cfg-group"; - section.dataset.group = group.title; - section.open = opened ? opened.has(group.title) : index === 0; + section.dataset.group = group.id; + section.open = opened ? opened.has(group.id) : index === 0; const summary = document.createElement("summary"); summary.innerHTML = @@ -1279,8 +829,10 @@ function renderGroupedFields(containerSelector, entries, groups, hints) { // Нумерованные проверки античита описывать поимённо нечем: игра не // раскрывает, что стоит за каждым номером. if (!hint && entry.key.startsWith("AntiCheatProtectionType")) { - hint = ["Античит: проверка " + entry.key.replace("AntiCheatProtectionType", "№"), - "Одна из встроенных проверок. Отключайте только при ложных срабатываниях."]; + hint = [ + t("config.anticheatLabel", { number: entry.key.replace("AntiCheatProtectionType", "") }), + t("config.anticheatHint"), + ]; } const label = Array.isArray(hint) ? hint[0] : hint; const ownDescription = Array.isArray(hint) ? hint[1] : null; @@ -1321,7 +873,7 @@ function buildConfigField(key, value, hint, description) { ["true", "false"].forEach((option) => { const el = document.createElement("option"); el.value = option; - el.textContent = option === "true" ? "да" : "нет"; + el.textContent = t(option === "true" ? "config.yes" : "config.no"); input.appendChild(el); }); input.value = value.toLowerCase(); @@ -1381,8 +933,8 @@ function refreshFieldSaveState(target) { const count = Object.keys(collectChanged(target.fields)).length; const button = $(target.button); button.disabled = count === 0; - button.textContent = count ? `Сохранить (${count})` : "Сохранить"; - button.title = count ? `Изменено параметров: ${count}` : "Изменений нет"; + button.textContent = count ? t("btn.saveCount", { count }) : t("btn.save"); + button.title = count ? t("config.changedCount", { count }) : t("config.noChangesTitle"); } SAVE_TARGETS.forEach((target) => { @@ -1404,13 +956,13 @@ function markSaved(containerSelector) { $("#btn-config-save").addEventListener("click", async () => { const values = collectChanged("#config-fields"); if (!Object.keys(values).length) { - toast("Нет изменений"); + toast(t("config.noChanges")); return; } try { await put("/api/config/server", { values }); markSaved("#config-fields"); - ok("Конфиг сохранён. Изменения применятся после перезапуска сервера."); + ok(t("config.saved")); } catch (error) { fail(error); } @@ -1460,12 +1012,13 @@ async function loadSandbox() { refreshFieldSaveState(SAVE_TARGETS[1]); return; } + const hints = await loadHints(); const entries = data.entries.map((entry) => ({ key: entry.path, value: entry.value, description: entry.comment, })); - renderGroupedFields("#sandbox-fields", entries, SANDBOX_GROUPS, SANDBOX_HINTS); + renderGroupedFields("#sandbox-fields", entries, SANDBOX_GROUPS, hints.sandbox); applyFieldFilter("#sandbox-filter", "#sandbox-fields"); refreshFieldSaveState(SAVE_TARGETS[1]); } @@ -1473,13 +1026,13 @@ async function loadSandbox() { $("#btn-sandbox-save").addEventListener("click", async () => { const values = collectChanged("#sandbox-fields"); if (!Object.keys(values).length) { - toast("Нет изменений"); + toast(t("config.noChanges")); return; } try { await put("/api/config/sandbox", { values }); markSaved("#sandbox-fields"); - ok("Песочница сохранена. Часть параметров действует только на новый мир."); + ok(t("sandbox.saved")); } catch (error) { fail(error); } @@ -1551,7 +1104,7 @@ function buildModRows() { modID: mod.mod_id, workshopID: mod.workshop_id, name: mod.name || mod.mod_id, - note: mod.workshop_id ? "" : "локальный мод", + note: mod.workshop_id ? "" : t("mods.local"), installed: true, }); }); @@ -1564,7 +1117,8 @@ function buildModRows() { modID, workshopID: "", name: modID, - note: "файлы не найдены — сервер скачает их при запуске", + note: t("mods.notFound"), + missing: true, installed: false, }); }); @@ -1577,8 +1131,9 @@ function buildModRows() { rows.push({ modID: "", workshopID, - name: info.title || `Мод ${workshopID}`, - note: info.error || "ещё не скачан — включить можно после запуска сервера", + name: info.title || t("mods.unnamed", { id: workshopID }), + note: info.error || t("mods.notDownloaded"), + missing: Boolean(info.error), installed: false, }); }); @@ -1590,7 +1145,7 @@ function buildModRows() { }; rows.sort((a, b) => { const diff = order(a) - order(b); - return diff !== 0 ? diff : a.name.localeCompare(b.name, "ru"); + return diff !== 0 ? diff : a.name.localeCompare(b.name, LANG); }); return rows; } @@ -1609,7 +1164,7 @@ function refreshModsSaveState() { const button = $("#btn-mods-save"); const changed = modsSnapshot !== "" && modsState() !== modsSnapshot; button.disabled = !changed; - button.title = changed ? "Есть несохранённые изменения" : "Изменений нет"; + button.title = t(changed ? "mods.unsaved" : "config.noChangesTitle"); } function renderMods() { @@ -1623,8 +1178,11 @@ function renderMods() { return `${row.name} ${row.modID} ${row.workshopID}`.toLowerCase().includes(filter); }); - $("#mod-summary").textContent = - `Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`; + $("#mod-summary").textContent = t("mods.summary", { + total: rows.length, + enabled: mods.enabled.length, + workshop: mods.workshop.length, + }); refreshModsSaveState(); @@ -1635,7 +1193,7 @@ function renderMods() { const preview = info.preview ? `` : ``; - const noteClass = row.note.includes("не найден") ? "error" : "muted"; + const noteClass = row.missing ? "error" : "muted"; const workshopLink = row.workshopID ? `${escapeHTML(row.workshopID)}` @@ -1644,21 +1202,21 @@ function renderMods() { // Перетаскивать можно только включённые моды: порядок хранится в Mods. return `
  • - + + ${row.modID ? "" : "disabled"} title="${t("mods.toggleTitle")}"> ${preview}
    ${escapeHTML(row.name)} - ${row.modID ? escapeHTML(row.modID) : "Mod ID неизвестен"}${workshopLink ? " · " + workshopLink : ""} + ${row.modID ? escapeHTML(row.modID) : t("mods.unknownID")}${workshopLink ? " · " + workshopLink : ""} ${row.note ? `${escapeHTML(row.note)}` : ""}
    - - - + + +
  • `; }) - .join("") || `
  • Ничего не найдено
  • `; + .join("") || `
  • ${t("mods.empty")}
  • `; } async function lookupWorkshopTitles() { @@ -1775,11 +1333,11 @@ $("#mod-add-form").addEventListener("submit", (event) => { event.preventDefault(); const id = extractWorkshopID($("#mod-input").value.trim()); if (!id) { - toast("Не похоже на ссылку или ID мода", "err"); + toast(t("mods.badInput"), "err"); return; } if (mods.workshop.includes(id)) { - toast("Этот мод уже в списке"); + toast(t("mods.duplicate")); return; } mods.workshop.push(id); @@ -1791,7 +1349,7 @@ $("#mod-add-form").addEventListener("submit", (event) => { $("#btn-add-collection").addEventListener("click", async () => { const input = $("#mod-input").value.trim(); if (!input) { - toast("Вставьте ссылку на коллекцию или её ID", "err"); + toast(t("mods.needCollection"), "err"); return; } try { @@ -1810,8 +1368,8 @@ $("#btn-add-collection").addEventListener("click", async () => { $("#mod-input").value = ""; renderMods(); ok(added - ? `Из коллекции добавлено модов: ${added} (всего в ней ${items.length})` - : `Все ${items.length} модов коллекции уже были в списке`); + ? t("mods.collectionAdded", { added, total: items.length }) + : t("mods.collectionKnown", { total: items.length })); } catch (error) { fail(error); } @@ -1827,7 +1385,7 @@ $("#btn-mods-save").addEventListener("click", async () => { }); modsSnapshot = modsState(); refreshModsSaveState(); - ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул."); + ok(t("mods.saved")); } catch (error) { fail(error); } @@ -1859,12 +1417,10 @@ function renderProfileSelect() { function renderProfileCards() { $("#profile-cards").innerHTML = profiles.list .map((p) => { - const badge = p.active ? `активный` : ""; + const badge = p.active ? `${t("profiles.active")}` : ""; const state = p.has_config - ? p.world_ready - ? "мир создан" - : "настроен, мир ещё не создан" - : "ни разу не запускался"; + ? t(p.world_ready ? "profiles.worldReady" : "profiles.configured") + : t("profiles.never"); return `

    ${escapeHTML(p.title || p.id)} ${badge}

    @@ -1872,31 +1428,30 @@ function renderProfileCards() {

    ${state}

    - - - + + +
    `; }) @@ -1908,15 +1463,16 @@ function renderProfileCards() { // memoryHint подсказывает разумный потолок: JVM должна поместиться в свободную // память, иначе сервер не поднимется вовсе. function memoryHint() { - if (!hostStats.mem_total_bytes) return "Например 4g"; - const total = formatBytes(hostStats.mem_total_bytes); - const free = formatBytes(hostStats.mem_available_bytes); - return `На машине ${total} ОЗУ, свободно ${free}. Оставьте запас системе.`; + if (!hostStats.mem_total_bytes) return t("profiles.memoryExample"); + return t("profiles.memoryHost", { + total: formatBytes(hostStats.mem_total_bytes), + free: formatBytes(hostStats.mem_available_bytes), + }); } function renderCopySource() { $("#new-profile-copy").innerHTML = - `` + + `` + profiles.list .filter((p) => p.has_config) .map((p) => ``) @@ -1929,7 +1485,7 @@ async function switchProfile(id) { try { await post(`/api/profiles/${encodeURIComponent(id)}/activate`); await loadProfiles(); - ok("Активный профиль переключён"); + ok(t("profiles.switched")); refreshStatus(); loadTab(currentTab); } catch (error) { @@ -1958,7 +1514,7 @@ $("#profile-cards").addEventListener("click", async (event) => { java_memory_min: field("java_memory_min"), admin_password: field("admin_password"), }); - ok("Профиль сохранён"); + ok(t("profiles.saved")); await loadProfiles(); break; @@ -1967,12 +1523,9 @@ $("#profile-cards").addEventListener("click", async (event) => { break; case "delete": - if (!confirm(`Убрать профиль ${id} из панели?\n\n` + - `Мир и конфиги останутся на диске — профиль можно вернуть, создав его с тем же идентификатором.`)) { - return; - } + if (!confirm(t("profiles.confirmDelete", { id }))) return; await del(`/api/profiles/${encodeURIComponent(id)}`); - ok("Профиль удалён"); + ok(t("profiles.deleted")); await loadProfiles(); break; } @@ -1993,7 +1546,7 @@ $("#profile-add-form").addEventListener("submit", async (event) => { $("#new-profile-id").value = ""; $("#new-profile-title").value = ""; $("#new-profile-memory").value = ""; - ok("Профиль создан"); + ok(t("profiles.created")); await loadProfiles(); } catch (error) { fail(error); @@ -2015,14 +1568,14 @@ async function loadBackups() { ${formatBytes(archive.size_bytes)} ${formatDate(archive.created_at)} - Скачать - - + ${t("backups.download")} + + ` ); $("#backup-rows").innerHTML = rows.join("") || - `Пока ни одного архива`; + `${t("backups.empty")}`; } // profileTitle подписывает архив понятным именем профиля. У архивов, снятых до @@ -2038,7 +1591,7 @@ $("#backup-form").addEventListener("submit", async (event) => { try { await post("/api/backups", { note: $("#backup-note").value.trim() }); $("#backup-note").value = ""; - ok("Бэкап запущен — прогресс во вкладке «Консоль»"); + ok(t("backups.started")); } catch (error) { fail(error); } @@ -2051,15 +1604,14 @@ $("#backup-rows").addEventListener("click", async (event) => { try { if (restore) { const name = restore.dataset.restore; - if (!confirm(`Восстановить мир из ${name}? Текущий мир будет перезаписан.\n` + - `Перед восстановлением менеджер сам сделает страховочный бэкап.`)) return; + if (!confirm(t("backups.confirmRestore", { name }))) return; await post(`/api/backups/${encodeURIComponent(name)}/restore`); - ok("Восстановление запущено"); + ok(t("backups.restoreStarted")); } else if (remove) { const name = remove.dataset.delete; - if (!confirm(`Удалить архив ${name}? Это действие необратимо.`)) return; + if (!confirm(t("backups.confirmDelete", { name }))) return; await del(`/api/backups/${encodeURIComponent(name)}`); - ok("Архив удалён"); + ok(t("backups.deleted")); loadBackups(); } } catch (error) { @@ -2088,38 +1640,39 @@ async function loadUpdateInfo() { if (info.error) { state.className = "muted small"; - state.textContent = `Не удалось проверить обновления: ${info.error}`; + state.textContent = t("update.failed", { error: info.error }); } else if (info.available) { state.className = "small update-available"; - state.textContent = `Доступна версия ${info.latest}`; - applyBtn.textContent = `Обновить до ${info.latest}`; + state.textContent = t("update.available", { version: info.latest }); + applyBtn.textContent = t("update.applyTo", { version: info.latest }); // Панель обновляется сама, только если установщик прописал ей права на // это. Иначе показываем команду для терминала. if (!info.can_apply) { applyBtn.hidden = true; manual.hidden = false; - manual.textContent = `Обновить можно командой: ${info.manual_command}`; + manual.textContent = t("update.manual", { command: info.manual_command }); } } else { state.className = "muted small"; state.textContent = info.latest - ? `Установлена последняя версия (${info.latest})` - : "Сведений о релизах нет"; + ? t("update.current", { version: info.latest }) + : t("update.unknown"); } const notes = $("#update-notes"); const changelog = info.changelog || []; notes.hidden = !(info.available && changelog.length); if (!notes.hidden) { - notes.querySelector("summary").textContent = - changelog.length > 1 ? `Что нового (версий: ${changelog.length})` : "Что нового"; + notes.querySelector("summary").textContent = changelog.length > 1 + ? t("update.whatsNewCount", { count: changelog.length }) + : t("update.whatsNew"); renderChangelog(changelog); } $("#btn-manager-restart").disabled = !info.can_apply; - $("#btn-manager-restart").title = info.can_apply - ? "Перезапустить службу панели" - : "Недоступно: панель не может управлять своей службой"; + $("#btn-manager-restart").title = t(info.can_apply + ? "update.restartTitle" + : "update.restartUnavailable"); } // Описание релиза приходит из Gitea разметкой Markdown, и показывать её как @@ -2163,7 +1716,7 @@ function renderChangelog(releases) { // Непривычный формат описания — показываем как есть, лишь бы не потерять. const raw = document.createElement("pre"); - raw.textContent = (release.notes || "").trim() || "Описание не заполнено"; + raw.textContent = (release.notes || "").trim() || t("update.noNotes"); body.appendChild(raw); }); @@ -2174,7 +1727,7 @@ function renderChangelog(releases) { link.target = "_blank"; link.rel = "noopener noreferrer"; link.className = "release-link"; - link.textContent = "Полное описание релизов"; + link.textContent = t("update.fullNotes"); body.appendChild(link); } } @@ -2184,7 +1737,9 @@ $("#btn-update-check").addEventListener("click", async () => { button.disabled = true; try { await loadUpdateInfo(); - ok(updateInfo.available ? `Есть новая версия: ${updateInfo.latest}` : "Обновлений нет"); + ok(updateInfo.available + ? t("update.hasNew", { version: updateInfo.latest }) + : t("update.upToDate")); } catch (error) { fail(error); } finally { @@ -2193,24 +1748,23 @@ $("#btn-update-check").addEventListener("click", async () => { }); $("#btn-update-apply").addEventListener("click", async () => { - if (!confirm(`Обновить панель до ${updateInfo.latest}?\n\n` + - `Игровой сервер будет остановлен, панель перезапустится.`)) return; + if (!confirm(t("update.confirm", { version: updateInfo.latest }))) return; try { await post("/api/update", { version: updateInfo.latest }); - waitForRestart("Обновление запущено"); + waitForRestart(t("update.started")); // Обновление выполняется отдельной службой, и её вывод в консоль панели // не попадает — панель в это время перезапускается. - toast("Ход обновления: journalctl -u pzmanager-update -f"); + toast(t("update.follow")); } catch (error) { fail(error); } }); $("#btn-manager-restart").addEventListener("click", async () => { - if (!confirm("Перезапустить панель?\n\nИгровой сервер будет остановлен.")) return; + if (!confirm(t("update.confirmRestart"))) return; try { await post("/api/manager/restart"); - waitForRestart("Панель перезапускается"); + waitForRestart(t("update.restarting")); } catch (error) { fail(error); } @@ -2220,19 +1774,19 @@ $("#btn-manager-restart").addEventListener("click", async () => { // страницу: во время перезапуска панель недоступна, и обычные опросы статуса // будут только сыпать ошибками. function waitForRestart(message) { - ok(message + " — жду, пока панель поднимется"); + ok(t("update.waiting", { message })); stopPolling(); closeConsoleStream(); const deadline = Date.now() + 5 * 60 * 1000; const state = $("#update-state"); state.className = "small"; - state.textContent = message + "…"; + state.textContent = t("update.waitingState", { message }); const probe = setInterval(async () => { if (Date.now() > deadline) { clearInterval(probe); - state.textContent = "Панель не поднялась за пять минут — проверьте journalctl -u pzmanager"; + state.textContent = t("update.timeout"); return; } try { @@ -2250,21 +1804,20 @@ function waitForRestart(message) { // Настройки менеджера и пользователи // --------------------------------------------------------------------------- -// Описание формы настроек: ключ в API, подпись, тип и пояснение. +// Описание формы настроек: ключ в API, ключ подписи, тип и ключ пояснения. const SETTINGS_FIELDS = [ - ["listen", "Адрес веб-панели", "text", "Например 127.0.0.1:8080. Применится после перезапуска pzmanager"], - ["server_dir", "Директория сервера", "text", "Куда SteamCMD ставит файлы Project Zomboid"], - ["zomboid_dir", "Директория данных (~/Zomboid)", "text", "Здесь лежат миры, конфиги и логи игры"], - ["steamcmd_path", "Путь к steamcmd.sh", "text", "Пусто — найти в PATH или скачать автоматически"], - ["autostart", "Запускать сервер вместе с менеджером", "checkbox", ""], - ["autorestart", "Поднимать сервер после падения", "checkbox", ""], - ["stop_timeout_sec", "Таймаут остановки, сек", "number", "Сколько ждать после команды quit до SIGTERM"], - ["log_buffer_lines", "Строк консоли в памяти", "number", ""], - ["backup_dir", "Директория бэкапов", "text", ""], - ["backup_schedule_min", "Автобэкап, минут", "number", "0 — отключить автоматические бэкапы"], - ["backup_keep", "Хранить архивов", "number", "0 — не удалять старые"], - ["backup_stop_server", "Останавливать сервер на время бэкапа", "checkbox", - "Даёт согласованный снимок, но выкидывает игроков"], + ["listen", "settings.listen", "text", "settings.listenHint"], + ["server_dir", "settings.serverDir", "text", "settings.serverDirHint"], + ["zomboid_dir", "settings.zomboidDir", "text", "settings.zomboidDirHint"], + ["steamcmd_path", "settings.steamcmdPath", "text", "settings.steamcmdPathHint"], + ["autostart", "settings.autostart", "checkbox", ""], + ["autorestart", "settings.autorestart", "checkbox", ""], + ["stop_timeout_sec", "settings.stopTimeout", "number", "settings.stopTimeoutHint"], + ["log_buffer_lines", "settings.logBuffer", "number", ""], + ["backup_dir", "settings.backupDir", "text", ""], + ["backup_schedule_min", "settings.backupSchedule", "number", "settings.backupScheduleHint"], + ["backup_keep", "settings.backupKeep", "number", "settings.backupKeepHint"], + ["backup_stop_server", "settings.backupStopServer", "checkbox", "settings.backupStopServerHint"], ]; async function loadSettings() { @@ -2277,7 +1830,7 @@ async function loadSettings() { wrapper.className = "cfg-field"; const labelEl = document.createElement("label"); - labelEl.textContent = label; + labelEl.textContent = t(label); wrapper.appendChild(labelEl); const input = document.createElement("input"); @@ -2294,7 +1847,7 @@ async function loadSettings() { if (hint) { const hintEl = document.createElement("div"); hintEl.className = "hint"; - hintEl.textContent = hint; + hintEl.textContent = t(hint); wrapper.appendChild(hintEl); } form.appendChild(wrapper); @@ -2302,7 +1855,7 @@ async function loadSettings() { const pathNote = document.createElement("p"); pathNote.className = "muted small"; - pathNote.textContent = `Файл конфигурации: ${config.config_path}`; + pathNote.textContent = t("settings.configPath", { path: config.config_path }); form.appendChild(pathNote); refreshSettingsSaveState(); @@ -2310,7 +1863,7 @@ async function loadSettings() { // Проверка обновления ходит в интернет и может не ответить — она не должна // мешать показу остальных настроек. loadUpdateInfo().catch((error) => { - $("#update-state").textContent = "Не удалось проверить обновления: " + error.message; + $("#update-state").textContent = t("update.failed", { error: error.message }); }); } @@ -2322,8 +1875,8 @@ function refreshSettingsSaveState() { const button = $("#btn-settings-save"); button.disabled = changed === 0; - button.textContent = changed ? `Сохранить настройки (${changed})` : "Сохранить настройки"; - button.title = changed ? `Изменено полей: ${changed}` : "Изменений нет"; + button.textContent = changed ? t("settings.saveCount", { count: changed }) : t("settings.save"); + button.title = changed ? t("settings.changedCount", { count: changed }) : t("config.noChangesTitle"); } ["input", "change"].forEach((event) => @@ -2344,7 +1897,7 @@ $("#btn-settings-save").addEventListener("click", async () => { }); try { const result = await put("/api/config/manager", payload); - ok(result.note || "Настройки сохранены"); + ok(result.note || t("settings.saved")); loadSettings(); } catch (error) { fail(error); @@ -2357,8 +1910,8 @@ async function loadUsers() { .map( (login) => `
  • ${escapeHTML(login)} - - + +
  • ` ) .join(""); @@ -2370,14 +1923,14 @@ $("#user-list").addEventListener("click", async (event) => { try { if (passwd) { - const password = prompt(`Новый пароль для ${passwd.dataset.passwd} (минимум 8 символов):`); + const password = prompt(t("users.newPassword", { login: passwd.dataset.passwd })); if (!password) return; await post(`/api/users/${encodeURIComponent(passwd.dataset.passwd)}/password`, { password }); - ok("Пароль изменён"); + ok(t("users.passwordChanged")); } else if (remove) { - if (!confirm(`Удалить пользователя ${remove.dataset.deluser}?`)) return; + if (!confirm(t("users.confirmDelete", { login: remove.dataset.deluser }))) return; await del(`/api/users/${encodeURIComponent(remove.dataset.deluser)}`); - ok("Пользователь удалён"); + ok(t("users.deleted")); loadUsers(); } } catch (error) { @@ -2394,7 +1947,7 @@ $("#user-add-form").addEventListener("submit", async (event) => { }); $("#new-user-login").value = ""; $("#new-user-password").value = ""; - ok("Пользователь добавлен"); + ok(t("users.added")); loadUsers(); } catch (error) { fail(error); @@ -2402,6 +1955,17 @@ $("#user-add-form").addEventListener("submit", async (event) => { }); // --------------------------------------------------------------------------- +// Запуск +// --------------------------------------------------------------------------- + +// Подписи разметки проставляются один раз: дальше их меняет только смена +// языка, а она перезагружает страницу. +applyStaticTranslations(); + +$$(".lang-select").forEach((select) => { + select.value = LANG; + select.addEventListener("change", (event) => setLang(event.target.value)); +}); checkSession().catch((error) => { fail(error); diff --git a/web/static/hints-en.json b/web/static/hints-en.json new file mode 100644 index 0000000..38f17a8 --- /dev/null +++ b/web/static/hints-en.json @@ -0,0 +1,1806 @@ +{ + "config": { + "PublicName": [ + "Server name", + "Shown in the in-game and Steam server lists." + ], + "PublicDescription": [ + "Server description", + "The text under the name in the server list. Line break — ." + ], + "Public": [ + "Show in the server list", + "A server with Steam authentication shows up in the Steam browser either way." + ], + "Open": [ + "Let players in without a whitelist", + "When off, the administrator creates the accounts by hand." + ], + "MaxPlayers": [ + "Maximum players", + "Administrators do not count. Above 32 players expect frame drops." + ], + "ServerWelcomeMessage": [ + "Welcome message", + "Shown in the chat right after joining. Supports and colours ." + ], + "ResetID": [ + "Reset identifier", + "Changes on a soft world reset: clients with a different value create a new character." + ], + "ServerPlayerID": [ + "Character identifier", + "The game uses it to tell this server's characters from other servers'." + ], + "Seed": [ + "World generation seed", + "To change it, enter a new value and delete map_worldgen.bin in the world folder." + ], + "server_browser_announced_ip": [ + "Announced IP", + "Needed when the machine has several addresses and a specific one must reach the server list." + ], + "Password": [ + "Server password", + "Empty — no password required." + ], + "MaxAccountsPerUser": [ + "Accounts per player", + "How many characters one Steam account may create. 0 — no limit." + ], + "DenyLoginOnOverloadedServer": [ + "Refuse entry when overloaded", + "Deny logins while the server cannot keep up with the load." + ], + "DropOffWhiteListAfterDeath": [ + "Drop from the whitelist on death", + "A player who dies loses access and has to be let in again." + ], + "LoginQueueEnabled": [ + "Login queue", + "Extra players wait for a free slot instead of being refused." + ], + "LoginQueueConnectTimeout": [ + "Queue timeout, sec", + "How long to wait for a player who reached the front of the queue to connect." + ], + "AllowCoop": [ + "Allow split screen", + "Several players from one client." + ], + "AllowNonAsciiUsername": [ + "Non-Latin nicknames", + "Allow Cyrillic and other non-ASCII characters in nicknames." + ], + "SteamScoreboard": [ + "Show Steam names", + "Display Steam names and avatars in the player list." + ], + "SteamVAC": [ + "Steam VAC protection", + "Turns on Valve's anti-cheat." + ], + "DoLuaChecksum": [ + "Verify Lua checksums", + "Disconnect players with modified Lua files." + ], + "DefaultPort": [ + "Game port (UDP)", + "16261 by default. Must be open on the firewall." + ], + "UDPPort": [ + "Second game port", + "16262 by default. Must be open as well." + ], + "RCONPort": [ + "RCON port", + "Port for controlling the server console from outside." + ], + "RCONPassword": [ + "RCON password", + "Empty — RCON is off. The panel does not need it: it writes to the console directly." + ], + "PingLimit": [ + "Ping limit, ms", + "Players above this latency get kicked. Going below 100 is not advisable." + ], + "UPnP": [ + "Forward ports via UPnP", + "Open the ports on the router automatically if it supports UPnP." + ], + "MaxPacketsPerSecond": [ + "Packets per second", + "Network traffic limit per player." + ], + "ClientCommandFilter": [ + "Client command filter", + "Commands that are kept out of the log." + ], + "MultiplayerStatisticsPeriod": [ + "Statistics period", + "How often network statistics are written. 0 — do not collect." + ], + "PVP": [ + "Allow player versus player", + "Players can wound and kill each other." + ], + "PVPLogToolChat": [ + "Report PVP to the admin chat", + "Tell administrators about PVP fights in the chat." + ], + "PVPLogToolFile": [ + "Write PVP to a file", + "Keep a log of PVP fights on disk." + ], + "SafetySystem": [ + "Safety system", + "Damage between players only lands when both have safety off." + ], + "ShowSafety": [ + "Show the safety icon", + "An icon above the head shows whether safety is off." + ], + "SafetyToggleTimer": [ + "Toggle delay, sec", + "How many seconds it takes to switch safety on or off." + ], + "SafetyCooldownTimer": [ + "Toggle cooldown, sec", + "How many seconds before safety can be switched again." + ], + "SafetyDisconnectDelay": [ + "PVP logout delay, sec", + "How long a player stays in the world after disconnecting during a fight." + ], + "PVPMeleeDamageModifier": [ + "Melee damage, %", + "Player-to-player damage multiplier for melee weapons." + ], + "PVPFirearmDamageModifier": [ + "Firearm damage, %", + "Player-to-player damage multiplier for firearms." + ], + "PVPMeleeWhileHitReaction": [ + "Hits during the hit reaction", + "Allow hitting a player who is still staggering from the previous blow." + ], + "KnockedDownAllowed": [ + "Allow knockdowns", + "Players can knock each other down." + ], + "SneakModeHideFromOtherPlayers": [ + "Sneaking hides from players", + "While sneaking, a player is invisible to other players." + ], + "PlayerBumpPlayer": [ + "Bump other players", + "Players collide with each other." + ], + "UsePhysicsHitReaction": [ + "Physical hit reaction", + "Turns on the recoil physics on a hit." + ], + "CarEngineAttractionModifier": [ + "Engine noise for zombies", + "How strongly a running engine draws zombies in." + ], + "Map": [ + "Maps", + "Maps separated by semicolons. The mod map goes first, the base map last." + ], + "SpawnPoint": [ + "Spawn point", + "Coordinates x,y,z. Zeroes — the map's own spawn points." + ], + "SpawnItems": [ + "Starting items", + "Items every new character is given." + ], + "SaveWorldEveryMinutes": [ + "Save the world every, min", + "0 — save only when players leave and when the server stops." + ], + "PauseEmpty": [ + "Pause when empty", + "Game time stops while nobody is on the server." + ], + "NoFire": [ + "Disable fire", + "Turns fire spreading off completely." + ], + "AllowDestructionBySledgehammer": [ + "Allow the sledgehammer", + "Players can break walls and objects with a sledgehammer." + ], + "SledgehammerOnlyInSafehouse": [ + "Sledgehammer in the safehouse only", + "Demolishing is allowed inside your own safehouse only." + ], + "PlayerRespawnWithSelf": [ + "Respawn at your own corpse", + "Allow spawning where the previous character died." + ], + "PlayerRespawnWithOther": [ + "Respawn next to a friend", + "Allow spawning next to another player." + ], + "FastForwardMultiplier": [ + "Time acceleration", + "How much faster time runs while fast-forwarding." + ], + "RemovePlayerCorpsesOnCorpseRemoval": [ + "Remove player corpses too", + "Automatic corpse cleanup also removes dead players." + ], + "TrashDeleteAll": [ + "The \"delete all\" button", + "Allow emptying a container in one action." + ], + "BloodSplatLifespanDays": [ + "Blood fades after, days", + "0 — blood stains stay forever." + ], + "ItemNumbersLimitPerContainer": [ + "Items per container", + "Limit on the number of items. 0 — no limit." + ], + "SafehousePreventsLootRespawn": [ + "Safehouses block loot respawn", + "Loot does not come back in buildings claimed as safehouses." + ], + "SwitchZombiesOwnershipEachUpdate": [ + "Hand zombies over every tick", + "Changes how zombies are distributed between clients." + ], + "UltraSpeedDoesnotAffectToAnimals": [ + "Fast-forward does not speed up animals", + "At maximum time speed animals still live at their normal pace." + ], + "SleepAllowed": [ + "Allow sleeping", + "Players may sleep; on a server with several players time is not fast-forwarded." + ], + "SleepNeeded": [ + "Require sleep", + "Tiredness builds up and has to be slept off." + ], + "SpeedLimit": [ + "Speed limit", + "Vehicle speed limit, km/h." + ], + "BackupsCount": [ + "Copies to keep", + "Backups the game itself makes." + ], + "BackupsOnStart": [ + "Copy on start", + "The game backs the world up on every server start." + ], + "BackupsOnVersionChange": [ + "Copy on version change", + "A backup before the first start on a new game version." + ], + "BackupsPeriod": [ + "Backup period, min", + "0 — the game's periodic backups are off. The panel has its own." + ], + "Mods": [ + "Enabled mods", + "Mod IDs in load order. Edited on the Mods tab." + ], + "WorkshopItems": [ + "Downloaded from the Workshop", + "Workshop IDs. Edited on the Mods tab." + ], + "GlobalChat": [ + "Global chat", + "Messages are seen by every player on the server." + ], + "ChatStreams": [ + "Available chat channels", + "Channel list: s — say, r — radio, a — admin, w — shout and so on." + ], + "VoiceEnable": [ + "Voice chat", + "Allow voice communication." + ], + "VoiceMinDistance": [ + "Full-volume distance", + "The distance at which the voice is heard without fading." + ], + "VoiceMaxDistance": [ + "Hearing limit", + "The distance beyond which the voice is not heard." + ], + "Voice3D": [ + "Positional voice", + "The voice comes from the direction the player speaks from." + ], + "ChatMessageCharacterLimit": [ + "Message length", + "Maximum characters in one chat message." + ], + "ChatMessageSlowModeTime": [ + "Pause between messages, sec", + "Flood protection: the minimum interval between messages." + ], + "AnnounceDeath": [ + "Announce deaths", + "Report player deaths in the chat." + ], + "AnnounceAnimalDeath": [ + "Announce animal deaths", + "Report in the chat when a farm animal dies." + ], + "BanKickGlobalSound": [ + "Sound on ban and kick", + "Play a sound for everyone when a player is removed." + ], + "BadWordListFile": [ + "Profanity list file", + "Path to the list of forbidden words." + ], + "GoodWordListFile": [ + "Exception file", + "Words the filter lets through." + ], + "BadWordPolicy": [ + "What to do with profanity", + "Replace the word or block the whole message." + ], + "BadWordReplacement": [ + "Replacement text", + "The text a forbidden word is replaced with." + ], + "DisplayUserName": [ + "Nicknames above heads", + "Show player names in the world." + ], + "ShowFirstAndLastName": [ + "First and last name", + "Show the character's full name instead of the nickname." + ], + "UsernameDisguises": [ + "Nickname disguise", + "The nickname is hidden while the player's face is covered by a mask or helmet." + ], + "HideDisguisedUserName": [ + "Hide disguised nicknames", + "A disguised player's name is not shown at all." + ], + "MouseOverToSeeDisplayName": [ + "Name on hover", + "The nickname is visible only with the cursor over the player." + ], + "HidePlayersBehindYou": [ + "Hide players behind you", + "Do not show players outside the character's field of view." + ], + "MapRemotePlayerVisibility": [ + "Players on the map", + "Whether other players are shown on the in-game map." + ], + "ShowCoordinates": [ + "Show coordinates", + "Players see the coordinates in the interface." + ], + "DisableScoreboard": [ + "Disable the player list", + "Hide the server's player list." + ], + "HideAdminsInPlayerList": [ + "Hide administrators", + "Administrators do not appear in the player list." + ], + "PlayerSafehouse": [ + "Players may claim safehouses", + "Allow ordinary players to claim a building." + ], + "AdminSafehouse": [ + "Admin-only safehouses", + "Only administrators may claim safehouses." + ], + "SafehouseAllowTrepass": [ + "Let outsiders in", + "Allow entering someone else's safehouse." + ], + "SafehouseAllowFire": [ + "Fire in the safehouse", + "Allow fires on the safehouse grounds." + ], + "SafehouseAllowLoot": [ + "Taking things in the safehouse", + "Allow outsiders to take items from a safehouse." + ], + "SafehouseAllowRespawn": [ + "Respawn in the safehouse", + "Members respawn inside it after dying." + ], + "SafehouseDaySurvivedToClaim": [ + "Days before claiming", + "How many days you must survive before claiming a building." + ], + "SafeHouseRemovalTime": [ + "Safehouse release, h", + "After how many hours of the owner's absence the claim is dropped." + ], + "SafehouseAllowNonResidential": [ + "Non-residential safehouses", + "Allow claiming shops, warehouses and other non-residential buildings." + ], + "SafehouseDisableDisguises": [ + "No disguises in the safehouse", + "Disguises do not work on the safehouse grounds." + ], + "MaxSafezoneSize": [ + "Safe zone size", + "Maximum safehouse area in tiles." + ], + "DisableSafehouseWhenOwnerConnected": [ + "Drop protection while the owner is on", + "Safehouse protection is off while the owner is in the game." + ], + "DisableSafehouseWhenPlayerConnected": [ + "Drop protection while players are inside", + "Protection is off while any player is inside." + ], + "Faction": [ + "Allow factions", + "Players can found groups." + ], + "FactionDaySurvivedToCreate": [ + "Days before founding a faction", + "How many days you must survive to found a faction." + ], + "FactionPlayersRequiredForTag": [ + "Players for a faction tag", + "How many members a faction needs before it gets a tag." + ], + "War": [ + "Allow faction wars", + "Factions can declare war on each other." + ], + "WarStartDelay": [ + "War start delay", + "How long after the declaration the war begins." + ], + "WarDuration": [ + "War duration", + "How long a declared war lasts." + ], + "WarSafehouseHitPoints": [ + "Safehouse strength in a war", + "How much damage buildings take during a war." + ], + "DisableRadioStaff": [ + "Hide the staff radio", + "Turns the staff radio channel off." + ], + "DisableRadioAdmin": [ + "Hide the admin radio", + "Turns the administrators' radio channel off." + ], + "DisableRadioGM": [ + "Hide the game master radio", + "Turns the GM radio channel off." + ], + "DisableRadioOverseer": [ + "Hide the overseer radio", + "Turns the overseers' radio channel off." + ], + "DisableRadioModerator": [ + "Hide the moderator radio", + "Turns the moderators' radio channel off." + ], + "DisableRadioInvisible": [ + "Hide the invisible radio", + "Turns the radio channel of hidden characters off." + ], + "DiscordEnable": [ + "Discord bridge", + "Relay the server chat to Discord and back." + ], + "DiscordToken": [ + "Bot token", + "The token of the Discord bot that carries the messages." + ], + "DiscordChannel": [ + "Chat channel", + "Name of the channel for the in-game chat." + ], + "DiscordChannelID": [ + "Channel identifier", + "Numeric ID of the Discord channel." + ], + "DiscordChatChannel": [ + "Chat channel", + "The channel the in-game chat is relayed to." + ], + "DiscordLogChannel": [ + "Log channel", + "Channel for the server's service messages." + ], + "DiscordCommandChannel": [ + "Command channel", + "The channel commands are accepted from." + ], + "WebhookAddress": [ + "Webhook address", + "Where to send server events over HTTP." + ], + "AntiCheatSafety": [ + "Anti-cheat: safety system", + "Checks for attempts to bypass the damage safety." + ], + "AntiCheatSpeed": [ + "Anti-cheat: speed", + "Checks for unnaturally fast movement." + ], + "AntiCheatNoClip": [ + "Anti-cheat: moving through walls", + "Checks for movement through obstacles." + ], + "AntiCheatHit": [ + "Anti-cheat: hits", + "Checks for hits from an impossible distance." + ], + "AntiCheatPacketException": [ + "Anti-cheat: broken packets", + "How to react to malformed network packets." + ], + "AntiCheatPermission": [ + "Anti-cheat: permissions", + "Checks for actions attempted without the rights for them." + ], + "AntiCheatXP": [ + "Anti-cheat: experience", + "Checks for unnatural skill growth." + ], + "AntiCheatSafeHouse": [ + "Anti-cheat: safehouses", + "Checks for bypassing safehouse protection." + ], + "AntiCheatPlayer": [ + "Anti-cheat: character", + "Checks for modified character stats." + ], + "AntiCheatChecksum": [ + "Anti-cheat: checksums", + "Verifies the client's game files." + ], + "ClientActionLogs": [ + "Client action log", + "Which player actions are written to the log." + ], + "PerkLogs": [ + "Skill log", + "Record changes of skill levels." + ], + "DisableVehicleTowing": [ + "Disable towing", + "Vehicles cannot be hitched to each other." + ], + "DisableTrailerTowing": [ + "Disable trailers", + "Trailers cannot be hitched to vehicles." + ], + "DisableBurntTowing": [ + "No towing burnt vehicles", + "Burnt-out vehicles cannot be towed." + ], + "AutoCreateUserInWhiteList": [ + "Create accounts automatically", + "On the first join a player picks their own login and password." + ], + "ConstructionPreventsLootRespawn": [ + "Player building blocks loot respawn", + "Loot does not come back in buildings players have built in." + ], + "HoursForLootRespawn": [ + "Loot respawn after, hours", + "0 — loot does not come back." + ], + "MaxItemsForLootRespawn": [ + "Items per respawn", + "How many items return to a container when loot refreshes." + ], + "KickFastPlayers": [ + "Kick for speed", + "Disconnect players moving unnaturally fast. Can fire on legitimate players." + ], + "MinutesPerPage": [ + "Minutes per page", + "How much game time it takes to read one page of a book." + ] + }, + "sandbox": { + "Zombies": [ + "Zombie count", + "How many zombies are in the world. Also changes the population multiplier in the advanced settings." + ], + "Distribution": [ + "Zombie distribution", + "Where zombies are denser: in the cities or evenly across the map." + ], + "ZombiesRespawn": [ + "Population respawn", + "Whether zombies come back to places that were already cleared." + ], + "ZombieRespawn": [ + "Population respawn", + "Whether zombies come back to places that were already cleared." + ], + "ZombieMigrate": [ + "Zombie migration", + "Whether zombies move between map zones, filling the emptied areas." + ], + "ZombieVoronoiNoise": [ + "Spread unevenness", + "How unevenly zombies are scattered: the higher the value, the more noticeable the crowds and the empty spots." + ], + "ZombieAttractionMultiplier": [ + "Attraction to noise", + "How strongly the noise of players and vehicles pulls zombies in." + ], + "ZombieHealthImpact": [ + "Zombie health", + "The overall toughness of zombies." + ], + "ZombiePopLootEffect": [ + "Population effect on loot", + "Ties the amount of loot to the zombie density: where there are more of them, the loot is richer." + ], + "MultiHitZombies": [ + "Hit several zombies", + "Allows one swing to hit several zombies." + ], + "RearVulnerability": [ + "Vulnerability from behind", + "How much more vulnerable a player is when attacked from behind." + ], + "SirenEffectsZombies": [ + "Sirens attract zombies", + "Whether zombies react to car sirens and alarms." + ], + "RallyGroupSize": [ + "Group size", + "How many zombies gather into a roaming horde." + ], + "RallyTravelDistance": [ + "Distance to join a group", + "From how far away zombies join a horde." + ], + "RallyGroupSeparation": [ + "Distance between groups", + "How far hordes keep from each other." + ], + "RallyGroupRadius": [ + "Group density", + "How tightly zombies walk inside a horde." + ], + "HoursForCorpseRemoval": [ + "Corpses disappear after, hours", + "After how many hours corpses vanish on their own. 0 — they stay forever." + ], + "DecayingCorpseHealthImpact": [ + "Harm from decaying corpses", + "How badly decaying corpses affect the health of anyone nearby." + ], + "BloodLevel": [ + "Amount of blood", + "How much blood is left on clothes, weapons and floors." + ], + "BloodSplatLifespanDays": [ + "Blood fades after, days", + "After how many days blood stains disappear. 0 — never." + ], + "DayLength": [ + "Day length", + "How much real time an in-game day takes." + ], + "StartYear": [ + "Starting year", + "The year the run begins in." + ], + "StartMonth": [ + "Starting month", + "The starting month: weather and harvest depend on it." + ], + "StartDay": [ + "Starting day", + "The day of the month the run begins on." + ], + "StartTime": [ + "Starting time", + "The time of day at the start." + ], + "DayNightCycle": [ + "Day and night cycle", + "How quickly day and night follow each other." + ], + "NightLength": [ + "Night length", + "What share of the day the night takes." + ], + "NightDarkness": [ + "Night darkness", + "How dark the nights are." + ], + "TimeSinceApo": [ + "Time since the outbreak", + "How much time passed between the outbreak and the start: it affects the state of the world and its supplies." + ], + "WaterShut": [ + "Water shutoff", + "When the water supply runs out." + ], + "ElecShut": [ + "Power shutoff", + "When the electricity goes off." + ], + "WaterShutModifier": [ + "Days until the water shutoff", + "After how many days the water is cut, if an exact term is chosen." + ], + "ElecShutModifier": [ + "Days until the power shutoff", + "After how many days the power is cut, if an exact term is chosen." + ], + "Alarm": [ + "Alarm frequency", + "How often house alarms go off and draw zombies in." + ], + "AlarmDecay": [ + "Alarm decay", + "Whether alarms stop working over time." + ], + "AlarmDecayModifier": [ + "Alarm decay speed", + "How quickly alarms break down." + ], + "LockedHouses": [ + "Locked houses", + "How often houses turn out to be locked." + ], + "ErosionSpeed": [ + "Overgrowth speed", + "How quickly the world is overgrown with grass and plants." + ], + "ErosionDays": [ + "Days until full overgrowth", + "In how many days the overgrowth reaches its limit. 0 — disabled." + ], + "EndRegen": [ + "Endurance regeneration", + "How quickly endurance comes back." + ], + "Helicopter": [ + "Helicopter", + "How often a helicopter flies over the map and drags zombies along." + ], + "MetaEvent": [ + "Random events", + "How often distant events happen: gunshots, screams, alarms." + ], + "SleepingEvent": [ + "Night events", + "Whether events may happen while the character sleeps." + ], + "AnnotatedMapChance": [ + "Chance of an annotated map", + "How often maps with marked stashes turn up." + ], + "VehicleStoryChance": [ + "Vehicle story frequency", + "How often vehicle scenes turn up: crashes, abandoned cars." + ], + "ZoneStoryChance": [ + "Zone story frequency", + "How often story scenes turn up in the landscape." + ], + "SurvivorHouseChance": [ + "Survivor house frequency", + "How often houses settled by other survivors turn up." + ], + "MaximumLootedBuildingRooms": [ + "Looted rooms per building", + "How many rooms in a building may already be looted." + ], + "ClimateCycle": [ + "Seasons", + "How pronounced the change of seasons is." + ], + "FogCycle": [ + "Fog frequency", + "How often fog rolls in." + ], + "Temperature": [ + "Temperature", + "The overall temperature background." + ], + "TemperatureModifier": [ + "Temperature correction", + "A correction to the temperature: colder or warmer than usual." + ], + "Rain": [ + "Rain", + "How often it rains." + ], + "RainModifier": [ + "Rain correction", + "A correction to the amount of rainfall." + ], + "MaxFogIntensity": [ + "Fog density", + "How thick the fog can get." + ], + "MaxRainFxIntensity": [ + "Rain strength", + "How heavy the rain looks." + ], + "EnableSnowOnGround": [ + "Snow on the ground", + "Whether snow settles on the ground in winter." + ], + "LootRespawn": [ + "Loot respawn", + "Whether loot comes back into containers that were already searched." + ], + "SeenHoursPreventLootRespawn": [ + "Hours after a look before respawn", + "How many hours a container stays empty after it was looked into." + ], + "HoursForLootRespawn": [ + "Loot respawn after, hours", + "After how many hours the loot returns. 0 — it does not." + ], + "MaxItemsForLootRespawn": [ + "Items per respawn", + "How many items appear in a container when loot refreshes." + ], + "ConstructionPreventsLootRespawn": [ + "Player building blocks respawn", + "Player-built structures in a building cancel the loot refresh." + ], + "RollsMultiplier": [ + "Loot roll multiplier", + "How many times the game tries to put an item into a container." + ], + "FoodLoot": [ + "Food", + "How often food turns up." + ], + "CannedFoodLoot": [ + "Canned food", + "How often canned food turns up." + ], + "LiteratureLoot": [ + "Books and magazines", + "How often books, magazines and newspapers turn up." + ], + "SurvivalGearsLoot": [ + "Survival gear", + "How often survival gear turns up." + ], + "MedicalLoot": [ + "Medical supplies", + "How often medical supplies turn up." + ], + "WeaponLoot": [ + "Weapons", + "How often melee weapons turn up." + ], + "RangedWeaponLoot": [ + "Firearms", + "How often firearms turn up." + ], + "AmmoLoot": [ + "Ammunition", + "How often ammunition turns up." + ], + "MechanicsLoot": [ + "Car parts", + "How often car parts and tools turn up." + ], + "OtherLoot": [ + "Other", + "How often other items turn up." + ], + "SkillBookLoot": [ + "Skill books", + "How often skill books turn up." + ], + "RecipeResourceLoot": [ + "Recipe materials", + "How often materials for recipes turn up." + ], + "RemoveStoryLoot": [ + "Remove story loot", + "Removes the loot from story scenes." + ], + "RemoveZombieLoot": [ + "Remove zombie loot", + "Removes the items dropped by zombies." + ], + "MaximumLooted": [ + "Looting limit", + "How looted the world becomes over time." + ], + "DaysUntilMaximumLooted": [ + "Days until the looting limit", + "In how many days the world reaches its maximum looted state." + ], + "RuralLooted": [ + "Rural looting", + "How looted the outlying areas are." + ], + "MaximumDiminishedLoot": [ + "Loot depletion limit", + "How much the loot thins out in places already visited." + ], + "DaysUntilMaximumDiminishedLoot": [ + "Days until loot depletion", + "In how many days the loot thins out to its limit." + ], + "XpMultiplier": [ + "Experience multiplier", + "How much faster the skills grow." + ], + "XpMultiplierAffectsPassive": [ + "Experience and passive skills", + "Whether the experience multiplier applies to strength and fitness." + ], + "StatsDecrease": [ + "Stat decay speed", + "How quickly the character gets hungry, tired and thirsty." + ], + "Endurance": [ + "Endurance", + "How quickly endurance is spent." + ], + "Nutrition": [ + "Track nutrition", + "Whether calories and the character's weight are tracked." + ], + "StarterKit": [ + "Starter kit", + "Whether a new character is given a basic set of things." + ], + "InjurySeverity": [ + "Injury severity", + "How severe the injuries are." + ], + "BoneFracture": [ + "Fractures", + "Whether bones can break." + ], + "CharacterFreePoints": [ + "Free character points", + "How many extra points are given at character creation." + ], + "ConstructionBonusPoints": [ + "Building bonus", + "A bonus to the strength of what the player builds." + ], + "NightVisionEnabled": [ + "Night vision", + "Whether the character sees better than usual in the dark." + ], + "InjuredHealingModifier": [ + "Healing speed", + "How quickly wounds heal." + ], + "MuscleStrainFactor": [ + "Muscle strain", + "How quickly the muscles tire from heavy weapons." + ], + "DiscomfortFactor": [ + "Discomfort", + "How quickly discomfort builds up from uncomfortable clothing and wounds." + ], + "WoundInfectionFactor": [ + "Wound infection", + "How likely an ordinary wound gets infected — not to be confused with a zombie bite." + ], + "NegativeTraitsPenalty": [ + "Negative trait penalty", + "How much the negative traits get in the way." + ], + "EnablePoisoning": [ + "Poisoning", + "Whether spoiled food and poisonous berries can poison the character." + ], + "ClothingDegradation": [ + "Clothing wear", + "How quickly clothing wears out." + ], + "AllClothesUnlocked": [ + "All clothing unlocked", + "Unlocks every piece of clothing at character creation." + ], + "NoBlackClothes": [ + "No black clothing", + "Removes black clothing from the game." + ], + "EasyClimbing": [ + "Easy climbing", + "Makes climbing over fences and obstacles easier." + ], + "AttackBlockMovements": [ + "Attacks block movement", + "You cannot move while swinging." + ], + "MetaKnowledge": [ + "Recipes known in advance", + "Whether the character knows recipes they have not learned yet." + ], + "SeeNotLearntRecipe": [ + "See unlearned recipes", + "Whether unlearned recipes are shown in the list." + ], + "LiteratureCooldown": [ + "Reading cooldown", + "How long to wait between reading the same books." + ], + "MinutesPerPage": [ + "Minutes per page", + "How many game minutes one page of a book takes." + ], + "LevelForMediaXPCutoff": [ + "Experience limit from media", + "Up to which skill level tapes and discs give experience." + ], + "LevelForDismantleXPCutoff": [ + "Experience limit from dismantling", + "Up to which skill level dismantling items gives experience." + ], + "FoodRotSpeed": [ + "Food rotting speed", + "How quickly food spoils." + ], + "FridgeFactor": [ + "Fridge efficiency", + "How much a fridge slows the spoiling down." + ], + "MaggotSpawn": [ + "Maggot spawn", + "Where maggots appear: in food, on corpses or nowhere." + ], + "EnableTaintedWaterText": [ + "Mark tainted water", + "Whether to warn that the water is not drinkable." + ], + "FishAbundance": [ + "Fish abundance", + "How much fish there is in the waters." + ], + "NatureAbundance": [ + "Foraging abundance", + "How many berries, mushrooms and other wild finds there are." + ], + "PlantResilience": [ + "Plant resilience", + "How resistant the plants are to disease and pests." + ], + "PlantAbundance": [ + "Crop yield", + "How generous the harvest from a plot is." + ], + "Farming": [ + "Growth speed", + "How quickly the crops grow." + ], + "FarmingSpeedNew": [ + "Farming speed", + "How quickly the crops grow." + ], + "FarmingAmountNew": [ + "Harvest per plot", + "How much one plot yields." + ], + "CompostTime": [ + "Composting time", + "How long waste takes to turn into compost." + ], + "KillInsideCrops": [ + "Kill indoor crops", + "Whether crops planted indoors die." + ], + "PlantGrowingSeasons": [ + "Growing seasons", + "Whether the time of year matters when planting." + ], + "PlaceDirtAboveground": [ + "Plots on any level", + "Whether plots can be made off the ground, on upper floors." + ], + "ClayLakeChance": [ + "Clay by the lakes", + "How often clay is found by the lakes." + ], + "ClayRiverChance": [ + "Clay by the rivers", + "How often clay is found by the rivers." + ], + "DaysForRottenFoodRemoval": [ + "Days until rot is removed", + "After how many days spoiled food disappears on its own. -1 — never." + ], + "AidPackage": [ + "Aid drops", + "How often humanitarian aid is dropped." + ], + "AnimalStatsModifier": [ + "Animal stats", + "A general multiplier for the stats of farm animals." + ], + "AnimalMetaStatsModifier": [ + "Wild animals", + "How numerous and hardy the wild animals are." + ], + "AnimalPregnancyTime": [ + "Pregnancy length", + "How long an animal pregnancy lasts." + ], + "AnimalAgeModifier": [ + "Maturing speed", + "How quickly animals grow up." + ], + "AnimalMilkIncModifier": [ + "Milk yield", + "How much milk the animals give." + ], + "AnimalWoolIncModifier": [ + "Wool yield", + "How much wool the animals give." + ], + "AnimalRanchChance": [ + "Ranch frequency", + "How often ranches with animals turn up on the map." + ], + "AnimalGrassRegrowTime": [ + "Grass regrowth", + "How quickly the grass grows back on a pasture." + ], + "AnimalMetaPredator": [ + "Predators", + "How many predators there are in the world." + ], + "AnimalMatingSeason": [ + "Mating season", + "Whether breeding is limited to a season." + ], + "AnimalEggHatch": [ + "Egg hatching", + "How often chicks hatch from eggs." + ], + "AnimalSoundAttractZombies": [ + "Animals attract zombies", + "Whether animal sounds draw zombies in." + ], + "AnimalTrackChance": [ + "Track frequency", + "How often animal tracks turn up." + ], + "AnimalPathChance": [ + "Trail frequency", + "How often animal trails turn up." + ], + "MaximumRatIndex": [ + "Rat limit", + "How far the rats multiply." + ], + "DaysUntilMaximumRatIndex": [ + "Days until the rat limit", + "In how many days the rats reach their limit." + ], + "CarSpawnRate": [ + "Vehicle frequency", + "How many cars are on the roads and in the car parks." + ], + "ChanceHasGas": [ + "Chance of fuel in the tank", + "How likely a found car has fuel in its tank." + ], + "InitialGas": [ + "Fuel in the tank", + "How much fuel the tanks hold at the start." + ], + "CarGasConsumption": [ + "Fuel consumption", + "How thirsty the vehicles are." + ], + "LockedCar": [ + "Locked cars", + "How often cars turn out to be locked." + ], + "CarGeneralCondition": [ + "Vehicle condition", + "What condition the found vehicles are in." + ], + "CarDamageOnImpact": [ + "Vehicle damage on impact", + "How badly a car suffers in a collision." + ], + "DamageToPlayerFromHitByACar": [ + "Damage from being hit by a car", + "How much damage a pedestrian takes from being run over." + ], + "TrafficJam": [ + "Traffic jams", + "How often roads are blocked by jams of abandoned cars." + ], + "CarAlarm": [ + "Car alarms", + "How often car alarms go off." + ], + "PlayerDamageFromCrash": [ + "Crash damage", + "How much damage the driver takes in a crash." + ], + "SirenShutoffHours": [ + "Siren goes quiet after, hours", + "After how many hours a police car siren falls silent." + ], + "RecentlySurvivorVehicles": [ + "Survivor vehicles", + "How often cars recently abandoned by survivors turn up." + ], + "EnableVehicles": [ + "Vehicles in the game", + "Whether there are vehicles at all." + ], + "VehicleEasyUse": [ + "Simplified driving", + "Simplifies driving: the car starts and runs without any fuss." + ], + "MassInvasion": [ + "Mass invasion", + "Sharply raises the number of zombies coming to vehicle noise." + ], + "FuelStationGas": [ + "Fuel at the stations", + "How much fuel is left at the petrol stations." + ], + "FuelStationGasInfinite": [ + "Endless fuel", + "The fuel at the stations never runs out." + ], + "FuelStationGasMin": [ + "Minimum fuel per station", + "The smallest fuel reserve at a station." + ], + "FuelStationGasMax": [ + "Maximum fuel per station", + "The largest fuel reserve at a station." + ], + "FuelStationGasEmptyChance": [ + "Chance of an empty station", + "How likely a station turns out to be empty." + ], + "FirearmUseDamageChance": [ + "Wear from firing", + "How quickly a weapon wears out from firing." + ], + "FirearmNoiseMultiplier": [ + "Gunshot loudness", + "How far away the shots are heard." + ], + "FirearmJamMultiplier": [ + "Jam frequency", + "How often a weapon jams." + ], + "FirearmMoodleMultiplier": [ + "Condition effect on aim", + "How much tiredness, panic and hunger spoil the aim." + ], + "FirearmWeatherMultiplier": [ + "Weather effect on aim", + "How much rain and fog spoil the aim." + ], + "FirearmHeadGearEffect": [ + "Headgear effect", + "How much headgear gets in the way of aiming." + ], + "Generator": [ + "Generator fuel use", + "How economically a generator burns fuel." + ], + "GeneratorSpawning": [ + "Generator frequency", + "How often generators turn up." + ], + "GeneratorFuelConsumption": [ + "Generator consumption", + "How much fuel a generator burns." + ], + "GeneratorTileRange": [ + "Generator power radius", + "How many tiles around it a generator powers." + ], + "GeneratorVerticalPowerRange": [ + "Generator floor range", + "How many floors up and down the power reaches." + ], + "AllowExteriorGenerator": [ + "Generator outdoors", + "Whether a generator can be placed under the open sky." + ], + "FireSpread": [ + "Fire spread", + "Whether fire spreads to nearby objects." + ], + "MaximumFireFuelHours": [ + "Hours of burning fuel", + "How many hours the fuel in a campfire burns." + ], + "LightBulbLifespan": [ + "Light bulb lifespan", + "How long light bulbs last. 0 — forever." + ], + "VERSION": [ + "Settings format version", + "The version of the settings format. The game maintains it, no need to edit it by hand." + ], + "FoodLootNew": [ + "Food", + "How often food turns up. The new Build 42 loot system." + ], + "CannedFoodLootNew": [ + "Canned food", + "How often canned food turns up." + ], + "LiteratureLootNew": [ + "Books and magazines", + "How often books and magazines turn up." + ], + "MedicalLootNew": [ + "Medical supplies", + "How often medical supplies turn up." + ], + "SurvivalGearsLootNew": [ + "Survival gear", + "How often survival gear turns up." + ], + "WeaponLootNew": [ + "Melee weapons", + "How often melee weapons turn up." + ], + "RangedWeaponLootNew": [ + "Firearms", + "How often firearms turn up." + ], + "AmmoLootNew": [ + "Ammunition", + "How often ammunition turns up." + ], + "MechanicsLootNew": [ + "Car parts", + "How often car parts turn up." + ], + "OtherLootNew": [ + "Other", + "How often other items turn up." + ], + "ClothingLootNew": [ + "Clothing", + "How often clothing turns up." + ], + "ContainerLootNew": [ + "Bags and containers", + "How often bags, backpacks and crates turn up." + ], + "KeyLootNew": [ + "Keys", + "How often keys turn up." + ], + "MediaLootNew": [ + "Tapes and discs", + "How often tapes, discs and records turn up." + ], + "MementoLootNew": [ + "Mementos", + "How often mementos and trinkets turn up." + ], + "CookwareLootNew": [ + "Cookware", + "How often cookware turns up." + ], + "MaterialLootNew": [ + "Building materials", + "How often building materials turn up." + ], + "FarmingLootNew": [ + "Farming supplies", + "How often seeds and gardening tools turn up." + ], + "ToolLootNew": [ + "Tools", + "How often tools turn up." + ], + "InsaneLootFactor": [ + "Insanely rare multiplier", + "The multiplier for items of the \"insanely rare\" category." + ], + "ExtremeLootFactor": [ + "Extremely rare multiplier", + "The multiplier for extremely rare items." + ], + "RareLootFactor": [ + "Rare multiplier", + "The multiplier for rare items." + ], + "NormalLootFactor": [ + "Normal multiplier", + "The multiplier for items of normal rarity." + ], + "CommonLootFactor": [ + "Common multiplier", + "The multiplier for commonly found items." + ], + "AbundantLootFactor": [ + "Abundant multiplier", + "The multiplier for the most ordinary items." + ], + "LootItemRemovalList": [ + "Items removed from loot", + "Items that will not be in the loot. A comma-separated list." + ], + "WorldItemRemovalList": [ + "Items removed from the ground", + "Items that are cleaned off the ground during the world cleanup." + ], + "HoursForWorldItemRemoval": [ + "Remove from the ground after, hours", + "After how many hours dropped items vanish from the ground. 0 — they do not." + ], + "ItemRemovalListBlacklistToggle": [ + "Treat the list as an exception", + "Read the list the other way round: remove everything except what is listed." + ], + "Basement.SpawnFrequency": [ + "Basement frequency", + "How often houses have basements." + ], + "Map.AllowMiniMap": [ + "Allow the minimap", + "Whether players have the minimap in the corner of the screen." + ], + "Map.AllowWorldMap": [ + "Allow the world map", + "Whether the full-screen world map is available." + ], + "Map.MapAllKnown": [ + "Map fully revealed", + "The map is revealed completely from the start." + ], + "Map.MapNeedsLight": [ + "The map needs light", + "The map cannot be read in the dark without a light source." + ], + "ZombieLore.Speed": [ + "Speed", + "How fast zombies move: from sprinters to slow shamblers." + ], + "ZombieLore.SprinterPercentage": [ + "Share of sprinters", + "What share of zombies runs when the speed is set to random." + ], + "ZombieLore.Strength": [ + "Strength", + "Zombie strength: how hard they are to push away and how much damage they deal." + ], + "ZombieLore.Toughness": [ + "Toughness", + "How hard a zombie is to kill." + ], + "ZombieLore.Transmission": [ + "Infection transmission", + "How the infection spreads: through bites, through any wound, or everyone is infected from the start." + ], + "ZombieLore.Mortality": [ + "Time until death from a bite", + "How long an infected character lives before dying." + ], + "ZombieLore.Reanimate": [ + "Time until reanimation", + "How long before the dead rise as zombies." + ], + "ZombieLore.Cognition": [ + "Cognition", + "How smart the zombies are: whether they go around obstacles and open doors." + ], + "ZombieLore.DoorOpeningPercentage": [ + "Share that opens doors", + "What share of zombies can open doors when the cognition is random." + ], + "ZombieLore.CrawlUnderVehicle": [ + "Crawl under vehicles", + "Whether crawling zombies can get under cars." + ], + "ZombieLore.Memory": [ + "Memory", + "How long zombies remember a player after losing sight of them." + ], + "ZombieLore.Sight": [ + "Sight", + "How far the zombies see." + ], + "ZombieLore.Hearing": [ + "Hearing", + "How far the zombies hear." + ], + "ZombieLore.SpottedLogic": [ + "Spotting logic", + "By what rules zombies notice a player." + ], + "ZombieLore.ThumpNoChasing": [ + "Thump without chasing", + "Whether zombies bang on doors and windows when they are not chasing anyone." + ], + "ZombieLore.ThumpOnConstruction": [ + "Break player constructions", + "Whether zombies break what the players have built." + ], + "ZombieLore.ActiveOnly": [ + "When active", + "When the zombies are active: around the clock, in the daytime only or at night only." + ], + "ZombieLore.TriggerHouseAlarm": [ + "Trigger house alarms", + "Whether zombies can set off a house alarm." + ], + "ZombieLore.ZombiesDragDown": [ + "Drag down from heights", + "Whether zombies can pull a player off a fence or out of a window." + ], + "ZombieLore.ZombiesCrawlersDragDown": [ + "Crawlers drag down", + "The same for crawling zombies." + ], + "ZombieLore.ZombiesFenceLunge": [ + "Lunge over fences", + "Whether zombies lunge over low fences." + ], + "ZombieLore.ZombiesArmorFactor": [ + "Armour effect", + "How much the zombies' armour absorbs damage." + ], + "ZombieLore.ZombiesMaxDefense": [ + "Defence limit", + "The defence limit a zombie can get from its armour." + ], + "ZombieLore.ChanceOfAttachedWeapon": [ + "Chance of a weapon in the body", + "The chance of finding a weapon stuck in a zombie's body." + ], + "ZombieLore.ZombiesFallDamage": [ + "Fall damage", + "Whether zombies take damage from falling." + ], + "ZombieLore.DisableFakeDead": [ + "No fake dead", + "Removes the zombies that play dead and rise when you come close." + ], + "ZombieLore.PlayerSpawnZombieRemoval": [ + "Cleanup radius on player spawn", + "The radius in which zombies are removed when a player enters the world." + ], + "ZombieLore.FenceThumpersRequired": [ + "Zombies needed to break a fence", + "How many zombies must pile on to break a fence." + ], + "ZombieLore.FenceDamageMultiplier": [ + "Fence damage", + "How quickly the zombies break the fences." + ], + "ZombieConfig.PopulationMultiplier": [ + "Population multiplier", + "A general multiplier on top of the \"Zombie count\" setting." + ], + "ZombieConfig.PopulationStartMultiplier": [ + "Population at the start", + "How many zombies are in the world on the first day." + ], + "ZombieConfig.PopulationPeakMultiplier": [ + "Population at the peak", + "How many zombies there will be at the peak of the growth." + ], + "ZombieConfig.PopulationPeakDay": [ + "Population peak day", + "On which day the population reaches its peak." + ], + "ZombieConfig.RespawnHours": [ + "Hours until respawn", + "After how many hours zombies come back to a cleared cell. 0 — they do not." + ], + "ZombieConfig.RespawnUnseenHours": [ + "Hours unseen", + "How many hours a cell must be free of players before zombies appear there again." + ], + "ZombieConfig.RespawnMultiplier": [ + "Respawn share", + "What share of the original population is restored at a time." + ], + "ZombieConfig.RedistributeHours": [ + "Hours until redistribution", + "How often the zombies are redistributed across the map." + ], + "ZombieConfig.FollowSoundDistance": [ + "Sound reaction distance", + "From how far away zombies walk towards a sound they heard." + ], + "ZombieConfig.RallyGroupSizeVariance": [ + "Group size variance", + "How much the horde sizes differ from the set value." + ], + "ZombieConfig.ZombiesCountBeforeDelete": [ + "Zombie limit before removal", + "How many zombies may be nearby before the extra ones are removed for performance." + ], + "MultiplierConfig.Global": [ + "Global experience multiplier", + "A single experience multiplier for every skill at once." + ], + "MultiplierConfig.GlobalToggle": [ + "Apply the global multiplier", + "Use the global multiplier instead of the per-skill values." + ], + "MultiplierConfig.Fitness": [ + "Fitness", + "How much faster the fitness skill grows." + ], + "MultiplierConfig.Strength": [ + "Strength", + "How much faster the strength skill grows." + ], + "MultiplierConfig.Sprinting": [ + "Sprinting", + "How much faster the sprinting skill grows." + ], + "MultiplierConfig.Lightfoot": [ + "Lightfooted", + "How much faster the lightfooted skill grows." + ], + "MultiplierConfig.Nimble": [ + "Nimble", + "How much faster the nimble skill grows." + ], + "MultiplierConfig.Sneak": [ + "Sneaking", + "How much faster the sneaking skill grows." + ], + "MultiplierConfig.Axe": [ + "Axe", + "How much faster the axe skill grows." + ], + "MultiplierConfig.Blunt": [ + "Long blunt", + "How much faster the long blunt skill grows." + ], + "MultiplierConfig.SmallBlunt": [ + "Short blunt", + "How much faster the short blunt skill grows." + ], + "MultiplierConfig.LongBlade": [ + "Long blade", + "How much faster the long blade skill grows." + ], + "MultiplierConfig.SmallBlade": [ + "Short blade", + "How much faster the short blade skill grows." + ], + "MultiplierConfig.Spear": [ + "Spear", + "How much faster the spear skill grows." + ], + "MultiplierConfig.Maintenance": [ + "Maintenance", + "How much faster the maintenance skill grows." + ], + "MultiplierConfig.Woodwork": [ + "Carpentry", + "How much faster the carpentry skill grows." + ], + "MultiplierConfig.Cooking": [ + "Cooking", + "How much faster the cooking skill grows." + ], + "MultiplierConfig.Doctor": [ + "First aid", + "How much faster the first aid skill grows." + ], + "MultiplierConfig.Electricity": [ + "Electrical", + "How much faster the electrical skill grows." + ], + "MultiplierConfig.MetalWelding": [ + "Metalworking", + "How much faster the metalworking skill grows." + ], + "MultiplierConfig.Mechanics": [ + "Mechanics", + "How much faster the mechanics skill grows." + ], + "MultiplierConfig.Tailoring": [ + "Tailoring", + "How much faster the tailoring skill grows." + ], + "MultiplierConfig.Aiming": [ + "Aiming", + "How much faster the aiming skill grows." + ], + "MultiplierConfig.Reloading": [ + "Reloading", + "How much faster the reloading skill grows." + ], + "MultiplierConfig.Fishing": [ + "Fishing", + "How much faster the fishing skill grows." + ], + "MultiplierConfig.Trapping": [ + "Trapping", + "How much faster the trapping skill grows." + ], + "MultiplierConfig.PlantScavenging": [ + "Foraging", + "How much faster the foraging skill grows." + ], + "MultiplierConfig.FlintKnapping": [ + "Flint knapping", + "How much faster the flint knapping skill grows." + ], + "MultiplierConfig.Masonry": [ + "Masonry", + "How much faster the masonry skill grows." + ], + "MultiplierConfig.Pottery": [ + "Pottery", + "How much faster the pottery skill grows." + ], + "MultiplierConfig.Carving": [ + "Carving", + "How much faster the carving skill grows." + ], + "MultiplierConfig.Husbandry": [ + "Husbandry", + "How much faster the husbandry skill grows." + ], + "MultiplierConfig.Tracking": [ + "Tracking", + "How much faster the tracking skill grows." + ], + "MultiplierConfig.Blacksmith": [ + "Blacksmithing", + "How much faster the blacksmithing skill grows." + ], + "MultiplierConfig.Butchering": [ + "Butchering", + "How much faster the butchering skill grows." + ], + "MultiplierConfig.Glassmaking": [ + "Glassmaking", + "How much faster the glassmaking skill grows." + ], + "MultiplierConfig.Farming": [ + "Farming", + "How much faster the farming skill grows." + ], + "ZombieConfig.RallyGroupSize": [ + "Group size", + "How many zombies gather into a roaming horde." + ], + "ZombieConfig.RallyTravelDistance": [ + "Distance to join a group", + "From how far away zombies join a horde." + ], + "ZombieConfig.RallyGroupSeparation": [ + "Distance between groups", + "How far hordes keep from each other." + ], + "ZombieConfig.RallyGroupRadius": [ + "Group density", + "How tightly zombies walk inside a horde." + ] + } +} diff --git a/web/static/hints-ru.json b/web/static/hints-ru.json new file mode 100644 index 0000000..4b13b78 --- /dev/null +++ b/web/static/hints-ru.json @@ -0,0 +1,1806 @@ +{ + "config": { + "PublicName": [ + "Название сервера", + "Показывается в списке серверов игры и Steam." + ], + "PublicDescription": [ + "Описание сервера", + "Текст под названием в списке серверов. Перенос строки — ." + ], + "Public": [ + "Показывать в списке серверов", + "Сервер со Steam-авторизацией виден в браузере Steam в любом случае." + ], + "Open": [ + "Пускать без белого списка", + "Если выключено, учётные записи создаёт администратор вручную." + ], + "MaxPlayers": [ + "Максимум игроков", + "Администраторы не считаются. Выше 32 игроков возможны просадки." + ], + "ServerWelcomeMessage": [ + "Приветственное сообщение", + "Показывается в чате сразу после входа. Поддерживает и цвета ." + ], + "ResetID": [ + "Идентификатор сброса", + "Меняется при мягком сбросе мира: клиенты с другим значением создают нового персонажа." + ], + "ServerPlayerID": [ + "Идентификатор персонажей", + "По нему игра отличает персонажей этого сервера от чужих." + ], + "Seed": [ + "Зерно генерации мира", + "Чтобы сменить, впишите новое значение и удалите map_worldgen.bin в папке мира." + ], + "server_browser_announced_ip": [ + "Объявляемый IP", + "Нужен, когда у машины несколько адресов и в список серверов должен попасть определённый." + ], + "Password": [ + "Пароль сервера", + "Пусто — вход без пароля." + ], + "MaxAccountsPerUser": [ + "Учёток на игрока", + "Сколько персонажей может завести один Steam-аккаунт. 0 — без ограничения." + ], + "DenyLoginOnOverloadedServer": [ + "Не пускать при перегрузке", + "Отказывать во входе, когда сервер не справляется с нагрузкой." + ], + "DropOffWhiteListAfterDeath": [ + "Убирать из белого списка после смерти", + "Погибший игрок теряет доступ и должен получить его заново." + ], + "LoginQueueEnabled": [ + "Очередь на вход", + "Лишние игроки ждут освободившегося места вместо отказа." + ], + "LoginQueueConnectTimeout": [ + "Таймаут очереди, сек", + "Сколько ждать подключения игрока, дошедшего до начала очереди." + ], + "AllowCoop": [ + "Разрешить разделённый экран", + "Несколько игроков с одного клиента." + ], + "AllowNonAsciiUsername": [ + "Ники не только латиницей", + "Разрешить кириллицу и другие не-ASCII символы в никах." + ], + "SteamScoreboard": [ + "Показывать Steam-имена", + "Отображать имена и аватары Steam в списке игроков." + ], + "SteamVAC": [ + "Защита Steam VAC", + "Включает античит-защиту Valve." + ], + "DoLuaChecksum": [ + "Сверять контрольные суммы Lua", + "Отключать игроков с изменёнными Lua-файлами." + ], + "DefaultPort": [ + "Игровой порт (UDP)", + "По умолчанию 16261. Должен быть открыт на firewall." + ], + "UDPPort": [ + "Второй игровой порт", + "По умолчанию 16262. Тоже должен быть открыт." + ], + "RCONPort": [ + "Порт RCON", + "Порт для внешнего управления консолью сервера." + ], + "RCONPassword": [ + "Пароль RCON", + "Пусто — RCON выключен. Панели он не нужен: она пишет прямо в консоль." + ], + "PingLimit": [ + "Предел пинга, мс", + "Игроков с задержкой выше выкидывает. Ниже 100 ставить не стоит." + ], + "UPnP": [ + "Пробрасывать порты через UPnP", + "Автоматически открыть порты на роутере, если тот поддерживает UPnP." + ], + "MaxPacketsPerSecond": [ + "Пакетов в секунду", + "Ограничение сетевого потока на игрока." + ], + "ClientCommandFilter": [ + "Фильтр команд клиента", + "Список команд, которые не пишутся в журнал." + ], + "MultiplayerStatisticsPeriod": [ + "Период сбора статистики", + "Как часто писать сетевую статистику. 0 — не собирать." + ], + "PVP": [ + "Разрешить бой между игроками", + "Игроки могут ранить и убивать друг друга." + ], + "PVPLogToolChat": [ + "Писать PVP в чат админов", + "Сообщать администраторам о PVP-стычках в чате." + ], + "PVPLogToolFile": [ + "Писать PVP в файл", + "Вести журнал PVP-стычек на диске." + ], + "SafetySystem": [ + "Система безопасности", + "Урон игроку проходит, только когда оба сняли защиту." + ], + "ShowSafety": [ + "Показывать значок защиты", + "Иконка над головой показывает, снята ли защита." + ], + "SafetyToggleTimer": [ + "Задержка переключения, сек", + "Сколько секунд занимает снятие или включение защиты." + ], + "SafetyCooldownTimer": [ + "Откат переключения, сек", + "Через сколько секунд защиту можно переключить снова." + ], + "SafetyDisconnectDelay": [ + "Задержка выхода в PVP, сек", + "Сколько игрок остаётся в мире после отключения во время боя." + ], + "PVPMeleeDamageModifier": [ + "Урон в ближнем бою, %", + "Множитель урона игрок-игрок для оружия ближнего боя." + ], + "PVPFirearmDamageModifier": [ + "Урон от огнестрела, %", + "Множитель урона игрок-игрок для огнестрельного оружия." + ], + "PVPMeleeWhileHitReaction": [ + "Удары во время реакции на попадание", + "Разрешить бить игрока, пока тот отшатывается от предыдущего удара." + ], + "KnockedDownAllowed": [ + "Разрешить сбивать с ног", + "Игроки могут сбивать друг друга с ног." + ], + "SneakModeHideFromOtherPlayers": [ + "Скрытность прячет от игроков", + "В режиме скрытности игрока не видят другие игроки." + ], + "PlayerBumpPlayer": [ + "Толкать других игроков", + "Столкновения игроков между собой." + ], + "UsePhysicsHitReaction": [ + "Физическая реакция на удар", + "Включает физику отдачи при попадании." + ], + "CarEngineAttractionModifier": [ + "Шум двигателя для зомби", + "Насколько сильно двигатель привлекает зомби." + ], + "Map": [ + "Карты", + "Список карт через точку с запятой. Первой идёт карта мода, последней — базовая." + ], + "SpawnPoint": [ + "Точка появления", + "Координаты x,y,z. Нули — стандартные точки появления карты." + ], + "SpawnItems": [ + "Стартовые предметы", + "Список предметов, которые получает новый персонаж." + ], + "SaveWorldEveryMinutes": [ + "Сохранять мир каждые, мин", + "0 — сохранять только при выходе игроков и остановке сервера." + ], + "PauseEmpty": [ + "Пауза без игроков", + "Игровое время останавливается, когда на сервере никого нет." + ], + "NoFire": [ + "Запретить огонь", + "Полностью отключает распространение огня." + ], + "AllowDestructionBySledgehammer": [ + "Разрешить кувалду", + "Игроки могут ломать стены и объекты кувалдой." + ], + "SledgehammerOnlyInSafehouse": [ + "Кувалда только в убежище", + "Ломать можно лишь внутри своего убежища." + ], + "PlayerRespawnWithSelf": [ + "Возрождение у своего трупа", + "Разрешить появляться на месте гибели прошлого персонажа." + ], + "PlayerRespawnWithOther": [ + "Возрождение у товарища", + "Разрешить появляться рядом с другим игроком." + ], + "FastForwardMultiplier": [ + "Ускорение времени", + "Во сколько раз ускоряется время при перемотке." + ], + "RemovePlayerCorpsesOnCorpseRemoval": [ + "Убирать и трупы игроков", + "Автоочистка трупов затрагивает и погибших игроков." + ], + "TrashDeleteAll": [ + "Кнопка «удалить всё»", + "Разрешить очищать контейнер целиком одним действием." + ], + "BloodSplatLifespanDays": [ + "Кровь исчезает через, дней", + "0 — пятна крови остаются навсегда." + ], + "ItemNumbersLimitPerContainer": [ + "Предметов в контейнере", + "Ограничение на количество предметов. 0 — без ограничения." + ], + "SafehousePreventsLootRespawn": [ + "Убежище блокирует респавн лута", + "В зданиях-убежищах лут заново не появляется." + ], + "SwitchZombiesOwnershipEachUpdate": [ + "Передавать зомби каждый такт", + "Меняет схему распределения зомби между клиентами." + ], + "UltraSpeedDoesnotAffectToAnimals": [ + "Перемотка не ускоряет животных", + "На максимальной скорости времени животные живут в обычном темпе." + ], + "SleepAllowed": [ + "Разрешить сон", + "Игроки могут спать; на сервере с несколькими игроками время не перематывается." + ], + "SleepNeeded": [ + "Требовать сон", + "Усталость накапливается и требует сна." + ], + "SpeedLimit": [ + "Ограничение скорости", + "Предел скорости транспорта, км/ч." + ], + "BackupsCount": [ + "Сколько копий хранить", + "Резервные копии, которые делает сама игра." + ], + "BackupsOnStart": [ + "Копия при запуске", + "Игра делает бэкап мира при каждом старте сервера." + ], + "BackupsOnVersionChange": [ + "Копия при смене версии", + "Бэкап перед первым запуском на новой версии игры." + ], + "BackupsPeriod": [ + "Период копий, мин", + "0 — периодические бэкапы игры выключены. У панели есть свои." + ], + "Mods": [ + "Включённые моды", + "Список Mod ID в порядке загрузки. Правится на вкладке «Моды»." + ], + "WorkshopItems": [ + "Загружаемые из мастерской", + "Список Workshop ID. Правится на вкладке «Моды»." + ], + "GlobalChat": [ + "Общий чат", + "Сообщения видят все игроки на сервере." + ], + "ChatStreams": [ + "Доступные каналы чата", + "Список каналов: s — сказать, r — рация, a — админ, w — крик и так далее." + ], + "VoiceEnable": [ + "Голосовой чат", + "Разрешить голосовое общение." + ], + "VoiceMinDistance": [ + "Слышимость вблизи", + "Расстояние, на котором голос слышен без затухания." + ], + "VoiceMaxDistance": [ + "Предел слышимости", + "Расстояние, дальше которого голос не слышен." + ], + "Voice3D": [ + "Объёмный звук голоса", + "Голос слышен с направления, откуда говорит игрок." + ], + "ChatMessageCharacterLimit": [ + "Длина сообщения", + "Максимум символов в одном сообщении чата." + ], + "ChatMessageSlowModeTime": [ + "Пауза между сообщениями, сек", + "Защита от флуда: минимальный интервал между сообщениями." + ], + "AnnounceDeath": [ + "Объявлять о смертях", + "Сообщать в чат о гибели игроков." + ], + "AnnounceAnimalDeath": [ + "Объявлять о гибели животных", + "Сообщать в чат, когда погибает домашнее животное." + ], + "BanKickGlobalSound": [ + "Звук при бане и кике", + "Проигрывать звук всем при исключении игрока." + ], + "BadWordListFile": [ + "Файл со списком брани", + "Путь к списку запрещённых слов." + ], + "GoodWordListFile": [ + "Файл с исключениями", + "Слова, которые фильтр пропускает." + ], + "BadWordPolicy": [ + "Что делать с бранью", + "Заменять слово или блокировать сообщение целиком." + ], + "BadWordReplacement": [ + "Чем заменять", + "Текст, на который меняется запрещённое слово." + ], + "DisplayUserName": [ + "Ники над головами", + "Показывать имена игроков в мире." + ], + "ShowFirstAndLastName": [ + "Имя и фамилия", + "Показывать полное имя персонажа вместо ника." + ], + "UsernameDisguises": [ + "Маскировка ника", + "Ник скрывается, когда лицо игрока закрыто маской или шлемом." + ], + "HideDisguisedUserName": [ + "Прятать ник замаскированных", + "У замаскированного игрока имя не показывается вовсе." + ], + "MouseOverToSeeDisplayName": [ + "Имя по наведению", + "Ник виден, только если навести на игрока курсор." + ], + "HidePlayersBehindYou": [ + "Скрывать игроков за спиной", + "Не показывать тех, кто вне поля зрения персонажа." + ], + "MapRemotePlayerVisibility": [ + "Игроки на карте", + "Показывать ли других игроков на внутриигровой карте." + ], + "ShowCoordinates": [ + "Показывать координаты", + "Координаты видны игрокам в интерфейсе." + ], + "DisableScoreboard": [ + "Отключить таблицу игроков", + "Скрыть список игроков сервера." + ], + "HideAdminsInPlayerList": [ + "Прятать администраторов", + "Администраторы не видны в списке игроков." + ], + "PlayerSafehouse": [ + "Игроки могут ставить убежища", + "Разрешить обычным игрокам объявлять здание своим." + ], + "AdminSafehouse": [ + "Убежища только у админов", + "Объявлять убежища могут лишь администраторы." + ], + "SafehouseAllowTrepass": [ + "Пускать посторонних", + "Разрешить входить в чужое убежище." + ], + "SafehouseAllowFire": [ + "Огонь в убежище", + "Разрешить возгорания на территории убежища." + ], + "SafehouseAllowLoot": [ + "Брать вещи в убежище", + "Разрешить посторонним забирать предметы из убежища." + ], + "SafehouseAllowRespawn": [ + "Возрождение в убежище", + "Члены убежища появляются в нём после гибели." + ], + "SafehouseDaySurvivedToClaim": [ + "Дней до права на убежище", + "Сколько дней надо прожить, прежде чем объявлять здание своим." + ], + "SafeHouseRemovalTime": [ + "Освобождение убежища, ч", + "Через сколько часов отсутствия владельца убежище снимается." + ], + "SafehouseAllowNonResidential": [ + "Нежилые здания под убежище", + "Разрешить занимать магазины, склады и прочие нежилые постройки." + ], + "SafehouseDisableDisguises": [ + "Без маскировки в убежище", + "Маскировка не действует на территории убежища." + ], + "MaxSafezoneSize": [ + "Размер безопасной зоны", + "Максимальная площадь убежища в клетках." + ], + "DisableSafehouseWhenOwnerConnected": [ + "Снимать защиту при владельце", + "Пока владелец в игре, защита убежища не действует." + ], + "DisableSafehouseWhenPlayerConnected": [ + "Снимать защиту при игроках", + "Защита не действует, пока внутри есть игроки." + ], + "Faction": [ + "Разрешить фракции", + "Игроки могут создавать группировки." + ], + "FactionDaySurvivedToCreate": [ + "Дней до создания фракции", + "Сколько дней нужно прожить, чтобы основать фракцию." + ], + "FactionPlayersRequiredForTag": [ + "Игроков для метки фракции", + "Сколько участников нужно, чтобы у фракции появился тег." + ], + "War": [ + "Разрешить войны фракций", + "Фракции могут объявлять друг другу войну." + ], + "WarStartDelay": [ + "Задержка начала войны", + "Через сколько времени после объявления начинается война." + ], + "WarDuration": [ + "Длительность войны", + "Сколько длится объявленная война." + ], + "WarSafehouseHitPoints": [ + "Прочность убежищ в войне", + "Сколько урона выдерживают постройки во время войны." + ], + "DisableRadioStaff": [ + "Скрыть рацию персонала", + "Отключить служебный радиоканал." + ], + "DisableRadioAdmin": [ + "Скрыть рацию админов", + "Отключить радиоканал администраторов." + ], + "DisableRadioGM": [ + "Скрыть рацию гейм-мастеров", + "Отключить радиоканал GM." + ], + "DisableRadioOverseer": [ + "Скрыть рацию наблюдателей", + "Отключить радиоканал наблюдателей." + ], + "DisableRadioModerator": [ + "Скрыть рацию модераторов", + "Отключить радиоканал модераторов." + ], + "DisableRadioInvisible": [ + "Скрыть рацию невидимых", + "Отключить радиоканал скрытых персонажей." + ], + "DiscordEnable": [ + "Связь с Discord", + "Пересылать чат сервера в Discord и обратно." + ], + "DiscordToken": [ + "Токен бота", + "Токен Discord-бота, через которого идёт связь." + ], + "DiscordChannel": [ + "Канал чата", + "Название канала для игрового чата." + ], + "DiscordChannelID": [ + "Идентификатор канала", + "Числовой ID канала Discord." + ], + "DiscordChatChannel": [ + "Канал чата", + "Канал, куда пересылается игровой чат." + ], + "DiscordLogChannel": [ + "Канал журнала", + "Канал для служебных сообщений сервера." + ], + "DiscordCommandChannel": [ + "Канал команд", + "Канал, из которого принимаются команды." + ], + "WebhookAddress": [ + "Адрес вебхука", + "Куда отправлять события сервера по HTTP." + ], + "AntiCheatSafety": [ + "Античит: система защиты", + "Проверка попыток обойти защиту от урона." + ], + "AntiCheatSpeed": [ + "Античит: скорость", + "Проверка неестественно быстрого перемещения." + ], + "AntiCheatNoClip": [ + "Античит: проход сквозь стены", + "Проверка перемещения через препятствия." + ], + "AntiCheatHit": [ + "Античит: попадания", + "Проверка ударов с недостижимого расстояния." + ], + "AntiCheatPacketException": [ + "Античит: битые пакеты", + "Реакция на некорректные сетевые пакеты." + ], + "AntiCheatPermission": [ + "Античит: права", + "Проверка попыток выполнить действия без прав." + ], + "AntiCheatXP": [ + "Античит: опыт", + "Проверка неестественного роста навыков." + ], + "AntiCheatSafeHouse": [ + "Античит: убежища", + "Проверка обхода защиты убежищ." + ], + "AntiCheatPlayer": [ + "Античит: персонаж", + "Проверка изменённых характеристик персонажа." + ], + "AntiCheatChecksum": [ + "Античит: контрольные суммы", + "Сверка файлов игры у клиента." + ], + "ClientActionLogs": [ + "Журнал действий клиента", + "Какие действия игроков записывать в журнал." + ], + "PerkLogs": [ + "Журнал навыков", + "Записывать изменения уровней навыков." + ], + "DisableVehicleTowing": [ + "Запретить буксировку", + "Машины нельзя цеплять друг к другу." + ], + "DisableTrailerTowing": [ + "Запретить прицепы", + "Прицепы нельзя цеплять к машинам." + ], + "DisableBurntTowing": [ + "Не буксировать сгоревшие", + "Сгоревший транспорт нельзя буксировать." + ], + "AutoCreateUserInWhiteList": [ + "Заводить учётки автоматически", + "Игрок при первом входе сам создаёт логин и пароль." + ], + "ConstructionPreventsLootRespawn": [ + "Постройки блокируют респавн лута", + "В зданиях, где игроки что-то построили, лут заново не появляется." + ], + "HoursForLootRespawn": [ + "Респавн лута через, часов", + "0 — лут не появляется заново." + ], + "MaxItemsForLootRespawn": [ + "Предметов при респавне", + "Сколько предметов возвращается в контейнер при обновлении лута." + ], + "KickFastPlayers": [ + "Выкидывать за скорость", + "Отключать игроков, движущихся неестественно быстро. Может срабатывать ложно." + ], + "MinutesPerPage": [ + "Минут на страницу", + "Сколько игрового времени уходит на чтение одной страницы книги." + ] + }, + "sandbox": { + "Zombies": [ + "Количество зомби", + "Сколько зомби в мире. Меняет и множитель популяции в расширенных настройках." + ], + "Distribution": [ + "Распределение зомби", + "Где зомби гуще: в городах или равномерно по всей карте." + ], + "ZombiesRespawn": [ + "Восстановление популяции", + "Появляются ли зомби заново в уже зачищенных местах." + ], + "ZombieRespawn": [ + "Восстановление популяции", + "Появляются ли зомби заново в уже зачищенных местах." + ], + "ZombieMigrate": [ + "Миграция зомби", + "Переходят ли зомби между зонами карты, заполняя опустевшие районы." + ], + "ZombieVoronoiNoise": [ + "Неравномерность расселения", + "Насколько неровно зомби рассеяны по карте: выше значение — заметнее скопления и пустоты." + ], + "ZombieAttractionMultiplier": [ + "Притяжение на шум", + "Насколько сильно шум игрока и техники стягивает зомби." + ], + "ZombieHealthImpact": [ + "Здоровье зомби", + "Общая живучесть зомби." + ], + "ZombiePopLootEffect": [ + "Влияние популяции на лут", + "Связывает количество лута с плотностью зомби: где их больше, там богаче добыча." + ], + "MultiHitZombies": [ + "Удар по нескольким зомби", + "Разрешает одним ударом задеть нескольких зомби." + ], + "RearVulnerability": [ + "Уязвимость со спины", + "Насколько игрок уязвимее при нападении со спины." + ], + "SirenEffectsZombies": [ + "Сирены привлекают зомби", + "Реагируют ли зомби на сирены машин и сигнализации." + ], + "RallyGroupSize": [ + "Размер скопления", + "Сколько зомби собирается в бродячую стаю." + ], + "RallyTravelDistance": [ + "Дальность схода в группу", + "С какого расстояния зомби подтягиваются к стае." + ], + "RallyGroupSeparation": [ + "Расстояние между группами", + "Насколько далеко стаи держатся друг от друга." + ], + "RallyGroupRadius": [ + "Плотность группы", + "Насколько плотно зомби идут внутри стаи." + ], + "HoursForCorpseRemoval": [ + "Трупы исчезают через, часов", + "Через сколько часов трупы исчезают сами. 0 — остаются навсегда." + ], + "DecayingCorpseHealthImpact": [ + "Вред от разлагающихся трупов", + "Насколько сильно разлагающиеся трупы вредят здоровью рядом." + ], + "BloodLevel": [ + "Количество крови", + "Сколько крови остаётся на одежде, оружии и полу." + ], + "BloodSplatLifespanDays": [ + "Кровь исчезает через, дней", + "Через сколько дней пятна крови исчезают. 0 — никогда." + ], + "DayLength": [ + "Длина игрового дня", + "Сколько реального времени длятся игровые сутки." + ], + "StartYear": [ + "Год начала", + "Год, с которого начинается партия." + ], + "StartMonth": [ + "Месяц начала", + "Месяц начала: от него зависят погода и урожай." + ], + "StartDay": [ + "День начала", + "День месяца, с которого начинается партия." + ], + "StartTime": [ + "Время начала", + "Время суток на старте." + ], + "DayNightCycle": [ + "Смена дня и ночи", + "Как быстро сменяются день и ночь." + ], + "NightLength": [ + "Длина ночи", + "Какую долю суток занимает ночь." + ], + "NightDarkness": [ + "Темнота ночью", + "Насколько темно ночью." + ], + "TimeSinceApo": [ + "Времени с начала эпидемии", + "Сколько времени прошло с начала эпидемии до старта: влияет на состояние мира и запасы." + ], + "WaterShut": [ + "Отключение воды", + "Когда в водопроводе кончится вода." + ], + "ElecShut": [ + "Отключение электричества", + "Когда отключится электричество." + ], + "WaterShutModifier": [ + "Дней до отключения воды", + "Через сколько дней отключат воду, если выбран точный срок." + ], + "ElecShutModifier": [ + "Дней до отключения света", + "Через сколько дней отключат свет, если выбран точный срок." + ], + "Alarm": [ + "Частота сигнализаций", + "Как часто в домах срабатывает сигнализация, привлекая зомби." + ], + "AlarmDecay": [ + "Затухание сигнализаций", + "Перестают ли сигнализации работать со временем." + ], + "AlarmDecayModifier": [ + "Скорость затухания сигнализаций", + "Как быстро сигнализации выходят из строя." + ], + "LockedHouses": [ + "Запертые дома", + "Как часто дома оказываются запертыми." + ], + "ErosionSpeed": [ + "Скорость зарастания", + "Как быстро мир зарастает травой и растительностью." + ], + "ErosionDays": [ + "Дней до полного зарастания", + "За сколько дней зарастание доходит до предела. 0 — отключено." + ], + "EndRegen": [ + "Восстановление выносливости", + "Как быстро восстанавливается выносливость." + ], + "Helicopter": [ + "Вертолёт", + "Как часто над картой пролетает вертолёт, стягивающий зомби." + ], + "MetaEvent": [ + "Случайные события", + "Частота отдалённых событий: выстрелов, криков, сигнализаций." + ], + "SleepingEvent": [ + "Ночные события", + "Могут ли события происходить, пока персонаж спит." + ], + "AnnotatedMapChance": [ + "Шанс найти карту с пометками", + "Как часто попадаются карты с пометками тайников." + ], + "VehicleStoryChance": [ + "Частота историй с транспортом", + "Как часто встречаются сцены с машинами: аварии, брошенный транспорт." + ], + "ZoneStoryChance": [ + "Частота историй на местности", + "Как часто встречаются сюжетные сцены на местности." + ], + "SurvivorHouseChance": [ + "Частота домов выживших", + "Как часто попадаются дома, обжитые другими выжившими." + ], + "MaximumLootedBuildingRooms": [ + "Комнат в разграбленном здании", + "Сколько комнат в здании может оказаться уже разграблено." + ], + "ClimateCycle": [ + "Смена времён года", + "Насколько выражена смена времён года." + ], + "FogCycle": [ + "Частота туманов", + "Как часто ложится туман." + ], + "Temperature": [ + "Температура", + "Общий температурный фон." + ], + "TemperatureModifier": [ + "Поправка температуры", + "Поправка к температуре: холоднее или теплее обычного." + ], + "Rain": [ + "Дожди", + "Как часто идут дожди." + ], + "RainModifier": [ + "Поправка дождей", + "Поправка к количеству осадков." + ], + "MaxFogIntensity": [ + "Плотность тумана", + "Насколько густым бывает туман." + ], + "MaxRainFxIntensity": [ + "Сила дождя", + "Насколько сильным выглядит дождь." + ], + "EnableSnowOnGround": [ + "Снег на земле", + "Ложится ли снег на землю зимой." + ], + "LootRespawn": [ + "Обновление лута", + "Появляется ли лут заново в уже обысканных контейнерах." + ], + "SeenHoursPreventLootRespawn": [ + "Часов после осмотра до респавна", + "Сколько часов после осмотра контейнер остаётся пустым." + ], + "HoursForLootRespawn": [ + "Респавн лута через, часов", + "Через сколько часов лут возвращается. 0 — не возвращается." + ], + "MaxItemsForLootRespawn": [ + "Предметов при респавне", + "Сколько предметов появляется в контейнере при обновлении." + ], + "ConstructionPreventsLootRespawn": [ + "Постройки блокируют респавн", + "Постройки игроков в здании отменяют обновление лута." + ], + "RollsMultiplier": [ + "Множитель бросков лута", + "Сколько раз игра пытается положить предмет в контейнер." + ], + "FoodLoot": [ + "Еда", + "Как часто попадается еда." + ], + "CannedFoodLoot": [ + "Консервы", + "Как часто попадаются консервы." + ], + "LiteratureLoot": [ + "Книги и журналы", + "Как часто попадаются книги, журналы и газеты." + ], + "SurvivalGearsLoot": [ + "Снаряжение", + "Как часто попадается снаряжение выживальщика." + ], + "MedicalLoot": [ + "Медикаменты", + "Как часто попадаются медикаменты." + ], + "WeaponLoot": [ + "Оружие", + "Как часто попадается оружие ближнего боя." + ], + "RangedWeaponLoot": [ + "Огнестрельное оружие", + "Как часто попадается огнестрельное оружие." + ], + "AmmoLoot": [ + "Патроны", + "Как часто попадаются патроны." + ], + "MechanicsLoot": [ + "Автозапчасти", + "Как часто попадаются автозапчасти и инструменты." + ], + "OtherLoot": [ + "Прочее", + "Как часто попадаются прочие предметы." + ], + "SkillBookLoot": [ + "Учебники навыков", + "Как часто попадаются учебники навыков." + ], + "RecipeResourceLoot": [ + "Материалы для рецептов", + "Как часто попадаются материалы для рецептов." + ], + "RemoveStoryLoot": [ + "Убирать сюжетный лут", + "Убирает лут из сюжетных сцен." + ], + "RemoveZombieLoot": [ + "Убирать лут с зомби", + "Убирает предметы, выпадающие из зомби." + ], + "MaximumLooted": [ + "Предел разграбленности", + "Насколько разграбленным мир становится со временем." + ], + "DaysUntilMaximumLooted": [ + "Дней до предела разграбленности", + "За сколько дней мир доходит до предельной разграбленности." + ], + "RuralLooted": [ + "Разграбленность сельской местности", + "Насколько разграблены отдалённые районы." + ], + "MaximumDiminishedLoot": [ + "Предел оскудения лута", + "Насколько скудеет лут в уже посещённых местах." + ], + "DaysUntilMaximumDiminishedLoot": [ + "Дней до оскудения лута", + "За сколько дней лут скудеет до предела." + ], + "XpMultiplier": [ + "Множитель опыта", + "Во сколько раз быстрее растут навыки." + ], + "XpMultiplierAffectsPassive": [ + "Опыт и пассивные навыки", + "Действует ли множитель опыта на силу и выносливость." + ], + "StatsDecrease": [ + "Скорость падения характеристик", + "Как быстро персонаж голодает, устаёт и хочет пить." + ], + "Endurance": [ + "Выносливость", + "Как быстро тратится выносливость." + ], + "Nutrition": [ + "Учитывать питание", + "Учитывать ли калории и вес персонажа." + ], + "StarterKit": [ + "Стартовый набор", + "Выдавать ли новому персонажу базовый набор вещей." + ], + "InjurySeverity": [ + "Тяжесть травм", + "Насколько тяжёлыми бывают травмы." + ], + "BoneFracture": [ + "Переломы", + "Могут ли ломаться кости." + ], + "CharacterFreePoints": [ + "Свободных очков персонажа", + "Сколько дополнительных очков даётся при создании персонажа." + ], + "ConstructionBonusPoints": [ + "Бонус к строительству", + "Бонус к прочности того, что строит игрок." + ], + "NightVisionEnabled": [ + "Ночное зрение", + "Видит ли персонаж в темноте лучше обычного." + ], + "InjuredHealingModifier": [ + "Скорость заживления", + "Как быстро заживают раны." + ], + "MuscleStrainFactor": [ + "Растяжение мышц", + "Как быстро устают мышцы от тяжёлого оружия." + ], + "DiscomfortFactor": [ + "Дискомфорт", + "Как быстро накапливается дискомфорт от неудобной одежды и ран." + ], + "WoundInfectionFactor": [ + "Заражение ран", + "Насколько вероятно заражение обычной раны — не путать с укусом зомби." + ], + "NegativeTraitsPenalty": [ + "Штраф отрицательных черт", + "Насколько сильно мешают отрицательные черты характера." + ], + "EnablePoisoning": [ + "Отравления", + "Можно ли отравиться испорченной едой и ядовитыми ягодами." + ], + "ClothingDegradation": [ + "Износ одежды", + "Как быстро изнашивается одежда." + ], + "AllClothesUnlocked": [ + "Вся одежда доступна", + "Открывает всю одежду при создании персонажа." + ], + "NoBlackClothes": [ + "Без чёрной одежды", + "Убирает чёрную одежду из игры." + ], + "EasyClimbing": [ + "Лёгкое перелезание", + "Упрощает перелезание через заборы и препятствия." + ], + "AttackBlockMovements": [ + "Атака блокирует движение", + "Нельзя двигаться во время удара." + ], + "MetaKnowledge": [ + "Знание рецептов заранее", + "Знает ли персонаж рецепты, которые ещё не изучил." + ], + "SeeNotLearntRecipe": [ + "Видеть неизученные рецепты", + "Показывать ли неизученные рецепты в списке." + ], + "LiteratureCooldown": [ + "Перерыв между чтением", + "Сколько нужно ждать между чтением одинаковых книг." + ], + "MinutesPerPage": [ + "Минут на страницу", + "Сколько игровых минут уходит на страницу книги." + ], + "LevelForMediaXPCutoff": [ + "Предел опыта с кассет", + "До какого уровня навыка кассеты и диски дают опыт." + ], + "LevelForDismantleXPCutoff": [ + "Предел опыта с разборки", + "До какого уровня разборка предметов даёт опыт." + ], + "FoodRotSpeed": [ + "Скорость порчи еды", + "Как быстро портится еда." + ], + "FridgeFactor": [ + "Эффективность холодильников", + "Насколько холодильник замедляет порчу продуктов." + ], + "MaggotSpawn": [ + "Появление личинок", + "Где заводятся личинки: в еде, на трупах или нигде." + ], + "EnableTaintedWaterText": [ + "Помечать грязную воду", + "Предупреждать ли, что вода непригодна для питья." + ], + "FishAbundance": [ + "Обилие рыбы", + "Сколько рыбы в водоёмах." + ], + "NatureAbundance": [ + "Обилие даров природы", + "Сколько ягод, грибов и прочих даров природы." + ], + "PlantResilience": [ + "Живучесть растений", + "Насколько растения устойчивы к болезням и вредителям." + ], + "PlantAbundance": [ + "Урожайность", + "Насколько щедрый урожай дают грядки." + ], + "Farming": [ + "Скорость роста", + "Как быстро растут посевы." + ], + "FarmingSpeedNew": [ + "Скорость земледелия", + "Как быстро растут посевы." + ], + "FarmingAmountNew": [ + "Урожай с грядки", + "Сколько плодов даёт одна грядка." + ], + "CompostTime": [ + "Время компостирования", + "За сколько времени отходы превращаются в компост." + ], + "KillInsideCrops": [ + "Губить посевы в помещении", + "Погибают ли посевы, высаженные в помещении." + ], + "PlantGrowingSeasons": [ + "Сезонность посевов", + "Учитывать ли время года при посадке." + ], + "PlaceDirtAboveground": [ + "Грядки на любом уровне", + "Можно ли делать грядки не на земле — на верхних этажах." + ], + "ClayLakeChance": [ + "Глина у озёр", + "Как часто у озёр попадается глина." + ], + "ClayRiverChance": [ + "Глина у рек", + "Как часто у рек попадается глина." + ], + "DaysForRottenFoodRemoval": [ + "Дней до удаления гнили", + "Через сколько дней испорченная еда исчезает сама. -1 — никогда." + ], + "AidPackage": [ + "Сброс гуманитарной помощи", + "Как часто сбрасывают гуманитарную помощь." + ], + "AnimalStatsModifier": [ + "Характеристики животных", + "Общий множитель характеристик домашних животных." + ], + "AnimalMetaStatsModifier": [ + "Дикие животные", + "Насколько многочисленны и живучи дикие животные." + ], + "AnimalPregnancyTime": [ + "Срок беременности", + "Сколько длится беременность у животных." + ], + "AnimalAgeModifier": [ + "Скорость взросления", + "Как быстро животные взрослеют." + ], + "AnimalMilkIncModifier": [ + "Надой молока", + "Сколько молока дают животные." + ], + "AnimalWoolIncModifier": [ + "Настриг шерсти", + "Сколько шерсти дают животные." + ], + "AnimalRanchChance": [ + "Частота ферм", + "Как часто на карте попадаются фермы с животными." + ], + "AnimalGrassRegrowTime": [ + "Отрастание травы", + "Как быстро отрастает трава на выпасе." + ], + "AnimalMetaPredator": [ + "Хищники", + "Сколько в мире хищников." + ], + "AnimalMatingSeason": [ + "Сезон спаривания", + "Ограничен ли приплод временем года." + ], + "AnimalEggHatch": [ + "Вылупление яиц", + "Как часто из яиц вылупляются птенцы." + ], + "AnimalSoundAttractZombies": [ + "Животные привлекают зомби", + "Привлекают ли зомби звуки животных." + ], + "AnimalTrackChance": [ + "Частота следов", + "Как часто попадаются следы животных." + ], + "AnimalPathChance": [ + "Частота троп", + "Как часто попадаются звериные тропы." + ], + "MaximumRatIndex": [ + "Предел числа крыс", + "Насколько сильно расплодятся крысы." + ], + "DaysUntilMaximumRatIndex": [ + "Дней до предела крыс", + "За сколько дней крысы доходят до предела." + ], + "CarSpawnRate": [ + "Частота машин", + "Сколько машин на дорогах и парковках." + ], + "ChanceHasGas": [ + "Шанс бензина в баке", + "Насколько вероятно, что в баке найденной машины есть бензин." + ], + "InitialGas": [ + "Бензина в баке", + "Сколько бензина в баках на старте." + ], + "CarGasConsumption": [ + "Расход топлива", + "Насколько прожорлив транспорт." + ], + "LockedCar": [ + "Запертые машины", + "Как часто машины оказываются запертыми." + ], + "CarGeneralCondition": [ + "Состояние машин", + "В каком состоянии находится найденный транспорт." + ], + "CarDamageOnImpact": [ + "Урон машине при ударе", + "Насколько сильно машина страдает при столкновении." + ], + "DamageToPlayerFromHitByACar": [ + "Урон игроку от наезда", + "Сколько урона получает пешеход от наезда." + ], + "TrafficJam": [ + "Пробки на дорогах", + "Как часто дороги перегорожены пробками из брошенных машин." + ], + "CarAlarm": [ + "Автосигнализации", + "Как часто у машин срабатывает сигнализация." + ], + "PlayerDamageFromCrash": [ + "Урон при аварии", + "Сколько урона получает водитель в аварии." + ], + "SirenShutoffHours": [ + "Сирена смолкает через, часов", + "Через сколько часов смолкает сирена полицейской машины." + ], + "RecentlySurvivorVehicles": [ + "Машины выживших", + "Как часто попадаются машины, недавно брошенные выжившими." + ], + "EnableVehicles": [ + "Транспорт в игре", + "Есть ли в игре транспорт вообще." + ], + "VehicleEasyUse": [ + "Упрощённое управление", + "Упрощает управление: машина заводится и едет без возни." + ], + "MassInvasion": [ + "Массовое нашествие", + "Резко увеличивает число зомби, идущих на шум транспорта." + ], + "FuelStationGas": [ + "Топливо на заправках", + "Сколько топлива осталось на заправках." + ], + "FuelStationGasInfinite": [ + "Бесконечное топливо", + "Топливо на заправках не кончается." + ], + "FuelStationGasMin": [ + "Минимум топлива на заправке", + "Наименьший запас топлива на заправке." + ], + "FuelStationGasMax": [ + "Максимум топлива на заправке", + "Наибольший запас топлива на заправке." + ], + "FuelStationGasEmptyChance": [ + "Шанс пустой заправки", + "Насколько вероятно, что заправка окажется пустой." + ], + "FirearmUseDamageChance": [ + "Износ при стрельбе", + "Как быстро оружие изнашивается от стрельбы." + ], + "FirearmNoiseMultiplier": [ + "Громкость выстрелов", + "Насколько далеко слышны выстрелы." + ], + "FirearmJamMultiplier": [ + "Частота осечек", + "Как часто оружие заклинивает." + ], + "FirearmMoodleMultiplier": [ + "Влияние состояния на меткость", + "Насколько усталость, паника и голод портят меткость." + ], + "FirearmWeatherMultiplier": [ + "Влияние погоды на меткость", + "Насколько дождь и туман портят меткость." + ], + "FirearmHeadGearEffect": [ + "Влияние головного убора", + "Насколько головной убор мешает целиться." + ], + "Generator": [ + "Расход топлива генератора", + "Насколько экономно генератор расходует топливо." + ], + "GeneratorSpawning": [ + "Частота генераторов", + "Как часто попадаются генераторы." + ], + "GeneratorFuelConsumption": [ + "Расход генератора", + "Сколько топлива сжигает генератор." + ], + "GeneratorTileRange": [ + "Радиус питания генератора", + "На сколько клеток вокруг генератор даёт электричество." + ], + "GeneratorVerticalPowerRange": [ + "Этажей питания генератора", + "На сколько этажей вверх и вниз достаёт питание." + ], + "AllowExteriorGenerator": [ + "Генератор на улице", + "Можно ли ставить генератор под открытым небом." + ], + "FireSpread": [ + "Распространение огня", + "Распространяется ли огонь на соседние объекты." + ], + "MaximumFireFuelHours": [ + "Часов горения топлива", + "Сколько часов горит топливо в костре." + ], + "LightBulbLifespan": [ + "Срок службы лампочек", + "Как долго служат лампочки. 0 — вечно." + ], + "VERSION": [ + "Версия набора настроек", + "Версия формата настроек. Меняет её сама игра, править вручную не нужно." + ], + "FoodLootNew": [ + "Еда", + "Как часто попадается еда. Новая система лута Build 42." + ], + "CannedFoodLootNew": [ + "Консервы", + "Как часто попадаются консервы." + ], + "LiteratureLootNew": [ + "Книги и журналы", + "Как часто попадаются книги и журналы." + ], + "MedicalLootNew": [ + "Медикаменты", + "Как часто попадаются медикаменты." + ], + "SurvivalGearsLootNew": [ + "Снаряжение выживальщика", + "Как часто попадается снаряжение выживальщика." + ], + "WeaponLootNew": [ + "Оружие ближнего боя", + "Как часто попадается оружие ближнего боя." + ], + "RangedWeaponLootNew": [ + "Огнестрельное оружие", + "Как часто попадается огнестрельное оружие." + ], + "AmmoLootNew": [ + "Патроны", + "Как часто попадаются патроны." + ], + "MechanicsLootNew": [ + "Автозапчасти", + "Как часто попадаются автозапчасти." + ], + "OtherLootNew": [ + "Прочее", + "Как часто попадаются прочие предметы." + ], + "ClothingLootNew": [ + "Одежда", + "Как часто попадается одежда." + ], + "ContainerLootNew": [ + "Сумки и контейнеры", + "Как часто попадаются сумки, рюкзаки и ящики." + ], + "KeyLootNew": [ + "Ключи", + "Как часто попадаются ключи." + ], + "MediaLootNew": [ + "Кассеты и диски", + "Как часто попадаются кассеты, диски и пластинки." + ], + "MementoLootNew": [ + "Памятные вещи", + "Как часто попадаются памятные вещи и безделушки." + ], + "CookwareLootNew": [ + "Кухонная утварь", + "Как часто попадается кухонная утварь." + ], + "MaterialLootNew": [ + "Стройматериалы", + "Как часто попадаются стройматериалы." + ], + "FarmingLootNew": [ + "Садовый инвентарь", + "Как часто попадаются семена и садовый инвентарь." + ], + "ToolLootNew": [ + "Инструменты", + "Как часто попадаются инструменты." + ], + "InsaneLootFactor": [ + "Множитель безумно редкого", + "Множитель для предметов категории «безумно редкие»." + ], + "ExtremeLootFactor": [ + "Множитель крайне редкого", + "Множитель для крайне редких предметов." + ], + "RareLootFactor": [ + "Множитель редкого", + "Множитель для редких предметов." + ], + "NormalLootFactor": [ + "Множитель обычного", + "Множитель для предметов обычной редкости." + ], + "CommonLootFactor": [ + "Множитель частого", + "Множитель для часто встречающихся предметов." + ], + "AbundantLootFactor": [ + "Множитель изобильного", + "Множитель для самых обычных предметов." + ], + "LootItemRemovalList": [ + "Предметы, убираемые из лута", + "Предметы, которых не будет в луте. Список через запятую." + ], + "WorldItemRemovalList": [ + "Предметы, убираемые с земли", + "Предметы, которые убираются с земли при уборке мира." + ], + "HoursForWorldItemRemoval": [ + "Убирать с земли через, часов", + "Через сколько часов брошенные предметы исчезают с земли. 0 — не исчезают." + ], + "ItemRemovalListBlacklistToggle": [ + "Список как исключение", + "Считать список наоборот: убирать всё, кроме перечисленного." + ], + "Basement.SpawnFrequency": [ + "Частота подвалов", + "Как часто в домах встречаются подвалы." + ], + "Map.AllowMiniMap": [ + "Разрешить мини-карту", + "Доступна ли игрокам мини-карта в углу экрана." + ], + "Map.AllowWorldMap": [ + "Разрешить карту мира", + "Доступна ли полноэкранная карта мира." + ], + "Map.MapAllKnown": [ + "Карта открыта полностью", + "Карта открыта целиком с самого начала." + ], + "Map.MapNeedsLight": [ + "Карте нужен свет", + "Карту нельзя читать в темноте без источника света." + ], + "ZombieLore.Speed": [ + "Скорость", + "Как быстро зомби передвигаются: от бегунов до медленно ковыляющих." + ], + "ZombieLore.SprinterPercentage": [ + "Доля бегунов", + "Какая доля зомби бегает, когда скорость выбрана случайной." + ], + "ZombieLore.Strength": [ + "Сила", + "Сила зомби: насколько тяжело их отталкивать и сколько урона они наносят." + ], + "ZombieLore.Toughness": [ + "Прочность", + "Насколько трудно убить зомби." + ], + "ZombieLore.Transmission": [ + "Передача заражения", + "Как передаётся заражение: через укусы, любые раны или все заражены изначально." + ], + "ZombieLore.Mortality": [ + "Время до смерти от укуса", + "Сколько живёт заражённый персонаж до смерти." + ], + "ZombieLore.Reanimate": [ + "Время до оживления", + "Через сколько умерший поднимается зомби." + ], + "ZombieLore.Cognition": [ + "Сообразительность", + "Насколько зомби сообразительны: умеют ли обходить препятствия и открывать двери." + ], + "ZombieLore.DoorOpeningPercentage": [ + "Доля открывающих двери", + "Какая доля зомби умеет открывать двери, когда сообразительность случайная." + ], + "ZombieLore.CrawlUnderVehicle": [ + "Ползут под машинами", + "Могут ли ползающие зомби пролезать под машинами." + ], + "ZombieLore.Memory": [ + "Память", + "Как долго зомби помнят игрока, потеряв его из виду." + ], + "ZombieLore.Sight": [ + "Зрение", + "Насколько далеко зомби видят." + ], + "ZombieLore.Hearing": [ + "Слух", + "Насколько далеко зомби слышат." + ], + "ZombieLore.SpottedLogic": [ + "Логика обнаружения", + "По каким правилам зомби замечают игрока." + ], + "ZombieLore.ThumpNoChasing": [ + "Ломятся, не преследуя", + "Ломятся ли зомби в двери и окна, когда никого не преследуют." + ], + "ZombieLore.ThumpOnConstruction": [ + "Ломают постройки игроков", + "Ломают ли зомби то, что построили игроки." + ], + "ZombieLore.ActiveOnly": [ + "Когда активны", + "Когда зомби активны: круглые сутки, только днём или только ночью." + ], + "ZombieLore.TriggerHouseAlarm": [ + "Включают сигнализацию", + "Могут ли зомби включить сигнализацию в доме." + ], + "ZombieLore.ZombiesDragDown": [ + "Стаскивают с высоты", + "Могут ли зомби стащить игрока с забора или из окна." + ], + "ZombieLore.ZombiesCrawlersDragDown": [ + "Ползуны стаскивают", + "То же самое для ползающих зомби." + ], + "ZombieLore.ZombiesFenceLunge": [ + "Бросаются через забор", + "Бросаются ли зомби через низкие заборы." + ], + "ZombieLore.ZombiesArmorFactor": [ + "Влияние брони", + "Насколько броня зомби гасит урон." + ], + "ZombieLore.ZombiesMaxDefense": [ + "Предел защиты", + "Предел защиты, который зомби может получить от брони." + ], + "ZombieLore.ChanceOfAttachedWeapon": [ + "Шанс оружия в теле", + "Шанс найти оружие, застрявшее в теле зомби." + ], + "ZombieLore.ZombiesFallDamage": [ + "Урон от падения", + "Получают ли зомби урон при падении с высоты." + ], + "ZombieLore.DisableFakeDead": [ + "Без притворяющихся мёртвыми", + "Убирает зомби, притворяющихся мёртвыми и встающих при приближении." + ], + "ZombieLore.PlayerSpawnZombieRemoval": [ + "Радиус очистки при появлении игрока", + "Радиус, в котором зомби убираются при появлении игрока в мире." + ], + "ZombieLore.FenceThumpersRequired": [ + "Зомби для слома забора", + "Сколько зомби должно навалиться, чтобы сломать забор." + ], + "ZombieLore.FenceDamageMultiplier": [ + "Урон заборам", + "Насколько быстро зомби ломают заборы." + ], + "ZombieConfig.PopulationMultiplier": [ + "Множитель популяции", + "Общий множитель числа зомби поверх настройки «Количество зомби»." + ], + "ZombieConfig.PopulationStartMultiplier": [ + "Популяция в начале", + "Сколько зомби в мире в первый день." + ], + "ZombieConfig.PopulationPeakMultiplier": [ + "Популяция на пике", + "Сколько зомби будет на пике роста популяции." + ], + "ZombieConfig.PopulationPeakDay": [ + "День пика популяции", + "На какой день популяция достигает пика." + ], + "ZombieConfig.RespawnHours": [ + "Часов до респавна", + "Через сколько часов зомби возвращаются в зачищенную клетку. 0 — не возвращаются." + ], + "ZombieConfig.RespawnUnseenHours": [ + "Часов без наблюдения", + "Сколько часов клетка должна быть без игроков, чтобы там снова появились зомби." + ], + "ZombieConfig.RespawnMultiplier": [ + "Доля восстановления", + "Какая доля от исходной популяции восстанавливается за раз." + ], + "ZombieConfig.RedistributeHours": [ + "Часов до перераспределения", + "Как часто зомби перераспределяются по карте." + ], + "ZombieConfig.FollowSoundDistance": [ + "Дальность реакции на звук", + "С какого расстояния зомби идут на услышанный звук." + ], + "ZombieConfig.RallyGroupSizeVariance": [ + "Разброс размера групп", + "Насколько сильно размер стай отличается от заданного." + ], + "ZombieConfig.ZombiesCountBeforeDelete": [ + "Предел зомби до удаления", + "Сколько зомби может быть рядом, прежде чем лишние удаляются ради производительности." + ], + "MultiplierConfig.Global": [ + "Общий множитель опыта", + "Общий множитель опыта для всех навыков сразу." + ], + "MultiplierConfig.GlobalToggle": [ + "Применять общий множитель", + "Применять общий множитель вместо отдельных значений по навыкам." + ], + "MultiplierConfig.Fitness": [ + "Физподготовка", + "Во сколько раз быстрее растёт навык «физподготовка»." + ], + "MultiplierConfig.Strength": [ + "Сила", + "Во сколько раз быстрее растёт навык «сила»." + ], + "MultiplierConfig.Sprinting": [ + "Спринт", + "Во сколько раз быстрее растёт навык «спринт»." + ], + "MultiplierConfig.Lightfoot": [ + "Лёгкая поступь", + "Во сколько раз быстрее растёт навык «лёгкая поступь»." + ], + "MultiplierConfig.Nimble": [ + "Ловкость", + "Во сколько раз быстрее растёт навык «ловкость»." + ], + "MultiplierConfig.Sneak": [ + "Скрытность", + "Во сколько раз быстрее растёт навык «скрытность»." + ], + "MultiplierConfig.Axe": [ + "Топоры", + "Во сколько раз быстрее растёт навык «топоры»." + ], + "MultiplierConfig.Blunt": [ + "Тяжёлое дробящее", + "Во сколько раз быстрее растёт навык «тяжёлое дробящее»." + ], + "MultiplierConfig.SmallBlunt": [ + "Лёгкое дробящее", + "Во сколько раз быстрее растёт навык «лёгкое дробящее»." + ], + "MultiplierConfig.LongBlade": [ + "Длинные клинки", + "Во сколько раз быстрее растёт навык «длинные клинки»." + ], + "MultiplierConfig.SmallBlade": [ + "Короткие клинки", + "Во сколько раз быстрее растёт навык «короткие клинки»." + ], + "MultiplierConfig.Spear": [ + "Копья", + "Во сколько раз быстрее растёт навык «копья»." + ], + "MultiplierConfig.Maintenance": [ + "Уход за оружием", + "Во сколько раз быстрее растёт навык «уход за оружием»." + ], + "MultiplierConfig.Woodwork": [ + "Столярное дело", + "Во сколько раз быстрее растёт навык «столярное дело»." + ], + "MultiplierConfig.Cooking": [ + "Готовка", + "Во сколько раз быстрее растёт навык «готовка»." + ], + "MultiplierConfig.Doctor": [ + "Медицина", + "Во сколько раз быстрее растёт навык «медицина»." + ], + "MultiplierConfig.Electricity": [ + "Электрика", + "Во сколько раз быстрее растёт навык «электрика»." + ], + "MultiplierConfig.MetalWelding": [ + "Сварка", + "Во сколько раз быстрее растёт навык «сварка»." + ], + "MultiplierConfig.Mechanics": [ + "Механика", + "Во сколько раз быстрее растёт навык «механика»." + ], + "MultiplierConfig.Tailoring": [ + "Портняжное дело", + "Во сколько раз быстрее растёт навык «портняжное дело»." + ], + "MultiplierConfig.Aiming": [ + "Меткость", + "Во сколько раз быстрее растёт навык «меткость»." + ], + "MultiplierConfig.Reloading": [ + "Перезарядка", + "Во сколько раз быстрее растёт навык «перезарядка»." + ], + "MultiplierConfig.Fishing": [ + "Рыбалка", + "Во сколько раз быстрее растёт навык «рыбалка»." + ], + "MultiplierConfig.Trapping": [ + "Ловушки", + "Во сколько раз быстрее растёт навык «ловушки»." + ], + "MultiplierConfig.PlantScavenging": [ + "Собирательство", + "Во сколько раз быстрее растёт навык «собирательство»." + ], + "MultiplierConfig.FlintKnapping": [ + "Обработка камня", + "Во сколько раз быстрее растёт навык «обработка камня»." + ], + "MultiplierConfig.Masonry": [ + "Каменная кладка", + "Во сколько раз быстрее растёт навык «каменная кладка»." + ], + "MultiplierConfig.Pottery": [ + "Гончарное дело", + "Во сколько раз быстрее растёт навык «гончарное дело»." + ], + "MultiplierConfig.Carving": [ + "Резьба", + "Во сколько раз быстрее растёт навык «резьба»." + ], + "MultiplierConfig.Husbandry": [ + "Животноводство", + "Во сколько раз быстрее растёт навык «животноводство»." + ], + "MultiplierConfig.Tracking": [ + "Следопытство", + "Во сколько раз быстрее растёт навык «следопытство»." + ], + "MultiplierConfig.Blacksmith": [ + "Кузнечное дело", + "Во сколько раз быстрее растёт навык «кузнечное дело»." + ], + "MultiplierConfig.Butchering": [ + "Разделка туш", + "Во сколько раз быстрее растёт навык «разделка туш»." + ], + "MultiplierConfig.Glassmaking": [ + "Стеклоделие", + "Во сколько раз быстрее растёт навык «стеклоделие»." + ], + "MultiplierConfig.Farming": [ + "Земледелие", + "Во сколько раз быстрее растёт навык «земледелие»." + ], + "ZombieConfig.RallyGroupSize": [ + "Размер скопления", + "Сколько зомби собирается в бродячую стаю." + ], + "ZombieConfig.RallyTravelDistance": [ + "Дальность схода в группу", + "С какого расстояния зомби подтягиваются к стае." + ], + "ZombieConfig.RallyGroupSeparation": [ + "Расстояние между группами", + "Насколько далеко стаи держатся друг от друга." + ], + "ZombieConfig.RallyGroupRadius": [ + "Плотность группы", + "Насколько плотно зомби идут внутри стаи." + ] + } +} diff --git a/web/static/i18n.js b/web/static/i18n.js new file mode 100644 index 0000000..aab6dfe --- /dev/null +++ b/web/static/i18n.js @@ -0,0 +1,672 @@ +"use strict"; + +// --------------------------------------------------------------------------- +// Локализация интерфейса +// --------------------------------------------------------------------------- +// Язык выбирается человеком и хранится в localStorage; пока выбора не было, +// панель ориентируется на язык браузера. Строки лежат плоским словарём: +// ключ -> текст, подстановки записываются как {name}. + +const LANG_KEY = "pzm-lang"; +const LANGS = ["ru", "en"]; +const FALLBACK_LANG = "en"; + +function detectLang() { + let saved = null; + try { + saved = localStorage.getItem(LANG_KEY); + } catch (error) { + // Приватный режим может запрещать хранилище — тогда просто определяем язык. + } + if (LANGS.includes(saved)) return saved; + + const preferred = navigator.languages && navigator.languages.length + ? navigator.languages + : [navigator.language || ""]; + for (const tag of preferred) { + const code = String(tag).toLowerCase().split("-")[0]; + if (LANGS.includes(code)) return code; + } + return FALLBACK_LANG; +} + +const LANG = detectLang(); + +// Сервер отдаёт свои сообщения об ошибках на языке панели, и узнаёт его из +// куки: заголовок Accept-Language говорит о браузере, а не о выборе человека. +document.cookie = `pzm_lang=${LANG}; path=/; max-age=31536000; samesite=lax`; +document.documentElement.lang = LANG; + +function setLang(lang) { + if (!LANGS.includes(lang) || lang === LANG) return; + try { + localStorage.setItem(LANG_KEY, lang); + } catch (error) { + // Без хранилища выбор не переживёт перезагрузку, но применится сейчас. + } + document.cookie = `pzm_lang=${lang}; path=/; max-age=31536000; samesite=lax`; + // Перерисовать всё на месте было бы можно, но перезагрузка честнее: она + // заново читает и подписи параметров, и данные с сервера. + location.reload(); +} + +// t возвращает строку по ключу. Неизвестный ключ — это ошибка перевода, а не +// повод показать пустое место: отдаём сам ключ, его видно при проверке. +function t(key, vars) { + const table = STRINGS[LANG] || {}; + let text = table[key]; + if (text === undefined) text = (STRINGS[FALLBACK_LANG] || {})[key]; + if (text === undefined) return key; + if (!vars) return text; + return text.replace(/\{(\w+)\}/g, (match, name) => + (vars[name] === undefined ? match : String(vars[name]))); +} + +// Разметка помечена атрибутами: data-i18n ставит текст, а варианты с суффиксом +// — соответствующий атрибут. Так HTML остаётся читаемым и не обрастает кодом. +function applyStaticTranslations(root = document) { + root.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + root.querySelectorAll("[data-i18n-html]").forEach((el) => { + el.innerHTML = t(el.dataset.i18nHtml); + }); + root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + root.querySelectorAll("[data-i18n-title]").forEach((el) => { + el.title = t(el.dataset.i18nTitle); + }); +} + +const STRINGS = { + ru: { + "lang.label": "Язык", + + "auth.subtitle": "Управление сервером Project Zomboid", + "auth.subtitleSetup": "Создайте учётную запись администратора", + "auth.token": "Код первичной настройки", + "auth.tokenHint": "Код напечатан в журнале менеджера при запуске: journalctl -u pzmanager", + "auth.login": "Логин", + "auth.password": "Пароль", + "auth.signIn": "Войти", + "auth.createAdmin": "Создать администратора", + "auth.expired": "Сессия истекла, войдите заново", + + "error.http": "Ошибка {status}", + "error.offline": "Нет связи с менеджером", + + "btn.start": "Запустить", + "btn.stop": "Остановить", + "btn.restart": "Перезапустить", + "btn.cancelRestart": "Отменить перезапуск", + "btn.logout": "Выйти", + "btn.cancel": "Отменить", + "btn.save": "Сохранить", + "btn.saveCount": "Сохранить ({count})", + "btn.delete": "Удалить", + + "state.stopped": "Остановлен", + "state.restarting": "Ждёт перезапуска", + "state.starting": "Запускается", + "state.running": "Работает", + "state.stopping": "Останавливается", + "state.restartIn": "Перезапуск через {sec} с", + + "tab.overview": "Обзор", + "tab.console": "Консоль", + "tab.config": "Конфиг сервера", + "tab.sandbox": "Песочница", + "tab.mods": "Моды", + "tab.backups": "Бэкапы", + "tab.profiles": "Профили", + "tab.settings": "Настройки", + + "overview.stateCard": "Состояние", + "overview.playersCard": "Игроки онлайн", + "overview.processCard": "Процесс сервера", + "overview.hostCard": "Хост", + "overview.cpu": "CPU", + "overview.memory": "Память", + "overview.threads": "Потоки", + "overview.ramFree": "ОЗУ свободно", + "overview.load": "Load average", + "overview.diskFree": "Диск свободно", + "overview.uptime": "Работает: {value}", + "overview.profile": "Профиль: {title}", + "overview.lastExit": "Последнее завершение: {reason}", + "overview.memoryOf": "{free} из {total}", + "overview.loadCores": "{load} на {count} ядер", + + "install.card": "Серверные файлы", + "install.installed": "Серверные файлы установлены.", + "install.missing": "Серверные файлы не найдены — сервер ещё не установлен.", + "install.install": "Установить сервер через SteamCMD", + "install.update": "Обновить сервер через SteamCMD", + "install.hintInstall": "Займёт несколько минут и около 3 ГБ на диске — прогресс виден во вкладке «Консоль».", + "install.hintUpdate": "Сервер должен быть остановлен. Обновление занимает несколько минут — прогресс виден во вкладке «Консоль».", + "install.confirmInstall": "Установить сервер через SteamCMD?", + "install.confirmUpdate": "Обновить серверные файлы через SteamCMD?", + "install.started": "Задача запущена — следите за вкладкой «Консоль»", + + "confirm.stop": "Остановить игровой сервер? Игроки будут отключены.", + "confirm.restart": "Перезапустить сервер? Игроки будут отключены.", + + "task.done": "{name} — готово", + + "console.autoscroll": "Прокручивать за выводом", + "console.filter": "Фильтр по строке…", + "console.clear": "Очистить экран", + "console.placeholder": "Команда сервера, например: players, save, quit", + "console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем", + "console.send": "Отправить", + "console.help": "Полезные команды: players, save, servermsg \"текст\", kickuser \"ник\", banuser \"ник\", checkModsNeedUpdate.", + + "config.title": "Конфигурация сервера", + "config.search": "Поиск параметра…", + "config.saved": "Конфиг сохранён. Изменения применятся после перезапуска сервера.", + "config.noChanges": "Нет изменений", + "config.changedCount": "Изменено параметров: {count}", + "config.noChangesTitle": "Изменений нет", + "config.yes": "да", + "config.no": "нет", + "config.anticheatLabel": "Античит: проверка {number}", + "config.anticheatHint": "Одна из встроенных проверок. Отключайте только при ложных срабатываниях.", + + "sandbox.title": "Настройки песочницы", + "sandbox.saved": "Песочница сохранена. Часть параметров действует только на новый мир.", + + "group.other": "Прочее", + "group.config.general": "Основное", + "group.config.access": "Доступ и учётные записи", + "group.config.network": "Сеть и порты", + "group.config.pvp": "PVP и урон", + "group.config.world": "Мир и правила", + "group.config.backups": "Бэкапы самой игры", + "group.config.mods": "Моды", + "group.config.chat": "Чат и голос", + "group.config.badwords": "Фильтр брани", + "group.config.visibility": "Видимость игроков", + "group.config.safehouse": "Убежища", + "group.config.factions": "Фракции и войны", + "group.config.radio": "Рации", + "group.config.discord": "Discord", + "group.config.anticheat": "Античит и журналы", + "group.config.vehicles": "Транспорт", + + "group.sandbox.zombies": "Зомби", + "group.sandbox.world": "Мир и время", + "group.sandbox.weather": "Погода", + "group.sandbox.loot": "Лут и предметы", + "group.sandbox.character": "Персонаж и навыки", + "group.sandbox.food": "Еда, вода и хозяйство", + "group.sandbox.animals": "Животные", + "group.sandbox.vehicles": "Транспорт", + "group.sandbox.firearms": "Огнестрельное оружие", + "group.sandbox.power": "Электричество и огонь", + "group.sandbox.service": "Служебное", + "group.sandbox.ZombieLore": "Зомби: повадки и характеристики", + "group.sandbox.ZombieConfig": "Зомби: популяция и респавн", + "group.sandbox.MultiplierConfig": "Множители опыта навыков", + "group.sandbox.Map": "Карта", + "group.sandbox.Basement": "Подвалы", + + "mods.title": "Моды", + "mods.subtitle": "Галочка включает мод, перетаскивание задаёт порядок загрузки. Сервер сам скачивает моды из мастерской при запуске.", + "mods.inputPlaceholder": "Ссылка на мод или коллекцию, либо просто ID", + "mods.add": "Добавить мод", + "mods.addCollection": "Добавить коллекцию", + "mods.collectionHint": "Коллекция разворачивается в список входящих в неё модов. Сами файлы сервер скачивает сам при запуске — после этого моды можно включать галочками.", + "mods.search": "Поиск по названию…", + "mods.onlyEnabled": "Только включённые", + "mods.mapsTitle": "Карты (Map)", + "mods.mapsPlaceholder": "Например: Muldraugh, KY", + "mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.", + "mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}", + "mods.local": "локальный мод", + "mods.notFound": "файлы не найдены — сервер скачает их при запуске", + "mods.notDownloaded": "ещё не скачан — включить можно после запуска сервера", + "mods.unnamed": "Мод {id}", + "mods.unknownID": "Mod ID неизвестен", + "mods.dragTitle": "Перетащите, чтобы изменить порядок", + "mods.dragDisabled": "Порядок задаётся у включённых модов", + "mods.toggleTitle": "Включить мод на сервере", + "mods.up": "Выше", + "mods.down": "Ниже", + "mods.remove": "Убрать", + "mods.empty": "Ничего не найдено", + "mods.unsaved": "Есть несохранённые изменения", + "mods.badInput": "Не похоже на ссылку или ID мода", + "mods.duplicate": "Этот мод уже в списке", + "mods.needCollection": "Вставьте ссылку на коллекцию или её ID", + "mods.collectionAdded": "Из коллекции добавлено модов: {added} (всего в ней {total})", + "mods.collectionKnown": "Все {total} модов коллекции уже были в списке", + "mods.saved": "Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.", + + "backups.title": "Резервные копии", + "backups.note": "Бэкап снимается с активного профиля: его мир и конфиги.", + "backups.notePlaceholder": "Пометка (необязательно)", + "backups.create": "Создать бэкап", + "backups.colArchive": "Архив", + "backups.colProfile": "Профиль", + "backups.colSize": "Размер", + "backups.colCreated": "Создан", + "backups.download": "Скачать", + "backups.restore": "Восстановить", + "backups.empty": "Пока ни одного архива", + "backups.started": "Бэкап запущен — прогресс во вкладке «Консоль»", + "backups.confirmRestore": "Восстановить мир из {name}? Текущий мир будет перезаписан.\nПеред восстановлением менеджер сам сделает страховочный бэкап.", + "backups.restoreStarted": "Восстановление запущено", + "backups.confirmDelete": "Удалить архив {name}? Это действие необратимо.", + "backups.deleted": "Архив удалён", + + "profiles.title": "Профили серверов", + "profiles.subtitle": "У каждого профиля свой мир, конфиг, песочница и набор модов. Одновременно работает один — переключать можно на остановленном сервере.", + "profiles.picker": "Профиль", + "profiles.pickerTitle": "Профиль сервера: свой мир, конфиг и моды", + "profiles.pickerLocked": "Остановите сервер, чтобы переключить профиль", + "profiles.active": "активный", + "profiles.worldReady": "мир создан", + "profiles.configured": "настроен, мир ещё не создан", + "profiles.never": "ни разу не запускался", + "profiles.name": "Название", + "profiles.memoryMax": "Память JVM, максимум", + "profiles.memoryMin": "Память JVM, начальная", + "profiles.memoryMinPlaceholder": "не задана", + "profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.", + "profiles.adminPassword": "Пароль админа игры", + "profiles.adminPasswordSet": "пароль задан", + "profiles.adminPasswordUnset": "не задан", + "profiles.activate": "Сделать активным", + "profiles.memoryExample": "Например 4g", + "profiles.memoryHost": "На машине {total} ОЗУ, свободно {free}. Оставьте запас системе.", + "profiles.switched": "Активный профиль переключён", + "profiles.saved": "Профиль сохранён", + "profiles.confirmDelete": "Убрать профиль {id} из панели?\n\nМир и конфиги останутся на диске — профиль можно вернуть, создав его с тем же идентификатором.", + "profiles.deleted": "Профиль удалён", + "profiles.created": "Профиль создан", + "profiles.newTitle": "Новый профиль", + "profiles.id": "Идентификатор", + "profiles.idHint": "Латиница, цифры, дефис. Станет именем конфига и папки мира.", + "profiles.nameHint": "Как профиль подписан в панели.", + "profiles.namePlaceholder": "Сборка с модами", + "profiles.memory": "Память JVM", + "profiles.memoryHint": "Применяется при запуске этого профиля.", + "profiles.copyFrom": "Скопировать настройки из", + "profiles.copyHint": "Копирует .ini и настройки песочницы. Мир останется новым.", + "profiles.copyNone": "не копировать", + "profiles.create": "Создать профиль", + + "settings.versionTitle": "Версия панели", + "settings.title": "Настройки менеджера", + "settings.save": "Сохранить настройки", + "settings.saveCount": "Сохранить настройки ({count})", + "settings.changedCount": "Изменено полей: {count}", + "settings.saved": "Настройки сохранены", + "settings.configPath": "Файл конфигурации: {path}", + "settings.usersTitle": "Пользователи панели", + + "settings.listen": "Адрес веб-панели", + "settings.listenHint": "Например 127.0.0.1:8080. Применится после перезапуска pzmanager", + "settings.serverDir": "Директория сервера", + "settings.serverDirHint": "Куда SteamCMD ставит файлы Project Zomboid", + "settings.zomboidDir": "Директория данных (~/Zomboid)", + "settings.zomboidDirHint": "Здесь лежат миры, конфиги и логи игры", + "settings.steamcmdPath": "Путь к steamcmd.sh", + "settings.steamcmdPathHint": "Пусто — найти в PATH или скачать автоматически", + "settings.autostart": "Запускать сервер вместе с менеджером", + "settings.autorestart": "Поднимать сервер после падения", + "settings.stopTimeout": "Таймаут остановки, сек", + "settings.stopTimeoutHint": "Сколько ждать после команды quit до SIGTERM", + "settings.logBuffer": "Строк консоли в памяти", + "settings.backupDir": "Директория бэкапов", + "settings.backupSchedule": "Автобэкап, минут", + "settings.backupScheduleHint": "0 — отключить автоматические бэкапы", + "settings.backupKeep": "Хранить архивов", + "settings.backupKeepHint": "0 — не удалять старые", + "settings.backupStopServer": "Останавливать сервер на время бэкапа", + "settings.backupStopServerHint": "Даёт согласованный снимок, но выкидывает игроков", + + "users.loginPlaceholder": "Логин", + "users.passwordPlaceholder": "Пароль (минимум 8 символов)", + "users.add": "Добавить пользователя", + "users.changePassword": "Сменить пароль", + "users.newPassword": "Новый пароль для {login} (минимум 8 символов):", + "users.passwordChanged": "Пароль изменён", + "users.confirmDelete": "Удалить пользователя {login}?", + "users.deleted": "Пользователь удалён", + "users.added": "Пользователь добавлен", + + "update.checking": "Проверяю обновления…", + "update.check": "Проверить", + "update.apply": "Обновить", + "update.applyTo": "Обновить до {version}", + "update.restart": "Перезапустить панель", + "update.restartTitle": "Перезапустить службу панели", + "update.restartUnavailable": "Недоступно: панель не может управлять своей службой", + "update.failed": "Не удалось проверить обновления: {error}", + "update.available": "Доступна версия {version}", + "update.manual": "Обновить можно командой: {command}", + "update.current": "Установлена последняя версия ({version})", + "update.unknown": "Сведений о релизах нет", + "update.whatsNew": "Что нового", + "update.whatsNewCount": "Что нового (версий: {count})", + "update.noNotes": "Описание не заполнено", + "update.fullNotes": "Полное описание релизов", + "update.hasNew": "Есть новая версия: {version}", + "update.upToDate": "Обновлений нет", + "update.confirm": "Обновить панель до {version}?\n\nИгровой сервер будет остановлен, панель перезапустится.", + "update.started": "Обновление запущено", + "update.follow": "Ход обновления: journalctl -u pzmanager-update -f", + "update.confirmRestart": "Перезапустить панель?\n\nИгровой сервер будет остановлен.", + "update.restarting": "Панель перезапускается", + "update.waiting": "{message} — жду, пока панель поднимется", + "update.waitingState": "{message}…", + "update.timeout": "Панель не поднялась за пять минут — проверьте journalctl -u pzmanager", + + "unit.b": "Б", + "unit.kb": "КБ", + "unit.mb": "МБ", + "unit.gb": "ГБ", + "unit.tb": "ТБ", + "unit.days": "{value} д", + "unit.hours": "{value} ч", + "unit.minutes": "{value} мин", + "locale.tag": "ru-RU", + }, + + en: { + "lang.label": "Language", + + "auth.subtitle": "Project Zomboid server management", + "auth.subtitleSetup": "Create an administrator account", + "auth.token": "Setup code", + "auth.tokenHint": "The code is printed to the manager log at startup: journalctl -u pzmanager", + "auth.login": "Login", + "auth.password": "Password", + "auth.signIn": "Sign in", + "auth.createAdmin": "Create administrator", + "auth.expired": "Session expired, please sign in again", + + "error.http": "Error {status}", + "error.offline": "No connection to the manager", + + "btn.start": "Start", + "btn.stop": "Stop", + "btn.restart": "Restart", + "btn.cancelRestart": "Cancel restart", + "btn.logout": "Sign out", + "btn.cancel": "Cancel", + "btn.save": "Save", + "btn.saveCount": "Save ({count})", + "btn.delete": "Delete", + + "state.stopped": "Stopped", + "state.restarting": "Waiting to restart", + "state.starting": "Starting", + "state.running": "Running", + "state.stopping": "Stopping", + "state.restartIn": "Restarting in {sec} s", + + "tab.overview": "Overview", + "tab.console": "Console", + "tab.config": "Server config", + "tab.sandbox": "Sandbox", + "tab.mods": "Mods", + "tab.backups": "Backups", + "tab.profiles": "Profiles", + "tab.settings": "Settings", + + "overview.stateCard": "State", + "overview.playersCard": "Players online", + "overview.processCard": "Server process", + "overview.hostCard": "Host", + "overview.cpu": "CPU", + "overview.memory": "Memory", + "overview.threads": "Threads", + "overview.ramFree": "RAM free", + "overview.load": "Load average", + "overview.diskFree": "Disk free", + "overview.uptime": "Uptime: {value}", + "overview.profile": "Profile: {title}", + "overview.lastExit": "Last exit: {reason}", + "overview.memoryOf": "{free} of {total}", + "overview.loadCores": "{load} on {count} cores", + + "install.card": "Server files", + "install.installed": "Server files are installed.", + "install.missing": "Server files not found — the server is not installed yet.", + "install.install": "Install server via SteamCMD", + "install.update": "Update server via SteamCMD", + "install.hintInstall": "Takes a few minutes and about 3 GB of disk space — watch the progress on the Console tab.", + "install.hintUpdate": "The server must be stopped. The update takes a few minutes — watch the progress on the Console tab.", + "install.confirmInstall": "Install the server via SteamCMD?", + "install.confirmUpdate": "Update the server files via SteamCMD?", + "install.started": "Task started — watch the Console tab", + + "confirm.stop": "Stop the game server? Players will be disconnected.", + "confirm.restart": "Restart the server? Players will be disconnected.", + + "task.done": "{name} — done", + + "console.autoscroll": "Scroll with output", + "console.filter": "Filter by text…", + "console.clear": "Clear screen", + "console.placeholder": "Server command, for example: players, save, quit", + "console.placeholderOffline": "Server stopped — the console only accepts commands while it runs", + "console.send": "Send", + "console.help": "Handy commands: players, save, servermsg \"text\", kickuser \"name\", banuser \"name\", checkModsNeedUpdate.", + + "config.title": "Server configuration", + "config.search": "Find a setting…", + "config.saved": "Config saved. Changes apply after a server restart.", + "config.noChanges": "Nothing changed", + "config.changedCount": "Settings changed: {count}", + "config.noChangesTitle": "No changes", + "config.yes": "yes", + "config.no": "no", + "config.anticheatLabel": "Anti-cheat: check {number}", + "config.anticheatHint": "One of the built-in checks. Disable it only if it fires on legitimate players.", + + "sandbox.title": "Sandbox settings", + "sandbox.saved": "Sandbox saved. Some settings only affect a freshly created world.", + + "group.other": "Other", + "group.config.general": "General", + "group.config.access": "Access and accounts", + "group.config.network": "Network and ports", + "group.config.pvp": "PVP and damage", + "group.config.world": "World and rules", + "group.config.backups": "In-game backups", + "group.config.mods": "Mods", + "group.config.chat": "Chat and voice", + "group.config.badwords": "Profanity filter", + "group.config.visibility": "Player visibility", + "group.config.safehouse": "Safehouses", + "group.config.factions": "Factions and wars", + "group.config.radio": "Radio", + "group.config.discord": "Discord", + "group.config.anticheat": "Anti-cheat and logs", + "group.config.vehicles": "Vehicles", + + "group.sandbox.zombies": "Zombies", + "group.sandbox.world": "World and time", + "group.sandbox.weather": "Weather", + "group.sandbox.loot": "Loot and items", + "group.sandbox.character": "Character and skills", + "group.sandbox.food": "Food, water and farming", + "group.sandbox.animals": "Animals", + "group.sandbox.vehicles": "Vehicles", + "group.sandbox.firearms": "Firearms", + "group.sandbox.power": "Power and fire", + "group.sandbox.service": "Service", + "group.sandbox.ZombieLore": "Zombies: behaviour and traits", + "group.sandbox.ZombieConfig": "Zombies: population and respawn", + "group.sandbox.MultiplierConfig": "Skill XP multipliers", + "group.sandbox.Map": "Map", + "group.sandbox.Basement": "Basements", + + "mods.title": "Mods", + "mods.subtitle": "The checkbox enables a mod, dragging sets the load order. The server downloads mods from the Workshop on startup.", + "mods.inputPlaceholder": "Link to a mod or collection, or just an ID", + "mods.add": "Add mod", + "mods.addCollection": "Add collection", + "mods.collectionHint": "A collection expands into the list of mods it contains. The server downloads the files itself on startup — after that the mods can be enabled with checkboxes.", + "mods.search": "Search by name…", + "mods.onlyEnabled": "Enabled only", + "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.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}", + "mods.local": "local mod", + "mods.notFound": "files not found — the server will download them on startup", + "mods.notDownloaded": "not downloaded yet — can be enabled after a server start", + "mods.unnamed": "Mod {id}", + "mods.unknownID": "Mod ID unknown", + "mods.dragTitle": "Drag to change the order", + "mods.dragDisabled": "Order applies to enabled mods only", + "mods.toggleTitle": "Enable the mod on the server", + "mods.up": "Up", + "mods.down": "Down", + "mods.remove": "Remove", + "mods.empty": "Nothing found", + "mods.unsaved": "There are unsaved changes", + "mods.badInput": "That does not look like a mod link or ID", + "mods.duplicate": "This mod is already on the list", + "mods.needCollection": "Paste a collection link or its ID", + "mods.collectionAdded": "Mods added from the collection: {added} (of {total} in it)", + "mods.collectionKnown": "All {total} mods of the collection were already on the list", + "mods.saved": "Mod list saved. Restart the server so it picks them up.", + + "backups.title": "Backups", + "backups.note": "The backup is taken from the active profile: its world and configs.", + "backups.notePlaceholder": "Note (optional)", + "backups.create": "Create backup", + "backups.colArchive": "Archive", + "backups.colProfile": "Profile", + "backups.colSize": "Size", + "backups.colCreated": "Created", + "backups.download": "Download", + "backups.restore": "Restore", + "backups.empty": "No archives yet", + "backups.started": "Backup started — progress on the Console tab", + "backups.confirmRestore": "Restore the world from {name}? The current world will be overwritten.\nThe manager takes a safety backup first.", + "backups.restoreStarted": "Restore started", + "backups.confirmDelete": "Delete the archive {name}? This cannot be undone.", + "backups.deleted": "Archive deleted", + + "profiles.title": "Server profiles", + "profiles.subtitle": "Every profile has its own world, config, sandbox and mod set. Only one runs at a time — switch it while the server is stopped.", + "profiles.picker": "Profile", + "profiles.pickerTitle": "Server profile: its own world, config and mods", + "profiles.pickerLocked": "Stop the server to switch the profile", + "profiles.active": "active", + "profiles.worldReady": "world created", + "profiles.configured": "configured, world not created yet", + "profiles.never": "never started", + "profiles.name": "Name", + "profiles.memoryMax": "JVM memory, maximum", + "profiles.memoryMin": "JVM memory, initial", + "profiles.memoryMinPlaceholder": "not set", + "profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.", + "profiles.adminPassword": "Game admin password", + "profiles.adminPasswordSet": "password set", + "profiles.adminPasswordUnset": "not set", + "profiles.activate": "Make active", + "profiles.memoryExample": "For example 4g", + "profiles.memoryHost": "The machine has {total} of RAM, {free} free. Leave some for the system.", + "profiles.switched": "Active profile switched", + "profiles.saved": "Profile saved", + "profiles.confirmDelete": "Remove the profile {id} from the panel?\n\nThe world and configs stay on disk — the profile can be brought back by creating it with the same identifier.", + "profiles.deleted": "Profile deleted", + "profiles.created": "Profile created", + "profiles.newTitle": "New profile", + "profiles.id": "Identifier", + "profiles.idHint": "Latin letters, digits, hyphen. Becomes the config name and the world folder.", + "profiles.nameHint": "How the profile is labelled in the panel.", + "profiles.namePlaceholder": "Modded run", + "profiles.memory": "JVM memory", + "profiles.memoryHint": "Applied when this profile starts.", + "profiles.copyFrom": "Copy settings from", + "profiles.copyHint": "Copies the .ini and the sandbox settings. The world stays new.", + "profiles.copyNone": "do not copy", + "profiles.create": "Create profile", + + "settings.versionTitle": "Panel version", + "settings.title": "Manager settings", + "settings.save": "Save settings", + "settings.saveCount": "Save settings ({count})", + "settings.changedCount": "Fields changed: {count}", + "settings.saved": "Settings saved", + "settings.configPath": "Configuration file: {path}", + "settings.usersTitle": "Panel users", + + "settings.listen": "Web panel address", + "settings.listenHint": "For example 127.0.0.1:8080. Applies after a pzmanager restart", + "settings.serverDir": "Server directory", + "settings.serverDirHint": "Where SteamCMD installs the Project Zomboid files", + "settings.zomboidDir": "Data directory (~/Zomboid)", + "settings.zomboidDirHint": "Worlds, configs and game logs live here", + "settings.steamcmdPath": "Path to steamcmd.sh", + "settings.steamcmdPathHint": "Empty — look it up in PATH or download automatically", + "settings.autostart": "Start the server together with the manager", + "settings.autorestart": "Bring the server back up after a crash", + "settings.stopTimeout": "Stop timeout, sec", + "settings.stopTimeoutHint": "How long to wait after the quit command before SIGTERM", + "settings.logBuffer": "Console lines kept in memory", + "settings.backupDir": "Backup directory", + "settings.backupSchedule": "Auto backup, minutes", + "settings.backupScheduleHint": "0 — turn automatic backups off", + "settings.backupKeep": "Archives to keep", + "settings.backupKeepHint": "0 — never delete old ones", + "settings.backupStopServer": "Stop the server while backing up", + "settings.backupStopServerHint": "Gives a consistent snapshot, but kicks the players", + + "users.loginPlaceholder": "Login", + "users.passwordPlaceholder": "Password (at least 8 characters)", + "users.add": "Add user", + "users.changePassword": "Change password", + "users.newPassword": "New password for {login} (at least 8 characters):", + "users.passwordChanged": "Password changed", + "users.confirmDelete": "Delete the user {login}?", + "users.deleted": "User deleted", + "users.added": "User added", + + "update.checking": "Checking for updates…", + "update.check": "Check", + "update.apply": "Update", + "update.applyTo": "Update to {version}", + "update.restart": "Restart panel", + "update.restartTitle": "Restart the panel service", + "update.restartUnavailable": "Unavailable: the panel cannot manage its own service", + "update.failed": "Could not check for updates: {error}", + "update.available": "Version {version} is available", + "update.manual": "Update with this command: {command}", + "update.current": "The latest version is installed ({version})", + "update.unknown": "No release information", + "update.whatsNew": "What's new", + "update.whatsNewCount": "What's new ({count} versions)", + "update.noNotes": "No description", + "update.fullNotes": "Full release notes", + "update.hasNew": "A new version is available: {version}", + "update.upToDate": "No updates", + "update.confirm": "Update the panel to {version}?\n\nThe game server will be stopped and the panel will restart.", + "update.started": "Update started", + "update.follow": "Update progress: journalctl -u pzmanager-update -f", + "update.confirmRestart": "Restart the panel?\n\nThe game server will be stopped.", + "update.restarting": "The panel is restarting", + "update.waiting": "{message} — waiting for the panel to come back", + "update.waitingState": "{message}…", + "update.timeout": "The panel did not come back within five minutes — check journalctl -u pzmanager", + + "unit.b": "B", + "unit.kb": "KB", + "unit.mb": "MB", + "unit.gb": "GB", + "unit.tb": "TB", + "unit.days": "{value} d", + "unit.hours": "{value} h", + "unit.minutes": "{value} min", + "locale.tag": "en-US", + }, +}; diff --git a/web/static/index.html b/web/static/index.html index 7b2a2b0..91d3e42 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -13,26 +13,34 @@ @@ -47,36 +55,42 @@
    - - - + + +
    + - +
    @@ -85,7 +99,7 @@
    -

    Состояние

    +

    @@ -93,59 +107,59 @@
    -

    Игроки онлайн

    +

    0
      -

      Процесс сервера

      -
      CPU
      -
      Память
      -
      Потоки
      +

      +
      +
      +
      -

      Хост

      -
      ОЗУ свободно
      -
      Load average
      -
      Диск свободно
      +

      +
      +
      +
      -

      Серверные файлы

      +

      - -

      Займёт несколько минут и около 3 ГБ на диске — прогресс виден во вкладке «Консоль».

      + +

      - - - + + +
      
             
      - - +
      -

      Полезные команды: players, save, servermsg "текст", - kickuser "ник", banuser "ник", checkModsNeedUpdate.

      +

      -

      Конфигурация сервера

      +

      - - + +
      @@ -156,12 +170,12 @@
      -

      Настройки песочницы

      +

      - - + +
      @@ -172,38 +186,35 @@
      -

      Моды

      -

      Галочка включает мод, перетаскивание задаёт порядок загрузки. - Сервер сам скачивает моды из мастерской при запуске.

      +

      +

      - +
      - - - + + +
      -

      Коллекция разворачивается в список входящих в неё модов. - Сами файлы сервер скачивает сам при запуске — после этого моды можно включать галочками.

      +

      - - + +
        -

        Карты (Map)

        - -

        Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.

        +

        + +

        @@ -211,17 +222,23 @@
        -

        Резервные копии

        +

        -

        Бэкап снимается с активного профиля: его мир и конфиги.

        +

        - - + +
        - + + + + + + +
        АрхивПрофильРазмерСоздан
        @@ -230,74 +247,74 @@
        -

        Профили серверов

        -

        У каждого профиля свой мир, конфиг, песочница и набор модов. - Одновременно работает один — переключать можно на остановленном сервере.

        +

        +

        -

        Новый профиль

        +

        - + -
        Латиница, цифры, дефис. Станет именем конфига и папки мира.
        +
        - - -
        Как профиль подписан в панели.
        + + +
        - + -
        Применяется при запуске этого профиля.
        +
        - + -
        Копирует .ini и настройки песочницы. Мир останется новым.
        +
        - +
        -

        Версия панели

        +

        -

        Проверяю обновления…

        +

        - - - + + +
        -

        Настройки менеджера

        +

        - + -

        Пользователи панели

        +

          - - - + + +
          @@ -305,6 +322,7 @@
          + diff --git a/web/static/style.css b/web/static/style.css index 53b370a..16cbdee 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -547,3 +547,22 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; } #update-manual { font-family: var(--mono); word-break: break-all; } .update-available { color: var(--accent); } + +/* Переключатель языка: в шапке — только список, на экране входа — с подписью. */ +.lang-picker { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--muted); +} +.lang-picker select { + width: auto; + padding: 5px 10px; + font-size: 13px; +} +.lang-picker-auth { + justify-content: center; + margin-top: 18px; + width: 100%; +}