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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user