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

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