diff --git a/CHANGELOG.md b/CHANGELOG.md index 86533e1da..964512cbc 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 +- 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. ### Changed diff --git a/server/channelserver/handlers_quest.go b/server/channelserver/handlers_quest.go index 6a6428ecc..2ac1bd90a 100644 --- a/server/channelserver/handlers_quest.go +++ b/server/channelserver/handlers_quest.go @@ -161,7 +161,7 @@ func loadQuestBinary(s *Session, filename string) ([]byte, error) { if err != nil { return nil, err } - compiled, err := CompileQuestJSON(jsonData) + compiled, err := CompileQuestJSON(jsonData, s.Lang()) if err != nil { return nil, fmt.Errorf("compile quest JSON %s: %w", filename, err) } @@ -248,7 +248,8 @@ func handleMsgMhfSaveFavoriteQuest(s *Session, p mhfpacket.MHFPacket) { } func loadQuestFile(s *Session, questId int) []byte { - if cached, ok := s.server.questCache.Get(questId); ok { + lang := s.Lang() + if cached, ok := s.server.questCache.Get(questId, lang); ok { return cached } @@ -257,7 +258,7 @@ func loadQuestFile(s *Session, questId int) []byte { if data, err := os.ReadFile(base + ".bin"); err == nil { decrypted = decryption.UnpackSimple(data) } else if jsonData, err := os.ReadFile(base + ".json"); err == nil { - compiled, err := CompileQuestJSON(jsonData) + compiled, err := CompileQuestJSON(jsonData, lang) if err != nil { s.logger.Error("loadQuestFile: failed to compile quest JSON", zap.Int("questId", questId), zap.Error(err)) @@ -313,7 +314,7 @@ func loadQuestFile(s *Session, questId int) []byte { questBody.WriteBytes(newStrings.Data()) result := questBody.Data() - s.server.questCache.Put(questId, result) + s.server.questCache.Put(questId, lang, result) return result } diff --git a/server/channelserver/localized_string.go b/server/channelserver/localized_string.go new file mode 100644 index 000000000..8979fe74c --- /dev/null +++ b/server/channelserver/localized_string.go @@ -0,0 +1,103 @@ +package channelserver + +import ( + "bytes" + "encoding/json" +) + +// LocalizedString is a JSON field that unmarshals from either a plain string +// (backwards-compatible single-language behaviour — the value is returned for +// every language) or a map keyed by language code, e.g. +// +// "title": "リオレウス" +// "title": { "jp": "リオレウス", "en": "Rathalos", "fr": "Rathalos" } +// +// It is the core primitive used by phase B of #188 to localize server-sent +// content (quest text, scenario strings, mail templates, ...) per session +// without breaking any existing single-language JSON file. +// +// Encoding note: strings that end up on the wire as Shift-JIS (quest text, +// scenario strings) must only use characters representable in Shift-JIS — +// ASCII, kana, and CJK. Latin-extended characters commonly used in European +// languages (ê, ñ, ß, ...) will be rejected by the encoder at compile time. +// For those languages prefer ASCII-only romanizations ("Quete de test", +// "Espana") until the Frontier binary protocol is extended to a wider +// encoding. +type LocalizedString struct { + // plain is set when the source was a bare JSON string. Treated as the + // fallback for every language so legacy single-language files keep + // working with no schema change. + plain string + // values is set when the source was a JSON object. Keys are language + // codes (lowercase, e.g. "jp", "en", "fr", "es"). + values map[string]string +} + +// NewLocalizedPlain wraps a single-language string — used internally by the +// binary-to-JSON reverse path (ParseQuestBinary etc.) where only one language +// is available from the source file. +func NewLocalizedPlain(s string) LocalizedString { + return LocalizedString{plain: s} +} + +// UnmarshalJSON accepts either a JSON string or a JSON object. +func (l *LocalizedString) UnmarshalJSON(data []byte) error { + trimmed := bytes.TrimSpace(data) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil + } + if trimmed[0] == '"' { + return json.Unmarshal(trimmed, &l.plain) + } + return json.Unmarshal(trimmed, &l.values) +} + +// MarshalJSON round-trips: plain strings stay plain; maps stay maps. This +// matters for the reverse ParseQuestBinary → JSON path, which should produce +// a plain string (backwards compatible), and for hypothetical editor tooling +// that reads a localized JSON, mutates it, and writes it back unchanged. +func (l LocalizedString) MarshalJSON() ([]byte, error) { + if l.values != nil { + return json.Marshal(l.values) + } + return json.Marshal(l.plain) +} + +// Resolve returns the best available string for the requested language code. +// Fallback order when the requested language is missing: +// 1. The plain-string form (single-language source) +// 2. jp (the canonical source language for MH Frontier) +// 3. en (the common secondary) +// 4. Any non-empty value in the map +// +// Returns "" only when nothing is set — callers that need a non-empty value +// for binary serialization should treat that as an empty quest string, which +// the existing toShiftJIS encoder already accepts. +func (l LocalizedString) Resolve(lang string) string { + if l.values == nil { + return l.plain + } + if v, ok := l.values[lang]; ok && v != "" { + return v + } + if l.plain != "" { + return l.plain + } + for _, fb := range []string{"jp", "en"} { + if v, ok := l.values[fb]; ok && v != "" { + return v + } + } + for _, v := range l.values { + if v != "" { + return v + } + } + return "" +} + +// IsLocalized reports whether the value was written as a language map (rather +// than a plain string). Mostly useful for tests and tooling. +func (l LocalizedString) IsLocalized() bool { + return l.values != nil +} diff --git a/server/channelserver/localized_string_test.go b/server/channelserver/localized_string_test.go new file mode 100644 index 000000000..ed92b9ac8 --- /dev/null +++ b/server/channelserver/localized_string_test.go @@ -0,0 +1,149 @@ +package channelserver + +import ( + "encoding/json" + "testing" +) + +func TestLocalizedString_UnmarshalPlainString(t *testing.T) { + var l LocalizedString + if err := json.Unmarshal([]byte(`"Rathalos"`), &l); err != nil { + t.Fatalf("unmarshal plain: %v", err) + } + if l.IsLocalized() { + t.Error("plain string should not be IsLocalized") + } + // Plain strings resolve to the same value regardless of language — this + // is the backwards-compatibility contract that keeps existing single- + // language quest JSONs working without a schema migration. + for _, lang := range []string{"", "jp", "en", "fr", "es", "klingon"} { + if got := l.Resolve(lang); got != "Rathalos" { + t.Errorf("Resolve(%q) = %q, want %q", lang, got, "Rathalos") + } + } +} + +func TestLocalizedString_UnmarshalMap(t *testing.T) { + var l LocalizedString + src := `{"jp": "リオレウス", "en": "Rathalos", "fr": "Rathalos"}` + if err := json.Unmarshal([]byte(src), &l); err != nil { + t.Fatalf("unmarshal map: %v", err) + } + if !l.IsLocalized() { + t.Error("map form should be IsLocalized") + } + cases := map[string]string{ + "jp": "リオレウス", + "en": "Rathalos", + "fr": "Rathalos", + } + for lang, want := range cases { + if got := l.Resolve(lang); got != want { + t.Errorf("Resolve(%q) = %q, want %q", lang, got, want) + } + } +} + +func TestLocalizedString_ResolveFallbackChain(t *testing.T) { + // Spanish not provided — should fall back to jp first (canonical source + // language for MHF), then en, then any non-empty. + var l LocalizedString + if err := json.Unmarshal([]byte(`{"jp": "リオレウス", "en": "Rathalos"}`), &l); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := l.Resolve("es"); got != "リオレウス" { + t.Errorf("missing-lang fallback = %q, want jp value %q", got, "リオレウス") + } + + // Only en provided → en must win even when jp is requested. + var l2 LocalizedString + if err := json.Unmarshal([]byte(`{"en": "Rathalos"}`), &l2); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := l2.Resolve("jp"); got != "Rathalos" { + t.Errorf("jp-missing fallback = %q, want %q", got, "Rathalos") + } + + // Neither jp nor en → any non-empty value. + var l3 LocalizedString + if err := json.Unmarshal([]byte(`{"fr": "Rathalos FR"}`), &l3); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := l3.Resolve("jp"); got != "Rathalos FR" { + t.Errorf("last-resort fallback = %q, want %q", got, "Rathalos FR") + } + + // Empty string entries are skipped by the fallback chain. + var l4 LocalizedString + if err := json.Unmarshal([]byte(`{"jp": "", "en": "Rathalos"}`), &l4); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := l4.Resolve("fr"); got != "Rathalos" { + t.Errorf("empty-jp fallback = %q, want %q (should skip empty jp)", got, "Rathalos") + } +} + +func TestLocalizedString_EmptyResolvesEmpty(t *testing.T) { + var l LocalizedString + if got := l.Resolve("jp"); got != "" { + t.Errorf("zero value Resolve = %q, want empty", got) + } +} + +func TestLocalizedString_MarshalRoundTrip(t *testing.T) { + // Plain string round-trip. + var plain LocalizedString + if err := json.Unmarshal([]byte(`"Rathalos"`), &plain); err != nil { + t.Fatal(err) + } + out, err := json.Marshal(plain) + if err != nil { + t.Fatal(err) + } + if string(out) != `"Rathalos"` { + t.Errorf("plain round-trip = %s, want %q", out, "Rathalos") + } + + // Map round-trip. + var m LocalizedString + if err := json.Unmarshal([]byte(`{"en":"Rathalos","jp":"リオレウス"}`), &m); err != nil { + t.Fatal(err) + } + out, err = json.Marshal(m) + if err != nil { + t.Fatal(err) + } + // Unmarshal again and compare — map key order is unstable so we don't + // string-compare the marshaled form directly. + var back LocalizedString + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + if got := back.Resolve("jp"); got != "リオレウス" { + t.Errorf("round-trip jp = %q", got) + } + if got := back.Resolve("en"); got != "Rathalos" { + t.Errorf("round-trip en = %q", got) + } +} + +func TestLocalizedString_NullUnmarshal(t *testing.T) { + // JSON null → zero value, no error. + var l LocalizedString + if err := json.Unmarshal([]byte(`null`), &l); err != nil { + t.Fatalf("unmarshal null: %v", err) + } + if l.IsLocalized() || l.Resolve("") != "" { + t.Error("null should produce zero LocalizedString") + } +} + +func TestNewLocalizedPlain(t *testing.T) { + l := NewLocalizedPlain("hello") + if l.IsLocalized() { + t.Error("NewLocalizedPlain should not be IsLocalized") + } + if got := l.Resolve("jp"); got != "hello" { + t.Errorf("Resolve = %q, want %q", got, "hello") + } +} diff --git a/server/channelserver/quest_cache.go b/server/channelserver/quest_cache.go index c36e781be..248437bd2 100644 --- a/server/channelserver/quest_cache.go +++ b/server/channelserver/quest_cache.go @@ -5,11 +5,22 @@ import ( "time" ) -// QuestCache is a thread-safe, expiring cache for parsed quest file data. +// questCacheKey identifies a cached quest variant. Phase B of #188 added the +// language dimension so localized quest text compiled for one session does +// not leak into another session's response. +type questCacheKey struct { + questID int + lang string +} + +// QuestCache is a thread-safe, expiring cache for parsed quest file data, +// keyed by (questID, language). Entries for different languages are stored +// independently so a Japanese client and a French client on the same server +// never share compiled binaries. type QuestCache struct { mu sync.RWMutex - data map[int][]byte - expiry map[int]time.Time + data map[questCacheKey][]byte + expiry map[questCacheKey]time.Time ttl time.Duration } @@ -17,33 +28,36 @@ type QuestCache struct { // A TTL of 0 disables caching (Get always misses). func NewQuestCache(ttlSeconds int) *QuestCache { return &QuestCache{ - data: make(map[int][]byte), - expiry: make(map[int]time.Time), + data: make(map[questCacheKey][]byte), + expiry: make(map[questCacheKey]time.Time), ttl: time.Duration(ttlSeconds) * time.Second, } } -// Get returns cached quest data if it exists and has not expired. -func (c *QuestCache) Get(questID int) ([]byte, bool) { +// Get returns cached quest data for the (questID, lang) variant if it exists +// and has not expired. +func (c *QuestCache) Get(questID int, lang string) ([]byte, bool) { if c.ttl <= 0 { return nil, false } + k := questCacheKey{questID: questID, lang: lang} c.mu.RLock() defer c.mu.RUnlock() - b, ok := c.data[questID] + b, ok := c.data[k] if !ok { return nil, false } - if time.Now().After(c.expiry[questID]) { + if time.Now().After(c.expiry[k]) { return nil, false } return b, true } -// Put stores quest data in the cache with the configured TTL. -func (c *QuestCache) Put(questID int, b []byte) { +// Put stores quest data for the (questID, lang) variant with the configured TTL. +func (c *QuestCache) Put(questID int, lang string, b []byte) { + k := questCacheKey{questID: questID, lang: lang} c.mu.Lock() - c.data[questID] = b - c.expiry[questID] = time.Now().Add(c.ttl) + c.data[k] = b + c.expiry[k] = time.Now().Add(c.ttl) c.mu.Unlock() } diff --git a/server/channelserver/quest_cache_test.go b/server/channelserver/quest_cache_test.go index 1b2d86190..07d63f26b 100644 --- a/server/channelserver/quest_cache_test.go +++ b/server/channelserver/quest_cache_test.go @@ -8,7 +8,7 @@ import ( func TestQuestCache_GetMiss(t *testing.T) { c := NewQuestCache(60) - _, ok := c.Get(999) + _, ok := c.Get(999, "jp") if ok { t.Error("expected cache miss for unknown quest ID") } @@ -17,9 +17,9 @@ func TestQuestCache_GetMiss(t *testing.T) { func TestQuestCache_PutGet(t *testing.T) { c := NewQuestCache(60) data := []byte{0xDE, 0xAD} - c.Put(1, data) + c.Put(1, "jp", data) - got, ok := c.Get(1) + got, ok := c.Get(1, "jp") if !ok { t.Fatal("expected cache hit") } @@ -30,9 +30,9 @@ func TestQuestCache_PutGet(t *testing.T) { func TestQuestCache_Expiry(t *testing.T) { c := NewQuestCache(0) // TTL=0 disables caching - c.Put(1, []byte{0x01}) + c.Put(1, "jp", []byte{0x01}) - _, ok := c.Get(1) + _, ok := c.Get(1, "jp") if ok { t.Error("expected cache miss when TTL is 0") } @@ -40,25 +40,44 @@ func TestQuestCache_Expiry(t *testing.T) { func TestQuestCache_ExpiryElapsed(t *testing.T) { c := &QuestCache{ - data: make(map[int][]byte), - expiry: make(map[int]time.Time), + data: make(map[questCacheKey][]byte), + expiry: make(map[questCacheKey]time.Time), ttl: 50 * time.Millisecond, } - c.Put(1, []byte{0x01}) + c.Put(1, "jp", []byte{0x01}) // Should hit immediately - if _, ok := c.Get(1); !ok { + if _, ok := c.Get(1, "jp"); !ok { t.Fatal("expected cache hit before expiry") } time.Sleep(60 * time.Millisecond) // Should miss after expiry - if _, ok := c.Get(1); ok { + if _, ok := c.Get(1, "jp"); ok { t.Error("expected cache miss after expiry") } } +// TestQuestCache_LangIsolation verifies that entries for different languages +// of the same quest ID are stored independently (phase B of #188). +func TestQuestCache_LangIsolation(t *testing.T) { + c := NewQuestCache(60) + c.Put(1, "jp", []byte{0x01}) + c.Put(1, "en", []byte{0x02}) + + if got, ok := c.Get(1, "jp"); !ok || got[0] != 0x01 { + t.Errorf("jp variant: got %v ok=%v, want [0x01] true", got, ok) + } + if got, ok := c.Get(1, "en"); !ok || got[0] != 0x02 { + t.Errorf("en variant: got %v ok=%v, want [0x02] true", got, ok) + } + // Unset language variant should miss. + if _, ok := c.Get(1, "fr"); ok { + t.Error("fr variant should miss when not populated") + } +} + func TestQuestCache_ConcurrentAccess(t *testing.T) { c := NewQuestCache(60) var wg sync.WaitGroup @@ -67,11 +86,11 @@ func TestQuestCache_ConcurrentAccess(t *testing.T) { id := i go func() { defer wg.Done() - c.Put(id, []byte{byte(id)}) + c.Put(id, "jp", []byte{byte(id)}) }() go func() { defer wg.Done() - c.Get(id) + c.Get(id, "jp") }() } wg.Wait() diff --git a/server/channelserver/quest_json.go b/server/channelserver/quest_json.go index 1814cfe3f..10359766b 100644 --- a/server/channelserver/quest_json.go +++ b/server/channelserver/quest_json.go @@ -210,15 +210,24 @@ type QuestJSON struct { // Quest identification QuestID uint16 `json:"quest_id"` - // Text (UTF-8; converted to Shift-JIS in binary) - Title string `json:"title"` - Description string `json:"description"` - TextMain string `json:"text_main"` - TextSubA string `json:"text_sub_a"` - TextSubB string `json:"text_sub_b"` - SuccessCond string `json:"success_cond"` - FailCond string `json:"fail_cond"` - Contractor string `json:"contractor"` + // Text (UTF-8; converted to Shift-JIS in binary). + // + // Each field accepts either a plain JSON string (single-language, treated + // as the value for every language) or a language-keyed object: + // + // "title": "リオレウス" + // "title": { "jp": "リオレウス", "en": "Rathalos", "fr": "Rathalos" } + // + // CompileQuestJSON resolves these based on the compiling session's + // language preference (see #188 phase B). + Title LocalizedString `json:"title"` + Description LocalizedString `json:"description"` + TextMain LocalizedString `json:"text_main"` + TextSubA LocalizedString `json:"text_sub_a"` + TextSubB LocalizedString `json:"text_sub_b"` + SuccessCond LocalizedString `json:"success_cond"` + FailCond LocalizedString `json:"fail_cond"` + Contractor LocalizedString `json:"contractor"` // General quest properties (generalQuestProperties section, 0x44–0x85) MonsterSizeMulti uint16 `json:"monster_size_multi"` // 100 = 100% @@ -421,7 +430,7 @@ func objectiveBytes(obj QuestObjectiveJSON) ([]byte, error) { // map sections, area mappings, area transitions, // map info, gathering points, area facilities, // some strings, gathering tables -func CompileQuestJSON(data []byte) ([]byte, error) { +func CompileQuestJSON(data []byte, lang string) ([]byte, error) { var q QuestJSON if err := json.Unmarshal(data, &q); err != nil { return nil, fmt.Errorf("parse quest JSON: %w", err) @@ -454,10 +463,14 @@ func CompileQuestJSON(data []byte) ([]byte, error) { // ── Build Shift-JIS strings ───────────────────────────────────────── // Order matches QuestText struct: title, textMain, textSubA, textSubB, - // successCond, failCond, contractor, description. + // successCond, failCond, contractor, description. Each LocalizedString + // is resolved against the requesting session's language — plain-string + // JSON fields resolve to their literal value for every language. rawTexts := []string{ - q.Title, q.TextMain, q.TextSubA, q.TextSubB, - q.SuccessCond, q.FailCond, q.Contractor, q.Description, + q.Title.Resolve(lang), q.TextMain.Resolve(lang), + q.TextSubA.Resolve(lang), q.TextSubB.Resolve(lang), + q.SuccessCond.Resolve(lang), q.FailCond.Resolve(lang), + q.Contractor.Resolve(lang), q.Description.Resolve(lang), } var sjisStrings [][]byte for _, s := range rawTexts { diff --git a/server/channelserver/quest_json_parser.go b/server/channelserver/quest_json_parser.go index b91a1efc6..d118134f1 100644 --- a/server/channelserver/quest_json_parser.go +++ b/server/channelserver/quest_json_parser.go @@ -171,14 +171,17 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) { } texts[i] = s } - q.Title = texts[0] - q.TextMain = texts[1] - q.TextSubA = texts[2] - q.TextSubB = texts[3] - q.SuccessCond = texts[4] - q.FailCond = texts[5] - q.Contractor = texts[6] - q.Description = texts[7] + // The binary carries only one language, so the reverse path emits + // plain-string LocalizedStrings. Editors wanting multi-language + // quests should wrap these as {"jp": "...", "en": "..."} by hand. + q.Title = NewLocalizedPlain(texts[0]) + q.TextMain = NewLocalizedPlain(texts[1]) + q.TextSubA = NewLocalizedPlain(texts[2]) + q.TextSubB = NewLocalizedPlain(texts[3]) + q.SuccessCond = NewLocalizedPlain(texts[4]) + q.FailCond = NewLocalizedPlain(texts[5]) + q.Contractor = NewLocalizedPlain(texts[6]) + q.Description = NewLocalizedPlain(texts[7]) } // ── Stages ─────────────────────────────────────────────────────────── diff --git a/server/channelserver/quest_json_test.go b/server/channelserver/quest_json_test.go index cf0ff89bf..d592f5d9d 100644 --- a/server/channelserver/quest_json_test.go +++ b/server/channelserver/quest_json_test.go @@ -6,6 +6,9 @@ import ( "encoding/json" "math" "testing" + + "golang.org/x/text/encoding/japanese" + "golang.org/x/text/transform" ) // minimalQuestJSON is a small but complete quest used across many test cases. @@ -57,7 +60,7 @@ var minimalQuestJSON = `{ // ── Compiler tests (existing) ──────────────────────────────────────────────── func TestCompileQuestJSON_MinimalQuest(t *testing.T) { - data, err := CompileQuestJSON([]byte(minimalQuestJSON)) + data, err := CompileQuestJSON([]byte(minimalQuestJSON), "") if err != nil { t.Fatalf("CompileQuestJSON: %v", err) } @@ -114,7 +117,7 @@ func TestCompileQuestJSON_BadObjectiveType(t *testing.T) { q.ObjectiveMain.Type = "invalid_type" b, _ := json.Marshal(q) - _, err := CompileQuestJSON(b) + _, err := CompileQuestJSON(b, "") if err == nil { t.Fatal("expected error for invalid objective type, got nil") } @@ -131,7 +134,7 @@ func TestCompileQuestJSON_AllObjectiveTypes(t *testing.T) { _ = json.Unmarshal([]byte(minimalQuestJSON), &q) q.ObjectiveMain.Type = typ b, _ := json.Marshal(q) - if _, err := CompileQuestJSON(b); err != nil { + if _, err := CompileQuestJSON(b, ""); err != nil { t.Fatalf("CompileQuestJSON with type %q: %v", typ, err) } }) @@ -143,7 +146,7 @@ func TestCompileQuestJSON_EmptyRewards(t *testing.T) { _ = json.Unmarshal([]byte(minimalQuestJSON), &q) q.Rewards = nil b, _ := json.Marshal(q) - if _, err := CompileQuestJSON(b); err != nil { + if _, err := CompileQuestJSON(b, ""); err != nil { t.Fatalf("unexpected error with no rewards: %v", err) } } @@ -156,7 +159,7 @@ func TestCompileQuestJSON_MultipleRewardTables(t *testing.T) { {TableID: 2, Items: []QuestRewardItemJSON{{Rate: 100, Item: 153, Quantity: 2}}}, } b, _ := json.Marshal(q) - data, err := CompileQuestJSON(b) + data, err := CompileQuestJSON(b, "") if err != nil { t.Fatalf("CompileQuestJSON: %v", err) } @@ -189,7 +192,7 @@ func TestParseQuestBinary_NullQuestTypeFlagsPtr(t *testing.T) { } func TestParseQuestBinary_MinimalQuest(t *testing.T) { - data, err := CompileQuestJSON([]byte(minimalQuestJSON)) + data, err := CompileQuestJSON([]byte(minimalQuestJSON), "") if err != nil { t.Fatalf("compile: %v", err) } @@ -204,24 +207,25 @@ func TestParseQuestBinary_MinimalQuest(t *testing.T) { t.Errorf("QuestID = %d, want 1", q.QuestID) } - // Text strings - if q.Title != "Test Quest" { - t.Errorf("Title = %q, want %q", q.Title, "Test Quest") + // Text strings — Resolve against empty lang so plain-string JSON fields + // return their literal value (phase B of #188). + if got := q.Title.Resolve(""); got != "Test Quest" { + t.Errorf("Title = %q, want %q", got, "Test Quest") } - if q.Description != "A test quest." { - t.Errorf("Description = %q, want %q", q.Description, "A test quest.") + if got := q.Description.Resolve(""); got != "A test quest." { + t.Errorf("Description = %q, want %q", got, "A test quest.") } - if q.TextMain != "Hunt the Rathalos." { - t.Errorf("TextMain = %q, want %q", q.TextMain, "Hunt the Rathalos.") + if got := q.TextMain.Resolve(""); got != "Hunt the Rathalos." { + t.Errorf("TextMain = %q, want %q", got, "Hunt the Rathalos.") } - if q.SuccessCond != "Slay the Rathalos." { - t.Errorf("SuccessCond = %q, want %q", q.SuccessCond, "Slay the Rathalos.") + if got := q.SuccessCond.Resolve(""); got != "Slay the Rathalos." { + t.Errorf("SuccessCond = %q, want %q", got, "Slay the Rathalos.") } - if q.FailCond != "Time runs out or all hunters faint." { - t.Errorf("FailCond = %q, want %q", q.FailCond, "Time runs out or all hunters faint.") + if got := q.FailCond.Resolve(""); got != "Time runs out or all hunters faint." { + t.Errorf("FailCond = %q, want %q", got, "Time runs out or all hunters faint.") } - if q.Contractor != "Guild Master" { - t.Errorf("Contractor = %q, want %q", q.Contractor, "Guild Master") + if got := q.Contractor.Resolve(""); got != "Guild Master" { + t.Errorf("Contractor = %q, want %q", got, "Guild Master") } // Numeric fields @@ -348,7 +352,7 @@ func TestParseQuestBinary_MinimalQuest(t *testing.T) { func roundTrip(t *testing.T, label, jsonSrc string) { t.Helper() - bin1, err := CompileQuestJSON([]byte(jsonSrc)) + bin1, err := CompileQuestJSON([]byte(jsonSrc), "") if err != nil { t.Fatalf("%s: compile(1): %v", label, err) } @@ -363,7 +367,7 @@ func roundTrip(t *testing.T, label, jsonSrc string) { t.Fatalf("%s: marshal: %v", label, err) } - bin2, err := CompileQuestJSON(jsonOut) + bin2, err := CompileQuestJSON(jsonOut, "") if err != nil { t.Fatalf("%s: compile(2): %v", label, err) } @@ -773,7 +777,7 @@ func TestRoundTrip_AllSections(t *testing.T) { // mainPropOffset = 0x86 (= headerSize + genPropSize) // questStringsPtr = 0x1C6 (= mainPropOffset + 320) func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { - data, err := CompileQuestJSON([]byte(minimalQuestJSON)) + data, err := CompileQuestJSON([]byte(minimalQuestJSON), "") if err != nil { t.Fatalf("compile: %v", err) } @@ -981,7 +985,7 @@ func TestGolden_GeneralQuestPropertiesCounts(t *testing.T) { } b, _ := json.Marshal(q) - data, err := CompileQuestJSON(b) + data, err := CompileQuestJSON(b, "") if err != nil { t.Fatalf("compile: %v", err) } @@ -1007,7 +1011,7 @@ func TestGolden_MapSectionsBinaryLayout(t *testing.T) { }, } - data, err := CompileQuestJSON(func() []byte { b, _ := json.Marshal(q); return b }()) + data, err := CompileQuestJSON(func() []byte { b, _ := json.Marshal(q); return b }(), "") if err != nil { t.Fatalf("compile: %v", err) } @@ -1097,7 +1101,7 @@ func TestGolden_GatheringTablesBinaryLayout(t *testing.T) { } b, _ := json.Marshal(q) - data, err := CompileQuestJSON(b) + data, err := CompileQuestJSON(b, "") if err != nil { t.Fatalf("compile: %v", err) } @@ -1263,3 +1267,127 @@ func assertF32(t *testing.T, data []byte, off int, want float32, label string) { t.Errorf("%s @ 0x%X: got %v, want %v", label, off, got, want) } } + +// ── Phase B: localized quest text (#188) ───────────────────────────────────── + +// localizedQuestJSON exercises the LocalizedString schema — title is a map, +// description is a mixed map, and the rest fall back to plain strings so the +// test also covers the "most fields stay plain" migration path. +var localizedQuestJSON = `{ + "quest_id": 1, + "title": { "jp": "テストクエスト", "en": "Test Quest EN", "fr": "Test Quest FR" }, + "description": { "jp": "説明", "en": "A test quest." }, + "text_main": "Hunt the Rathalos.", + "text_sub_a": "", + "text_sub_b": "", + "success_cond": "Slay the Rathalos.", + "fail_cond": "Time runs out or all hunters faint.", + "contractor": "Guild Master", + "monster_size_multi": 100, + "main_rank_points": 120, + "sub_a_rank_points": 60, + "sub_b_rank_points": 0, + "fee": 500, + "reward_main": 5000, + "reward_sub_a": 1000, + "reward_sub_b": 0, + "time_limit_minutes": 50, + "map": 2, + "rank_band": 0, + "objective_main": {"type": "hunt", "target": 11, "count": 1}, + "objective_sub_a": {"type": "deliver", "target": 149, "count": 3}, + "objective_sub_b": {"type": "none"}, + "large_monsters": [ + {"id": 11, "spawn_amount": 1, "spawn_stage": 5, "orientation": 180, "x": 1500.0, "y": 0.0, "z": -2000.0} + ], + "rewards": [ + {"table_id": 1, "items": [{"rate": 50, "item": 149, "quantity": 1}]} + ], + "supply_main": [{"item": 1, "quantity": 5}], + "stages": [{"stage_id": 2}] +}` + +// extractQuestTitle reads the first Shift-JIS null-terminated string pointed +// to by the QuestText pointer table and decodes it back to UTF-8. This lets +// the test verify which language variant the compiler selected without +// replicating the full binary layout. +func extractQuestTitle(t *testing.T, data []byte) string { + t.Helper() + // Header offset 0x00 is the first pointer = questTypeFlagsPtr = 0x86. + // QuestStringsTablePtr is at headerSize + genPropSize + mainPropSize. + const questStringsTableOff = 68 + 66 + questBodyLenZZ // 0x1C6 + if questStringsTableOff+4 > len(data) { + t.Fatalf("data too short for quest strings table: %d", len(data)) + } + // First 4 bytes of the strings table point to the title string. + titlePtr := binary.LittleEndian.Uint32(data[questStringsTableOff:]) + if int(titlePtr) >= len(data) { + t.Fatalf("title pointer 0x%X out of range (len=%d)", titlePtr, len(data)) + } + end := int(titlePtr) + for end < len(data) && data[end] != 0 { + end++ + } + sjis := data[titlePtr:end] + decoded, _, err := transform.Bytes(japanese.ShiftJIS.NewDecoder(), sjis) + if err != nil { + t.Fatalf("decode title: %v", err) + } + return string(decoded) +} + +func TestCompileQuestJSON_LocalizedTitle_JapanesePicked(t *testing.T) { + data, err := CompileQuestJSON([]byte(localizedQuestJSON), "jp") + if err != nil { + t.Fatalf("CompileQuestJSON: %v", err) + } + if got := extractQuestTitle(t, data); got != "テストクエスト" { + t.Errorf("jp title = %q, want %q", got, "テストクエスト") + } +} + +func TestCompileQuestJSON_LocalizedTitle_EnglishPicked(t *testing.T) { + data, err := CompileQuestJSON([]byte(localizedQuestJSON), "en") + if err != nil { + t.Fatalf("CompileQuestJSON: %v", err) + } + if got := extractQuestTitle(t, data); got != "Test Quest EN" { + t.Errorf("en title = %q, want %q", got, "Test Quest EN") + } +} + +func TestCompileQuestJSON_LocalizedTitle_FrenchPicked(t *testing.T) { + data, err := CompileQuestJSON([]byte(localizedQuestJSON), "fr") + if err != nil { + t.Fatalf("CompileQuestJSON: %v", err) + } + if got := extractQuestTitle(t, data); got != "Test Quest FR" { + t.Errorf("fr title = %q, want %q", got, "Test Quest FR") + } +} + +// Phase B fallback: Spanish is not provided in localizedQuestJSON, so the +// compiler should fall back to the canonical jp variant. +func TestCompileQuestJSON_LocalizedTitle_MissingLangFallsBackToJP(t *testing.T) { + data, err := CompileQuestJSON([]byte(localizedQuestJSON), "es") + if err != nil { + t.Fatalf("CompileQuestJSON: %v", err) + } + if got := extractQuestTitle(t, data); got != "テストクエスト" { + t.Errorf("es fallback title = %q, want jp %q", got, "テストクエスト") + } +} + +// Phase B backwards-compat: existing plain-string quest JSON must produce +// the exact same title regardless of requested language. +func TestCompileQuestJSON_PlainString_SameAcrossLanguages(t *testing.T) { + for _, lang := range []string{"", "jp", "en", "fr", "es"} { + data, err := CompileQuestJSON([]byte(minimalQuestJSON), lang) + if err != nil { + t.Fatalf("lang=%q: CompileQuestJSON: %v", lang, err) + } + if got := extractQuestTitle(t, data); got != "Test Quest" { + t.Errorf("lang=%q: title = %q, want %q", lang, got, "Test Quest") + } + } +}