diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index ce25fc403..f29931ab8 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -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(std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count())); } Workers::Start(); diff --git a/dDashboardServer/routes/RouteUtils.cpp b/dDashboardServer/routes/RouteUtils.cpp index b565ba64b..31bf0e0f7 100644 --- a/dDashboardServer/routes/RouteUtils.cpp +++ b/dDashboardServer/routes/RouteUtils.cpp @@ -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())); + }); + env.add_callback("phrase", 1, [](inja::Arguments& args) { + return RouteUtils::EscapeHtml(GameText::TextOrKey(args.at(0)->get())); + }); + 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, for scripts + data["game"] = GameText::PageJson(); + data["gameJson"] = data["game"].dump(); data["prefs"] = context.isAuthenticated && context.accountId ? Database::Get()->GetDashboardPreferences(context.accountId) : "{}"; EscapeHtmlStrings(data); diff --git a/dDashboardServer/routes/Workers.cpp b/dDashboardServer/routes/Workers.cpp index 1cef75d3d..8e312afbc 100644 --- a/dDashboardServer/routes/Workers.cpp +++ b/dDashboardServer/routes/Workers.cpp @@ -4,6 +4,7 @@ #include #include +#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); diff --git a/dDashboardServer/static/js/common.js b/dDashboardServer/static/js/common.js index 48738ac9a..aa2880c5b 100644 --- a/dDashboardServer/static/js/common.js +++ b/dDashboardServer/static/js/common.js @@ -177,8 +177,10 @@ return '' + esc(kind || ('LOT ' + lot)) + ' (' + esc(lot) + ')'; }, + // 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)) + ' (' + esc(id) + ')'; + var known = window.GameText && GameText.zones()[String(id)]; + return esc(known || name || ('Zone ' + id)) + ' (' + esc(id) + ')'; } }; @@ -333,6 +335,55 @@ }; })(); + /** + * The game's own text from the client's locale, in the viewer's language (GameText.h), on : + * 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 diff --git a/dDashboardServer/templates/base.jinja2 b/dDashboardServer/templates/base.jinja2 index a20a9350b..7be954669 100644 --- a/dDashboardServer/templates/base.jinja2 +++ b/dDashboardServer/templates/base.jinja2 @@ -15,7 +15,7 @@ {% block css %}{% endblock %} - + {% if username and username != "" %}
{% include "header.jinja2" %} @@ -32,6 +32,8 @@