feat(i18n): localized quest text and per-lang quest cache

Phase B of #188. Quest JSON title/description/text_main/text_sub_a/
text_sub_b/success_cond/fail_cond/contractor now accept either a plain
string (existing behaviour) or a language-keyed object like
  "title": { "jp": "...", "en": "...", "fr": "..." }
CompileQuestJSON takes the compiling session's language and resolves
each field through a fallback chain (requested -> plain -> jp -> en ->
any non-empty), so existing single-language quest JSONs keep working
byte-for-byte unchanged.

The quest cache is re-keyed on (questID, language) so compiled binaries
for different languages never leak between sessions on a multi-language
server. loadQuestBinary and loadQuestFile now pass s.Lang() into both
the compiler and the cache.

ParseQuestBinary emits plain-string LocalizedStrings, so the
binary -> JSON -> binary round-trip still produces identical output.

The new LocalizedString type lives in its own file and is reusable by
phase C (scenarios, mail templates, shop text). Shift-JIS encoding
still applies to the wire format, so localized values must use
characters representable in Shift-JIS — ASCII, kana, CJK — which is
documented on the type.
This commit is contained in:
Houmgaor
2026-04-06 20:00:43 +02:00
parent 5b38bfde3f
commit f7ea275540
9 changed files with 506 additions and 75 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 <en|jp|fr|es>` 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. - 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 <en|jp|fr|es>` 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 ### Changed

View File

