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

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