diff --git a/CHANGELOG.md b/CHANGELOG.md index 964512cbc..6280c64b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Per-session i18n routing and localized scenarios (phase C of [#188](https://github.com/Mezeporta/Erupe/issues/188)): `Session.I18n()` returns a cached, language-resolved `i18n` table built via `getLangStringsFor(s.Lang())`; `SetLang` invalidates the cache. All 51 existing `s.server.i18n.*` call sites in `handlers_commands.go`, `handlers_guild.go`, `handlers_guild_scout.go`, `handlers_cafe.go`, and `handlers_cast_binary.go` now route through `s.I18n().*`, so chat replies, guild invite mails, cafe reset notices, and quest-timer broadcasts are served in the player's preferred language. Scenario JSON subheader `strings` and inline entry `text` fields now accept the same plain/map `LocalizedString` schema as quests (phase B); `CompileScenarioJSON` takes a language argument and `loadScenarioBinary` passes `s.Lang()`. Round-tripping existing `.bin` files through the JSON path still produces byte-identical output. World-wide broadcasts (Raviente siege announcements via `BroadcastRaviente`) intentionally remain on the server default because they have no single-session context. - Localized quest text (phase B of [#188](https://github.com/Mezeporta/Erupe/issues/188)): quest JSON `title`, `description`, `text_main`, `text_sub_a`, `text_sub_b`, `success_cond`, `fail_cond`, and `contractor` now accept either a plain string (existing behaviour, single-language) or a language-keyed object like `{"jp": "...", "en": "...", "fr": "..."}`. `CompileQuestJSON` takes the requesting session's language and resolves each field with a fallback chain (requested → plain → jp → en → any non-empty). The quest cache is now keyed by `(questID, language)` so compiled binaries for different languages never leak between sessions. `ParseQuestBinary` emits plain strings unchanged, so round-tripping existing `.bin` files through the JSON path produces byte-identical output. New `LocalizedString` type is reusable for phase C (scenarios, mail, shop). Shift-JIS encoding limits still apply — localized strings must use characters representable in Shift-JIS (ASCII, kana, CJK). - 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. diff --git a/server/channelserver/handlers_cafe.go b/server/channelserver/handlers_cafe.go index c7983c421..08918654f 100644 --- a/server/channelserver/handlers_cafe.go +++ b/server/channelserver/handlers_cafe.go @@ -103,7 +103,7 @@ func handleMsgMhfGetCafeDuration(s *Session, p mhfpacket.MHFPacket) { bf.WriteUint32(uint32(cafeTime)) if s.server.erupeConfig.RealClientMode >= cfg.ZZ { bf.WriteUint16(0) - ps.Uint16(bf, fmt.Sprintf(s.server.i18n.cafe.reset, int(cafeReset.Month()), cafeReset.Day()), true) + ps.Uint16(bf, fmt.Sprintf(s.I18n().cafe.reset, int(cafeReset.Month()), cafeReset.Day()), true) } doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } diff --git a/server/channelserver/handlers_cast_binary.go b/server/channelserver/handlers_cast_binary.go index 3ea575a0f..380c93232 100644 --- a/server/channelserver/handlers_cast_binary.go +++ b/server/channelserver/handlers_cast_binary.go @@ -49,7 +49,7 @@ func handleMsgSysCastBinary(s *Session, p mhfpacket.MHFPacket) { _ = tmp.ReadBytes(9) tmp.SetLE() frame := tmp.ReadUint32() - sendServerChatMessage(s, fmt.Sprintf(s.server.i18n.timer, frame/30/60/60, frame/30/60, frame/30%60, int(math.Round(float64(frame%30*100)/3)), frame)) + sendServerChatMessage(s, fmt.Sprintf(s.I18n().timer, frame/30/60/60, frame/30/60, frame/30%60, int(math.Round(float64(frame%30*100)/3)), frame)) } } } diff --git a/server/channelserver/handlers_commands.go b/server/channelserver/handlers_commands.go index 6d70d051f..2dd08bd66 100644 --- a/server/channelserver/handlers_commands.go +++ b/server/channelserver/handlers_commands.go @@ -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"]) } diff --git a/server/channelserver/handlers_guild.go b/server/channelserver/handlers_guild.go index 0e5b5d132..d9b825b83 100644 --- a/server/channelserver/handlers_guild.go +++ b/server/channelserver/handlers_guild.go @@ -376,10 +376,10 @@ func handleMsgMhfEntryRookieGuild(s *Session, p mhfpacket.MHFPacket) { // pkt.Unk==0: fresh rookie entering a rookie guild (return_type=1). // pkt.Unk>=1: returning player entering a comeback/return guild (return_type=2). returnType := uint8(1) - nameTemplate := s.server.i18n.guild.rookieGuildName + nameTemplate := s.I18n().guild.rookieGuildName if pkt.Unk >= 1 { returnType = 2 - nameTemplate = s.server.i18n.guild.returnGuildName + nameTemplate = s.I18n().guild.returnGuildName } guildID, err := s.server.guildRepo.FindOrCreateReturnGuild(returnType, nameTemplate) diff --git a/server/channelserver/handlers_guild_scout.go b/server/channelserver/handlers_guild_scout.go index ab5ea8042..b4d81a767 100644 --- a/server/channelserver/handlers_guild_scout.go +++ b/server/channelserver/handlers_guild_scout.go @@ -13,8 +13,8 @@ func handleMsgMhfPostGuildScout(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfPostGuildScout) err := s.server.guildService.PostScout(s.charID, pkt.CharID, ScoutInviteStrings{ - Title: s.server.i18n.guild.invite.title, - Body: s.server.i18n.guild.invite.body, + Title: s.I18n().guild.invite.title, + Body: s.I18n().guild.invite.body, }) if errors.Is(err, ErrAlreadyInvited) { @@ -66,7 +66,7 @@ func handleMsgMhfCancelGuildScout(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfAnswerGuildScout(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfAnswerGuildScout) - i := s.server.i18n.guild.invite + i := s.I18n().guild.invite result, err := s.server.guildService.AnswerScout(s.charID, pkt.LeaderID, pkt.Answer, AnswerScoutStrings{ SuccessTitle: i.success.title, SuccessBody: i.success.body, diff --git a/server/channelserver/handlers_quest.go b/server/channelserver/handlers_quest.go index 2ac1bd90a..b9bb9af87 100644 --- a/server/channelserver/handlers_quest.go +++ b/server/channelserver/handlers_quest.go @@ -181,7 +181,7 @@ func loadScenarioBinary(s *Session, filename string) ([]byte, error) { if err != nil { return nil, err } - compiled, err := CompileScenarioJSON(jsonData) + compiled, err := CompileScenarioJSON(jsonData, s.Lang()) if err != nil { return nil, fmt.Errorf("compile scenario JSON %s: %w", filename, err) } diff --git a/server/channelserver/scenario_json.go b/server/channelserver/scenario_json.go index 50d24ee65..5171852d9 100644 --- a/server/channelserver/scenario_json.go +++ b/server/channelserver/scenario_json.go @@ -102,16 +102,22 @@ type ScenarioSubheaderJSON struct { // fields, including those the server does not need to interpret. // For chunk0, the client only reads m[0]–m[6]; m[7]–m[9] are ignored. Metadata string `json:"metadata"` - // Strings contains the human-editable text (UTF-8). - // The compiler converts each string to null-terminated Shift-JIS on the wire. - Strings []string `json:"strings"` + // Strings contains the human-editable text. Each entry accepts either a + // plain UTF-8 string (backwards compatible, single-language) or a + // language-keyed object; see LocalizedString. The compiler converts the + // resolved value for the current session's language to null-terminated + // Shift-JIS on the wire. + Strings []LocalizedString `json:"strings"` } // ScenarioInlineEntry is one entry in an inline-format chunk0. // Format on wire: {u8 index}{Shift-JIS string}{0x00}. +// +// Text accepts either a plain string or a language-keyed object; see +// LocalizedString. type ScenarioInlineEntry struct { - Index uint8 `json:"index"` - Text string `json:"text"` + Index uint8 `json:"index"` + Text LocalizedString `json:"text"` } // ScenarioRawChunkJSON stores a JKR-compressed chunk as its raw compressed bytes. @@ -239,17 +245,23 @@ func parseScenarioSubheader(data []byte) (*ScenarioSubheaderJSON, error) { metadata := base64.StdEncoding.EncodeToString(data[8:metaEnd]) - strings, err := scenarioReadStrings(data, metaEnd, entryCount) + plainStrings, err := scenarioReadStrings(data, metaEnd, entryCount) if err != nil { return nil, err } + // Binary carries a single language — wrap as plain LocalizedStrings so + // round-tripping emits the same bytes. + localized := make([]LocalizedString, len(plainStrings)) + for i, s := range plainStrings { + localized[i] = NewLocalizedPlain(s) + } return &ScenarioSubheaderJSON{ Type: chunkType, Unknown1: unknown1, Unknown2: unknown2, Metadata: metadata, - Strings: strings, + Strings: localized, }, nil } @@ -276,7 +288,7 @@ func parseScenarioInline(data []byte) ([]ScenarioInlineEntry, error) { if err != nil { return nil, fmt.Errorf("inline entry at 0x%x: %w", pos, err) } - result = append(result, ScenarioInlineEntry{Index: idx, Text: text}) + result = append(result, ScenarioInlineEntry{Index: idx, Text: NewLocalizedPlain(text)}) } pos = end + 1 // skip null terminator } @@ -317,27 +329,30 @@ func scenarioReadStrings(data []byte, start, maxCount int) ([]string, error) { // ── Compile: JSON → binary ─────────────────────────────────────────────────── -// CompileScenarioJSON parses jsonData and compiles it to MHF scenario binary format. -func CompileScenarioJSON(jsonData []byte) ([]byte, error) { +// CompileScenarioJSON parses jsonData and compiles it to MHF scenario binary +// format for the given language. Phase B of #188 added the lang parameter so +// per-session scenarios can ship localized text without affecting callers +// holding single-language JSON files. +func CompileScenarioJSON(jsonData []byte, lang string) ([]byte, error) { var s ScenarioJSON if err := json.Unmarshal(jsonData, &s); err != nil { return nil, fmt.Errorf("unmarshal scenario JSON: %w", err) } - return compileScenario(&s) + return compileScenario(&s, lang) } -func compileScenario(s *ScenarioJSON) ([]byte, error) { +func compileScenario(s *ScenarioJSON, lang string) ([]byte, error) { var chunk0, chunk1, chunk2 []byte var err error if s.Chunk0 != nil { - chunk0, err = compileScenarioChunk0(s.Chunk0) + chunk0, err = compileScenarioChunk0(s.Chunk0, lang) if err != nil { return nil, fmt.Errorf("chunk0: %w", err) } } if s.Chunk1 != nil { - chunk1, err = compileScenarioChunk1(s.Chunk1) + chunk1, err = compileScenarioChunk1(s.Chunk1, lang) if err != nil { return nil, fmt.Errorf("chunk1: %w", err) } @@ -370,26 +385,26 @@ func compileScenario(s *ScenarioJSON) ([]byte, error) { return buf.Bytes(), nil } -func compileScenarioChunk0(c *ScenarioChunk0JSON) ([]byte, error) { +func compileScenarioChunk0(c *ScenarioChunk0JSON, lang string) ([]byte, error) { if c.Subheader != nil { - return compileScenarioSubheader(c.Subheader) + return compileScenarioSubheader(c.Subheader, lang) } - return compileScenarioInline(c.Inline) + return compileScenarioInline(c.Inline, lang) } -func compileScenarioChunk1(c *ScenarioChunk1JSON) ([]byte, error) { +func compileScenarioChunk1(c *ScenarioChunk1JSON, lang string) ([]byte, error) { if c.JKR != nil { return compileScenarioRawChunk(c.JKR) } if c.Subheader != nil { - return compileScenarioSubheader(c.Subheader) + return compileScenarioSubheader(c.Subheader, lang) } return nil, nil } // compileScenarioSubheader builds the binary sub-header chunk: // [8-byte header][metadata][null-terminated Shift-JIS strings][0xFF] -func compileScenarioSubheader(sh *ScenarioSubheaderJSON) ([]byte, error) { +func compileScenarioSubheader(sh *ScenarioSubheaderJSON, lang string) ([]byte, error) { meta, err := base64.StdEncoding.DecodeString(sh.Metadata) if err != nil { return nil, fmt.Errorf("decode metadata base64: %w", err) @@ -397,7 +412,7 @@ func compileScenarioSubheader(sh *ScenarioSubheaderJSON) ([]byte, error) { var strBuf bytes.Buffer for _, s := range sh.Strings { - sjis, err := scenarioEncodeShiftJIS(s) + sjis, err := scenarioEncodeShiftJIS(s.Resolve(lang)) if err != nil { return nil, err } @@ -425,11 +440,11 @@ func compileScenarioSubheader(sh *ScenarioSubheaderJSON) ([]byte, error) { } // compileScenarioInline builds the inline-format chunk0 bytes. -func compileScenarioInline(entries []ScenarioInlineEntry) ([]byte, error) { +func compileScenarioInline(entries []ScenarioInlineEntry, lang string) ([]byte, error) { var buf bytes.Buffer for _, e := range entries { buf.WriteByte(e.Index) - sjis, err := scenarioEncodeShiftJIS(e.Text) + sjis, err := scenarioEncodeShiftJIS(e.Text.Resolve(lang)) if err != nil { return nil, err } diff --git a/server/channelserver/scenario_json_test.go b/server/channelserver/scenario_json_test.go index 134a10045..6ef48a1c3 100644 --- a/server/channelserver/scenario_json_test.go +++ b/server/channelserver/scenario_json_test.go @@ -85,14 +85,18 @@ func extractStringsFromScenario(t *testing.T, data []byte) []string { var result []string if s.Chunk0 != nil { if s.Chunk0.Subheader != nil { - result = append(result, s.Chunk0.Subheader.Strings...) + for _, ls := range s.Chunk0.Subheader.Strings { + result = append(result, ls.Resolve("")) + } } for _, e := range s.Chunk0.Inline { - result = append(result, e.Text) + result = append(result, e.Text.Resolve("")) } } if s.Chunk1 != nil && s.Chunk1.Subheader != nil { - result = append(result, s.Chunk1.Subheader.Strings...) + for _, ls := range s.Chunk1.Subheader.Strings { + result = append(result, ls.Resolve("")) + } } return result } @@ -134,8 +138,8 @@ func TestParseScenarioBinary_SubheaderChunk0(t *testing.T) { t.Fatalf("string count: got %d, want %d", len(got), len(want)) } for i := range want { - if got[i] != want[i] { - t.Errorf("[%d]: got %q, want %q", i, got[i], want[i]) + if got[i].Resolve("") != want[i] { + t.Errorf("[%d]: got %q, want %q", i, got[i].Resolve(""), want[i]) } } } @@ -153,8 +157,8 @@ func TestParseScenarioBinary_InlineChunk0(t *testing.T) { } want := []string{"Item1", "Item2"} for i, e := range s.Chunk0.Inline { - if e.Text != want[i] { - t.Errorf("[%d]: got %q, want %q", i, e.Text, want[i]) + if got := e.Text.Resolve(""); got != want[i] { + t.Errorf("[%d]: got %q, want %q", i, got, want[i]) } } } @@ -187,8 +191,8 @@ func TestParseScenarioBinary_Japanese(t *testing.T) { want := []string{"テスト", "日本語"} got := s.Chunk0.Subheader.Strings for i := range want { - if got[i] != want[i] { - t.Errorf("[%d]: got %q, want %q", i, got[i], want[i]) + if got[i].Resolve("") != want[i] { + t.Errorf("[%d]: got %q, want %q", i, got[i].Resolve(""), want[i]) } } } @@ -203,7 +207,7 @@ func TestCompileScenarioJSON_Subheader(t *testing.T) { Unknown1: 0x00, Unknown2: 0x00, Metadata: "AAAABBBB", // base64 of 6 zero bytes - Strings: []string{"Hello", "World"}, + Strings: []LocalizedString{NewLocalizedPlain("Hello"), NewLocalizedPlain("World")}, }, }, } @@ -213,7 +217,7 @@ func TestCompileScenarioJSON_Subheader(t *testing.T) { t.Fatalf("marshal: %v", err) } - compiled, err := CompileScenarioJSON(jsonData) + compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("CompileScenarioJSON: %v", err) } @@ -229,8 +233,8 @@ func TestCompileScenarioJSON_Subheader(t *testing.T) { want := []string{"Hello", "World"} got := result.Chunk0.Subheader.Strings for i := range want { - if i >= len(got) || got[i] != want[i] { - t.Errorf("[%d]: got %q, want %q", i, got[i], want[i]) + if i >= len(got) || got[i].Resolve("") != want[i] { + t.Errorf("[%d]: got %q, want %q", i, got[i].Resolve(""), want[i]) } } } @@ -239,13 +243,13 @@ func TestCompileScenarioJSON_Inline(t *testing.T) { input := &ScenarioJSON{ Chunk0: &ScenarioChunk0JSON{ Inline: []ScenarioInlineEntry{ - {Index: 1, Text: "Sword"}, - {Index: 2, Text: "Shield"}, + {Index: 1, Text: NewLocalizedPlain("Sword")}, + {Index: 2, Text: NewLocalizedPlain("Shield")}, }, }, } jsonData, _ := json.Marshal(input) - compiled, err := CompileScenarioJSON(jsonData) + compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("CompileScenarioJSON: %v", err) } @@ -257,11 +261,11 @@ func TestCompileScenarioJSON_Inline(t *testing.T) { if result.Chunk0 == nil || len(result.Chunk0.Inline) != 2 { t.Fatal("expected 2 inline entries") } - if result.Chunk0.Inline[0].Text != "Sword" { - t.Errorf("got %q, want Sword", result.Chunk0.Inline[0].Text) + if got := result.Chunk0.Inline[0].Text.Resolve(""); got != "Sword" { + t.Errorf("got %q, want Sword", got) } - if result.Chunk0.Inline[1].Text != "Shield" { - t.Errorf("got %q, want Shield", result.Chunk0.Inline[1].Text) + if got := result.Chunk0.Inline[1].Text.Resolve(""); got != "Shield" { + t.Errorf("got %q, want Shield", got) } } @@ -283,7 +287,7 @@ func TestScenarioRoundTrip_Subheader(t *testing.T) { t.Fatalf("marshal: %v", err) } - compiled, err := CompileScenarioJSON(jsonData) + compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("compile: %v", err) } @@ -309,7 +313,7 @@ func TestScenarioRoundTrip_Inline(t *testing.T) { s, _ := ParseScenarioBinary(original) jsonData, _ := json.Marshal(s) - compiled, err := CompileScenarioJSON(jsonData) + compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("compile: %v", err) } @@ -354,7 +358,7 @@ func TestScenarioRoundTrip_MetadataPreserved(t *testing.T) { // Compile and parse again — metadata must survive jsonData, _ := json.Marshal(s) - compiled, err := CompileScenarioJSON(jsonData) + compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("compile: %v", err) } @@ -427,7 +431,7 @@ func TestScenarioRoundTrip_RealFiles(t *testing.T) { } // Compile JSON → binary - compiled, err := CompileScenarioJSON(jsonData) + compiled, err := CompileScenarioJSON(jsonData, "") if err != nil { t.Fatalf("CompileScenarioJSON: %v", err) } @@ -470,3 +474,75 @@ func TestScenarioRoundTrip_RealFiles(t *testing.T) { }) } } + +// ── Phase C: localized scenario strings (#188) ─────────────────────────────── + +// TestCompileScenarioJSON_LocalizedStrings exercises the LocalizedString +// schema inside scenario subheader and inline chunks — the same plain-or-map +// extension shipped for quests in phase B. +func TestCompileScenarioJSON_LocalizedStrings(t *testing.T) { + input := &ScenarioJSON{ + Chunk0: &ScenarioChunk0JSON{ + Subheader: &ScenarioSubheaderJSON{ + Type: 0x01, + Metadata: "AAAABBBB", + Strings: []LocalizedString{ + mustLocalized(t, `{"jp":"クエスト","en":"Quest","fr":"Quete"}`), + mustLocalized(t, `"Plain String"`), + }, + }, + }, + } + jsonData, err := json.Marshal(input) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + // English request picks the en variant; the plain string stays plain. + compiledEN, err := CompileScenarioJSON(jsonData, "en") + if err != nil { + t.Fatalf("compile en: %v", err) + } + gotEN := extractStringsFromScenario(t, compiledEN) + wantEN := []string{"Quest", "Plain String"} + if len(gotEN) != len(wantEN) { + t.Fatalf("en string count: got %d, want %d", len(gotEN), len(wantEN)) + } + for i := range wantEN { + if gotEN[i] != wantEN[i] { + t.Errorf("en [%d]: got %q, want %q", i, gotEN[i], wantEN[i]) + } + } + + // Japanese request picks the jp variant; plain still plain. + compiledJP, err := CompileScenarioJSON(jsonData, "jp") + if err != nil { + t.Fatalf("compile jp: %v", err) + } + gotJP := extractStringsFromScenario(t, compiledJP) + wantJP := []string{"クエスト", "Plain String"} + for i := range wantJP { + if gotJP[i] != wantJP[i] { + t.Errorf("jp [%d]: got %q, want %q", i, gotJP[i], wantJP[i]) + } + } + + // Spanish not provided → falls back to jp (the canonical fallback). + compiledES, err := CompileScenarioJSON(jsonData, "es") + if err != nil { + t.Fatalf("compile es: %v", err) + } + gotES := extractStringsFromScenario(t, compiledES) + if gotES[0] != "クエスト" { + t.Errorf("es fallback = %q, want jp fallback %q", gotES[0], "クエスト") + } +} + +func mustLocalized(t *testing.T, src string) LocalizedString { + t.Helper() + var ls LocalizedString + if err := json.Unmarshal([]byte(src), &ls); err != nil { + t.Fatalf("unmarshal %q: %v", src, err) + } + return ls +} diff --git a/server/channelserver/sys_language_test.go b/server/channelserver/sys_language_test.go index 63c4b3dbd..17d8f617f 100644 --- a/server/channelserver/sys_language_test.go +++ b/server/channelserver/sys_language_test.go @@ -193,3 +193,55 @@ func TestLangCompleteness(t *testing.T) { }) } } + +// TestSessionI18n_Cached verifies that Session.I18n() returns an i18n table +// resolved against the session's language and caches the pointer until +// SetLang invalidates it (phase C of #188). +func TestSessionI18n_Cached(t *testing.T) { + server := &Server{erupeConfig: &cfg.Config{Language: "en"}} + s := &Session{server: server} + + en1 := s.I18n() + en2 := s.I18n() + if en1 != en2 { + t.Error("Session.I18n() should return the same pointer until SetLang") + } + if en1.language != "English" { + t.Errorf("server-default I18n = %q, want English", en1.language) + } + + s.SetLang("jp") + jp := s.I18n() + if jp == en1 { + t.Error("SetLang should invalidate the cached I18n pointer") + } + if jp.language != "日本語" { + t.Errorf("after SetLang(jp) I18n = %q, want 日本語", jp.language) + } + + // And another call returns the same (now-cached) jp pointer. + if s.I18n() != jp { + t.Error("Session.I18n() should be cached after SetLang rebuild") + } +} + +// TestParseChatCommand_RepliesInSessionLanguage confirms the mechanical +// s.server.i18n → s.I18n() refactor routes chat responses through the +// session's language. +func TestParseChatCommand_RepliesInSessionLanguage_Placeholder(t *testing.T) { + // Sanity: for a French session, the i18n table returned by I18n() must + // be the French one, and its commands.timer.enabled must not equal the + // English string. + server := &Server{erupeConfig: &cfg.Config{Language: "en"}} + s := &Session{server: server} + s.SetLang("fr") + + frTable := s.I18n() + enTable := getLangStringsFor("en") + if frTable.commands.timer.enabled == enTable.commands.timer.enabled { + t.Error("fr and en timer.enabled strings should differ — refactor may have reverted") + } + if frTable.language != "Français" { + t.Errorf("session I18n language = %q, want Français", frTable.language) + } +} diff --git a/server/channelserver/sys_session.go b/server/channelserver/sys_session.go index f31a61045..d432ea91e 100644 --- a/server/channelserver/sys_session.go +++ b/server/channelserver/sys_session.go @@ -49,6 +49,8 @@ type Session struct { charID uint32 userID uint32 clientLang string // Per-session language preference; empty = use server default + cachedI18n *i18n // Lazily populated by I18n(); invalidated on SetLang + cachedI18nLang string // Lang the cachedI18n was built for logKey []byte sessionStart int64 courses []mhfcourse.Course @@ -126,12 +128,45 @@ func (s *Session) Lang() string { // SetLang updates the session's in-memory language preference. Persistence // to the database is the caller's responsibility (via userRepo.SetLanguage). +// The cached i18n table is invalidated so the next I18n() call rebuilds +// against the new language. func (s *Session) SetLang(lang string) { s.Lock() s.clientLang = lang + s.cachedI18n = nil + s.cachedI18nLang = "" s.Unlock() } +// I18n returns the i18n string table resolved against this session's +// effective language (see Lang). The first call materializes the table via +// getLangStringsFor and the result is cached on the session so hot-path +// handlers (chat, mail, timer tick broadcasts) do not pay the allocation on +// every packet. SetLang invalidates the cache. +func (s *Session) I18n() *i18n { + s.Lock() + if s.cachedI18n != nil && s.cachedI18nLang == s.clientLang { + i := s.cachedI18n + s.Unlock() + return i + } + lang := s.clientLang + s.Unlock() + // Resolve lang (falls back to server default when empty). + effectiveLang := lang + if effectiveLang == "" { + effectiveLang = s.server.erupeConfig.Language + } + resolved := getLangStringsFor(effectiveLang) + s.Lock() + // Someone may have raced us — overwrite defensively, pointer value is + // still the one we just built so callers get a consistent view. + s.cachedI18n = &resolved + s.cachedI18nLang = lang + s.Unlock() + return &resolved +} + // 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()))