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

@@ -49,6 +49,8 @@ type Session struct {
charID uint32
userID uint32
clientLang string // Per-session language preference; empty = use server default
cachedI18n *i18n // Lazily populated by I18n(); invalidated on SetLang
cachedI18nLang string // Lang the cachedI18n was built for
logKey []byte
sessionStart int64
courses []mhfcourse.Course
@@ -126,12 +128,45 @@ func (s *Session) Lang() string {
// SetLang updates the session's in-memory language preference. Persistence
// to the database is the caller's responsibility (via userRepo.SetLanguage).
// The cached i18n table is invalidated so the next I18n() call rebuilds
// against the new language.
func (s *Session) SetLang(lang string) {
s.Lock()
s.clientLang = lang
s.cachedI18n = nil
s.cachedI18nLang = ""
s.Unlock()
}
// I18n returns the i18n string table resolved against this session's
// effective language (see Lang). The first call materializes the table via
// getLangStringsFor and the result is cached on the session so hot-path
// handlers (chat, mail, timer tick broadcasts) do not pay the allocation on
// every packet. SetLang invalidates the cache.
func (s *Session) I18n() *i18n {
s.Lock()
if s.cachedI18n != nil && s.cachedI18nLang == s.clientLang {
i := s.cachedI18n
s.Unlock()
return i
}
lang := s.clientLang
s.Unlock()
// Resolve lang (falls back to server default when empty).
effectiveLang := lang
if effectiveLang == "" {
effectiveLang = s.server.erupeConfig.Language
}
resolved := getLangStringsFor(effectiveLang)
s.Lock()
// Someone may have raced us — overwrite defensively, pointer value is
// still the one we just built so callers get a consistent view.
s.cachedI18n = &resolved
s.cachedI18nLang = lang
s.Unlock()
return &resolved
}
// Start starts the session packet send and recv loop(s).
func (s *Session) Start() {
s.logger.Debug("New connection", zap.String("RemoteAddr", s.rawConn.RemoteAddr().String()))