mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }} · 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>
|
||||
|
||||
Reference in New Issue
Block a user