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

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

View File

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

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"])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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