diff --git a/CHANGELOG.md b/CHANGELOG.md index 0994c72da..86533e1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Per-session language preference (phase A of [#188](https://github.com/Mezeporta/Erupe/issues/188)): new `users.language` column (migration `0022_user_language`), `UserRepo.GetLanguage`/`SetLanguage`, `Session.Lang()`/`SetLang()` accessors, and a `!lang ` command to show or change the session's language live. The preference is loaded on login and persisted across sessions; an empty value falls back to `config.Language`. The `getLangStringsFor(code)` primitive is the new way to resolve an i18n table from a concrete code — existing callers keep working via the unchanged `getLangStrings(server)` wrapper. This is plumbing only: localized quest/scenario content comes in phase B. + ### Changed - Shutdown now proceeds in three phases: listeners close immediately on signal, then a passive drain waits up to `ShutdownDrainSeconds` (default 30) for sessions to disconnect naturally, then remaining connections are force-closed. This prevents players from starting new quests after the countdown begins ([#179](https://github.com/Mezeporta/Erupe/issues/179)). diff --git a/config.reference.json b/config.reference.json index d59b37e3a..110ea03e7 100644 --- a/config.reference.json +++ b/config.reference.json @@ -186,6 +186,11 @@ "Enabled": true, "Description": "Show your playtime", "Prefix": "playtime" + }, { + "Name": "Language", + "Enabled": true, + "Description": "Show or change your preferred language (en|jp|fr|es)", + "Prefix": "lang" } ], "Courses": [ diff --git a/config/config.go b/config/config.go index 8e6ed2ca5..0cc19b5c0 100644 --- a/config/config.go +++ b/config/config.go @@ -446,6 +446,7 @@ func registerDefaults() { {Name: "Ban", Enabled: false, Description: "Ban/Temp Ban a user", Prefix: "ban"}, {Name: "Timer", Enabled: true, Description: "Toggle the Quest timer", Prefix: "timer"}, {Name: "Playtime", Enabled: true, Description: "Show your playtime", Prefix: "playtime"}, + {Name: "Language", Enabled: true, Description: "Show or change your preferred language (en|jp|fr|es)", Prefix: "lang"}, }) // Courses diff --git a/config/config_load_test.go b/config/config_load_test.go index 48ede18d7..ea9535f5c 100644 --- a/config/config_load_test.go +++ b/config/config_load_test.go @@ -552,8 +552,8 @@ func TestMinimalConfigDefaults(t *testing.T) { } // Commands should be present - if len(cfg.Commands) != 12 { - t.Errorf("Commands = %d, want 12", len(cfg.Commands)) + if len(cfg.Commands) != 13 { + t.Errorf("Commands = %d, want 13", len(cfg.Commands)) } // Courses should be present @@ -644,8 +644,8 @@ func TestFullConfigBackwardCompat(t *testing.T) { if len(cfg.Entrance.Entries) != 6 { t.Errorf("Entrance.Entries = %d, want 6", len(cfg.Entrance.Entries)) } - if len(cfg.Commands) != 12 { - t.Errorf("Commands = %d, want 12", len(cfg.Commands)) + if len(cfg.Commands) != 13 { + t.Errorf("Commands = %d, want 13", len(cfg.Commands)) } if cfg.GameplayOptions.MaximumNP != 100000 { t.Errorf("MaximumNP = %d, want 100000", cfg.GameplayOptions.MaximumNP) diff --git a/server/channelserver/handlers_commands.go b/server/channelserver/handlers_commands.go index 611541547..6d70d051f 100644 --- a/server/channelserver/handlers_commands.go +++ b/server/channelserver/handlers_commands.go @@ -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 { diff --git a/server/channelserver/handlers_commands_test.go b/server/channelserver/handlers_commands_test.go index 3b23aa0a3..aa9b27540 100644 --- a/server/channelserver/handlers_commands_test.go +++ b/server/channelserver/handlers_commands_test.go @@ -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) { diff --git a/server/channelserver/handlers_session.go b/server/channelserver/handlers_session.go index 71713b6c2..284be833a 100644 --- a/server/channelserver/handlers_session.go +++ b/server/channelserver/handlers_session.go @@ -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) } diff --git a/server/channelserver/lang_en.go b/server/channelserver/lang_en.go index 797c79388..ed190c23d 100644 --- a/server/channelserver/lang_en.go +++ b/server/channelserver/lang_en.go @@ -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 " + 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!" diff --git a/server/channelserver/lang_es.go b/server/channelserver/lang_es.go index c1f1cdd44..231c7a2ea 100644 --- a/server/channelserver/lang_es.go +++ b/server/channelserver/lang_es.go @@ -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 " + 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!" diff --git a/server/channelserver/lang_fr.go b/server/channelserver/lang_fr.go index 2db839b84..bdc7d251d 100644 --- a/server/channelserver/lang_fr.go +++ b/server/channelserver/lang_fr.go @@ -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 " + 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é !" diff --git a/server/channelserver/lang_jp.go b/server/channelserver/lang_jp.go index c4ff8b0a8..3d039edd6 100644 --- a/server/channelserver/lang_jp.go +++ b/server/channelserver/lang_jp.go @@ -39,6 +39,11 @@ func langJapanese() i18n { i.commands.timer.enabled = "クエストタイマーが有効になりました" i.commands.timer.disabled = "クエストタイマーが無効になりました" + i.commands.lang.usage = "使い方: %s " + 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 = "大討伐は既に開催されています" diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index b25087b6f..02ac7efab 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -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. diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index f7085890a..58916b342 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -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 --- diff --git a/server/channelserver/repo_user.go b/server/channelserver/repo_user.go index 919f33dd1..3c9a1ddf6 100644 --- a/server/channelserver/repo_user.go +++ b/server/channelserver/repo_user.go @@ -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 diff --git a/server/channelserver/sys_language.go b/server/channelserver/sys_language.go index 6c970909f..577d9c1f7 100644 --- a/server/channelserver/sys_language.go +++ b/server/channelserver/sys_language.go @@ -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) +} diff --git a/server/channelserver/sys_language_test.go b/server/channelserver/sys_language_test.go index d0665c4d6..63c4b3dbd 100644 --- a/server/channelserver/sys_language_test.go +++ b/server/channelserver/sys_language_test.go @@ -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(), diff --git a/server/channelserver/sys_session.go b/server/channelserver/sys_session.go index 6f3c7a235..f31a61045 100644 --- a/server/channelserver/sys_session.go +++ b/server/channelserver/sys_session.go @@ -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())) diff --git a/server/migrations/sql/0022_user_language.sql b/server/migrations/sql/0022_user_language.sql new file mode 100644 index 000000000..52b40c83b --- /dev/null +++ b/server/migrations/sql/0022_user_language.sql @@ -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;