@@ -161,7 +161,7 @@ func loadQuestBinary(s *Session, filename string) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
compiled, err := CompileQuestJSON(jsonData) compiled, err := CompileQuestJSON(jsonData, s.Lang())
if err != nil { if err != nil {
return nil, fmt.Errorf("compile quest JSON %s: %w", filename, err) 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 { 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 return cached
} }
@@ -257,7 +258,7 @@ func loadQuestFile(s *Session, questId int) []byte {
if data, err := os.ReadFile(base + ".bin"); err == nil { if data, err := os.ReadFile(base + ".bin"); err == nil {
decrypted = decryption.UnpackSimple(data) decrypted = decryption.UnpackSimple(data)
} else if jsonData, err := os.ReadFile(base + ".json"); err == nil { } else if jsonData, err := os.ReadFile(base + ".json"); err == nil {
compiled, err := CompileQuestJSON(jsonData) compiled, err := CompileQuestJSON(jsonData, lang)
if err != nil { if err != nil {
s.logger.Error("loadQuestFile: failed to compile quest JSON", s.logger.Error("loadQuestFile: failed to compile quest JSON",
zap.Int("questId", questId), zap.Error(err)) zap.Int("questId", questId), zap.Error(err))
@@ -313,7 +314,7 @@ func loadQuestFile(s *Session, questId int) []byte {
questBody.WriteBytes(newStrings.Data()) questBody.WriteBytes(newStrings.Data())
result := questBody.Data() result := questBody.Data()
s.server.questCache.Put(questId, result) s.server.questCache.Put(questId, lang, result)
return result return result
} }

View File

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

View File

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

View File

@@ -5,11 +5,22 @@ import (
"time" "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 { type QuestCache struct {
mu sync.RWMutex mu sync.RWMutex
data map[int][]byte data map[questCacheKey][]byte
expiry map[int]time.Time expiry map[questCacheKey]time.Time
ttl time.Duration ttl time.Duration
} }
@@ -17,33 +28,36 @@ type QuestCache struct {
// A TTL of 0 disables caching (Get always misses). // A TTL of 0 disables caching (Get always misses).
func NewQuestCache(ttlSeconds int) *QuestCache { func NewQuestCache(ttlSeconds int) *QuestCache {
return &QuestCache{ return &QuestCache{
data: make(map[int][]byte), data: make(map[questCacheKey][]byte),
expiry: make(map[int]time.Time), expiry: make(map[questCacheKey]time.Time),
ttl: time.Duration(ttlSeconds) * time.Second, ttl: time.Duration(ttlSeconds) * time.Second,
} }
} }
// Get returns cached quest data if it exists and has not expired. // Get returns cached quest data for the (questID, lang) variant if it exists
func (c *QuestCache) Get(questID int) ([]byte, bool) { // and has not expired.
func (c *QuestCache) Get(questID int, lang string) ([]byte, bool) {
if c.ttl <= 0 { if c.ttl <= 0 {
return nil, false return nil, false
} }
k := questCacheKey{questID: questID, lang: lang}
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
b, ok := c.data[questID] b, ok := c.data[k]
if !ok { if !ok {
return nil, false return nil, false
} }
if time.Now().After(c.expiry[questID]) { if time.Now().After(c.expiry[k]) {
return nil, false return nil, false
} }
return b, true return b, true
} }
// Put stores quest data in the cache with the configured TTL. // Put stores quest data for the (questID, lang) variant with the configured TTL.
func (c *QuestCache) Put(questID int, b []byte) { func (c *QuestCache) Put(questID int, lang string, b []byte) {
k := questCacheKey{questID: questID, lang: lang}
c.mu.Lock() c.mu.Lock()
c.data[questID] = b c.data[k] = b
c.expiry[questID] = time.Now().Add(c.ttl) c.expiry[k] = time.Now().Add(c.ttl)
c.mu.Unlock() c.mu.Unlock()
} }

View File

@@ -8,7 +8,7 @@ import (
func TestQuestCache_GetMiss(t *testing.T) { func TestQuestCache_GetMiss(t *testing.T) {
c := NewQuestCache(60) c := NewQuestCache(60)
_, ok := c.Get(999) _, ok := c.Get(999, "jp")
if ok { if ok {
t.Error("expected cache miss for unknown quest ID") 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) { func TestQuestCache_PutGet(t *testing.T) {
c := NewQuestCache(60) c := NewQuestCache(60)
data := []byte{0xDE, 0xAD} 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 { if !ok {
t.Fatal("expected cache hit") t.Fatal("expected cache hit")
} }
@@ -30,9 +30,9 @@ func TestQuestCache_PutGet(t *testing.T) {
func TestQuestCache_Expiry(t *testing.T) { func TestQuestCache_Expiry(t *testing.T) {
c := NewQuestCache(0) // TTL=0 disables caching 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 { if ok {
t.Error("expected cache miss when TTL is 0") 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) { func TestQuestCache_ExpiryElapsed(t *testing.T) {
c := &QuestCache{ c := &QuestCache{
data: make(map[int][]byte), data: make(map[questCacheKey][]byte),
expiry: make(map[int]time.Time), expiry: make(map[questCacheKey]time.Time),
ttl: 50 * time.Millisecond, ttl: 50 * time.Millisecond,
} }
c.Put(1, []byte{0x01}) c.Put(1, "jp", []byte{0x01})
// Should hit immediately // Should hit immediately
if _, ok := c.Get(1); !ok { if _, ok := c.Get(1, "jp"); !ok {
t.Fatal("expected cache hit before expiry") t.Fatal("expected cache hit before expiry")
} }
time.Sleep(60 * time.Millisecond) time.Sleep(60 * time.Millisecond)
// Should miss after expiry // Should miss after expiry
if _, ok := c.Get(1); ok { if _, ok := c.Get(1, "jp"); ok {
t.Error("expected cache miss after expiry") 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) { func TestQuestCache_ConcurrentAccess(t *testing.T) {
c := NewQuestCache(60) c := NewQuestCache(60)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -67,11 +86,11 @@ func TestQuestCache_ConcurrentAccess(t *testing.T) {
id := i id := i
go func() { go func() {
defer wg.Done() defer wg.Done()
c.Put(id, []byte{byte(id)}) c.Put(id, "jp", []byte{byte(id)})
}() }()
go func() { go func() {
defer wg.Done() defer wg.Done()
c.Get(id) c.Get(id, "jp")
}() }()
} }
wg.Wait() wg.Wait()

View File

@@ -210,15 +210,24 @@ type QuestJSON struct {
// Quest identification // Quest identification
QuestID uint16 `json:"quest_id"` QuestID uint16 `json:"quest_id"`
// Text (UTF-8; converted to Shift-JIS in binary) // Text (UTF-8; converted to Shift-JIS in binary).
Title string `json:"title"` //
Description string `json:"description"` // Each field accepts either a plain JSON string (single-language, treated
TextMain string `json:"text_main"` // as the value for every language) or a language-keyed object:
TextSubA string `json:"text_sub_a"` //
TextSubB string `json:"text_sub_b"` // "title": "リオレウス"
SuccessCond string `json:"success_cond"` // "title": { "jp": "リオレウス", "en": "Rathalos", "fr": "Rathalos" }
FailCond string `json:"fail_cond"` //
Contractor string `json:"contractor"` // 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, 0x440x85) // General quest properties (generalQuestProperties section, 0x440x85)
MonsterSizeMulti uint16 `json:"monster_size_multi"` // 100 = 100% 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 sections, area mappings, area transitions,
// map info, gathering points, area facilities, // map info, gathering points, area facilities,
// some strings, gathering tables // some strings, gathering tables
func CompileQuestJSON(data []byte) ([]byte, error) { func CompileQuestJSON(data []byte, lang string) ([]byte, error) {
var q QuestJSON var q QuestJSON
if err := json.Unmarshal(data, &q); err != nil { if err := json.Unmarshal(data, &q); err != nil {
return nil, fmt.Errorf("parse quest JSON: %w", err) return nil, fmt.Errorf("parse quest JSON: %w", err)
@@ -454,10 +463,14 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
// ── Build Shift-JIS strings ───────────────────────────────────────── // ── Build Shift-JIS strings ─────────────────────────────────────────
// Order matches QuestText struct: title, textMain, textSubA, textSubB, // 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{ rawTexts := []string{
q.Title, q.TextMain, q.TextSubA, q.TextSubB, q.Title.Resolve(lang), q.TextMain.Resolve(lang),
q.SuccessCond, q.FailCond, q.Contractor, q.Description, 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 var sjisStrings [][]byte
for _, s := range rawTexts { for _, s := range rawTexts {

View File

@@ -171,14 +171,17 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
} }
texts[i] = s texts[i] = s
} }
q.Title = texts[0] // The binary carries only one language, so the reverse path emits
q.TextMain = texts[1] // plain-string LocalizedStrings. Editors wanting multi-language
q.TextSubA = texts[2] // quests should wrap these as {"jp": "...", "en": "..."} by hand.
q.TextSubB = texts[3] q.Title = NewLocalizedPlain(texts[0])
q.SuccessCond = texts[4] q.TextMain = NewLocalizedPlain(texts[1])
q.FailCond = texts[5] q.TextSubA = NewLocalizedPlain(texts[2])
q.Contractor = texts[6] q.TextSubB = NewLocalizedPlain(texts[3])
q.Description = texts[7] q.SuccessCond = NewLocalizedPlain(texts[4])
q.FailCond = NewLocalizedPlain(texts[5])
q.Contractor = NewLocalizedPlain(texts[6])
q.Description = NewLocalizedPlain(texts[7])
} }
// ── Stages ─────────────────────────────────────────────────────────── // ── Stages ───────────────────────────────────────────────────────────

View File

@@ -6,6 +6,9 @@ import (
"encoding/json" "encoding/json"
"math" "math"
"testing" "testing"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/transform"
) )
// minimalQuestJSON is a small but complete quest used across many test cases. // minimalQuestJSON is a small but complete quest used across many test cases.
@@ -57,7 +60,7 @@ var minimalQuestJSON = `{
// ── Compiler tests (existing) ──────────────────────────────────────────────── // ── Compiler tests (existing) ────────────────────────────────────────────────
func TestCompileQuestJSON_MinimalQuest(t *testing.T) { func TestCompileQuestJSON_MinimalQuest(t *testing.T) {
data, err := CompileQuestJSON([]byte(minimalQuestJSON)) data, err := CompileQuestJSON([]byte(minimalQuestJSON), "")
if err != nil { if err != nil {
t.Fatalf("CompileQuestJSON: %v", err) t.Fatalf("CompileQuestJSON: %v", err)
} }
@@ -114,7 +117,7 @@ func TestCompileQuestJSON_BadObjectiveType(t *testing.T) {
q.ObjectiveMain.Type = "invalid_type" q.ObjectiveMain.Type = "invalid_type"
b, _ := json.Marshal(q) b, _ := json.Marshal(q)
_, err := CompileQuestJSON(b) _, err := CompileQuestJSON(b, "")
if err == nil { if err == nil {
t.Fatal("expected error for invalid objective type, got 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) _ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.ObjectiveMain.Type = typ q.ObjectiveMain.Type = typ
b, _ := json.Marshal(q) 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) t.Fatalf("CompileQuestJSON with type %q: %v", typ, err)
} }
}) })
@@ -143,7 +146,7 @@ func TestCompileQuestJSON_EmptyRewards(t *testing.T) {
_ = json.Unmarshal([]byte(minimalQuestJSON), &q) _ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.Rewards = nil q.Rewards = nil
b, _ := json.Marshal(q) 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) 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}}}, {TableID: 2, Items: []QuestRewardItemJSON{{Rate: 100, Item: 153, Quantity: 2}}},
} }
b, _ := json.Marshal(q) b, _ := json.Marshal(q)
data, err := CompileQuestJSON(b) data, err := CompileQuestJSON(b, "")
if err != nil { if err != nil {
t.Fatalf("CompileQuestJSON: %v", err) t.Fatalf("CompileQuestJSON: %v", err)
} }
@@ -189,7 +192,7 @@ func TestParseQuestBinary_NullQuestTypeFlagsPtr(t *testing.T) {
} }
func TestParseQuestBinary_MinimalQuest(t *testing.T) { func TestParseQuestBinary_MinimalQuest(t *testing.T) {
data, err := CompileQuestJSON([]byte(minimalQuestJSON)) data, err := CompileQuestJSON([]byte(minimalQuestJSON), "")
if err != nil { if err != nil {
t.Fatalf("compile: %v", err) t.Fatalf("compile: %v", err)
} }
@@ -204,24 +207,25 @@ func TestParseQuestBinary_MinimalQuest(t *testing.T) {
t.Errorf("QuestID = %d, want 1", q.QuestID) t.Errorf("QuestID = %d, want 1", q.QuestID)
} }
// Text strings // Text strings — Resolve against empty lang so plain-string JSON fields
if q.Title != "Test Quest" { // return their literal value (phase B of #188).
t.Errorf("Title = %q, want %q", q.Title, "Test Quest") if got := q.Title.Resolve(""); got != "Test Quest" {
t.Errorf("Title = %q, want %q", got, "Test Quest")
} }
if q.Description != "A test quest." { if got := q.Description.Resolve(""); got != "A test quest." {
t.Errorf("Description = %q, want %q", q.Description, "A test quest.") t.Errorf("Description = %q, want %q", got, "A test quest.")
} }
if q.TextMain != "Hunt the Rathalos." { if got := q.TextMain.Resolve(""); got != "Hunt the Rathalos." {
t.Errorf("TextMain = %q, want %q", q.TextMain, "Hunt the Rathalos.") t.Errorf("TextMain = %q, want %q", got, "Hunt the Rathalos.")
} }
if q.SuccessCond != "Slay the Rathalos." { if got := q.SuccessCond.Resolve(""); got != "Slay the Rathalos." {
t.Errorf("SuccessCond = %q, want %q", q.SuccessCond, "Slay the Rathalos.") t.Errorf("SuccessCond = %q, want %q", got, "Slay the Rathalos.")
} }
if 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", q.FailCond, "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" { if got := q.Contractor.Resolve(""); got != "Guild Master" {
t.Errorf("Contractor = %q, want %q", q.Contractor, "Guild Master") t.Errorf("Contractor = %q, want %q", got, "Guild Master")
} }
// Numeric fields // Numeric fields
@@ -348,7 +352,7 @@ func TestParseQuestBinary_MinimalQuest(t *testing.T) {
func roundTrip(t *testing.T, label, jsonSrc string) { func roundTrip(t *testing.T, label, jsonSrc string) {
t.Helper() t.Helper()
bin1, err := CompileQuestJSON([]byte(jsonSrc)) bin1, err := CompileQuestJSON([]byte(jsonSrc), "")
if err != nil { if err != nil {
t.Fatalf("%s: compile(1): %v", label, err) 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) t.Fatalf("%s: marshal: %v", label, err)
} }
bin2, err := CompileQuestJSON(jsonOut) bin2, err := CompileQuestJSON(jsonOut, "")
if err != nil { if err != nil {
t.Fatalf("%s: compile(2): %v", label, err) t.Fatalf("%s: compile(2): %v", label, err)
} }
@@ -773,7 +777,7 @@ func TestRoundTrip_AllSections(t *testing.T) {
// mainPropOffset = 0x86 (= headerSize + genPropSize) // mainPropOffset = 0x86 (= headerSize + genPropSize)
// questStringsPtr = 0x1C6 (= mainPropOffset + 320) // questStringsPtr = 0x1C6 (= mainPropOffset + 320)
func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
data, err := CompileQuestJSON([]byte(minimalQuestJSON)) data, err := CompileQuestJSON([]byte(minimalQuestJSON), "")
if err != nil { if err != nil {
t.Fatalf("compile: %v", err) t.Fatalf("compile: %v", err)
} }
@@ -981,7 +985,7 @@ func TestGolden_GeneralQuestPropertiesCounts(t *testing.T) {
} }
b, _ := json.Marshal(q) b, _ := json.Marshal(q)
data, err := CompileQuestJSON(b) data, err := CompileQuestJSON(b, "")
if err != nil { if err != nil {
t.Fatalf("compile: %v", err) 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 { if err != nil {
t.Fatalf("compile: %v", err) t.Fatalf("compile: %v", err)
} }
@@ -1097,7 +1101,7 @@ func TestGolden_GatheringTablesBinaryLayout(t *testing.T) {
} }
b, _ := json.Marshal(q) b, _ := json.Marshal(q)
data, err := CompileQuestJSON(b) data, err := CompileQuestJSON(b, "")
if err != nil { if err != nil {
t.Fatalf("compile: %v", err) 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) 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")
}
}
}