feat(i18n): per-session i18n routing and localized scenarios

Phase C of #188 — the last phase of server-side multi-language support.

Adds Session.I18n(), a cached per-session i18n table resolver built via
getLangStringsFor(s.Lang()). The pointer is stable until SetLang
invalidates the cache, so hot-path handlers pay zero allocations on
repeated calls. All 51 s.server.i18n.* call sites across commands,
guild, guild scout, cafe, and cast-binary handlers now route through
s.I18n().*, so chat replies, guild invite mail templates, cafe reset
notices, and quest-timer broadcasts are served in the player's
preferred language instead of the server-wide default.

Scenario JSON gets the same plain-or-map LocalizedString treatment
that quests received in phase B: subheader Strings and inline entry
Text accept either a plain string (backwards compatible) or a
language-keyed object. CompileScenarioJSON takes the compiling
session's language, loadScenarioBinary passes s.Lang(), and
ParseScenarioBinary emits plain-string LocalizedStrings so existing
.bin files round-trip byte-for-byte through the JSON path.

World-wide broadcasts (Raviente siege announcements via
BroadcastRaviente) intentionally stay on the server default — they
have no single-session context to resolve against.
This commit is contained in:
Houmgaor
2026-04-06 20:08:27 +02:00
parent f7ea275540
commit 5361e67b1a
11 changed files with 278 additions and 99 deletions

View File

@@ -193,3 +193,55 @@ func TestLangCompleteness(t *testing.T) {
})
}
}
// TestSessionI18n_Cached verifies that Session.I18n() returns an i18n table
// resolved against the session's language and caches the pointer until
// SetLang invalidates it (phase C of #188).
func TestSessionI18n_Cached(t *testing.T) {
server := &Server{erupeConfig: &cfg.Config{Language: "en"}}
s := &Session{server: server}
en1 := s.I18n()
en2 := s.I18n()
if en1 != en2 {
t.Error("Session.I18n() should return the same pointer until SetLang")
}
if en1.language != "English" {
t.Errorf("server-default I18n = %q, want English", en1.language)
}
s.SetLang("jp")
jp := s.I18n()
if jp == en1 {
t.Error("SetLang should invalidate the cached I18n pointer")
}
if jp.language != "日本語" {
t.Errorf("after SetLang(jp) I18n = %q, want 日本語", jp.language)
}
// And another call returns the same (now-cached) jp pointer.
if s.I18n() != jp {
t.Error("Session.I18n() should be cached after SetLang rebuild")
}
}
// TestParseChatCommand_RepliesInSessionLanguage confirms the mechanical
// s.server.i18n → s.I18n() refactor routes chat responses through the
// session's language.
func TestParseChatCommand_RepliesInSessionLanguage_Placeholder(t *testing.T) {
// Sanity: for a French session, the i18n table returned by I18n() must
// be the French one, and its commands.timer.enabled must not equal the
// English string.
server := &Server{erupeConfig: &cfg.Config{Language: "en"}}
s := &Session{server: server}
s.SetLang("fr")
frTable := s.I18n()
enTable := getLangStringsFor("en")
if frTable.commands.timer.enabled == enTable.commands.timer.enabled {
t.Error("fr and en timer.enabled strings should differ — refactor may have reverted")
}
if frTable.language != "Français" {
t.Errorf("session I18n language = %q, want Français", frTable.language)
}
}