feat(i18n): per-session i18n routing and localized scenarios

Phase C of #188 — the last phase of server-side multi-language support.

Adds Session.I18n(), a cached per-session i18n table resolver built via
getLangStringsFor(s.Lang()). The pointer is stable until SetLang
invalidates the cache, so hot-path handlers pay zero allocations on
repeated calls. All 51 s.server.i18n.* call sites across commands,
guild, guild scout, cafe, and cast-binary handlers now route through
s.I18n().*, so chat replies, guild invite mail templates, cafe reset
notices, and quest-timer broadcasts are served in the player's
preferred language instead of the server-wide default.

Scenario JSON gets the same plain-or-map LocalizedString treatment
that quests received in phase B: subheader Strings and inline entry
Text accept either a plain string (backwards compatible) or a
language-keyed object. CompileScenarioJSON takes the compiling
session's language, loadScenarioBinary passes s.Lang(), and
ParseScenarioBinary emits plain-string LocalizedStrings so existing
.bin files round-trip byte-for-byte through the JSON path.

World-wide broadcasts (Raviente siege announcements via
BroadcastRaviente) intentionally stay on the server default — they
have no single-session context to resolve against.
This commit is contained in:
Houmgaor
2026-04-06 20:08:27 +02:00
parent f7ea275540
commit 5361e67b1a
11 changed files with 278 additions and 99 deletions

View File

