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:
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 <en|jp|fr|es>` 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
|
### 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)).
|
- 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)).
|
||||||
|
|||||||
@@ -186,6 +186,11 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"Description": "Show your playtime",
|
"Description": "Show your playtime",
|
||||||
"Prefix": "playtime"
|
"Prefix": "playtime"
|
||||||
|
}, {
|
||||||
|
"Name": "Language",
|
||||||
|
"Enabled": true,
|
||||||
|
"Description": "Show or change your preferred language (en|jp|fr|es)",
|
||||||
|
"Prefix": "lang"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Courses": [
|
"Courses": [
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ func registerDefaults() {
|
|||||||
{Name: "Ban", Enabled: false, Description: "Ban/Temp Ban a user", Prefix: "ban"},
|
{Name: "Ban", Enabled: false, Description: "Ban/Temp Ban a user", Prefix: "ban"},
|
||||||
{Name: "Timer", Enabled: true, Description: "Toggle the Quest timer", Prefix: "timer"},
|
{Name: "Timer", Enabled: true, Description: "Toggle the Quest timer", Prefix: "timer"},
|
||||||
{Name: "Playtime", Enabled: true, Description: "Show your playtime", Prefix: "playtime"},
|
{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
|
// Courses
|
||||||
|
|||||||
@@ -552,8 +552,8 @@ func TestMinimalConfigDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commands should be present
|
// Commands should be present
|
||||||
if len(cfg.Commands) != 12 {
|
if len(cfg.Commands) != 13 {
|
||||||
t.Errorf("Commands = %d, want 12", len(cfg.Commands))
|
t.Errorf("Commands = %d, want 13", len(cfg.Commands))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Courses should be present
|
// Courses should be present
|
||||||
@@ -644,8 +644,8 @@ func TestFullConfigBackwardCompat(t *testing.T) {
|
|||||||
if len(cfg.Entrance.Entries) != 6 {
|
if len(cfg.Entrance.Entries) != 6 {
|
||||||
t.Errorf("Entrance.Entries = %d, want 6", len(cfg.Entrance.Entries))
|
t.Errorf("Entrance.Entries = %d, want 6", len(cfg.Entrance.Entries))
|
||||||
}
|
}
|
||||||
if len(cfg.Commands) != 12 {
|
if len(cfg.Commands) != 13 {
|
||||||
t.Errorf("Commands = %d, want 12", len(cfg.Commands))
|
t.Errorf("Commands = %d, want 13", len(cfg.Commands))
|
||||||
}
|
}
|
||||||
if cfg.GameplayOptions.MaximumNP != 100000 {
|
if cfg.GameplayOptions.MaximumNP != 100000 {
|
||||||
t.Errorf("MaximumNP = %d, want 100000", cfg.GameplayOptions.MaximumNP)
|
t.Errorf("MaximumNP = %d, want 100000", cfg.GameplayOptions.MaximumNP)
|
||||||
|
|||||||
@@ -144,6 +144,33 @@ func parseChatCommand(s *Session, command string) {
|
|||||||
} else {
|
} else {
|
||||||
sendDisabledCommandMessage(s, commands["Timer"])
|
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:
|
case commands["PSN"].Prefix:
|
||||||
if commands["PSN"].Enabled || s.isOp() {
|
if commands["PSN"].Enabled || s.isOp() {
|
||||||
if len(args) > 1 {
|
if len(args) > 1 {
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ type mockUserRepoCommands struct {
|
|||||||
// Rights
|
// Rights
|
||||||
rightsVal uint32
|
rightsVal uint32
|
||||||
setRightsVal uint32
|
setRightsVal uint32
|
||||||
|
|
||||||
|
// Language
|
||||||
|
langStored string
|
||||||
|
langSetCalled bool
|
||||||
|
langSetErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockUserRepoCommands) IsOp(_ uint32) (bool, error) { return m.opResult, nil }
|
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
|
m.setRightsVal = v
|
||||||
return nil
|
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 ---
|
// --- helpers ---
|
||||||
|
|
||||||
@@ -100,6 +111,7 @@ func setupCommandsMap(allEnabled bool) {
|
|||||||
"Discord": {Name: "Discord", Prefix: "discord", Enabled: allEnabled},
|
"Discord": {Name: "Discord", Prefix: "discord", Enabled: allEnabled},
|
||||||
"Playtime": {Name: "Playtime", Prefix: "playtime", Enabled: allEnabled},
|
"Playtime": {Name: "Playtime", Prefix: "playtime", Enabled: allEnabled},
|
||||||
"Help": {Name: "Help", Prefix: "help", 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 ---
|
// --- Unknown command ---
|
||||||
|
|
||||||
func TestParseChatCommand_UnknownCommand(t *testing.T) {
|
func TestParseChatCommand_UnknownCommand(t *testing.T) {
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ func handleMsgSysLogin(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
}
|
}
|
||||||
s.userID = userID
|
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 {
|
if s.captureConn != nil {
|
||||||
s.captureConn.SetSessionInfo(s.charID, s.userID)
|
s.captureConn.SetSessionInfo(s.charID, s.userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ func langEnglish() i18n {
|
|||||||
i.commands.timer.enabled = "Quest timer enabled"
|
i.commands.timer.enabled = "Quest timer enabled"
|
||||||
i.commands.timer.disabled = "Quest timer disabled"
|
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.noCommand = "No Raviente command specified!"
|
||||||
i.commands.ravi.start.success = "The Great Slaying will begin in a moment"
|
i.commands.ravi.start.success = "The Great Slaying will begin in a moment"
|
||||||
i.commands.ravi.start.error = "The Great Slaying has already begun!"
|
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.enabled = "Temporizador de misión activado"
|
||||||
i.commands.timer.disabled = "Temporizador de misión desactivado"
|
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.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.success = "La Gran Cacería comenzará en un momento"
|
||||||
i.commands.ravi.start.error = "¡La Gran Cacería ya ha comenzado!"
|
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.enabled = "Minuteur de quête activé"
|
||||||
i.commands.timer.disabled = "Minuteur de quête désactivé"
|
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.noCommand = "Aucune commande Raviente spécifiée !"
|
||||||
i.commands.ravi.start.success = "La Grande Chasse va commencer dans un instant"
|
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é !"
|
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.enabled = "クエストタイマーが有効になりました"
|
||||||
i.commands.timer.disabled = "クエストタイマーが無効になりました"
|
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.noCommand = "ラヴィコマンドが指定されていません"
|
||||||
i.commands.ravi.start.success = "大討伐を開始します"
|
i.commands.ravi.start.success = "大討伐を開始します"
|
||||||
i.commands.ravi.start.error = "大討伐は既に開催されています"
|
i.commands.ravi.start.error = "大討伐は既に開催されています"
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ type UserRepo interface {
|
|||||||
SetPasswordByDiscordID(discordID string, hash []byte) error
|
SetPasswordByDiscordID(discordID string, hash []byte) error
|
||||||
GetByIDAndUsername(charID uint32) (userID uint32, username string, err error)
|
GetByIDAndUsername(charID uint32) (userID uint32, username string, err error)
|
||||||
BanUser(userID uint32, expires *time.Time) 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.
|
// GachaRepo defines the contract for gacha system data access.
|
||||||
|
|||||||
@@ -672,6 +672,8 @@ func (m *mockUserRepoForItems) GetByIDAndUsername(_ uint32) (uint32, string, err
|
|||||||
return 0, "", nil
|
return 0, "", nil
|
||||||
}
|
}
|
||||||
func (m *mockUserRepoForItems) BanUser(_ uint32, _ *time.Time) error { return 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 ---
|
// --- mockStampRepoForItems ---
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,31 @@ func (r *UserRepository) SetTimer(userID uint32, value bool) error {
|
|||||||
return err
|
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.
|
// CountByPSNID returns the number of users with the given PSN ID.
|
||||||
func (r *UserRepository) CountByPSNID(psnID string) (int, error) {
|
func (r *UserRepository) CountByPSNID(psnID string) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ type i18n struct {
|
|||||||
enabled string
|
enabled string
|
||||||
disabled string
|
disabled string
|
||||||
}
|
}
|
||||||
|
lang struct {
|
||||||
|
usage string
|
||||||
|
invalid string
|
||||||
|
success string
|
||||||
|
current string
|
||||||
|
}
|
||||||
ravi struct {
|
ravi struct {
|
||||||
noCommand string
|
noCommand string
|
||||||
start struct {
|
start struct {
|
||||||
@@ -132,17 +138,44 @@ func (i *i18n) beadDescription(beadType int) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLangStrings returns the i18n string table for the configured language,
|
// supportedLangs lists the language codes the server can serve. Kept in one
|
||||||
// falling back to English for unknown language codes.
|
// place so the !lang command validator and future API handlers stay in sync
|
||||||
func getLangStrings(s *Server) i18n {
|
// with getLangStringsFor.
|
||||||
switch s.erupeConfig.Language {
|
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":
|
case "jp":
|
||||||
return langJapanese()
|
return langJapanese()
|
||||||
case "fr":
|
case "fr":
|
||||||
return langFrench()
|
return langFrench()
|
||||||
case "es":
|
case "es":
|
||||||
return langSpanish()
|
return langSpanish()
|
||||||
|
case "en":
|
||||||
|
return langEnglish()
|
||||||
default:
|
default:
|
||||||
return langEnglish()
|
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) {
|
func TestLangCompleteness(t *testing.T) {
|
||||||
languages := map[string]i18n{
|
languages := map[string]i18n{
|
||||||
"en": langEnglish(),
|
"en": langEnglish(),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type Session struct {
|
|||||||
prevGuildID uint32 // Stores the last GuildID used in InfoGuild
|
prevGuildID uint32 // Stores the last GuildID used in InfoGuild
|
||||||
charID uint32
|
charID uint32
|
||||||
userID uint32
|
userID uint32
|
||||||
|
clientLang string // Per-session language preference; empty = use server default
|
||||||
logKey []byte
|
logKey []byte
|
||||||
sessionStart int64
|
sessionStart int64
|
||||||
courses []mhfcourse.Course
|
courses []mhfcourse.Course
|
||||||
@@ -109,6 +110,28 @@ func NewSession(server *Server, conn net.Conn) *Session {
|
|||||||
return s
|
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).
|
// Start starts the session packet send and recv loop(s).
|
||||||
func (s *Session) Start() {
|
func (s *Session) Start() {
|
||||||
s.logger.Debug("New connection", zap.String("RemoteAddr", s.rawConn.RemoteAddr().String()))
|
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