mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +02:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
103
server/channelserver/localized_string.go
Normal file
103
server/channelserver/localized_string.go
Normal 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
|
||||
}
|
||||
149
server/channelserver/localized_string_test.go
Normal file
149
server/channelserver/localized_string_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user