mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +02:00
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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user