@@ -41,7 +41,7 @@ func initCommands(cmds []cfg.Command, logger *zap.Logger) {
}
func sendDisabledCommandMessage(s *Session, cmd cfg.Command) {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.disabled, cmd.Name))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.disabled, cmd.Name))
}
const chatFlagServer = 0x80 // marks a message as server-originated
@@ -95,7 +95,7 @@ func parseChatCommand(s *Session, command string) {
expiry = time.Now().Add(time.Duration(length) * time.Hour * 24 * 365)
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.ban.error)
sendServerChatMessage(s, s.I18n().commands.ban.error)
return
}
}
@@ -107,25 +107,25 @@ func parseChatCommand(s *Session, command string) {
if err := s.server.userRepo.BanUser(uid, nil); err != nil {
s.logger.Error("Failed to ban user", zap.Error(err))
}
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.ban.success, uname))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.ban.success, uname))
} else {
if err := s.server.userRepo.BanUser(uid, &expiry); err != nil {
s.logger.Error("Failed to ban user with expiry", zap.Error(err))
}
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.ban.success, uname)+fmt.Sprintf(s.server.i18n.commands.ban.length, expiry.Format(time.DateTime)))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.ban.success, uname)+fmt.Sprintf(s.I18n().commands.ban.length, expiry.Format(time.DateTime)))
}
s.server.DisconnectUser(uid)
} else {
sendServerChatMessage(s, s.server.i18n.commands.ban.noUser)
sendServerChatMessage(s, s.I18n().commands.ban.noUser)
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.ban.invalid)
sendServerChatMessage(s, s.I18n().commands.ban.invalid)
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.ban.error)
sendServerChatMessage(s, s.I18n().commands.ban.error)
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.noOp)
sendServerChatMessage(s, s.I18n().commands.noOp)
}
case commands["Timer"].Prefix:
if commands["Timer"].Enabled || s.isOp() {
@@ -137,9 +137,9 @@ func parseChatCommand(s *Session, command string) {
s.logger.Error("Failed to update timer setting", zap.Error(err))
}
if state {
sendServerChatMessage(s, s.server.i18n.commands.timer.disabled)
sendServerChatMessage(s, s.I18n().commands.timer.disabled)
} else {
sendServerChatMessage(s, s.server.i18n.commands.timer.enabled)
sendServerChatMessage(s, s.I18n().commands.timer.enabled)
}
} else {
sendDisabledCommandMessage(s, commands["Timer"])
@@ -181,20 +181,20 @@ func parseChatCommand(s *Session, command string) {
if exists == 0 {
err := s.server.userRepo.SetPSNID(s.userID, args[1])
if err == nil {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.psn.success, args[1]))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.psn.success, args[1]))
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.psn.exists)
sendServerChatMessage(s, s.I18n().commands.psn.exists)
}
} else {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.psn.error, commands["PSN"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.psn.error, commands["PSN"].Prefix))
}
} else {
sendDisabledCommandMessage(s, commands["PSN"])
}
case commands["Reload"].Prefix:
if commands["Reload"].Enabled || s.isOp() {
sendServerChatMessage(s, s.server.i18n.commands.reload)
sendServerChatMessage(s, s.I18n().commands.reload)
var temp mhfpacket.MHFPacket
deleteNotif := byteframe.NewByteFrame()
for _, object := range s.stage.objects {
@@ -256,24 +256,24 @@ func parseChatCommand(s *Session, command string) {
case commands["KeyQuest"].Prefix:
if commands["KeyQuest"].Enabled || s.isOp() {
if s.server.erupeConfig.RealClientMode < cfg.G10 {
sendServerChatMessage(s, s.server.i18n.commands.kqf.version)
sendServerChatMessage(s, s.I18n().commands.kqf.version)
} else {
if len(args) > 1 {
switch args[1] {
case "get":
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.kqf.get, s.kqf))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.kqf.get, s.kqf))
case "set":
if len(args) > 2 && len(args[2]) == 16 {
hexd, err := hex.DecodeString(args[2])
if err != nil {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.kqf.set.error, commands["KeyQuest"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.kqf.set.error, commands["KeyQuest"].Prefix))
return
}
s.kqf = hexd
s.kqfOverride = true
sendServerChatMessage(s, s.server.i18n.commands.kqf.set.success)
sendServerChatMessage(s, s.I18n().commands.kqf.set.success)
} else {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.kqf.set.error, commands["KeyQuest"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.kqf.set.error, commands["KeyQuest"].Prefix))
}
}
}
@@ -286,17 +286,17 @@ func parseChatCommand(s *Session, command string) {
if len(args) > 1 {
v, err := strconv.Atoi(args[1])
if err != nil || v < 0 || v > math.MaxUint32 {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.rights.error, commands["Rights"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.rights.error, commands["Rights"].Prefix))
return
}
err = s.server.userRepo.SetRights(s.userID, uint32(v))
if err == nil {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.rights.success, v))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.rights.success, v))
} else {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.rights.error, commands["Rights"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.rights.error, commands["Rights"].Prefix))
}
} else {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.rights.error, commands["Rights"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.rights.error, commands["Rights"].Prefix))
}
} else {
sendDisabledCommandMessage(s, commands["Rights"])
@@ -320,11 +320,11 @@ func parseChatCommand(s *Session, command string) {
})
if ei != -1 {
delta = uint32(-1 * math.Pow(2, float64(course.ID)))
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.course.disabled, course.Aliases()[0]))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.course.disabled, course.Aliases()[0]))
}
} else {
delta = uint32(math.Pow(2, float64(course.ID)))
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.course.enabled, course.Aliases()[0]))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.course.enabled, course.Aliases()[0]))
}
rightsInt, err := s.server.userRepo.GetRights(s.userID)
if err == nil {
@@ -334,14 +334,14 @@ func parseChatCommand(s *Session, command string) {
}
updateRights(s)
} else {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.course.locked, course.Aliases()[0]))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.course.locked, course.Aliases()[0]))
}
return
}
}
}
} else {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.course.error, commands["Course"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.course.error, commands["Course"].Prefix))
}
} else {
sendDisabledCommandMessage(s, commands["Course"])
@@ -354,45 +354,45 @@ func parseChatCommand(s *Session, command string) {
case "start":
if s.server.raviente.register[1] == 0 {
s.server.raviente.register[1] = s.server.raviente.register[3]
sendServerChatMessage(s, s.server.i18n.commands.ravi.start.success)
sendServerChatMessage(s, s.I18n().commands.ravi.start.success)
s.notifyRavi()
} else {
sendServerChatMessage(s, s.server.i18n.commands.ravi.start.error)
sendServerChatMessage(s, s.I18n().commands.ravi.start.error)
}
case "cm", "check", "checkmultiplier", "multiplier":
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.ravi.multiplier, s.server.GetRaviMultiplier()))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.ravi.multiplier, s.server.GetRaviMultiplier()))
case "sr", "sendres", "resurrection", "ss", "sendsed", "rs", "reqsed":
if s.server.erupeConfig.RealClientMode == cfg.ZZ {
switch args[1] {
case "sr", "sendres", "resurrection":
if s.server.raviente.state[28] > 0 {
sendServerChatMessage(s, s.server.i18n.commands.ravi.res.success)
sendServerChatMessage(s, s.I18n().commands.ravi.res.success)
s.server.raviente.state[28] = 0
} else {
sendServerChatMessage(s, s.server.i18n.commands.ravi.res.error)
sendServerChatMessage(s, s.I18n().commands.ravi.res.error)
}
case "ss", "sendsed":
sendServerChatMessage(s, s.server.i18n.commands.ravi.sed.success)
sendServerChatMessage(s, s.I18n().commands.ravi.sed.success)
// Total BerRavi HP
HP := s.server.raviente.state[0] + s.server.raviente.state[1] + s.server.raviente.state[2] + s.server.raviente.state[3] + s.server.raviente.state[4]
s.server.raviente.support[1] = HP
case "rs", "reqsed":
sendServerChatMessage(s, s.server.i18n.commands.ravi.request)
sendServerChatMessage(s, s.I18n().commands.ravi.request)
// Total BerRavi HP
HP := s.server.raviente.state[0] + s.server.raviente.state[1] + s.server.raviente.state[2] + s.server.raviente.state[3] + s.server.raviente.state[4]
s.server.raviente.support[1] = HP + 1
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.ravi.version)
sendServerChatMessage(s, s.I18n().commands.ravi.version)
}
default:
sendServerChatMessage(s, s.server.i18n.commands.ravi.error)
sendServerChatMessage(s, s.I18n().commands.ravi.error)
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.ravi.noPlayers)
sendServerChatMessage(s, s.I18n().commands.ravi.noPlayers)
}
} else {
sendServerChatMessage(s, s.server.i18n.commands.ravi.error)
sendServerChatMessage(s, s.I18n().commands.ravi.error)
}
} else {
sendDisabledCommandMessage(s, commands["Raviente"])
@@ -402,12 +402,12 @@ func parseChatCommand(s *Session, command string) {
if len(args) > 2 {
x, err := strconv.ParseInt(args[1], 10, 16)
if err != nil {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.teleport.error, commands["Teleport"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.teleport.error, commands["Teleport"].Prefix))
return
}
y, err := strconv.ParseInt(args[2], 10, 16)
if err != nil {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.teleport.error, commands["Teleport"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.teleport.error, commands["Teleport"].Prefix))
return
}
payload := byteframe.NewByteFrame()
@@ -421,9 +421,9 @@ func parseChatCommand(s *Session, command string) {
MessageType: BinaryMessageTypeState,
RawDataPayload: payloadBytes,
})
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.teleport.success, x, y))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.teleport.success, x, y))
} else {
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.teleport.error, commands["Teleport"].Prefix))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.teleport.error, commands["Teleport"].Prefix))
}
} else {
sendDisabledCommandMessage(s, commands["Teleport"])
@@ -439,14 +439,14 @@ func parseChatCommand(s *Session, command string) {
s.logger.Error("Failed to update discord token", zap.Error(err))
}
}
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.discord.success, _token))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.discord.success, _token))
} else {
sendDisabledCommandMessage(s, commands["Discord"])
}
case commands["Playtime"].Prefix:
if commands["Playtime"].Enabled || s.isOp() {
playtime := s.playtime + uint32(time.Since(s.playtimeTime).Seconds())
sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.commands.playtime, playtime/60/60, playtime/60%60, playtime%60))
sendServerChatMessage(s, fmt.Sprintf(s.I18n().commands.playtime, playtime/60/60, playtime/60%60, playtime%60))
} else {
sendDisabledCommandMessage(s, commands["Playtime"])
}