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:
Houmgaor
2026-04-06 19:52:19 +02:00
parent 803996adac
commit 5b38bfde3f
18 changed files with 341 additions and 8 deletions

View File

@@ -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)).

View File

@@ -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": [

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)
} }

View File

@@ -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!"

View File

@@ -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!"

View File

@@ -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é !"

View File

@@ -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 = "大討伐は既に開催されています"

View File

@@ -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.

View File

@@ -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 ---

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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(),

View File

@@ -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()))

View 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;