feat(dashboard): every request and page gets game text in the viewer's language

The dashboard loads every language in the client's locale; each request
runs in the viewer's language (Workers::Reply carries it to the worker),
templates get game.* and phrase()/zone_name(), scripts get GameText.* on
<body data-game>, and the user menu picks the game text language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 22:06:22 -05:00
parent a6fc67a4bb
commit 17b9479c0e
5 changed files with 81 additions and 17 deletions

View File

@@ -24,6 +24,7 @@
#include "dCommonVars.h"
#include "Diagnostics.h"
#include "Locale.h"
#include "GameText.h"
#include "Web.h"
#include "Server.h"
@@ -138,14 +139,6 @@ namespace {
std::chrono::steady_clock::time_point g_NextServerListRequest{};
constexpr auto SERVER_LIST_INTERVAL = std::chrono::seconds(30);
std::string GetZoneDisplayName(uint32_t mapID) {
if (mapID == 0) return "Character Select";
std::string key = "ZoneTable_" + std::to_string(mapID) + "_DisplayDescription";
const auto& name = Locale::GetPhrase(key);
if (!name.empty()) return name;
return "Zone " + std::to_string(mapID);
}
/**
* Load the JWT signing secret: jwt_secret from config if set, otherwise a random secret generated
* on first start and kept in a file next to the binary so sessions survive restarts.
@@ -226,7 +219,7 @@ namespace {
info.ip = instance.ip.string;
info.port = instance.port;
info.isPrivate = instance.isPrivate != 0;
info.zoneName = GetZoneDisplayName(info.mapID);
info.zoneName = GameText::ZoneName(info.mapID);
AddPropertyDetails(info);
using eState = MasterPackets::ServerListResponse::eState;
if (instance.state == eState::READY || instance.state == eState::DRAINING) {
@@ -280,7 +273,7 @@ namespace {
info.ip = ready.ip.string;
info.port = ready.port;
info.isPrivate = ready.isPrivate != 0;
info.zoneName = GetZoneDisplayName(zoneID);
info.zoneName = GameText::ZoneName(zoneID);
AddPropertyDetails(info);
// Master can report a world more than once (in the server list and when it becomes ready): replace, don't add
auto& instances = ServerState::g_WorldInstances;
@@ -411,12 +404,13 @@ int main(int argc, char** argv) {
// Settings edited on the dashboard (server_config table) are layered over the files from here on
Game::config->SetDatabaseSync(ConfigSync::Sync);
// Load locale translations
// Load the client's locale, every language in it: the pages show game text in the viewer's language (GameText.h)
std::string clientPath = Game::config->GetValue("client_location");
if (!clientPath.empty()) {
std::string localePath = clientPath + "/locale/locale.xml";
Locale::LoadFromFile(localePath);
Locale::LoadFromFile(localePath, GameText::DEFAULT_LANGUAGE, true);
}
GameText::Init();
// Get master info from database
std::string masterIP = "localhost";
@@ -458,7 +452,6 @@ int main(int argc, char** argv) {
PreloadZoneData();
Scenery::Preload();
WorldView::Preload();
ZoneNames();
LOG("Read the client data for the 3D views in %lld ms", static_cast<long long>(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count()));
}
Workers::Start();

View File

@@ -4,6 +4,7 @@
#include "Database.h"
#include "GameLabels.h"
#include "GameText.h"
#include "Game.h"
#include "Logger.h"
#include "dConfig.h"
@@ -24,7 +25,16 @@ namespace {
static inja::Environment env = [] {
// No trim_blocks/lstrip_blocks: inja's versions also eat the spaces around a tag on the same line
// ('class="a{% if x %} b{% endif %}"' would render "ab"), unlike Jinja2's
return inja::Environment{ TEMPLATE_DIR };
inja::Environment env{ TEMPLATE_DIR };
// Game text from the client's locale in the viewer's language (GameText.h), escaped like the page data:
// {{ zone_name(1150) }}, {{ phrase("UI_COINS") }}
env.add_callback("zone_name", 1, [](inja::Arguments& args) {
return RouteUtils::EscapeHtml(GameText::ZoneName(args.at(0)->get<uint32_t>()));
});
env.add_callback("phrase", 1, [](inja::Arguments& args) {
return RouteUtils::EscapeHtml(GameText::TextOrKey(args.at(0)->get<std::string>()));
});
return env;
}();
return env;
}
@@ -54,6 +64,8 @@ namespace RouteUtils {
.method = method,
.middleware = std::move(middleware),
.handle = [path, handler = std::move(handler)](HTTPReply& reply, const HTTPContext& context) {
// Game text in this request comes in the viewer's language
const GameText::LanguageScope language(context);
try {
handler(reply, context);
} catch (const std::exception& ex) {
@@ -293,6 +305,9 @@ namespace RouteUtils {
// Names for the game's numbered values, from the server's enums (GameLabels.h)
data["labels"] = GameLabels::Json();
data["labelsJson"] = GameLabels::Json().dump();
// The game's words and zone names in the viewer's language: `game` for templates, <body data-game> for scripts
data["game"] = GameText::PageJson();
data["gameJson"] = data["game"].dump();
data["prefs"] = context.isAuthenticated && context.accountId ? Database::Get()->GetDashboardPreferences(context.accountId) : "{}";
EscapeHtmlStrings(data);

View File

@@ -4,6 +4,7 @@
#include <exception>
#include <thread>
#include "GameText.h"
#include "RouteUtils.h"
#include "Web.h"
@@ -37,8 +38,10 @@ namespace Workers {
if (ready || !g_Pool.Running()) return fill(reply);
const auto deferred = Web::Defer(reply, context);
const auto path = context.path;
g_Pool.Submit(priority, [deferred, path, fill = std::move(fill)] {
// The worker writes game text in the viewer's language too
g_Pool.Submit(priority, [deferred, path, language = GameText::Language(), fill = std::move(fill)] {
if (deferred.Cancelled()) return;
const GameText::LanguageScope scope(language);
HTTPReply out;
try {
fill(out);

View File

@@ -177,8 +177,10 @@
return '<span class="text-nowrap"><img src="/api/icon/' + esc(lot) + '" width="28" height="28" class="me-1 align-middle" alt="" loading="lazy">' +
esc(kind || ('LOT ' + lot)) + ' <span class="small text-body-secondary">(' + esc(lot) + ')</span></span>';
},
// A zone's name (the locale's, in the viewer's language, when it has one) and ID
zone: function (id, name) {
return esc(name || ('Zone ' + id)) + ' <span class="small text-body-secondary">(' + esc(id) + ')</span>';
var known = window.GameText && GameText.zones()[String(id)];
return esc(known || name || ('Zone ' + id)) + ' <span class="small text-body-secondary">(' + esc(id) + ')</span>';
}
};
@@ -333,6 +335,55 @@
};
})();
/**
* The game's own text from the client's locale, in the viewer's language (GameText.h), on <body data-game>:
* GameText.term('coins') is "Coins", GameText.zone(1200) the zone's name. Pages never write game text themselves.
* The language is the account's gameLanguage preference (the user menu's picker), else the browser's; the server
* reads it from the game_lang cookie kept here.
*/
window.GameText = (function () {
var game = {};
try { game = JSON.parse(document.body.dataset.game || '{}') || {}; } catch (e) {}
var COOKIE = 'game_lang';
function cookie() {
var match = document.cookie.match(/(?:^|;\s*)game_lang=([^;]*)/);
return match ? decodeURIComponent(match[1]) : '';
}
function setCookie(value) {
document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax; max-age=' + (value ? 31536000 : 0);
}
// A locale name ("de_DE") as its own language calls it ("Deutsch (Deutschland)")
function languageName(code) {
try { return new Intl.DisplayNames([code.replace('_', '-')], { type: 'language' }).of(code.replace('_', '-')) || code; } catch (e) { return code; }
}
var api = {
language: game.language || 'en_US',
languages: game.languages || [],
languageName: languageName,
term: function (name) { return (game.terms || {})[name] || name; },
zone: function (id) { return (game.zones || {})[String(id)] || ('Zone ' + id); },
zones: function () { return game.zones || {}; }
};
// Keep the cookie in step with the saved choice (signed in on another browser, or chosen before)
var picked = window.Prefs ? Prefs.get('gameLanguage', '') : '';
if (document.body.dataset.username && picked !== cookie() && (picked === '' || api.languages.indexOf(picked) !== -1)) setCookie(picked);
document.querySelectorAll('[data-game-language]').forEach(function (select) {
api.languages.forEach(function (code) {
var option = document.createElement('option');
option.value = code;
option.textContent = languageName(code);
select.appendChild(option);
});
select.value = api.languages.indexOf(picked) !== -1 ? picked : '';
select.addEventListener('change', function () {
if (window.Prefs) Prefs.set('gameLanguage', select.value);
setCookie(select.value);
location.reload();
});
});
return api;
})();
/**
* Layout helpers (dashboard.css). --topbar-h is the height of the sticky top bar, for things that stick below it.
* .fill-viewport[-lg|-xl] get --fill-top: where they start on the page plus what comes after the page content (its

View File

@@ -15,7 +15,7 @@
{% block css %}{% endblock %}
<meta name="page-head-end">
</head>
<body data-username="{{ username }}" data-gm-level="{{ gmLevel }}" data-account-id="{{ accountId }}" data-prefs="{{ prefs }}" data-labels="{{ labelsJson }}" data-can="{% for key, allowed in can %}{% if allowed %}{{ key }} {% endif %}{% endfor %}">
<body data-username="{{ username }}" data-gm-level="{{ gmLevel }}" data-account-id="{{ accountId }}" data-prefs="{{ prefs }}" data-labels="{{ labelsJson }}" data-game="{{ gameJson }}" data-can="{% for key, allowed in can %}{% if allowed %}{{ key }} {% endif %}{% endfor %}">
{% if username and username != "" %}
<div class="d-flex">
{% include "header.jinja2" %}
@@ -32,6 +32,8 @@
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><h6 class="dropdown-header">{{ username }} &middot; GM level {{ gmLevel }}</h6></li>
<li><a class="dropdown-item" href="/account">My account</a></li>
<li class="px-3 py-1"><label class="form-label small text-body-secondary mb-1" for="gameLanguage">Game text language</label>
<select class="form-select form-select-sm" id="gameLanguage" data-game-language><option value="">Browser's language</option></select></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/api_docs">API</a></li>
<li><a class="dropdown-item" href="/about">About</a></li>