diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index 6116a62bc..e4680f3db 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -11,6 +11,7 @@ set(DASHBOARDROUTES_SOURCES "PlayerActions.cpp" "AccountRoutes.cpp" "ClientAssets.cpp" + "GameText.cpp" "MaintenanceRoutes.cpp" "ReportRoutes.cpp" "EconomyPlaces.cpp" diff --git a/dDashboardServer/routes/GameText.cpp b/dDashboardServer/routes/GameText.cpp new file mode 100644 index 000000000..b0a968677 --- /dev/null +++ b/dDashboardServer/routes/GameText.cpp @@ -0,0 +1,195 @@ +#include "GameText.h" + +#include +#include +#include + +#include "HTTPContext.h" +#include "Locale.h" +#include "LocaleText.h" + +namespace { + thread_local std::string t_Language = GameText::DEFAULT_LANGUAGE; + + // Zone names per language, built by Init (read-only afterwards) + std::map g_ZoneNames; + const nlohmann::json g_NoZones = nlohmann::json{ { "0", "Character Select" } }; + + // The part of a tag before the region: "de_DE" -> "de" + std::string_view Primary(std::string_view tag) { + return tag.substr(0, tag.find('_')); + } + + bool EqualNoCase(std::string_view a, std::string_view b) { + return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { + return std::tolower(static_cast(x)) == std::tolower(static_cast(y)); + }); + } + + std::string_view Trim(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front()))) text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast(text.back()))) text.remove_suffix(1); + return text; + } + + nlohmann::json BuildZoneNames(const std::string& language) { + nlohmann::json names = g_NoZones; + constexpr std::string_view PREFIX = "ZoneTable_", SUFFIX = "_DisplayDescription"; + for (const auto& key : Locale::GetPhraseIdsWithPrefix(std::string(PREFIX))) { + if (key.size() <= PREFIX.size() + SUFFIX.size() || !key.ends_with(SUFFIX)) continue; + const auto& name = Locale::GetPhrase(key, language); + if (!name.empty()) names[key.substr(PREFIX.size(), key.size() - PREFIX.size() - SUFFIX.size())] = name; + } + return names; + } +} + +namespace GameText { + std::string NormalizeTag(std::string_view tag) { + std::string out(Trim(tag)); + std::replace(out.begin(), out.end(), '-', '_'); + const auto split = out.find('_'); + for (size_t i = 0; i < out.size(); i++) { + const auto c = static_cast(out[i]); + out[i] = static_cast(split != std::string::npos && i > split ? std::toupper(c) : std::tolower(c)); + } + return out; + } + + std::optional MatchAcceptLanguage(std::string_view header, const std::vector& available) { + struct Wanted { std::string tag; double weight; size_t order; }; + std::vector wanted; + size_t order = 0; + while (!header.empty()) { + const auto comma = header.find(','); + auto part = header.substr(0, comma); + header = comma == std::string_view::npos ? std::string_view{} : header.substr(comma + 1); + double weight = 1.0; + if (const auto semi = part.find(';'); semi != std::string_view::npos) { + const auto param = Trim(part.substr(semi + 1)); + if (param.starts_with("q=")) { + try { weight = std::stod(std::string(param.substr(2))); } catch (...) { weight = 0.0; } + } + part = part.substr(0, semi); + } + const auto tag = NormalizeTag(part); + if (tag.empty() || tag == "*" || weight <= 0.0) continue; + wanted.push_back({ tag, weight, order++ }); + } + std::stable_sort(wanted.begin(), wanted.end(), [](const Wanted& a, const Wanted& b) { return a.weight > b.weight; }); + for (const auto& want : wanted) { + for (const auto& language : available) if (EqualNoCase(NormalizeTag(language), want.tag)) return language; + for (const auto& language : available) if (EqualNoCase(Primary(NormalizeTag(language)), Primary(want.tag))) return language; + } + return std::nullopt; + } + + std::string CookieValue(std::string_view cookieHeader, std::string_view name) { + while (!cookieHeader.empty()) { + const auto semi = cookieHeader.find(';'); + const auto part = Trim(cookieHeader.substr(0, semi)); + cookieHeader = semi == std::string_view::npos ? std::string_view{} : cookieHeader.substr(semi + 1); + const auto equals = part.find('='); + if (equals != std::string_view::npos && Trim(part.substr(0, equals)) == name) return std::string(Trim(part.substr(equals + 1))); + } + return ""; + } + + std::string ChooseLanguage(std::string_view cookie, std::string_view acceptLanguage, const std::vector& available) { + if (!cookie.empty()) { + for (const auto& language : available) if (language == cookie) return language; + } + if (const auto match = MatchAcceptLanguage(acceptLanguage, available)) return *match; + return available.empty() ? DEFAULT_LANGUAGE : available.front(); + } + + std::string Key(std::string_view table, int64_t id, std::string_view column) { + std::string key(table); + key += '_'; + key += std::to_string(id); + key += '_'; + key += column; + return key; + } + + const std::vector& Languages() { + static const std::vector fallback{ DEFAULT_LANGUAGE }; + const auto& loaded = Locale::GetLocales(); + return loaded.empty() ? fallback : loaded; + } + + const std::string& Language() { + return t_Language; + } + + LanguageScope::LanguageScope(std::string language) : m_Previous(std::move(t_Language)) { + t_Language = std::move(language); + } + + LanguageScope::LanguageScope(const HTTPContext& context) + : LanguageScope(ChooseLanguage(CookieValue(context.GetHeader("cookie"), COOKIE), context.GetHeader("accept-language"), Languages())) {} + + LanguageScope::~LanguageScope() { + t_Language = std::move(m_Previous); + } + + const std::string& Phrase(const std::string& key) { + return Locale::GetPhrase(key, t_Language); + } + + std::string Text(const std::string& key, const std::string& fallback) { + const auto& phrase = Phrase(key); + return phrase.empty() ? fallback : phrase; + } + + std::string TextOrKey(const std::string& key) { + return Text(key, key); + } + + std::string Name(std::string_view table, int64_t id, std::string_view column) { + return Text(Key(table, id, column), std::string(table) + " " + std::to_string(id)); + } + + std::string ZoneName(uint32_t zoneId) { + const auto& names = ZoneNames(); + const auto key = std::to_string(zoneId); + if (const auto it = names.find(key); it != names.end() && it->is_string()) return it->get(); + return Text("ZoneTable_" + key + "_DisplayDescription", "Zone " + key); + } + + const nlohmann::json& ZoneNames() { + if (const auto it = g_ZoneNames.find(t_Language); it != g_ZoneNames.end()) return it->second; + if (const auto it = g_ZoneNames.find(Languages().front()); it != g_ZoneNames.end()) return it->second; + return g_NoZones; + } + + std::string Expand(const std::string& text) { + return LocaleText::Expand(text, [](const std::string& key) -> const std::string& { return Phrase(key); }); + } + + const std::vector>& TermKeys() { + static const std::vector> keys{ + { "coins", "UI_COINS" }, + { "uscore", "UI_UNIVERSE_SCORE" }, + { "imagination", "UI_IMAGINATION" }, + { "reputation", "UI_REPUTATION" }, + { "life", "UI_HEALTH" }, + }; + return keys; + } + + nlohmann::json Terms() { + nlohmann::json terms = nlohmann::json::object(); + for (const auto& [name, key] : TermKeys()) terms[name] = TextOrKey(key); + return terms; + } + + nlohmann::json PageJson() { + return { { "language", t_Language }, { "languages", Languages() }, { "terms", Terms() }, { "zones", ZoneNames() } }; + } + + void Init() { + g_ZoneNames.clear(); + for (const auto& language : Languages()) g_ZoneNames[language] = BuildZoneNames(language); + } +} diff --git a/dDashboardServer/routes/GameText.h b/dDashboardServer/routes/GameText.h new file mode 100644 index 000000000..4f816a314 --- /dev/null +++ b/dDashboardServer/routes/GameText.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "json.hpp" + +struct HTTPContext; + +/** + * The game's own text (zone, object, mission, activity names, currencies, ...) as the client's locale has it, in the + * viewer's language. The dashboard never keeps its own copies of game text: C++ routes call these, templates get them + * through the `game` data and the phrase()/zone_name() callbacks, and scripts read them with GameText.* in common.js. + * + * The phrases are loaded once at startup (Locale::LoadFromFile with every locale, then Init) and only read afterwards, + * so any thread may look them up. The viewer's language is per thread: RouteUtils sets it for each request + * (LanguageScope) and Workers::Reply carries it to the worker thread. + */ +namespace GameText { + constexpr const char* DEFAULT_LANGUAGE = "en_US"; + constexpr const char* COOKIE = "game_lang"; // the viewer's pick (common.js keeps it in step with the account's preference) + + // ---- Pure helpers (unit tested) ---- + + // "de-DE" / "de_de" -> "de_DE"; "de" -> "de" + std::string NormalizeTag(std::string_view tag); + + /** + * The best of `available` (locale names like "de_DE") for an Accept-Language header ("de-CH,de;q=0.9,en;q=0.5"): + * by the header's weights, an exact match first, then the same language in another region (de-CH -> de_DE, en -> + * en_US when listed first). nullopt when nothing matches. + */ + std::optional MatchAcceptLanguage(std::string_view header, const std::vector& available); + + // A cookie's value from a Cookie header ("a=1; game_lang=de_DE"); empty when absent + std::string CookieValue(std::string_view cookieHeader, std::string_view name); + + /** + * The language to show: the cookie's pick when it's one of `available`, else the best Accept-Language match, + * else `available`'s first (the default), else DEFAULT_LANGUAGE + */ + std::string ChooseLanguage(std::string_view cookie, std::string_view acceptLanguage, const std::vector& available); + + // "__": Key("Objects", 1727) is "Objects_1727_name" + std::string Key(std::string_view table, int64_t id, std::string_view column = "name"); + + // ---- The viewer's language ---- + + // The languages the client's locale has, the default first + const std::vector& Languages(); + // This thread's viewer language (DEFAULT_LANGUAGE outside a request) + const std::string& Language(); + + // Sets this thread's language for its lifetime + class LanguageScope { + public: + explicit LanguageScope(std::string language); + // The request's language (cookie, then Accept-Language) + explicit LanguageScope(const HTTPContext& context); + ~LanguageScope(); + LanguageScope(const LanguageScope&) = delete; + LanguageScope& operator=(const LanguageScope&) = delete; + private: + std::string m_Previous; + }; + + // ---- Lookups, in the viewer's language, then the default; the fallbacks are the raw key or id ---- + + // The phrase for `key`; empty when the locale lacks it + const std::string& Phrase(const std::string& key); + // The phrase for `key`, else `fallback` + std::string Text(const std::string& key, const std::string& fallback); + // The phrase for `key`, else the key itself + std::string TextOrKey(const std::string& key); + // A row's localized column ("Missions", 5, "name"), else "
" + std::string Name(std::string_view table, int64_t id, std::string_view column = "name"); + // A zone's display name (ZoneTable__DisplayDescription), else "Zone "; 0 is character select + std::string ZoneName(uint32_t zoneId); + // {"": name} for every zone the locale names (and "0"), in the viewer's language + const nlohmann::json& ZoneNames(); + // Every %[KEY] in `text` replaced by its phrase (mail and bug reports store text this way) + std::string Expand(const std::string& text); + + /** + * The game's words the pages show as labels ({coins: "Coins", uscore: "Universe Score", imagination, reputation, + * life}), in the viewer's language; the key when the locale lacks one + */ + nlohmann::json Terms(); + // The key -> locale phrase id table behind Terms() + const std::vector>& TermKeys(); + + // What every page gets: {language, languages, terms, zones} + nlohmann::json PageJson(); + + // Build the per-language caches (zone names). Call on the main thread after the locale loads, before workers start. + void Init(); +} diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index e169fbd94..17e0d0cd4 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -36,6 +36,8 @@ set(DWEBTESTS_SOURCES "ModeratorHelperTests.cpp" "ChatHistoryTests.cpp" "ClientSysInfoViewTests.cpp" + "GameTextTests.cpp" + "${PROJECT_SOURCE_DIR}/dDashboardServer/routes/GameText.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/routes/ClientSysInfoView.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/routes/ChatHistory.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ClaudeClient.cpp" diff --git a/tests/dWebTests/GameTextTests.cpp b/tests/dWebTests/GameTextTests.cpp new file mode 100644 index 000000000..9e371891c --- /dev/null +++ b/tests/dWebTests/GameTextTests.cpp @@ -0,0 +1,147 @@ +#include + +#include +#include + +#include "GameText.h" +#include "HTTPContext.h" +#include "Locale.h" + +using namespace GameText; + +namespace { + const std::vector LANGUAGES{ "en_US", "de_DE", "en_GB" }; + + // A small locale.xml like the client's: three locales, a few phrases (some without every translation) + class GameTextLocaleTest : public ::testing::Test { + protected: + void SetUp() override { + m_Path = std::filesystem::temp_directory_path() / "dlu_gametext_test_locale.xml"; + std::ofstream out(m_Path); + out << R"( + + en_USde_DEen_GB + + Test StationTest-StationTest Station + Test Gardens + Test HatTesthut + CoinsMuenzen + HelloHallo + +)"; + out.close(); + Locale::LoadFromFile(m_Path.string(), "en_US", true); + Init(); + } + + void TearDown() override { + std::filesystem::remove(m_Path); + } + + std::filesystem::path m_Path; + }; +} + +TEST(GameTextTest, NormalizesTags) { + EXPECT_EQ(NormalizeTag("de-DE"), "de_DE"); + EXPECT_EQ(NormalizeTag(" EN-gb "), "en_GB"); + EXPECT_EQ(NormalizeTag("de"), "de"); +} + +TEST(GameTextTest, MatchesAcceptLanguage) { + EXPECT_EQ(MatchAcceptLanguage("de-DE,de;q=0.9,en;q=0.8", LANGUAGES), "de_DE"); + EXPECT_EQ(MatchAcceptLanguage("en-GB", LANGUAGES), "en_GB"); + // Same language, other region: the first listed of that language + EXPECT_EQ(MatchAcceptLanguage("de-CH", LANGUAGES), "de_DE"); + EXPECT_EQ(MatchAcceptLanguage("en-AU,en;q=0.5", LANGUAGES), "en_US"); + // Weights win over order + EXPECT_EQ(MatchAcceptLanguage("fr;q=0.9,de;q=0.95", LANGUAGES), "de_DE"); + EXPECT_EQ(MatchAcceptLanguage("en;q=0.2, de-AT", LANGUAGES), "de_DE"); + // Nothing fitting, refused (q=0) or junk + EXPECT_EQ(MatchAcceptLanguage("fr-FR,fr;q=0.9", LANGUAGES), std::nullopt); + EXPECT_EQ(MatchAcceptLanguage("de;q=0", LANGUAGES), std::nullopt); + EXPECT_EQ(MatchAcceptLanguage("*", LANGUAGES), std::nullopt); + EXPECT_EQ(MatchAcceptLanguage("", LANGUAGES), std::nullopt); + EXPECT_EQ(MatchAcceptLanguage("de;q=abc", LANGUAGES), std::nullopt); +} + +TEST(GameTextTest, ReadsCookies) { + EXPECT_EQ(CookieValue("token=abc; game_lang=de_DE; x=1", "game_lang"), "de_DE"); + EXPECT_EQ(CookieValue("game_lang=en_GB", "game_lang"), "en_GB"); + EXPECT_EQ(CookieValue("my_game_lang=de_DE", "game_lang"), ""); + EXPECT_EQ(CookieValue("", "game_lang"), ""); +} + +TEST(GameTextTest, ChoosesLanguage) { + EXPECT_EQ(ChooseLanguage("de_DE", "en-US", LANGUAGES), "de_DE"); // the viewer's pick wins + EXPECT_EQ(ChooseLanguage("xx_XX", "en-GB", LANGUAGES), "en_GB"); // an unknown pick is ignored + EXPECT_EQ(ChooseLanguage("", "fr-FR", LANGUAGES), "en_US"); // nothing fits: the default + EXPECT_EQ(ChooseLanguage("", "", {}), DEFAULT_LANGUAGE); +} + +TEST(GameTextTest, BuildsKeys) { + EXPECT_EQ(Key("Objects", 1727), "Objects_1727_name"); + EXPECT_EQ(Key("Missions", 5, "description"), "Missions_5_description"); +} + +TEST_F(GameTextLocaleTest, LoadsEveryLocale) { + EXPECT_EQ(Languages(), LANGUAGES); + EXPECT_EQ(Locale::GetPhrase("Objects_1727_name"), "Test Hat"); + EXPECT_EQ(Locale::GetPhrase("Objects_1727_name", "de_DE"), "Testhut"); + // Falls back to the default locale + EXPECT_EQ(Locale::GetPhrase("Objects_1727_name", "en_GB"), "Test Hat"); + EXPECT_EQ(Locale::GetPhrase("Objects_1727_name", "xx_XX"), "Test Hat"); + EXPECT_EQ(Locale::GetPhrase("Nope", "de_DE"), ""); +} + +TEST_F(GameTextLocaleTest, LooksUpInTheViewersLanguage) { + EXPECT_EQ(Language(), DEFAULT_LANGUAGE); + EXPECT_EQ(ZoneName(1200), "Test Station"); + EXPECT_EQ(Name("Objects", 1727), "Test Hat"); + { + LanguageScope german("de_DE"); + EXPECT_EQ(Language(), "de_DE"); + EXPECT_EQ(ZoneName(1200), "Test-Station"); + EXPECT_EQ(ZoneName(1100), "Test Gardens"); // no German name: the default's + EXPECT_EQ(ZoneNames()["1200"], "Test-Station"); + EXPECT_EQ(Name("Objects", 1727), "Testhut"); + EXPECT_EQ(Terms()["coins"], "Muenzen"); + EXPECT_EQ(Expand("%[MissionEmail_1_subjectText]!"), "Hallo!"); + EXPECT_EQ(PageJson()["language"], "de_DE"); + } + EXPECT_EQ(Language(), DEFAULT_LANGUAGE); + EXPECT_EQ(Terms()["coins"], "Coins"); +} + +TEST_F(GameTextLocaleTest, FallsBackToTheKeyOrId) { + EXPECT_EQ(ZoneName(9999), "Zone 9999"); + EXPECT_EQ(ZoneName(0), "Character Select"); + EXPECT_EQ(Name("Objects", 42), "Objects 42"); + EXPECT_EQ(Phrase("UI_NOT_THERE"), ""); + EXPECT_EQ(TextOrKey("UI_NOT_THERE"), "UI_NOT_THERE"); + EXPECT_EQ(Text("UI_NOT_THERE", "fallback"), "fallback"); + EXPECT_EQ(Terms()["reputation"], "UI_REPUTATION"); + EXPECT_EQ(Expand("%[Unknown] ok"), "%[Unknown] ok"); +} + +TEST_F(GameTextLocaleTest, PicksTheRequestsLanguage) { + HTTPContext context; + context.headers["accept-language"] = "de-AT,de;q=0.9"; + { + LanguageScope scope(context); + EXPECT_EQ(Language(), "de_DE"); + } + context.headers["cookie"] = "game_lang=en_GB"; + { + LanguageScope scope(context); + EXPECT_EQ(Language(), "en_GB"); + } + EXPECT_EQ(Language(), DEFAULT_LANGUAGE); +} + +TEST_F(GameTextLocaleTest, EveryTermHasAKey) { + for (const auto& [name, key] : TermKeys()) { + EXPECT_FALSE(name.empty()); + EXPECT_TRUE(key.starts_with("UI_")) << key; + } +}