mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +02:00
feat(i18n): per-session language preference and !lang command
Phase A plumbing for #188. Adds a users.language column (migration 0022), UserRepo.GetLanguage/SetLanguage, and Session.Lang()/SetLang accessors so future phases can resolve localized content per session instead of falling back to the server-wide config.Language. The preference is loaded from the DB on login and persisted via a new !lang <en|jp|fr|es> chat command that shows the current language when called without an argument, validates the code (case-insensitive), and replies in the newly selected language so the switch is visible immediately. An empty stored value falls back to config.Language. sys_language.go exposes getLangStringsFor(code) as the new dispatch primitive; getLangStrings(server) is now a thin wrapper so existing callers keep working unchanged. isSupportedLang + supportedLangs keep the !lang validator in sync with the dispatcher. Localized quest/scenario content and per-session i18n lookups in existing handlers are deliberately out of scope for phase A — this commit ships only the plumbing so it can be reviewed and deployed independently.
This commit is contained in:
@@ -144,6 +144,33 @@ func parseChatCommand(s *Session, command string) {
|
||||
} else {
|
||||
sendDisabledCommandMessage(s, commands["Timer"])
|
||||
}
|
||||
case commands["Language"].Prefix:
|
||||
if commands["Language"].Enabled || s.isOp() {
|
||||
// Use the session's *current* i18n table so replies come back in
|
||||
// the language the player was using before the change — the
|
||||
// success message reflects the new language via its argument.
|
||||
i := getLangStringsFor(s.Lang())
|
||||
if len(args) < 2 {
|
||||
sendServerChatMessage(s, fmt.Sprintf(i.commands.lang.current, s.Lang()))
|
||||
sendServerChatMessage(s, fmt.Sprintf(i.commands.lang.usage, s.server.erupeConfig.CommandPrefix+commands["Language"].Prefix))
|
||||
} else {
|
||||
requested := strings.ToLower(args[1])
|
||||
if !isSupportedLang(requested) {
|
||||
sendServerChatMessage(s, fmt.Sprintf(i.commands.lang.invalid, requested))
|
||||
} else {
|
||||
if err := s.server.userRepo.SetLanguage(s.userID, requested); err != nil {
|
||||
s.logger.Error("Failed to persist language preference", zap.Error(err), zap.String("lang", requested))
|
||||
}
|
||||
s.SetLang(requested)
|
||||
// Reply in the *new* language so the player immediately
|
||||
// sees the server switched.
|
||||
newI := getLangStringsFor(requested)
|
||||
sendServerChatMessage(s, fmt.Sprintf(newI.commands.lang.success, requested))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sendDisabledCommandMessage(s, commands["Language"])
|
||||
}
|
||||
case commands["PSN"].Prefix:
|
||||
if commands["PSN"].Enabled || s.isOp() {
|
||||
if len(args) > 1 {
|
||||
|
||||
@@ -49,6 +49,11 @@ type mockUserRepoCommands struct {
|
||||
// Rights
|
||||
rightsVal uint32
|
||||
setRightsVal uint32
|
||||
|
||||
// Language
|
||||
langStored string
|
||||
langSetCalled bool
|
||||
langSetErr error
|
||||
}
|
||||
|
||||
func (m *mockUserRepoCommands) IsOp(_ uint32) (bool, error) { return m.opResult, nil }
|
||||
@@ -83,6 +88,12 @@ func (m *mockUserRepoCommands) SetRights(_ uint32, v uint32) error {
|
||||
m.setRightsVal = v
|
||||
return nil
|
||||
}
|
||||
func (m *mockUserRepoCommands) GetLanguage(_ uint32) (string, error) { return m.langStored, nil }
|
||||
func (m *mockUserRepoCommands) SetLanguage(_ uint32, lang string) error {
|
||||
m.langSetCalled = true
|
||||
m.langStored = lang
|
||||
return m.langSetErr
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
@@ -100,6 +111,7 @@ func setupCommandsMap(allEnabled bool) {
|
||||
"Discord": {Name: "Discord", Prefix: "discord", Enabled: allEnabled},
|
||||
"Playtime": {Name: "Playtime", Prefix: "playtime", Enabled: allEnabled},
|
||||
"Help": {Name: "Help", Prefix: "help", Enabled: allEnabled},
|
||||
"Language": {Name: "Language", Prefix: "lang", Enabled: allEnabled},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1242,6 +1254,98 @@ func TestSendDisabledCommandMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Language ---
|
||||
|
||||
func TestParseChatCommand_Lang_ShowsUsageWhenNoArg(t *testing.T) {
|
||||
setupCommandsMap(true)
|
||||
repo := &mockUserRepoCommands{}
|
||||
s := createCommandSession(repo)
|
||||
s.server.erupeConfig.Language = "en"
|
||||
|
||||
parseChatCommand(s, "!lang")
|
||||
|
||||
if repo.langSetCalled {
|
||||
t.Error("SetLanguage should not be called when no argument provided")
|
||||
}
|
||||
// Two messages: current + usage.
|
||||
if n := drainChatResponses(s); n != 2 {
|
||||
t.Errorf("chat responses = %d, want 2 (current + usage)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChatCommand_Lang_ValidCodePersistsAndSwitches(t *testing.T) {
|
||||
setupCommandsMap(true)
|
||||
repo := &mockUserRepoCommands{}
|
||||
s := createCommandSession(repo)
|
||||
s.server.erupeConfig.Language = "en"
|
||||
|
||||
parseChatCommand(s, "!lang fr")
|
||||
|
||||
if !repo.langSetCalled {
|
||||
t.Fatal("SetLanguage should be called")
|
||||
}
|
||||
if repo.langStored != "fr" {
|
||||
t.Errorf("langStored = %q, want %q", repo.langStored, "fr")
|
||||
}
|
||||
if got := s.Lang(); got != "fr" {
|
||||
t.Errorf("session Lang() = %q, want %q", got, "fr")
|
||||
}
|
||||
if n := drainChatResponses(s); n != 1 {
|
||||
t.Errorf("chat responses = %d, want 1 (success)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChatCommand_Lang_InvalidCodeRejected(t *testing.T) {
|
||||
setupCommandsMap(true)
|
||||
repo := &mockUserRepoCommands{}
|
||||
s := createCommandSession(repo)
|
||||
s.server.erupeConfig.Language = "en"
|
||||
|
||||
parseChatCommand(s, "!lang klingon")
|
||||
|
||||
if repo.langSetCalled {
|
||||
t.Error("SetLanguage should not be called for unknown code")
|
||||
}
|
||||
if got := s.Lang(); got != "en" {
|
||||
t.Errorf("session Lang() = %q, want unchanged %q", got, "en")
|
||||
}
|
||||
if n := drainChatResponses(s); n != 1 {
|
||||
t.Errorf("chat responses = %d, want 1 (invalid message)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChatCommand_Lang_DisabledNonOp(t *testing.T) {
|
||||
setupCommandsMap(false)
|
||||
repo := &mockUserRepoCommands{opResult: false}
|
||||
s := createCommandSession(repo)
|
||||
s.server.erupeConfig.Language = "en"
|
||||
|
||||
parseChatCommand(s, "!lang fr")
|
||||
|
||||
if repo.langSetCalled {
|
||||
t.Error("SetLanguage should not be called when command is disabled for non-op")
|
||||
}
|
||||
if got := s.Lang(); got != "en" {
|
||||
t.Errorf("session Lang() = %q, want unchanged %q", got, "en")
|
||||
}
|
||||
if n := drainChatResponses(s); n != 1 {
|
||||
t.Errorf("chat responses = %d, want 1 (disabled message)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChatCommand_Lang_UppercaseNormalized(t *testing.T) {
|
||||
setupCommandsMap(true)
|
||||
repo := &mockUserRepoCommands{}
|
||||
s := createCommandSession(repo)
|
||||
s.server.erupeConfig.Language = "en"
|
||||
|
||||
parseChatCommand(s, "!lang FR")
|
||||
|
||||
if repo.langStored != "fr" {
|
||||
t.Errorf("langStored = %q, want %q (should be lowercased)", repo.langStored, "fr")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unknown command ---
|
||||
|
||||
func TestParseChatCommand_UnknownCommand(t *testing.T) {
|
||||
|
||||
@@ -76,6 +76,15 @@ func handleMsgSysLogin(s *Session, p mhfpacket.MHFPacket) {
|
||||
}
|
||||
s.userID = userID
|
||||
|
||||
// Load per-user language preference. A DB error or an empty value both
|
||||
// mean "use the server default", which is what Session.Lang() returns
|
||||
// when clientLang is empty — so we don't need to fail the login here.
|
||||
if lang, langErr := s.server.userRepo.GetLanguage(userID); langErr == nil && lang != "" {
|
||||
s.SetLang(lang)
|
||||
} else if langErr != nil {
|
||||
s.logger.Warn("Failed to load user language preference", zap.Error(langErr), zap.Uint32("userID", userID))
|
||||
}
|
||||
|
||||
if s.captureConn != nil {
|
||||
s.captureConn.SetSessionInfo(s.charID, s.userID)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ func langEnglish() i18n {
|
||||
i.commands.timer.enabled = "Quest timer enabled"
|
||||
i.commands.timer.disabled = "Quest timer disabled"
|
||||
|
||||
i.commands.lang.usage = "Usage: %s <en|jp|fr|es>"
|
||||
i.commands.lang.invalid = "Unknown language %q. Supported: en, jp, fr, es"
|
||||
i.commands.lang.success = "Language set to %s"
|
||||
i.commands.lang.current = "Current language: %s"
|
||||
|
||||
i.commands.ravi.noCommand = "No Raviente command specified!"
|
||||
i.commands.ravi.start.success = "The Great Slaying will begin in a moment"
|
||||
i.commands.ravi.start.error = "The Great Slaying has already begun!"
|
||||
|
||||
@@ -39,6 +39,11 @@ func langSpanish() i18n {
|
||||
i.commands.timer.enabled = "Temporizador de misión activado"
|
||||
i.commands.timer.disabled = "Temporizador de misión desactivado"
|
||||
|
||||
i.commands.lang.usage = "Uso: %s <en|jp|fr|es>"
|
||||
i.commands.lang.invalid = "Idioma desconocido %q. Compatibles: en, jp, fr, es"
|
||||
i.commands.lang.success = "Idioma establecido en %s"
|
||||
i.commands.lang.current = "Idioma actual: %s"
|
||||
|
||||
i.commands.ravi.noCommand = "No se especificó ningún comando de Raviente"
|
||||
i.commands.ravi.start.success = "La Gran Cacería comenzará en un momento"
|
||||
i.commands.ravi.start.error = "¡La Gran Cacería ya ha comenzado!"
|
||||
|
||||
@@ -39,6 +39,11 @@ func langFrench() i18n {
|
||||
i.commands.timer.enabled = "Minuteur de quête activé"
|
||||
i.commands.timer.disabled = "Minuteur de quête désactivé"
|
||||
|
||||
i.commands.lang.usage = "Utilisation : %s <en|jp|fr|es>"
|
||||
i.commands.lang.invalid = "Langue inconnue %q. Prises en charge : en, jp, fr, es"
|
||||
i.commands.lang.success = "Langue définie sur %s"
|
||||
i.commands.lang.current = "Langue actuelle : %s"
|
||||
|
||||
i.commands.ravi.noCommand = "Aucune commande Raviente spécifiée !"
|
||||
i.commands.ravi.start.success = "La Grande Chasse va commencer dans un instant"
|
||||
i.commands.ravi.start.error = "La Grande Chasse a déjà commencé !"
|
||||
|
||||
@@ -39,6 +39,11 @@ func langJapanese() i18n {
|
||||
i.commands.timer.enabled = "クエストタイマーが有効になりました"
|
||||
i.commands.timer.disabled = "クエストタイマーが無効になりました"
|
||||
|
||||
i.commands.lang.usage = "使い方: %s <en|jp|fr|es>"
|
||||
i.commands.lang.invalid = "未対応の言語 %q。対応言語: en, jp, fr, es"
|
||||
i.commands.lang.success = "言語を %s に設定しました"
|
||||
i.commands.lang.current = "現在の言語: %s"
|
||||
|
||||
i.commands.ravi.noCommand = "ラヴィコマンドが指定されていません"
|
||||
i.commands.ravi.start.success = "大討伐を開始します"
|
||||
i.commands.ravi.start.error = "大討伐は既に開催されています"
|
||||
|
||||
@@ -159,6 +159,13 @@ type UserRepo interface {
|
||||
SetPasswordByDiscordID(discordID string, hash []byte) error
|
||||
GetByIDAndUsername(charID uint32) (userID uint32, username string, err error)
|
||||
BanUser(userID uint32, expires *time.Time) error
|
||||
// GetLanguage returns the user's preferred language code (e.g. "en", "jp").
|
||||
// An empty string means the user has no preference set and the server
|
||||
// default should be used.
|
||||
GetLanguage(userID uint32) (string, error)
|
||||
// SetLanguage stores the user's preferred language code. Passing an empty
|
||||
// string clears the preference (reverts to server default on next login).
|
||||
SetLanguage(userID uint32, lang string) error
|
||||
}
|
||||
|
||||
// GachaRepo defines the contract for gacha system data access.
|
||||
|
||||
@@ -672,6 +672,8 @@ func (m *mockUserRepoForItems) GetByIDAndUsername(_ uint32) (uint32, string, err
|
||||
return 0, "", nil
|
||||
}
|
||||
func (m *mockUserRepoForItems) BanUser(_ uint32, _ *time.Time) error { return nil }
|
||||
func (m *mockUserRepoForItems) GetLanguage(_ uint32) (string, error) { return "", nil }
|
||||
func (m *mockUserRepoForItems) SetLanguage(_ uint32, _ string) error { return nil }
|
||||
|
||||
// --- mockStampRepoForItems ---
|
||||
|
||||
|
||||
@@ -146,6 +146,31 @@ func (r *UserRepository) SetTimer(userID uint32, value bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLanguage returns the user's preferred language code. An empty string
|
||||
// means "no preference set" (caller should fall back to the server default).
|
||||
func (r *UserRepository) GetLanguage(userID uint32) (string, error) {
|
||||
var lang sql.NullString
|
||||
err := r.db.QueryRow(`SELECT language FROM users WHERE id=$1`, userID).Scan(&lang)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !lang.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return lang.String, nil
|
||||
}
|
||||
|
||||
// SetLanguage stores the user's preferred language code. An empty string
|
||||
// clears the preference.
|
||||
func (r *UserRepository) SetLanguage(userID uint32, lang string) error {
|
||||
if lang == "" {
|
||||
_, err := r.db.Exec(`UPDATE users SET language=NULL WHERE id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
_, err := r.db.Exec(`UPDATE users SET language=$1 WHERE id=$2`, lang, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountByPSNID returns the number of users with the given PSN ID.
|
||||
func (r *UserRepository) CountByPSNID(psnID string) (int, error) {
|
||||
var count int
|
||||
|
||||
@@ -60,6 +60,12 @@ type i18n struct {
|
||||
enabled string
|
||||
disabled string
|
||||
}
|
||||
lang struct {
|
||||
usage string
|
||||
invalid string
|
||||
success string
|
||||
current string
|
||||
}
|
||||
ravi struct {
|
||||
noCommand string
|
||||
start struct {
|
||||
@@ -132,17 +138,44 @@ func (i *i18n) beadDescription(beadType int) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// getLangStrings returns the i18n string table for the configured language,
|
||||
// falling back to English for unknown language codes.
|
||||
func getLangStrings(s *Server) i18n {
|
||||
switch s.erupeConfig.Language {
|
||||
// supportedLangs lists the language codes the server can serve. Kept in one
|
||||
// place so the !lang command validator and future API handlers stay in sync
|
||||
// with getLangStringsFor.
|
||||
var supportedLangs = []string{"en", "jp", "fr", "es"}
|
||||
|
||||
// isSupportedLang reports whether the given code is one the server can serve.
|
||||
func isSupportedLang(code string) bool {
|
||||
for _, l := range supportedLangs {
|
||||
if l == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getLangStringsFor returns the i18n string table for the given language code,
|
||||
// falling back to English for unknown or empty codes. This is the primitive
|
||||
// callers should use when they have a concrete language (e.g. a per-session
|
||||
// preference from the database); callers that only want the server default
|
||||
// should use getLangStrings.
|
||||
func getLangStringsFor(lang string) i18n {
|
||||
switch lang {
|
||||
case "jp":
|
||||
return langJapanese()
|
||||
case "fr":
|
||||
return langFrench()
|
||||
case "es":
|
||||
return langSpanish()
|
||||
case "en":
|
||||
return langEnglish()
|
||||
default:
|
||||
return langEnglish()
|
||||
}
|
||||
}
|
||||
|
||||
// getLangStrings returns the i18n string table for the server's globally
|
||||
// configured language. Per-session localization should resolve the language
|
||||
// first and call getLangStringsFor directly.
|
||||
func getLangStrings(s *Server) i18n {
|
||||
return getLangStringsFor(s.erupeConfig.Language)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,72 @@ func checkNoEmptyStrings(t *testing.T, v reflect.Value, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLangStringsFor covers every supported language code and the
|
||||
// unknown-code fallback, ensuring the new direct-dispatch primitive stays in
|
||||
// sync with supportedLangs.
|
||||
func TestGetLangStringsFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
code string
|
||||
wantLang string
|
||||
wantNonJP bool // ensure unknown falls back to English, not Japanese
|
||||
wantPrefix string
|
||||
}{
|
||||
{"en", "English", true, ""},
|
||||
{"jp", "日本語", false, ""},
|
||||
{"fr", "Français", true, ""},
|
||||
{"es", "Español", true, ""},
|
||||
{"", "English", true, ""},
|
||||
{"xx", "English", true, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.code, func(t *testing.T) {
|
||||
got := getLangStringsFor(tc.code)
|
||||
if got.language != tc.wantLang {
|
||||
t.Errorf("getLangStringsFor(%q).language = %q, want %q", tc.code, got.language, tc.wantLang)
|
||||
}
|
||||
if got.commands.lang.usage == "" {
|
||||
t.Errorf("%q: commands.lang.usage should not be empty", tc.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSupportedLang(t *testing.T) {
|
||||
for _, code := range []string{"en", "jp", "fr", "es"} {
|
||||
if !isSupportedLang(code) {
|
||||
t.Errorf("isSupportedLang(%q) = false, want true", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []string{"", "de", "EN", "english"} {
|
||||
if isSupportedLang(code) {
|
||||
t.Errorf("isSupportedLang(%q) = true, want false", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLang_FallbackAndOverride verifies that Session.Lang() returns the
|
||||
// server default when no per-session preference is set, and the preference
|
||||
// once SetLang is called.
|
||||
func TestSessionLang_FallbackAndOverride(t *testing.T) {
|
||||
server := &Server{erupeConfig: &cfg.Config{Language: "jp"}}
|
||||
s := &Session{server: server}
|
||||
|
||||
if got := s.Lang(); got != "jp" {
|
||||
t.Errorf("Lang() with no override = %q, want %q (server default)", got, "jp")
|
||||
}
|
||||
|
||||
s.SetLang("fr")
|
||||
if got := s.Lang(); got != "fr" {
|
||||
t.Errorf("Lang() after SetLang(fr) = %q, want %q", got, "fr")
|
||||
}
|
||||
|
||||
// Empty SetLang clears the override — falls back to server default again.
|
||||
s.SetLang("")
|
||||
if got := s.Lang(); got != "jp" {
|
||||
t.Errorf("Lang() after SetLang(\"\") = %q, want %q (server default)", got, "jp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLangCompleteness(t *testing.T) {
|
||||
languages := map[string]i18n{
|
||||
"en": langEnglish(),
|
||||
|
||||
@@ -48,6 +48,7 @@ type Session struct {
|
||||
prevGuildID uint32 // Stores the last GuildID used in InfoGuild
|
||||
charID uint32
|
||||
userID uint32
|
||||
clientLang string // Per-session language preference; empty = use server default
|
||||
logKey []byte
|
||||
sessionStart int64
|
||||
courses []mhfcourse.Course
|
||||
@@ -109,6 +110,28 @@ func NewSession(server *Server, conn net.Conn) *Session {
|
||||
return s
|
||||
}
|
||||
|
||||
// Lang returns the session's effective language code, falling back to the
|
||||
// server's globally configured language when no per-user preference has been
|
||||
// loaded. Callers should use this instead of reading erupeConfig.Language
|
||||
// directly so that later phases can route localized content per session.
|
||||
func (s *Session) Lang() string {
|
||||
s.Lock()
|
||||
lang := s.clientLang
|
||||
s.Unlock()
|
||||
if lang != "" {
|
||||
return lang
|
||||
}
|
||||
return s.server.erupeConfig.Language
|
||||
}
|
||||
|
||||
// SetLang updates the session's in-memory language preference. Persistence
|
||||
// to the database is the caller's responsibility (via userRepo.SetLanguage).
|
||||
func (s *Session) SetLang(lang string) {
|
||||
s.Lock()
|
||||
s.clientLang = lang
|
||||
s.Unlock()
|
||||
}
|
||||
|
||||
// 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()))
|
||||
|
||||
7
server/migrations/sql/0022_user_language.sql
Normal file
7
server/migrations/sql/0022_user_language.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Per-user preferred language for server-generated content (chat commands,
|
||||
-- mail templates, future localized quest/scenario text). NULL means "use the
|
||||
-- server default" (config.Language). See #188 (server-side multi-language
|
||||
-- support), phase A (plumbing).
|
||||
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN IF NOT EXISTS language TEXT;
|
||||
Reference in New Issue
Block a user