updated localization

master
Denis Ranneft 1 month ago
parent dd21bff29d
commit a9b452e713

@ -0,0 +1,25 @@
package game
import "github.com/denisovdennis/autohero/internal/model"
// combatLogPhraseKey maps combat swing to a client phrase key (see frontend adventureLog phrases).
func combatLogPhraseKey(source, outcome string) string {
switch source {
case "hero":
switch outcome {
case attackOutcomeStun:
return model.LogPhraseCombatHeroStun
case attackOutcomeDodge:
return model.LogPhraseCombatHeroDodge
default:
return model.LogPhraseCombatHeroHit
}
case "enemy":
if outcome == attackOutcomeBlock {
return model.LogPhraseCombatEnemyBlock
}
return model.LogPhraseCombatEnemyHit
default:
return model.LogPhraseCombatHeroHit
}
}

@ -513,7 +513,7 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) {
if e.adventureLog != nil {
e.adventureLog(msg.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogUsedHealingPotion,
Code: model.LogPhraseUsedHealingPotion,
Args: map[string]any{"amount": healAmount},
},
})
@ -1075,8 +1075,8 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
if e.adventureLog != nil {
e.adventureLog(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug, "enemyName": enemy.Name},
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
}
@ -1559,19 +1559,16 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
return
}
args := map[string]any{
"source": evt.Source,
"outcome": evt.Outcome,
"damage": evt.Damage,
"isCrit": evt.IsCrit,
"enemyType": cs.Enemy.Slug,
"enemyName": cs.Enemy.Name,
}
if evt.DebuffApplied != "" {
args["debuffType"] = evt.DebuffApplied
}
e.adventureLog(cs.HeroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogCombatSwing,
Code: combatLogPhraseKey(evt.Source, evt.Outcome),
Args: args,
},
})
@ -1689,7 +1686,7 @@ func (e *Engine) processAutoReviveLocked(now time.Time) {
if e.adventureLog != nil {
e.adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogAutoReviveAfterSec,
Code: model.LogPhraseAutoReviveAfterSec,
Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)},
},
})

@ -0,0 +1,82 @@
// Resident hero policy (engine memory):
// - After the last WebSocket disconnect, the hero stays in Engine.movements; the world keeps ticking.
// - Cold start: ListHeroesForEngineBootstrap loads rows with ws_disconnected_at set (cap 500 default in main).
// - Full hero row is saved every offlineDisconnectedFullSaveInterval while heroSubscriber reports false.
package game
import (
"context"
"log/slog"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage"
)
// BootstrapResidentHeroes loads heroes whose WebSocket session had ended before this process started,
// catches up wall time using the same batch path as server-downtime recovery, then registers them
// in the engine so movement and combat continue without a live subscriber.
func BootstrapResidentHeroes(ctx context.Context, e *Engine, heroStore *storage.HeroStore, sim *OfflineSimulator, limit int, logger *slog.Logger) {
if e == nil || heroStore == nil || sim == nil {
return
}
heroes, err := heroStore.ListHeroesForEngineBootstrap(ctx, limit)
if err != nil {
if logger != nil {
logger.Error("engine bootstrap: list heroes", "error", err)
}
return
}
now := time.Now()
for _, h := range heroes {
if h == nil {
continue
}
e.mu.Lock()
_, already := e.movements[h.ID]
rg := e.roadGraph
e.mu.Unlock()
if already || rg == nil {
continue
}
e.mergeTownSessionFromRedis(h)
if err := sim.SimulateHeroAt(ctx, h, now, true); err != nil {
if logger != nil {
logger.Error("engine bootstrap: catch-up sim", "hero_id", h.ID, "error", err)
}
continue
}
e.mu.Lock()
if e.roadGraph == nil {
e.mu.Unlock()
return
}
if _, taken := e.movements[h.ID]; taken {
e.mu.Unlock()
continue
}
hm := NewHeroMovement(h, e.roadGraph, now)
e.movements[h.ID] = hm
hm.MarkTownPausePersisted(hm.townPausePersistSignature())
hm.SyncToHero()
if hm.State == model.StateFighting {
if _, exists := e.combats[h.ID]; !exists {
en := PickEnemyForLevel(h.Level)
if en.Slug != "" {
e.startCombatLocked(hm.Hero, &en)
} else {
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
}
}
}
e.mu.Unlock()
if logger != nil {
logger.Info("engine bootstrap: resident hero registered", "hero_id", h.ID)
}
}
}

@ -0,0 +1,63 @@
package game
import (
"log/slog"
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
)
func TestHeroSocketDetachedKeepsMovement(t *testing.T) {
e := NewEngine(100*time.Millisecond, make(chan model.CombatEvent, 8), slog.Default())
e.SetRoadGraph(testGraph())
h := &model.Hero{
ID: 1, State: model.StateWalking, HP: 10, MaxHP: 10, Level: 1,
PositionX: 1, PositionY: 1,
}
hm := NewHeroMovement(h, testGraph(), time.Now())
e.mu.Lock()
e.movements[1] = hm
e.mu.Unlock()
disconnectAt := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
e.HeroSocketDetached(1, true, disconnectAt)
e.mu.RLock()
_, ok := e.movements[1]
e.mu.RUnlock()
if !ok {
t.Fatal("expected hero to remain resident in engine after last WS disconnect")
}
if h.WsDisconnectedAt == nil || !h.WsDisconnectedAt.Equal(disconnectAt) {
t.Fatalf("expected WsDisconnectedAt on in-memory hero, got %v", h.WsDisconnectedAt)
}
}
func TestMergeResidentHeroState(t *testing.T) {
e := NewEngine(100*time.Millisecond, make(chan model.CombatEvent, 8), slog.Default())
e.SetRoadGraph(testGraph())
dst := &model.Hero{ID: 7, State: model.StateWalking, HP: 5, MaxHP: 10, Level: 2}
if e.MergeResidentHeroState(dst) {
t.Fatal("expected false when hero not resident")
}
h := &model.Hero{
ID: 7, State: model.StateWalking, HP: 9, MaxHP: 10, Level: 3,
PositionX: 2, PositionY: 3,
}
hm := NewHeroMovement(h, testGraph(), time.Now())
e.mu.Lock()
e.movements[7] = hm
e.mu.Unlock()
dst2 := &model.Hero{ID: 7, HP: 1, Level: 1}
if !e.MergeResidentHeroState(dst2) {
t.Fatal("expected true when resident")
}
if dst2.HP != 9 || dst2.Level != 3 {
t.Fatalf("expected engine stats copied, got hp=%d level=%d", dst2.HP, dst2.Level)
}
}

@ -1450,12 +1450,7 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv
lineIdx := hm.TownVisitLogsEmitted
log(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogTownNPCVisitLine,
Args: map[string]any{
"npcType": hm.TownVisitNPCType,
"npcKey": hm.TownVisitNPCKey,
"line": lineIdx,
},
Code: model.TownVisitPhraseKey(hm.TownVisitNPCType, lineIdx),
},
})
hm.TownVisitLogsEmitted++
@ -1764,12 +1759,13 @@ func ProcessSingleHeroMovementTick(
hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second)
}
if !now.Before(hm.RoadsideThoughtNextAt) {
if n := len(model.RoadsideSlugs); n > 0 {
adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogThoughtRoadside,
Args: map[string]any{"idx": rand.Intn(model.RoadsideThoughtCount)},
Code: model.RoadsidePhraseKey(model.RoadsideSlugs[rand.Intn(n)]),
},
})
}
hm.RoadsideThoughtNextAt = now.Add(time.Duration(30+rand.Intn(61)) * time.Second)
}
}
@ -1948,7 +1944,7 @@ func ProcessSingleHeroMovementTick(
if soldItems > 0 && adventureLog != nil {
adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogSoldItemsMerchant,
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
@ -1960,7 +1956,7 @@ func ProcessSingleHeroMovementTick(
if adventureLog != nil {
adventureLog(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogNPCSkippedVisit,
Code: model.LogPhraseNPCSkippedVisit,
Args: map[string]any{"npcKey": npc.NameKey},
},
})

@ -127,7 +127,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
hero.Debuffs = nil
s.addLog(ctx, hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogAutoReviveAfterSec,
Code: model.LogPhraseAutoReviveAfterSec,
Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)},
},
})
@ -160,8 +160,8 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug, "enemyName": enemy.Name},
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
rewardDeps := s.rewardDeps(tickNow)
@ -184,9 +184,9 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
if survived {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogDefeatedEnemy,
Code: model.LogPhraseDefeatedEnemy,
Args: map[string]any{
"enemyType": en.Slug, "enemyName": en.Name,
"enemyType": en.Slug,
"xp": xpGained, "gold": goldGained,
},
},
@ -195,8 +195,8 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
} else {
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogDiedFighting,
Args: map[string]any{"enemyType": en.Slug, "enemyName": en.Name},
Code: model.LogPhraseDiedFighting,
Args: map[string]any{"enemyType": en.Slug},
},
})
hm.Die()
@ -219,7 +219,7 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
_ = tickNow
_ = cost
s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{Code: model.LogWanderingMerchant},
Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant},
})
}
adventureLog := func(heroID int64, line model.AdventureLogLine) {
@ -309,7 +309,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if soldItems > 0 && al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogSoldItemsMerchant,
Code: model.LogPhraseSoldItemsMerchant,
Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
},
})
@ -321,7 +321,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPurchasedPotionFromNPC,
Code: model.LogPhrasePurchasedPotionFromNPC,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
@ -335,7 +335,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPaidHealerFull,
Code: model.LogPhrasePaidHealerFull,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
@ -369,7 +369,7 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
if al != nil {
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogQuestGiverChecked,
Code: model.LogPhraseQuestGiverChecked,
Args: map[string]any{"npcKey": npc.NameKey},
},
})
@ -389,8 +389,8 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID
}
al(heroID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogQuestAccepted,
Args: map[string]any{"questKey": qk, "title": pick.Title},
Code: model.LogPhraseQuestAccepted,
Args: map[string]any{"questKey": qk},
},
})
}

@ -139,8 +139,10 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogEquippedNew,
Args: map[string]any{"slot": string(slot), "itemName": item.Name},
Code: model.LogPhraseEquippedNew,
Args: map[string]any{
"slot": string(slot), "itemId": item.ID, "rarity": string(item.Rarity), "formId": item.FormID,
},
},
})
}
@ -166,8 +168,10 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogInventoryFullDropped,
Args: map[string]any{"itemName": item.Name, "rarity": string(item.Rarity)},
Code: model.LogPhraseInventoryFullDropped,
Args: map[string]any{
"itemId": item.ID, "slot": string(item.Slot), "rarity": string(item.Rarity), "formId": item.FormID,
},
},
})
}
@ -223,9 +227,9 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
if deps.LogWriter != nil {
deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogDefeatedEnemy,
Code: model.LogPhraseDefeatedEnemy,
Args: map[string]any{
"enemyType": enemy.Slug, "enemyName": enemy.Name,
"enemyType": enemy.Slug,
"xp": enemy.XPReward, "gold": goldGained,
},
},
@ -233,7 +237,7 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ {
deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogLeveledUp,
Code: model.LogPhraseLeveledUp,
Args: map[string]any{"level": l},
},
})
@ -280,10 +284,9 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de
}
deps.LogWriter(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogAchievementUnlocked,
Code: model.LogPhraseAchievementUnlocked,
Args: map[string]any{
"achievementId": a.ID,
"title": a.Title,
"rewardAmount": a.RewardAmount,
"rewardType": a.RewardType,
},

@ -275,7 +275,7 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
)
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogBuffActivated,
Code: model.LogPhraseBuffActivated,
Args: map[string]any{"buffType": string(bt)},
},
})
@ -362,7 +362,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
}
h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogHeroRevived}})
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}})
hero.RefreshDerivedCombatStats(now)
writeHeroJSON(w, http.StatusOK, hero)
@ -437,7 +437,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
// 10% chance to encounter a wandering NPC instead of an enemy.
if rand.Float64() < cfg.RESTEncounterNPCChance {
cost := game.WanderingMerchantCost(hero.Level)
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogWanderingMerchant}})
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant}})
h.encounterMu.Lock()
h.lastCombatEncounterAt[hero.ID] = now
h.encounterMu.Unlock()
@ -460,8 +460,8 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
h.encounterMu.Unlock()
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug, "enemyName": enemy.Name},
Code: model.LogPhraseEncounteredEnemy,
Args: map[string]any{"enemyType": enemy.Slug},
},
})
writeJSON(w, http.StatusOK, encounterEnemyResponse{
@ -891,7 +891,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
}
hero.State = model.StateWalking
hero.Debuffs = nil
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogAutoReviveHours}})
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseAutoReviveHours}})
if err := h.store.Save(r.Context(), hero); err != nil {
h.logger.Error("failed to save hero after auto-revive", "hero_id", hero.ID, "error", err)
}
@ -1238,7 +1238,7 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request)
"price_rub", priceRUB,
)
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{Code: model.LogPurchasedBuffRefill, Args: map[string]any{"buffType": string(bt)}},
Event: &model.AdventureLogEvent{Code: model.LogPhrasePurchasedBuffRefill, Args: map[string]any{"buffType": string(bt)}},
})
hero.RefreshDerivedCombatStats(now)
@ -1303,7 +1303,7 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques
h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt)
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogSubscribed,
Code: model.LogPhraseSubscribed,
Args: map[string]any{
"durationKey": "subscription.week",
"priceRub": model.SubscriptionWeeklyPrice(),
@ -1412,7 +1412,7 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
}
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{Code: model.LogUsedHealingPotion, Args: map[string]any{"amount": healAmount}},
Event: &model.AdventureLogEvent{Code: model.LogPhraseUsedHealingPotion, Args: map[string]any{"amount": healAmount}},
})
now := time.Now()
@ -1607,10 +1607,9 @@ func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) {
}
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogAchievementUnlocked,
Code: model.LogPhraseAchievementUnlocked,
Args: map[string]any{
"achievementId": a.ID,
"title": a.Title,
"rewardAmount": a.RewardAmount,
"rewardType": a.RewardType,
},

@ -222,7 +222,7 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
// Log the meeting.
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogMetNPC,
Code: model.LogPhraseMetNPC,
Args: map[string]any{"npcKey": npc.NameKey, "townKey": town.NameKey},
},
})
@ -394,8 +394,10 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
}
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogWanderingAlmsEquipped,
Args: map[string]any{"itemName": item.Name},
Code: model.LogPhraseWanderingAlmsEquipped,
Args: map[string]any{
"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID,
},
},
})
}
@ -412,8 +414,8 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
cancelDel()
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogWanderingAlmsDropped,
Args: map[string]any{"itemName": item.Name, "rarity": string(item.Rarity)},
Code: model.LogPhraseWanderingAlmsDropped,
Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
},
})
} else {
@ -429,8 +431,8 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no
hero.Inventory = append(hero.Inventory, item)
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogWanderingAlmsStashed,
Args: map[string]any{"itemName": item.Name},
Code: model.LogPhraseWanderingAlmsStashed,
Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
},
})
}
@ -649,7 +651,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
return
}
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogHealedFullTown}})
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHealedFullTown}})
// Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client.
writeHeroJSON(w, http.StatusOK, hero)
}
@ -699,6 +701,6 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
return
}
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogBoughtPotionTown}})
h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseBoughtPotionTown}})
writeHeroJSON(w, http.StatusOK, hero)
}

@ -278,7 +278,7 @@ func (h *PaymentsHandler) applySubscription(ctx context.Context, hero *model.Her
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogSubscribed,
Code: model.LogPhraseSubscribed,
Args: map[string]any{"durationKey": "subscription.week", "priceRub": model.SubscriptionWeeklyPrice()},
},
})
@ -332,7 +332,7 @@ func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero,
h.addLogLine(hero.ID, model.AdventureLogLine{
Event: &model.AdventureLogEvent{
Code: model.LogPurchasedBuffRefillRub,
Code: model.LogPhrasePurchasedBuffRefillRub,
Args: map[string]any{"buffType": string(bt), "priceRub": priceRUB},
},
})

@ -1,285 +0,0 @@
package model
import (
"fmt"
"strings"
)
// EnglishAdventureLogFallback returns a readable English line for SQL/admin when Message is empty.
// Keep roughly aligned with frontend adventureLogFormat.ts (EN branch). Sync new event codes here.
func EnglishAdventureLogFallback(ev *AdventureLogEvent) string {
if ev == nil {
return ""
}
a := ev.Args
switch ev.Code {
case LogCombatSwing:
return englishCombatSwing(a)
case LogDefeatedEnemy:
return fmt.Sprintf("Defeated %s (+%v XP, +%v gold).", englishEnemyLogName(a), logArgFloat(a, "xp"), logArgFloat(a, "gold"))
case LogLeveledUp:
return fmt.Sprintf("Reached level %d!", logArgInt(a, "level"))
case LogEquippedNew:
return fmt.Sprintf("Equipped new %s: %s.", englishSlotName(logArgStr(a, "slot")), logArgStr(a, "itemName"))
case LogInventoryFullDropped:
return fmt.Sprintf("Inventory full — dropped %s (%s).", logArgStr(a, "itemName"), englishRarityName(logArgStr(a, "rarity")))
case LogBuffActivated:
return fmt.Sprintf("%s activated.", englishBuffName(logArgStr(a, "buffType")))
case LogHeroRevived:
return "You revived."
case LogWanderingMerchant:
return "A hooded merchant blocks your path, jingling a pouch of odd trinkets."
case LogEncounteredEnemy:
return fmt.Sprintf("You encounter %s.", englishEnemyLogName(a))
case LogDiedFighting:
return fmt.Sprintf("You died fighting %s.", englishEnemyLogName(a))
case LogAutoReviveHours:
return "Hours passed; you revived in town."
case LogAutoReviveAfterSec:
return fmt.Sprintf("Auto-revived after %ds offline.", logArgInt(a, "seconds"))
case LogPurchasedBuffRefill:
return fmt.Sprintf("Refilled charges: %s.", englishBuffName(logArgStr(a, "buffType")))
case LogPurchasedBuffRefillRub:
return fmt.Sprintf("Purchased refill for %s (%d RUB).", englishBuffName(logArgStr(a, "buffType")), logArgInt(a, "priceRub"))
case LogSubscribed:
dk := logArgStr(a, "durationKey")
price := logArgInt(a, "priceRub")
dur := dk
if dk == "subscription.week" {
dur = "one week of subscription"
}
return fmt.Sprintf("Subscribed: %s (%d RUB).", dur, price)
case LogUsedHealingPotion:
return fmt.Sprintf("Used healing potion (+%d HP).", logArgInt(a, "amount"))
case LogAchievementUnlocked:
title := logArgStr(a, "title")
rt := logArgStr(a, "rewardType")
ra := logArgInt(a, "rewardAmount")
switch rt {
case "gold":
return fmt.Sprintf("Achievement: %s (+%d gold).", title, ra)
case "potion":
return fmt.Sprintf("Achievement: %s (+%d potions).", title, ra)
default:
return fmt.Sprintf("Achievement: %s.", title)
}
case LogMetNPC:
return fmt.Sprintf("Met %s in %s.", logArgStr(a, "npcKey"), logArgStr(a, "townKey"))
case LogWanderingAlmsEquipped:
return fmt.Sprintf("Equipped from the merchant: %s.", logArgStr(a, "itemName"))
case LogWanderingAlmsDropped:
return fmt.Sprintf("Dropped %s (%s) — no room.", logArgStr(a, "itemName"), englishRarityName(logArgStr(a, "rarity")))
case LogWanderingAlmsStashed:
return fmt.Sprintf("Stashed %s in your inventory.", logArgStr(a, "itemName"))
case LogHealedFullTown:
return "Paid for a full heal."
case LogBoughtPotionTown:
return "Bought a potion in town."
case LogSoldItemsMerchant:
return fmt.Sprintf("Sold %d items to %s (+%v gold).", logArgInt(a, "count"), logArgStr(a, "npcKey"), logArgFloat(a, "gold"))
case LogNPCSkippedVisit:
return fmt.Sprintf("Skipped visiting %s.", logArgStr(a, "npcKey"))
case LogThoughtRoadside:
idx := logArgInt(a, "idx")
return fmt.Sprintf("Roadside thought (%d).", idx)
case LogPurchasedPotionFromNPC:
return fmt.Sprintf("Bought a potion from %s.", logArgStr(a, "npcKey"))
case LogPaidHealerFull:
return fmt.Sprintf("Paid %s for a full heal.", logArgStr(a, "npcKey"))
case LogQuestGiverChecked:
return fmt.Sprintf("Checked in with %s — no new quests.", logArgStr(a, "npcKey"))
case LogQuestAccepted:
return fmt.Sprintf("Accepted quest: %s.", logArgStr(a, "title"))
case LogTownNPCVisitLine:
npcType := logArgStr(a, "npcType")
line := logArgInt(a, "line")
return fmt.Sprintf("Town visit (%s): beat %d/6.", npcType, line+1)
default:
if ev.Code != "" {
return fmt.Sprintf("[%s]", ev.Code)
}
return ""
}
}
func englishCombatSwing(a map[string]any) string {
source := logArgStr(a, "source")
outcome := logArgStr(a, "outcome")
damage := logArgInt(a, "damage")
isCrit := logArgBool(a, "isCrit")
enemyName := englishEnemyLogName(a)
critSuffix := ""
if isCrit {
critSuffix = " (crit)"
}
var msg string
switch source {
case "hero":
switch outcome {
case "stun":
msg = "You are stunned and cannot attack."
case "dodge":
msg = enemyName + " dodged your attack."
default:
msg = fmt.Sprintf("You hit %s for %d damage%s.", enemyName, damage, critSuffix)
}
case "enemy":
switch outcome {
case "block":
msg = fmt.Sprintf("You block %s's attack.", enemyName)
default:
msg = fmt.Sprintf("%s hits you for %d damage%s.", enemyName, damage, critSuffix)
}
}
debuff := logArgStr(a, "debuffType")
if debuff != "" {
msg += " " + englishDebuffName(debuff) + " applied."
}
return msg
}
// englishEnemyLogName prefers arg enemyName (DB) when present; else template name from slug.
func englishEnemyLogName(a map[string]any) string {
if a == nil {
return "enemy"
}
if n := strings.TrimSpace(logArgStr(a, "enemyName")); n != "" {
return n
}
return englishEnemyDisplayName(logArgStr(a, "enemyType"))
}
func englishEnemyDisplayName(slug string) string {
slug = strings.TrimSpace(slug)
if slug == "" {
return "enemy"
}
if e, ok := EnemyBySlug(slug); ok && strings.TrimSpace(e.Name) != "" {
return e.Name
}
return slug
}
func englishDebuffName(debuffType string) string {
dt, ok := ValidDebuffType(debuffType)
if !ok {
return debuffType
}
if def, ok := DebuffDefinition(dt); ok && def.Name != "" {
return def.Name
}
return debuffType
}
func englishBuffName(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
bt, ok := ValidBuffType(raw)
if !ok {
return raw
}
if b, ok := BuffDefinition(bt); ok && b.Name != "" {
return b.Name
}
return raw
}
func englishSlotName(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
m := map[string]string{
"main_hand": "weapon",
"off_hand": "off-hand",
"head": "head",
"chest": "chest",
"legs": "legs",
"feet": "feet",
"cloak": "cloak",
"neck": "neck",
"finger": "ring",
"wrist": "wrist",
"hands": "hands",
"quiver": "quiver",
}
if s, ok := m[raw]; ok {
return s
}
return raw
}
func englishRarityName(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
m := map[string]string{
"common": "common",
"uncommon": "uncommon",
"rare": "rare",
"epic": "epic",
"legendary": "legendary",
}
if s, ok := m[raw]; ok {
return s
}
return raw
}
func logArgStr(a map[string]any, key string) string {
if a == nil {
return ""
}
v, ok := a[key]
if !ok || v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case fmt.Stringer:
return x.String()
default:
return fmt.Sprint(x)
}
}
func logArgFloat(a map[string]any, key string) float64 {
if a == nil {
return 0
}
v, ok := a[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case uint64:
return float64(x)
default:
return 0
}
}
func logArgInt(a map[string]any, key string) int {
return int(logArgFloat(a, key))
}
func logArgBool(a map[string]any, key string) bool {
if a == nil {
return false
}
v, ok := a[key]
if !ok || v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(x, "true") || x == "1"
default:
return false
}
}

@ -1,34 +0,0 @@
package model
import "testing"
func TestEnglishAdventureLogFallback_combatSwing(t *testing.T) {
got := EnglishAdventureLogFallback(&AdventureLogEvent{
Code: LogCombatSwing,
Args: map[string]any{
"source": "hero",
"outcome": "hit",
"damage": 12,
"isCrit": true,
"enemyType": "wolf_test_slug",
},
})
want := "You hit wolf_test_slug for 12 damage (crit)."
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestEnglishAdventureLogFallback_heroRevived(t *testing.T) {
got := EnglishAdventureLogFallback(&AdventureLogEvent{Code: LogHeroRevived})
if got != "You revived." {
t.Fatalf("got %q", got)
}
}
func TestEnglishAdventureLogFallback_unknownCode(t *testing.T) {
got := EnglishAdventureLogFallback(&AdventureLogEvent{Code: "future_event_xyz"})
if got != "[future_event_xyz]" {
t.Fatalf("got %q", got)
}
}

@ -1,60 +1,21 @@
package model
// AdventureLogEvent is a machine-readable log line; the client maps Code + Args to localized text.
// AdventureLogEvent is persisted and sent over WebSocket.
// Code is a phrase key (dot-separated), e.g. log.defeated_enemy, roadside.silence_loading, town_visit.merchant.bell_traveler_pack.
// Args must be structured only: stable ids (enemyType, npcKey, questKey, achievementId), numbers, bools — no display sentences.
type AdventureLogEvent struct {
Code string `json:"code"`
Args map[string]any `json:"args,omitempty"`
}
// AdventureLogLine is written to the DB and sent over WebSocket.
// Legacy rows have only Message; new rows set Event and optional Message (English fallback).
// Message is legacy plain text only; new rows use Event with phrase key and empty Message.
type AdventureLogLine struct {
Message string `json:"message,omitempty"`
Event *AdventureLogEvent `json:"event,omitempty"`
}
// Adventure log event codes (keep in sync with frontend adventureLogFormat.ts).
//
// For events with arg "enemyType" (enemies.type slug), also send "enemyName" when known:
// English display name from DB for client fallback and SQL message column.
const (
LogDefeatedEnemy = "defeated_enemy"
LogLeveledUp = "leveled_up"
LogEquippedNew = "equipped_new"
LogInventoryFullDropped = "inventory_full_dropped"
LogBuffActivated = "buff_activated"
LogHeroRevived = "hero_revived"
LogWanderingMerchant = "wandering_merchant_encounter"
LogEncounteredEnemy = "encountered_enemy"
LogDiedFighting = "died_fighting"
LogAutoReviveHours = "auto_revive_hours"
LogAutoReviveAfterSec = "auto_revive_after_sec"
LogPurchasedBuffRefill = "purchased_buff_refill"
LogPurchasedBuffRefillRub = "purchased_buff_refill_rub"
LogSubscribed = "subscribed"
LogUsedHealingPotion = "used_healing_potion"
LogAchievementUnlocked = "achievement_unlocked"
LogMetNPC = "met_npc"
LogWanderingAlmsEquipped = "wandering_alms_equipped"
LogWanderingAlmsDropped = "wandering_alms_dropped"
LogWanderingAlmsStashed = "wandering_alms_stashed"
LogHealedFullTown = "healed_full_town"
LogBoughtPotionTown = "bought_potion_town"
LogSoldItemsMerchant = "sold_items_merchant"
LogNPCSkippedVisit = "npc_skipped_visit"
LogThoughtRoadside = "thought_roadside"
LogPurchasedPotionFromNPC = "purchased_potion_from_npc"
LogPaidHealerFull = "paid_healer_full"
LogQuestGiverChecked = "quest_giver_checked"
LogQuestAccepted = "quest_accepted"
LogTownNPCVisitLine = "town_npc_visit_line"
LogCombatSwing = "combat_swing"
)
// RoadsideThoughtCount must match len(roadsideThoughtsEn) on the frontend.
const RoadsideThoughtCount = 52
// Wandering merchant (road encounter) — stable keys for client i18n.
// Wandering merchant (road encounter) — stable keys for other packages (NPC labels live in client i18n).
const (
WanderingMerchantNPCKey = "npc.wandering_merchant.v1"
WanderingMerchantDialogueKey = "npc.wandering_merchant.dialogue.v1"

@ -9,7 +9,7 @@ func TestAdventureLogLine_JSON_roundTrip(t *testing.T) {
line := AdventureLogLine{
Message: "legacy",
Event: &AdventureLogEvent{
Code: LogDefeatedEnemy,
Code: LogPhraseDefeatedEnemy,
Args: map[string]any{"enemyType": "wolf_l1_1_meadow", "xp": float64(10), "gold": float64(5)},
},
}
@ -24,7 +24,7 @@ func TestAdventureLogLine_JSON_roundTrip(t *testing.T) {
if got.Message != line.Message {
t.Fatalf("message: got %q want %q", got.Message, line.Message)
}
if got.Event == nil || got.Event.Code != LogDefeatedEnemy {
if got.Event == nil || got.Event.Code != LogPhraseDefeatedEnemy {
t.Fatalf("event code: %+v", got.Event)
}
if got.Event.Args["enemyType"] != "wolf_l1_1_meadow" {

@ -0,0 +1,91 @@
package model
// Phrase keys for adventure_log.event_code / WS adventure_log_line.event.code.
// No human-readable text on the server — only keys and structured args.
const (
LogPhraseDefeatedEnemy = "log.defeated_enemy"
LogPhraseLeveledUp = "log.leveled_up"
LogPhraseEquippedNew = "log.equipped_new"
LogPhraseInventoryFullDropped = "log.inventory_full_dropped"
LogPhraseBuffActivated = "log.buff_activated"
LogPhraseHeroRevived = "log.hero_revived"
LogPhraseWanderingMerchant = "log.wandering_merchant_encounter"
LogPhraseEncounteredEnemy = "log.encountered_enemy"
LogPhraseDiedFighting = "log.died_fighting"
LogPhraseAutoReviveHours = "log.auto_revive_hours"
LogPhraseAutoReviveAfterSec = "log.auto_revive_after_sec"
LogPhrasePurchasedBuffRefill = "log.purchased_buff_refill"
LogPhrasePurchasedBuffRefillRub = "log.purchased_buff_refill_rub"
LogPhraseSubscribed = "log.subscribed"
LogPhraseUsedHealingPotion = "log.used_healing_potion"
LogPhraseAchievementUnlocked = "log.achievement_unlocked"
LogPhraseMetNPC = "log.met_npc"
LogPhraseWanderingAlmsEquipped = "log.wandering_alms_equipped"
LogPhraseWanderingAlmsDropped = "log.wandering_alms_dropped"
LogPhraseWanderingAlmsStashed = "log.wandering_alms_stashed"
LogPhraseHealedFullTown = "log.healed_full_town"
LogPhraseBoughtPotionTown = "log.bought_potion_town"
LogPhraseSoldItemsMerchant = "log.sold_items_merchant"
LogPhraseNPCSkippedVisit = "log.npc_skipped_visit"
LogPhrasePurchasedPotionFromNPC = "log.purchased_potion_from_npc"
LogPhrasePaidHealerFull = "log.paid_healer_full"
LogPhraseQuestGiverChecked = "log.quest_giver_checked"
LogPhraseQuestAccepted = "log.quest_accepted"
LogPhraseCombatHeroHit = "log.combat.hero_hit"
LogPhraseCombatHeroDodge = "log.combat.hero_dodge"
LogPhraseCombatHeroStun = "log.combat.hero_stun"
LogPhraseCombatEnemyHit = "log.combat.enemy_hit"
LogPhraseCombatEnemyBlock = "log.combat.enemy_block"
LogPhraseCombatDebuffSuffix = "log.combat.debuff_suffix"
)
// Town visit line slugs per NPC kind (order = timed line 0..5). Unknown npcType uses generic slugs with key prefix "generic".
var townVisitLineSlugs = map[string][]string{
"merchant": {
"crates_in_shade",
"practiced_tired_smile",
"chalk_prices_twice",
"rumors_bandits_carts",
"bell_traveler_pack",
"step_back_tally_gold",
},
"healer": {
"linens_herbs_tent",
"professional_frown_onceover",
"slept_badly_nod",
"tonic_steams_table",
"blessings_salves_bandages",
"lighter_under_canvas",
},
"quest_giver": {
"scrolls_wax_desk",
"ink_stained_map_tap",
"busy_roads_noncommittal",
"draft_parchment_smell",
"squint_spine_legend",
"promise_listen_worth_it",
},
"generic": {
"town_noise_blanket",
"grain_prices_argument",
"dust_sunbeam_time",
"strap_tighten_pretend",
"dog_boring_sleeps",
"breathe_ready_move_on",
},
}
// TownVisitPhraseKey returns e.g. town_visit.merchant.bell_traveler_pack (lineIdx 0..5).
func TownVisitPhraseKey(npcType string, lineIdx int) string {
slugs, ok := townVisitLineSlugs[npcType]
keyType := npcType
if !ok {
slugs = townVisitLineSlugs["generic"]
keyType = "generic"
}
if lineIdx < 0 || lineIdx >= len(slugs) {
return ""
}
return "town_visit." + keyType + "." + slugs[lineIdx]
}

@ -0,0 +1,31 @@
package model
import (
"strings"
"testing"
)
func TestRoadsideSlugsWellFormed(t *testing.T) {
if len(RoadsideSlugs) == 0 {
t.Fatal("RoadsideSlugs empty")
}
for _, s := range RoadsideSlugs {
if strings.Contains(s, ".") {
t.Fatalf("roadside slug must not contain dot: %q", s)
}
if RoadsidePhraseKey(s) != "roadside."+s {
t.Fatalf("RoadsidePhraseKey(%q)=%q want roadside.%s", s, RoadsidePhraseKey(s), s)
}
}
}
func TestTownVisitPhraseKeyUsesSlugs(t *testing.T) {
k := TownVisitPhraseKey("merchant", 4)
if k != "town_visit.merchant.bell_traveler_pack" {
t.Fatalf("got %q", k)
}
k2 := TownVisitPhraseKey("unknown_npc", 0)
if k2 != "town_visit.generic.town_noise_blanket" {
t.Fatalf("unknown type should use generic slugs, got %q", k2)
}
}

@ -0,0 +1,62 @@
package model
// RoadsideSlugs are stable suffixes; full event codes are "roadside." + slug (matches en.yml / ru.yml keys under `roadside:`).
var RoadsideSlugs = []string{
"nothing_matters_crit",
"road_chose_you",
"coin_heavier_than_sword",
"consciousness_buff",
"grass_philosophical",
"braver_tomorrow",
"hero_job_or_tax",
"scars_bookmarks",
"breaths_on_purpose",
"universe_simulation_texture",
"resting_cheating_alive",
"real_loot_npcs",
"memoir_soup",
"time_circle_hp",
"silence_loading",
"meaning_lunch_later",
"trees_gossip_breaks",
"courage_silly_face",
"miss_never_met",
"wind_advice_ignore",
"gold_boots_happiness",
"gratitude_not_dummy",
"legend_sat_tired",
"fear_debuff_curiosity",
"slimes_electric_sheep",
"patience_skill_tree",
"road_crooked_stand",
"narrate_life_xp",
"gods_patch_notes",
"rock_throne_dramatic",
"forgive_panic_roll",
"love_side_quest",
"thoughts_loot_encumbered",
"sun_sets_optimize",
"fate_bad_ui",
"wounded_poetic_upgrade",
"heroic_pose_nobody",
"wisdom_stop_swinging",
"endgame_good_chair",
"doubt_armor_unkillable",
"world_spinning_pause",
"bird_screams_relate",
"regrets_shorter_list",
"hope_hp_cynical_patch",
"courage_stubborn_pr",
"merchants_fixed_prices",
"pause_rebellion_grind",
"dirt_nails_showed_up",
"meaning_hammer",
"smile_nothing_helps",
"tomorrow_walk_tonight_breathe",
"grind_volume_down",
}
// RoadsidePhraseKey returns the full phrase code for a slug suffix.
func RoadsidePhraseKey(slug string) string {
return "roadside." + slug
}

@ -47,9 +47,6 @@ func (s *LogStore) Add(ctx context.Context, heroID int64, line model.AdventureLo
}
}
msg := strings.TrimSpace(line.Message)
if msg == "" && line.Event != nil {
msg = model.EnglishAdventureLogFallback(line.Event)
}
_, err = s.pool.Exec(ctx,
`INSERT INTO adventure_log (hero_id, message, event_code, event_args) VALUES ($1, $2, $3, $4)`,
heroID, msg, code, argsJSON,

@ -12,7 +12,8 @@
"dependencies": {
"pixi.js": "^8.6.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/react": "^19.2.14",

@ -54,7 +54,12 @@ import {
buildAdventureLogEntriesFromRaw,
type AdventureLogRawRow,
} from './game/adventureLogMap';
import { parseAdventureLogLine, shouldSuppressThoughtBubblePayload } from './game/adventureLogMarkers';
import {
isAdventureLogCombatCode,
isAdventureLogEncounterCode,
parseAdventureLogLine,
shouldSuppressThoughtBubblePayload,
} from './game/adventureLogMarkers';
import { formatAdventureLogPayload, formatClientLogLine } from './game/adventureLogFormat';
import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels';
import type { AdventureLogLinePayload } from './game/types';
@ -397,7 +402,7 @@ export function App() {
eng.applyAdventureLogLine(thoughtText);
}
if (p.event?.code === 'encountered_enemy' && thoughtText) {
if (isAdventureLogEncounterCode(p.event?.code) && thoughtText) {
setCombatLogLines([thoughtText]);
} else {
const parsed = parseAdventureLogLine(p.message ?? '');
@ -405,7 +410,7 @@ export function App() {
setCombatLogLines([parsed.title]);
} else if (parsed.type === 'battle') {
setCombatLogLines((prev) => [...prev, parsed.text].slice(-5));
} else if (p.event?.code === 'combat_swing' && thoughtText) {
} else if (isAdventureLogCombatCode(p.event?.code) && thoughtText) {
setCombatLogLines((prev) => [...prev, thoughtText].slice(-5));
}
}

@ -1,5 +1,5 @@
import type { Translations } from '../i18n/en';
import type { Locale } from '../i18n/index';
import type { Translations } from '../i18n/types';
import type { Locale } from '../i18n/localeCodes';
import { t } from '../i18n/index';
import {
dialogueText,
@ -8,8 +8,13 @@ import {
townLabel,
WANDERING_MERCHANT_DIALOGUE_KEY,
} from '../i18n/contentLabels';
import { ROADSIDE_THOUGHTS_EN, ROADSIDE_THOUGHTS_RU } from '../i18n/roadsideThoughts';
import { townNpcVisitLineText } from '../i18n/townNpcVisitLines';
import {
adventureLogTemplate,
achievementLogTitle,
roadsidePhraseText,
townNpcVisitPhraseText,
} from '../i18n/loadLocales';
import { localizedQuestText } from '../i18n/questCopy';
export interface AdventureLogEventWire {
code: string;
@ -113,240 +118,294 @@ function subscriptionDuration(locale: Locale, key: string): string {
return key;
}
function roadsideThoughtText(locale: Locale, idx: number): string {
const arr = locale === 'ru' ? ROADSIDE_THOUGHTS_RU : ROADSIDE_THOUGHTS_EN;
if (!arr.length) return '';
const i = ((idx % arr.length) + arr.length) % arr.length;
return arr[i] ?? '';
/** Map legacy DB/WS semantic codes to phrase keys (or dynamic roadside/town). */
const LEGACY_SEMANTIC_TO_PHRASE: Record<string, string> = {
defeated_enemy: 'log.defeated_enemy',
leveled_up: 'log.leveled_up',
equipped_new: 'log.equipped_new',
inventory_full_dropped: 'log.inventory_full_dropped',
buff_activated: 'log.buff_activated',
hero_revived: 'log.hero_revived',
wandering_merchant_encounter: 'log.wandering_merchant_encounter',
encountered_enemy: 'log.encountered_enemy',
died_fighting: 'log.died_fighting',
auto_revive_hours: 'log.auto_revive_hours',
auto_revive_after_sec: 'log.auto_revive_after_sec',
purchased_buff_refill: 'log.purchased_buff_refill',
purchased_buff_refill_rub: 'log.purchased_buff_refill_rub',
subscribed: 'log.subscribed',
used_healing_potion: 'log.used_healing_potion',
achievement_unlocked: 'log.achievement_unlocked',
met_npc: 'log.met_npc',
wandering_alms_equipped: 'log.wandering_alms_equipped',
wandering_alms_dropped: 'log.wandering_alms_dropped',
wandering_alms_stashed: 'log.wandering_alms_stashed',
healed_full_town: 'log.healed_full_town',
bought_potion_town: 'log.bought_potion_town',
sold_items_merchant: 'log.sold_items_merchant',
npc_skipped_visit: 'log.npc_skipped_visit',
purchased_potion_from_npc: 'log.purchased_potion_from_npc',
paid_healer_full: 'log.paid_healer_full',
quest_giver_checked: 'log.quest_giver_checked',
quest_accepted: 'log.quest_accepted',
town_npc_visit_line: '__dynamic_town_visit__',
combat_swing: 'combat_swing',
};
/**
* Normalize event.code to a phrase key (log.*, roadside.<slug>, town_visit.type.<slug|line>).
* Legacy rows without dots are mapped; phrase keys pass through.
*/
export function normalizeAdventureLogCode(code: string, args?: Record<string, unknown>): string {
if (!code) return '';
if (code.includes('.')) {
return code;
}
const mapped = LEGACY_SEMANTIC_TO_PHRASE[code];
const a = args ?? {};
if (mapped === '__dynamic_town_visit__') {
const npcType = strArg(a, 'npcType') || 'generic';
const line = intArg(a, 'line');
return `town_visit.${npcType}.${line}`;
}
if (mapped) return mapped;
return code;
}
/** Localized single log line from structured event (+ optional legacy message). */
export function formatAdventureLogEvent(
function dynamicRoadsideLine(locale: Locale, code: string): string | undefined {
if (!code.startsWith('roadside.')) return undefined;
const text = roadsidePhraseText(locale, code);
return text || undefined;
}
function dynamicTownVisitLine(locale: Locale, code: string): string | undefined {
if (!code.startsWith('town_visit.')) return undefined;
const text = townNpcVisitPhraseText(locale, code);
return text || undefined;
}
function formatLegacyCombatSwing(
locale: Locale,
tr: Translations,
code: string,
args?: Record<string, unknown>,
args: Record<string, unknown>,
legacyMessage?: string,
): string {
const a = args ?? {};
const source = strArg(args, 'source');
const outcome = strArg(args, 'outcome');
const damage = intArg(args, 'damage');
const isCrit = boolArg(args, 'isCrit');
const enemySlug = strArg(args, 'enemyType');
const enemyDbName = strArg(args, 'enemyName');
const enemy = enemyFamilyLabel(locale, enemySlug, enemyDbName || enemySlug);
const debuff = strArg(args, 'debuffType');
const dn = debuff ? debuffName(tr, debuff) : '';
const debuffPart =
debuff && dn ? (locale === 'ru' ? ` ${dn} применён.` : ` ${dn} applied.`) : '';
const crit = isCrit ? (locale === 'ru' ? ' (крит)' : ' (crit)') : '';
let phraseKey = 'log.combat.hero_hit';
if (source === 'hero') {
if (outcome === 'stun') phraseKey = 'log.combat.hero_stun';
else if (outcome === 'dodge') phraseKey = 'log.combat.hero_dodge';
} else if (source === 'enemy') {
phraseKey = outcome === 'block' ? 'log.combat.enemy_block' : 'log.combat.enemy_hit';
}
const tmpl = adventureLogTemplate(locale, phraseKey);
if (!tmpl) return legacyMessage ?? '';
const vars: Record<string, string | number> = {
enemy,
damage,
crit: phraseKey === 'log.combat.hero_stun' ? '' : crit,
debuffPart,
};
return t(tmpl, vars);
}
switch (code) {
case 'defeated_enemy': {
function resolveAdventureLogVars(
locale: Locale,
tr: Translations,
phraseKey: string,
args: Record<string, unknown>,
legacyMessage?: string,
): Record<string, string | number> | null {
const a = args;
switch (phraseKey) {
case 'log.defeated_enemy':
case 'log.encountered_enemy':
case 'log.died_fighting': {
const slug = strArg(a, 'enemyType');
const dbName = strArg(a, 'enemyName');
const enemy = enemyFamilyLabel(locale, slug, dbName || slug);
const xp = numArg(a, 'xp');
const gold = numArg(a, 'gold');
return locale === 'ru'
? `Побеждён ${enemy} (+${xp} опыта, +${gold} золота).`
: `Defeated ${enemy} (+${xp} XP, +${gold} gold).`;
}
case 'leveled_up': {
const level = intArg(a, 'level');
return locale === 'ru' ? `Достигнут ${level} уровень!` : `Reached level ${level}!`;
return {
enemy: enemyFamilyLabel(locale, slug, dbName || slug),
xp: numArg(a, 'xp'),
gold: numArg(a, 'gold'),
};
}
case 'equipped_new': {
case 'log.leveled_up':
return { level: intArg(a, 'level') };
case 'log.equipped_new':
case 'log.wandering_alms_equipped': {
const slot = slotName(tr, strArg(a, 'slot'));
const itemName = strArg(a, 'itemName');
return locale === 'ru'
? `Экипировано: ${slot}${itemName}.`
: `Equipped new ${slot}: ${itemName}.`;
}
case 'inventory_full_dropped': {
const itemName = strArg(a, 'itemName');
const rarity = rarityName(tr, strArg(a, 'rarity'));
return locale === 'ru'
? `Инвентарь полон — выброшено ${itemName} (${rarity}).`
: `Inventory full — dropped ${itemName} (${rarity}).`;
}
case 'buff_activated':
return locale === 'ru'
? `Активирован бафф: ${buffName(tr, strArg(a, 'buffType'))}.`
: `${buffName(tr, strArg(a, 'buffType'))} activated.`;
case 'hero_revived':
return locale === 'ru' ? 'Вы воскрешены.' : 'You revived.';
case 'wandering_merchant_encounter':
return dialogueText(locale, WANDERING_MERCHANT_DIALOGUE_KEY, legacyMessage ?? '');
case 'encountered_enemy': {
const slug = strArg(a, 'enemyType');
const dbName = strArg(a, 'enemyName');
const enemy = enemyFamilyLabel(locale, slug, dbName || slug);
return locale === 'ru' ? `Вы встречаете ${enemy}.` : `You encounter ${enemy}.`;
}
case 'died_fighting': {
const slug = strArg(a, 'enemyType');
const dbName = strArg(a, 'enemyName');
const enemy = enemyFamilyLabel(locale, slug, dbName || slug);
return locale === 'ru' ? `Вы погибли в бою с ${enemy}.` : `You died fighting ${enemy}.`;
}
case 'auto_revive_hours':
return locale === 'ru'
? 'Прошло время; вы воскресли в городе.'
: 'Hours passed; you revived in town.';
case 'auto_revive_after_sec': {
const sec = intArg(a, 'seconds');
return locale === 'ru'
? `Авто-воскрешение оффлайн через ${sec} с.`
: `Auto-revived after ${sec}s offline.`;
}
case 'purchased_buff_refill':
return locale === 'ru'
? `Пополнены заряды: ${buffName(tr, strArg(a, 'buffType'))}.`
: `Refilled charges: ${buffName(tr, strArg(a, 'buffType'))}.`;
case 'purchased_buff_refill_rub': {
const price = intArg(a, 'priceRub');
return locale === 'ru'
? `Куплено пополнение ${buffName(tr, strArg(a, 'buffType'))} (${price} ₽).`
: `Purchased refill for ${buffName(tr, strArg(a, 'buffType'))} (${price} RUB).`;
}
case 'subscribed': {
const dk = strArg(a, 'durationKey');
const price = intArg(a, 'priceRub');
const dur = subscriptionDuration(locale, dk);
return locale === 'ru'
? `Оформлена подписка: ${dur} (${price} ₽).`
: `Subscribed: ${dur} (${price} RUB).`;
}
case 'used_healing_potion': {
const amount = intArg(a, 'amount');
return locale === 'ru'
? `Использовано зелье лечения (+${amount} HP).`
: `Used healing potion (+${amount} HP).`;
}
case 'achievement_unlocked': {
const title = strArg(a, 'title');
const legacyName = strArg(a, 'itemName');
const item =
legacyName ||
(locale === 'ru' ? `${rarity} (${slot})` : `${rarity}${slot}`);
return { slot, item };
}
case 'log.inventory_full_dropped':
case 'log.wandering_alms_dropped':
case 'log.wandering_alms_stashed': {
const slot = slotName(tr, strArg(a, 'slot'));
const rarity = rarityName(tr, strArg(a, 'rarity'));
const legacyName = strArg(a, 'itemName');
const item =
legacyName ||
(locale === 'ru' ? `${rarity} (${slot})` : `${rarity} (${slot})`);
return { item };
}
case 'log.buff_activated':
return { buff: buffName(tr, strArg(a, 'buffType')) };
case 'log.hero_revived':
case 'log.auto_revive_hours':
case 'log.healed_full_town':
case 'log.bought_potion_town':
return {};
case 'log.auto_revive_after_sec':
return { seconds: intArg(a, 'seconds') };
case 'log.purchased_buff_refill':
return { buff: buffName(tr, strArg(a, 'buffType')) };
case 'log.purchased_buff_refill_rub':
return {
buff: buffName(tr, strArg(a, 'buffType')),
price: intArg(a, 'priceRub'),
};
case 'log.subscribed':
return {
duration: subscriptionDuration(locale, strArg(a, 'durationKey')),
price: intArg(a, 'priceRub'),
};
case 'log.used_healing_potion':
return { amount: intArg(a, 'amount') };
case 'log.achievement_unlocked': {
const rt = strArg(a, 'rewardType');
const ra = intArg(a, 'rewardAmount');
let rewardSuffix = '';
if (rt === 'gold') {
return locale === 'ru'
? `Достижение: «${title}» (+${ra} золота).`
: `Achievement: ${title} (+${ra} gold).`;
}
if (rt === 'potion') {
return locale === 'ru'
? `Достижение: «${title}» (+${ra} зелий).`
: `Achievement: ${title} (+${ra} potions).`;
}
return locale === 'ru' ? `Достижение: «${title}».` : `Achievement: ${title}.`;
rewardSuffix = locale === 'ru' ? ` (+${ra} золота)` : ` (+${ra} gold)`;
} else if (rt === 'potion') {
rewardSuffix = locale === 'ru' ? ` (+${ra} зелий)` : ` (+${ra} potions)`;
}
const id = strArg(a, 'achievementId');
const legacyTitle = strArg(a, 'title');
const title = achievementLogTitle(locale, id) || legacyTitle || id || '';
return {
title,
rewardSuffix,
};
}
case 'met_npc': {
case 'log.met_npc': {
const nk = strArg(a, 'npcKey');
const tk = strArg(a, 'townKey');
const npc = npcLabel(locale, nk, nk);
const town = townLabel(locale, tk, tk);
return locale === 'ru' ? `Встреча с ${npc} в ${town}.` : `Met ${npc} in ${town}.`;
}
case 'wandering_alms_equipped': {
const itemName = strArg(a, 'itemName');
return locale === 'ru'
? `Экипировано сделанное у купца: ${itemName}.`
: `Equipped from the merchant: ${itemName}.`;
}
case 'wandering_alms_dropped': {
const itemName = strArg(a, 'itemName');
const rarity = rarityName(tr, strArg(a, 'rarity'));
return locale === 'ru'
? `Выброшено ${itemName} (${rarity}) — нет места.`
: `Dropped ${itemName} (${rarity}) — no room.`;
}
case 'wandering_alms_stashed': {
const itemName = strArg(a, 'itemName');
return locale === 'ru'
? `${itemName} убрано в инвентарь.`
: `Stashed ${itemName} in your inventory.`;
}
case 'healed_full_town':
return locale === 'ru' ? 'Оплачено полное лечение.' : 'Paid for a full heal.';
case 'bought_potion_town':
return locale === 'ru' ? 'Куплено зелье в городе.' : 'Bought a potion in town.';
case 'sold_items_merchant': {
const count = intArg(a, 'count');
const gold = numArg(a, 'gold');
const nk = strArg(a, 'npcKey');
const npc = npcLabel(locale, nk, legacyMessage ?? '');
return locale === 'ru'
? `Продано предметов: ${count} торговцу ${npc} (+${gold} золота).`
: `Sold ${count} items to ${npc} (+${gold} gold).`;
}
case 'npc_skipped_visit': {
const nk = strArg(a, 'npcKey');
const npc = npcLabel(locale, nk, nk);
return locale === 'ru' ? `Пропущена встреча с ${npc}.` : `Skipped visiting ${npc}.`;
}
case 'thought_roadside': {
const idx = intArg(a, 'idx');
return roadsideThoughtText(locale, idx);
}
case 'purchased_potion_from_npc': {
const nk = strArg(a, 'npcKey');
const npc = npcLabel(locale, nk, nk);
return locale === 'ru' ? `Куплено зелье у ${npc}.` : `Bought a potion from ${npc}.`;
return {
npc: npcLabel(locale, nk, nk),
town: townLabel(locale, tk, tk),
};
}
case 'paid_healer_full': {
case 'log.sold_items_merchant': {
const nk = strArg(a, 'npcKey');
const npc = npcLabel(locale, nk, nk);
return locale === 'ru' ? `Оплачено полное лечение у ${npc}.` : `Paid ${npc} for a full heal.`;
return {
count: intArg(a, 'count'),
gold: numArg(a, 'gold'),
npc: npcLabel(locale, nk, legacyMessage ?? nk),
};
}
case 'quest_giver_checked': {
case 'log.npc_skipped_visit':
case 'log.purchased_potion_from_npc':
case 'log.paid_healer_full':
case 'log.quest_giver_checked': {
const nk = strArg(a, 'npcKey');
const npc = npcLabel(locale, nk, nk);
return locale === 'ru'
? `Заглянули к ${npc} — новых квестов нет.`
: `Checked in with ${npc} — no new quests.`;
}
case 'quest_accepted': {
const title = strArg(a, 'title');
return locale === 'ru' ? `Принят квест: ${title}.` : `Accepted quest: ${title}.`;
return { npc: npcLabel(locale, nk, nk) };
}
case 'log.quest_accepted': {
const qk = strArg(a, 'questKey');
const legacyTitle = strArg(a, 'title');
const fromKey = localizedQuestText(locale, qk, 'title', qk || '');
return { title: fromKey || legacyTitle || qk };
}
case 'log.combat.hero_hit':
case 'log.combat.hero_dodge':
case 'log.combat.hero_stun':
case 'log.combat.enemy_hit':
case 'log.combat.enemy_block': {
const slug = strArg(a, 'enemyType');
const enemy = enemyFamilyLabel(locale, slug, slug);
const debuff = strArg(a, 'debuffType');
const dn = debuff ? debuffName(tr, debuff) : '';
const debuffPart =
debuff && dn ? (locale === 'ru' ? ` ${dn} применён.` : ` ${dn} applied.`) : '';
const isCrit = boolArg(a, 'isCrit');
const crit =
phraseKey === 'log.combat.hero_stun'
? ''
: isCrit
? locale === 'ru'
? ' (крит)'
: ' (crit)'
: '';
return {
enemy,
damage: intArg(a, 'damage'),
crit,
debuffPart,
};
}
case 'town_npc_visit_line': {
const npcType = strArg(a, 'npcType');
const line = intArg(a, 'line');
return townNpcVisitLineText(locale, npcType, line);
default:
return null;
}
case 'combat_swing': {
const source = strArg(a, 'source');
const outcome = strArg(a, 'outcome');
const damage = intArg(a, 'damage');
const isCrit = boolArg(a, 'isCrit');
const enemySlug = strArg(a, 'enemyType');
const enemyDbName = strArg(a, 'enemyName');
const enemy = enemyFamilyLabel(locale, enemySlug, enemyDbName || enemySlug);
const debuff = strArg(a, 'debuffType');
const critEn = isCrit ? ' (crit)' : '';
const critRu = isCrit ? ' (крит)' : '';
}
let base = '';
if (source === 'hero') {
if (outcome === 'stun') {
base = locale === 'ru' ? 'Вы оглушены и не можете атаковать.' : 'You are stunned and cannot attack.';
} else if (outcome === 'dodge') {
base =
locale === 'ru' ? `${enemy} уклонился от вашей атаки.` : `${enemy} dodged your attack.`;
} else {
base =
locale === 'ru'
? `Вы бьёте ${enemy} на ${damage} урона${critRu}.`
: `You hit ${enemy} for ${damage} damage${critEn}.`;
}
} else if (source === 'enemy') {
if (outcome === 'block') {
base = locale === 'ru' ? `Вы блокируете атаку ${enemy}.` : `You block ${enemy}'s attack.`;
} else {
base =
locale === 'ru'
? `${enemy} бьёт вас на ${damage} урона${critRu}.`
: `${enemy} hits you for ${damage} damage${critEn}.`;
/** Localized single log line from structured event (+ optional legacy message). */
export function formatAdventureLogEvent(
locale: Locale,
tr: Translations,
code: string,
args?: Record<string, unknown>,
legacyMessage?: string,
): string {
const rawArgs = args ?? {};
const phraseKey = normalizeAdventureLogCode(code, rawArgs);
if (phraseKey === 'combat_swing') {
return formatLegacyCombatSwing(locale, tr, rawArgs, legacyMessage);
}
if (phraseKey === 'log.wandering_merchant_encounter') {
return dialogueText(locale, WANDERING_MERCHANT_DIALOGUE_KEY, legacyMessage ?? '');
}
if (debuff) {
const dn = debuffName(tr, debuff);
base += locale === 'ru' ? ` ${dn} применён.` : ` ${dn} applied.`;
const roadside = dynamicRoadsideLine(locale, phraseKey);
if (roadside !== undefined) return roadside;
const town = dynamicTownVisitLine(locale, phraseKey);
if (town !== undefined) return town;
const template = adventureLogTemplate(locale, phraseKey);
if (!template) {
if (import.meta.env.DEV) {
console.warn('[adventure log] missing template for', phraseKey, 'raw code', code);
}
return base || (legacyMessage ?? '');
return legacyMessage?.trim() ? legacyMessage : '';
}
default:
return legacyMessage ?? '';
const vars = resolveAdventureLogVars(locale, tr, phraseKey, rawArgs, legacyMessage);
if (vars === null) {
return legacyMessage?.trim() ? legacyMessage : '';
}
return t(template, vars);
}
export function formatAdventureLogPayload(

@ -1,8 +1,12 @@
import type { AdventureLogBattleGroup, AdventureLogEntry, AdventureLogPlainEntry } from './types';
import type { LogEntry } from '../network/api';
import type { Translations } from '../i18n/en';
import type { Translations } from '../i18n/types';
import type { Locale } from '../i18n/index';
import { parseAdventureLogLine } from './adventureLogMarkers';
import {
isAdventureLogCombatCode,
isAdventureLogEncounterCode,
parseAdventureLogLine,
} from './adventureLogMarkers';
import type { AdventureLogEventWire } from './adventureLogFormat';
import { formatAdventureLogEvent, formatAdventureLogPayload } from './adventureLogFormat';
@ -14,26 +18,28 @@ export interface AdventureLogRawRow {
}
function isEncounterStart(row: AdventureLogRawRow): boolean {
if (row.event?.code === 'encountered_enemy') return true;
if (isAdventureLogEncounterCode(row.event?.code)) return true;
return parseAdventureLogLine(row.message).type === 'encounter';
}
function isBattleLine(row: AdventureLogRawRow): boolean {
if (row.event?.code === 'combat_swing') return true;
if (isAdventureLogCombatCode(row.event?.code)) return true;
return parseAdventureLogLine(row.message).type === 'battle';
}
function encounterTitle(locale: Locale, tr: Translations, row: AdventureLogRawRow): string {
if (row.event?.code === 'encountered_enemy') {
return formatAdventureLogEvent(locale, tr, 'encountered_enemy', row.event.args, row.message);
const c = row.event?.code;
if (isAdventureLogEncounterCode(c)) {
return formatAdventureLogEvent(locale, tr, c!, row.event?.args, row.message);
}
const p = parseAdventureLogLine(row.message);
return p.type === 'encounter' ? p.title : row.message;
}
function battleLineText(locale: Locale, tr: Translations, row: AdventureLogRawRow): string {
if (row.event?.code === 'combat_swing') {
const formatted = formatAdventureLogEvent(locale, tr, 'combat_swing', row.event.args, row.message);
const c = row.event?.code;
if (isAdventureLogCombatCode(c)) {
const formatted = formatAdventureLogEvent(locale, tr, c!, row.event?.args, row.message);
if (formatted) return formatted;
}
const p = parseAdventureLogLine(row.message);

@ -22,12 +22,23 @@ export function shouldSuppressThoughtBubble(raw: string): boolean {
}
/** Thought bubble: hide for combat grouping lines; show roadside thoughts and plain social lines. */
export function isAdventureLogEncounterCode(code: string | undefined): boolean {
return code === 'encountered_enemy' || code === 'log.encountered_enemy';
}
export function isAdventureLogCombatCode(code: string | undefined): boolean {
if (!code) return false;
if (code === 'combat_swing') return true;
return code.startsWith('log.combat.');
}
export function shouldSuppressThoughtBubblePayload(p: {
message?: string;
event?: { code?: string };
}): boolean {
const msg = p.message ?? '';
if (msg.startsWith(AH_ENC_PREFIX) || msg.startsWith(AH_BAT_PREFIX)) return true;
if (p.event?.code === 'encountered_enemy' || p.event?.code === 'combat_swing') return true;
if (isAdventureLogEncounterCode(p.event?.code)) return true;
if (isAdventureLogCombatCode(p.event?.code)) return true;
return false;
}

@ -1,5 +1,5 @@
import type { Locale } from './index';
import { ENEMY_TYPE_LABELS } from './enemyTypeLabels';
import type { Locale } from './localeCodes';
import { enemyTypeLabel } from './loadLocales';
/** Stable keys aligned with backend migrations / model constants. */
export const WANDERING_MERCHANT_NPC_KEY = 'npc.wandering_merchant.v1';
@ -65,12 +65,12 @@ export function dialogueText(locale: Locale, key: string | undefined, fallback:
/**
* Display name for an enemy template: optional i18n by `enemies.type`, else DB name, else slug.
* @param enemyTypeSlug - value of `enemies.type` / API `enemy.type`
* @param dbName - `enemies.name` from API (English); used when no entry in ENEMY_TYPE_LABELS
* @param dbName - `enemies.name` from API (English); used when no entry in locale `enemy_types`
*/
export function enemyFamilyLabel(locale: Locale, enemyTypeSlug: string, dbName: string): string {
const slug = enemyTypeSlug?.trim() ?? '';
const b = slug && slug in ENEMY_TYPE_LABELS ? ENEMY_TYPE_LABELS[slug] : undefined;
if (b) return pick(locale, b);
const fromLocale = slug ? enemyTypeLabel(locale, slug) : '';
if (fromLocale) return fromLocale;
const name = dbName?.trim();
if (name) return name;
return slug || dbName;

@ -1,225 +1,2 @@
export const en = {
// General
loading: 'Loading hero...',
close: 'Close',
cancel: 'Cancel',
confirm: 'Confirm',
empty: 'Empty',
none: 'None',
error: 'Error',
back: 'Back',
// Stats
hp: 'HP',
atk: 'ATK',
def: 'DEF',
spd: 'Speed',
moveSpd: 'Move SPD',
str: 'STR',
con: 'CON',
agi: 'AGI',
luck: 'LUCK',
xp: 'XP',
gold: 'Gold',
level: 'Lv',
stat: 'STAT',
// Hero Panel
heroStats: 'Hero Stats',
experience: 'Experience',
activeBuffs: 'Active Buffs',
activeDebuffs: 'Active Debuffs',
// Equipment
equipment: 'Equipment',
slotWeapon: 'Weapon',
slotOffHand: 'Off Hand',
slotHead: 'Head',
slotChest: 'Chest',
slotLegs: 'Legs',
slotFeet: 'Feet',
slotCloak: 'Cloak',
slotNeck: 'Neck',
slotRing: 'Ring',
slotWrist: 'Wrist',
slotHands: 'Hands',
slotQuiver: 'Quiver',
inventory: 'Inventory',
// Rarity
common: 'Common',
uncommon: 'Uncommon',
rare: 'Rare',
epic: 'Epic',
legendary: 'Legendary',
// Buff names
buffRush: 'Rush',
buffRage: 'Rage',
buffShield: 'Shield',
buffLuck: 'Luck',
buffResurrection: 'Resurrect',
buffHeal: 'Heal',
buffPowerPotion: 'Power',
buffWarCry: 'War Cry',
// Buff descriptions
buffRushDesc: '+50% movement speed',
buffRageDesc: '+100% damage',
buffShieldDesc: '-50% incoming damage',
buffLuckDesc: 'x2.5 loot drops',
buffResurrectionDesc: 'Revive at 50% HP',
buffHealDesc: '+50% HP instant',
buffPowerPotionDesc: '+150% damage',
buffWarCryDesc: '+100% attack speed',
// Buff UI
charges: 'Charges',
refillsAt: 'Refills at',
refill: 'Refill',
refillQuestion: 'Refill {label}?',
noChargesLeft: 'No charges left for {label}',
// Debuff names
debuffPoison: 'Poison',
debuffFreeze: 'Freeze',
debuffBurn: 'Burn',
debuffStun: 'Stun',
debuffSlow: 'Slow',
debuffWeaken: 'Weaken',
debuffIceSlow: 'Ice Slow',
// Quest system
questLog: 'Quest Log',
noActiveQuests: 'No active quests. Visit an NPC to accept quests!',
claimRewards: 'Claim Rewards',
claimRewardsDisabledDead: 'Revive to claim quest rewards',
questDestination: 'Destination',
abandon: 'Abandon',
acceptQuest: 'Accept',
questAccepted: 'Quest accepted!',
inProgressQuests: 'In Progress',
availableQuestsSection: 'Available Quests',
loadingQuests: 'Loading quests...',
noQuestsRightNow: 'No quests available right now.',
yourGoldLabel: 'Your gold: {amount}',
servicesSection: 'Services',
questRewardsClaimed: 'Quest rewards claimed!',
questAbandoned: 'Quest abandoned',
failedToAcceptQuest: 'Failed to accept quest',
failedToClaimRewards: 'Failed to claim rewards',
failedToAbandonQuest: 'Failed to abandon quest',
completed: 'Completed',
// NPC
questGiver: 'Quest Giver',
merchant: 'Merchant',
healer: 'Healer',
npc: 'NPC',
buyPotion: 'Buy Potion',
buyPotionForGold: 'Buy Potion ({cost}g)',
healToFull: 'Heal to Full',
healToFullForGold: 'Heal to Full ({cost}g)',
viewQuests: 'View Quests',
npcInteractTalk: 'Talk',
shopHealingPotionName: 'Healing Potion',
shopHealingPotionDesc: 'Restores health. Always handy in a pinch.',
shopFullHealName: 'Full Heal',
shopFullHealDesc: 'Restore hero to full HP.',
boughtPotion: 'Bought a potion for {cost} gold',
healedToFull: 'Healed to full HP!',
notEnoughGold: 'Not enough gold!',
failedToBuyPotion: 'Failed to buy potion',
failedToHeal: 'Failed to heal',
// Wandering NPC
giveGoldForItem: 'Give {cost} gold for a mysterious item?',
accept: 'Accept',
decline: 'Decline',
giving: 'Giving...',
// Death screen
youDied: 'YOU DIED',
reviveNow: 'REVIVE NOW',
freeRevivesLeft: 'Free revives left: {count}',
revivesUnlimitedSubscription: 'Unlimited revives (subscription)',
reviveNowWithCount: 'REVIVE NOW ({count})',
autoReviveIn: 'Auto-revive in {timer}s',
noFreeRevives: 'No free revives left \u2014 subscription required',
// Name entry
chooseHeroName: 'Choose Your Hero Name',
enterName: 'Enter a name...',
continue: 'Continue',
saving: 'Saving...',
nameTaken: 'Name already taken, try another',
invalidName: 'Invalid name',
serverError: 'Server error ({status})',
connectionFailed: 'Connection failed, please retry',
// Offline report
whileYouWereAway: 'While you were away...',
killedMonsters: 'Killed {count} monster(s)',
gainedXP: '+{xp} XP',
gainedGold: '+{gold} gold',
gainedLevels: 'Gained {levels} level(s)!',
offlineDeaths: 'Deaths: {count}',
offlineRevives: 'Auto-revives: {count}',
offlineLootFound: 'Loot:',
tapToDismiss: 'Tap anywhere to dismiss',
// Toasts
achievementUnlockedToast: 'Achievement unlocked: {title}!',
toastGainedXp: '+{xp} XP',
toastGainedGold: '+{gold} gold',
toastFoundItem: 'Found {name}',
levelUp: 'Level up! Now level {level}',
heroRevived: 'Hero revived!',
entering: 'Entering {townName}',
newEquipment: 'New {slot}: {itemName}',
potionsCollected: '+{count} potion(s)',
questProgress: '{title} ({current}/{target})',
questCompleted: 'Quest completed: {title}!',
buffLimitReached: 'Buff limit reached',
reviveNotAllowed: 'Revive not allowed',
dailyTaskClaimed: 'Daily task reward claimed!',
failedToClaimReward: 'Failed to claim reward',
// Minimap
map: 'MAP',
// Adventure log
noEventsYet: 'No events yet...',
combatLogTitle: 'Combat',
logEnteredTown: 'Entered {town}.',
logDeclinedWanderingMerchant: 'Declined the wandering merchant.',
logMerchantMovedOn: 'The wandering merchant moved on.',
// Misc
adventureLog: 'Adventure Log',
shopLabel: 'Shop',
healerLabel: 'Healer',
questLabel: 'Quest',
// Hero Sheet tabs
heroSheetQuestBadgeAria: 'Quests ready to turn in: {count}',
stats: 'Stats',
character: 'Char',
journal: 'Journal',
quests: 'Quests',
hero: 'Hero',
// Changelog (server release notes)
changelogTitle: "What's new",
changelogOk: 'Got it',
changelogVersion: 'Version {version}',
// Settings
settings: 'Settings',
language: 'Language',
english: 'English',
russian: 'Russian',
} as const;
export type TranslationKey = keyof typeof en;
export type Translations = Record<TranslationKey, string>;
export { en } from './loadLocales';
export type { TranslationKey, Translations } from './types';

@ -0,0 +1,530 @@
# English locale — ui, adventure_log phrase templates, achievements, enemy_types (DB enemies.type), roadside, town NPC visit.
ui:
loading: Loading hero...
close: Close
cancel: Cancel
confirm: Confirm
empty: Empty
none: None
error: Error
back: Back
hp: HP
atk: ATK
def: DEF
spd: Speed
moveSpd: Move SPD
str: STR
con: CON
agi: AGI
luck: LUCK
xp: XP
gold: Gold
level: Lv
stat: STAT
heroStats: Hero Stats
experience: Experience
activeBuffs: Active Buffs
activeDebuffs: Active Debuffs
equipment: Equipment
slotWeapon: Weapon
slotOffHand: Off Hand
slotHead: Head
slotChest: Chest
slotLegs: Legs
slotFeet: Feet
slotCloak: Cloak
slotNeck: Neck
slotRing: Ring
slotWrist: Wrist
slotHands: Hands
slotQuiver: Quiver
inventory: Inventory
common: Common
uncommon: Uncommon
rare: Rare
epic: Epic
legendary: Legendary
buffRush: Rush
buffRage: Rage
buffShield: Shield
buffLuck: Luck
buffResurrection: Resurrect
buffHeal: Heal
buffPowerPotion: Power
buffWarCry: War Cry
buffRushDesc: "+50% movement speed"
buffRageDesc: "+100% damage"
buffShieldDesc: "-50% incoming damage"
buffLuckDesc: x2.5 loot drops
buffResurrectionDesc: Revive at 50% HP
buffHealDesc: "+50% HP instant"
buffPowerPotionDesc: "+150% damage"
buffWarCryDesc: "+100% attack speed"
charges: Charges
refillsAt: Refills at
refill: Refill
refillQuestion: Refill {label}?
noChargesLeft: No charges left for {label}
debuffPoison: Poison
debuffFreeze: Freeze
debuffBurn: Burn
debuffStun: Stun
debuffSlow: Slow
debuffWeaken: Weaken
debuffIceSlow: Ice Slow
questLog: Quest Log
noActiveQuests: No active quests. Visit an NPC to accept quests!
claimRewards: Claim Rewards
claimRewardsDisabledDead: Revive to claim quest rewards
questDestination: Destination
abandon: Abandon
acceptQuest: Accept
questAccepted: Quest accepted!
inProgressQuests: In Progress
availableQuestsSection: Available Quests
loadingQuests: Loading quests...
noQuestsRightNow: No quests available right now.
yourGoldLabel: Your gold: {amount}
servicesSection: Services
questRewardsClaimed: Quest rewards claimed!
questAbandoned: Quest abandoned
failedToAcceptQuest: Failed to accept quest
failedToClaimRewards: Failed to claim rewards
failedToAbandonQuest: Failed to abandon quest
completed: Completed
questGiver: Quest Giver
merchant: Merchant
healer: Healer
npc: NPC
buyPotion: Buy Potion
buyPotionForGold: Buy Potion ({cost}g)
healToFull: Heal to Full
healToFullForGold: Heal to Full ({cost}g)
viewQuests: View Quests
npcInteractTalk: Talk
shopHealingPotionName: Healing Potion
shopHealingPotionDesc: Restores health. Always handy in a pinch.
shopFullHealName: Full Heal
shopFullHealDesc: Restore hero to full HP.
boughtPotion: Bought a potion for {cost} gold
healedToFull: Healed to full HP!
notEnoughGold: Not enough gold!
failedToBuyPotion: Failed to buy potion
failedToHeal: Failed to heal
giveGoldForItem: Give {cost} gold for a mysterious item?
accept: Accept
decline: Decline
giving: Giving...
youDied: YOU DIED
reviveNow: REVIVE NOW
freeRevivesLeft: Free revives left: {count}
revivesUnlimitedSubscription: Unlimited revives (subscription)
reviveNowWithCount: REVIVE NOW ({count})
autoReviveIn: Auto-revive in {timer}s
noFreeRevives: No free revives left — subscription required
chooseHeroName: Choose Your Hero Name
enterName: Enter a name...
continue: Continue
saving: Saving...
nameTaken: Name already taken, try another
invalidName: Invalid name
serverError: Server error ({status})
connectionFailed: Connection failed, please retry
whileYouWereAway: While you were away...
killedMonsters: Killed {count} monster(s)
gainedXP: +{xp} XP
gainedGold: +{gold} gold
gainedLevels: Gained {levels} level(s)!
offlineDeaths: Deaths: {count}
offlineRevives: Auto-revives: {count}
offlineLootFound: Loot:
tapToDismiss: Tap anywhere to dismiss
achievementUnlockedToast: Achievement unlocked: {title}!
toastGainedXp: +{xp} XP
toastGainedGold: +{gold} gold
toastFoundItem: Found {name}
levelUp: Level up! Now level {level}
heroRevived: Hero revived!
entering: Entering {townName}
newEquipment: New {slot}: {itemName}
potionsCollected: +{count} potion(s)
questProgress: '{title} ({current}/{target})'
questCompleted: Quest completed: {title}!
buffLimitReached: Buff limit reached
reviveNotAllowed: Revive not allowed
dailyTaskClaimed: Daily task reward claimed!
failedToClaimReward: Failed to claim reward
map: MAP
noEventsYet: No events yet...
combatLogTitle: Combat
logEnteredTown: Entered {town}.
logDeclinedWanderingMerchant: Declined the wandering merchant.
logMerchantMovedOn: The wandering merchant moved on.
adventureLog: Adventure Log
shopLabel: Shop
healerLabel: Healer
questLabel: Quest
heroSheetQuestBadgeAria: Quests ready to turn in: {count}
stats: Stats
character: Char
journal: Journal
quests: Quests
hero: Hero
changelogTitle: "What's new"
changelogOk: Got it
changelogVersion: Version {version}
settings: Settings
language: Language
english: English
russian: Russian
adventure_log:
log.defeated_enemy: Defeated {enemy} (+{xp} XP, +{gold} gold).
log.leveled_up: Reached level {level}!
log.equipped_new: 'Equipped new {slot}: {item}.'
log.inventory_full_dropped: Inventory full — dropped {item}.
log.buff_activated: '{buff} activated.'
log.hero_revived: You revived.
log.encountered_enemy: You encounter {enemy}.
log.died_fighting: You died fighting {enemy}.
log.auto_revive_hours: Hours passed; you revived in town.
log.auto_revive_after_sec: Auto-revived after {seconds}s offline.
log.purchased_buff_refill: 'Refilled charges: {buff}.'
log.purchased_buff_refill_rub: Purchased refill for {buff} ({price} RUB).
log.subscribed: 'Subscribed: {duration} ({price} RUB).'
log.used_healing_potion: Used healing potion (+{amount} HP).
log.achievement_unlocked: 'Achievement: {title}{rewardSuffix}.'
log.met_npc: Met {npc} in {town}.
log.wandering_alms_equipped: 'Equipped from the merchant: {item}.'
log.wandering_alms_dropped: Dropped {item} — no room.
log.wandering_alms_stashed: Stashed {item} in your inventory.
log.healed_full_town: Paid for a full heal.
log.bought_potion_town: Bought a potion in town.
log.sold_items_merchant: Sold {count} items to {npc} (+{gold} gold).
log.npc_skipped_visit: Skipped visiting {npc}.
log.purchased_potion_from_npc: Bought a potion from {npc}.
log.paid_healer_full: Paid {npc} for a full heal.
log.quest_giver_checked: Checked in with {npc} — no new quests.
log.quest_accepted: 'Accepted quest: {title}.'
log.combat.hero_hit: 'You hit {enemy} for {damage} damage{crit}{debuffPart}'
log.combat.hero_dodge: '{enemy} dodged your attack.{debuffPart}'
log.combat.hero_stun: You are stunned and cannot attack.{debuffPart}
log.combat.enemy_hit: '{enemy} hits you for {damage} damage{crit}{debuffPart}'
log.combat.enemy_block: "You block {enemy}'s attack.{debuffPart}"
achievements:
first_blood: First Blood
warrior: Warrior
legend: Legend
hunter: Hunter
slayer: Slayer
rich: Rich
lucky: Lucky
undying: Undying
elite_hunter: Elite Hunter
enemy_types:
wolf_l1_1_meadow: Elder Verdant Wolf
wolf_l1_1_forest: Woodland Elder Wolf
wolf_l2_2_forest: Young Woodland Wolf
wolf_l2_2_ruins: Forgotten Young Wolf
wolf_l3_3_ruins: Lost Forgotten Wolf
wolf_l3_3_canyon: Rift Lost Wolf
wolf_l4_4_canyon: Cursed Rift Wolf
wolf_l4_4_swamp: Bog Cursed Wolf
wolf_l5_5_volcanic: Rogue Ember Wolf
wolf_l5_5_astral: Astral Rogue Wolf
boar_l2_2_meadow: Elder Verdant Boar
boar_l2_2_forest: Woodland Elder Boar
boar_l3_3_forest: Young Woodland Boar
boar_l3_3_ruins: Forgotten Young Boar
boar_l4_4_ruins: Lost Forgotten Boar
boar_l4_4_canyon: Rift Lost Boar
boar_l5_5_canyon: Cursed Rift Boar
boar_l5_5_swamp: Bog Cursed Boar
boar_l6_6_volcanic: Rogue Ember Boar
boar_l6_6_astral: Astral Rogue Boar
zombie_l3_4_meadow: Elder Verdant Zombie
zombie_l3_4_forest: Woodland Elder Zombie
zombie_l5_5_forest: Young Woodland Zombie
zombie_l5_5_ruins: Forgotten Young Zombie
zombie_l6_6_ruins: Lost Forgotten Zombie
zombie_l6_6_canyon: Rift Lost Zombie
zombie_l7_7_canyon: Cursed Rift Zombie
zombie_l7_7_swamp: Bog Cursed Zombie
zombie_l8_8_volcanic: Rogue Ember Zombie
zombie_l8_8_astral: Astral Rogue Zombie
spider_l4_5_meadow: Elder Verdant Spider
spider_l4_5_forest: Woodland Elder Spider
spider_l6_6_forest: Young Woodland Spider
spider_l6_6_ruins: Forgotten Young Spider
spider_l7_7_ruins: Lost Forgotten Spider
spider_l7_7_canyon: Rift Lost Spider
spider_l8_8_canyon: Cursed Rift Spider
spider_l8_8_swamp: Bog Cursed Spider
spider_l9_9_volcanic: Rogue Ember Spider
spider_l9_9_astral: Astral Rogue Spider
orc_l5_6_meadow: Elder Verdant Orc
orc_l5_6_forest: Woodland Elder Orc
orc_l7_8_forest: Young Woodland Orc
orc_l7_8_ruins: Forgotten Young Orc
orc_l9_10_ruins: Lost Forgotten Orc
orc_l9_10_canyon: Rift Lost Orc
orc_l11_11_canyon: Cursed Rift Orc
orc_l11_11_swamp: Bog Cursed Orc
orc_l12_12_volcanic: Rogue Ember Orc
orc_l12_12_astral: Astral Rogue Orc
skeleton_l6_7_meadow: Elder Verdant Skeleton
skeleton_l6_7_forest: Woodland Elder Skeleton
skeleton_l8_9_forest: Young Woodland Skeleton
skeleton_l8_9_ruins: Forgotten Young Skeleton
skeleton_l10_11_ruins: Lost Forgotten Skeleton
skeleton_l10_11_canyon: Rift Lost Skeleton
skeleton_l12_13_canyon: Cursed Rift Skeleton
skeleton_l12_13_swamp: Bog Cursed Skeleton
skeleton_l14_14_volcanic: Rogue Ember Skeleton
skeleton_l14_14_astral: Astral Rogue Skeleton
battle_lizard_l7_8_meadow: Elder Verdant Scaleback
battle_lizard_l7_8_forest: Woodland Elder Scaleback
battle_lizard_l9_10_forest: Young Woodland Scaleback
battle_lizard_l9_10_ruins: Forgotten Young Scaleback
battle_lizard_l11_12_ruins: Lost Forgotten Scaleback
battle_lizard_l11_12_canyon: Rift Lost Scaleback
battle_lizard_l13_14_canyon: Cursed Rift Scaleback
battle_lizard_l13_14_swamp: Bog Cursed Scaleback
battle_lizard_l15_15_volcanic: Rogue Ember Scaleback
battle_lizard_l15_15_astral: Astral Rogue Scaleback
element_l18_20_meadow: Elder Verdant Elemental
element_l12_14_forest: Woodland Elder Elemental
element_l21_22_forest: Young Woodland Elemental
element_l15_16_ruins: Forgotten Young Elemental
element_l23_24_ruins: Lost Forgotten Elemental
element_l17_18_canyon: Rift Lost Elemental
element_l25_26_canyon: Cursed Rift Elemental
element_l19_20_swamp: Bog Cursed Elemental
element_l27_28_volcanic: Rogue Ember Elemental
element_l21_22_astral: Astral Rogue Elemental
demon_l10_12_meadow: Elder Verdant Demon
demon_l10_12_forest: Woodland Elder Demon
demon_l13_14_forest: Young Woodland Demon
demon_l13_14_ruins: Forgotten Young Demon
demon_l15_16_ruins: Lost Forgotten Demon
demon_l15_16_canyon: Rift Lost Demon
demon_l17_18_canyon: Cursed Rift Demon
demon_l17_18_swamp: Bog Cursed Demon
demon_l19_20_volcanic: Rogue Ember Demon
demon_l19_20_astral: Astral Rogue Demon
skeleton_king_l15_17_meadow: Elder Verdant Bone Sovereign
skeleton_king_l15_17_forest: Woodland Elder Bone Sovereign
skeleton_king_l18_19_forest: Young Woodland Bone Sovereign
skeleton_king_l18_19_ruins: Forgotten Young Bone Sovereign
skeleton_king_l20_21_ruins: Lost Forgotten Bone Sovereign
skeleton_king_l20_21_canyon: Rift Lost Bone Sovereign
skeleton_king_l22_23_canyon: Cursed Rift Bone Sovereign
skeleton_king_l22_23_swamp: Bog Cursed Bone Sovereign
skeleton_king_l24_25_volcanic: Rogue Ember Bone Sovereign
skeleton_king_l24_25_astral: Astral Rogue Bone Sovereign
forest_warden_l20_22_meadow: Elder Verdant Warden
forest_warden_l20_22_forest: Woodland Elder Warden
forest_warden_l23_24_forest: Young Woodland Warden
forest_warden_l23_24_ruins: Forgotten Young Warden
forest_warden_l25_26_ruins: Lost Forgotten Warden
forest_warden_l25_26_canyon: Rift Lost Warden
forest_warden_l27_28_canyon: Cursed Rift Warden
forest_warden_l27_28_swamp: Bog Cursed Warden
forest_warden_l29_30_volcanic: Rogue Ember Warden
forest_warden_l29_30_astral: Astral Rogue Warden
titan_l25_27_meadow: Elder Verdant Titan
titan_l25_27_forest: Woodland Elder Titan
titan_l28_29_forest: Young Woodland Titan
titan_l28_29_ruins: Forgotten Young Titan
titan_l30_31_ruins: Lost Forgotten Titan
titan_l30_31_canyon: Rift Lost Titan
titan_l32_33_canyon: Cursed Rift Titan
titan_l32_33_swamp: Bog Cursed Titan
titan_l34_35_volcanic: Rogue Ember Titan
titan_l34_35_astral: Astral Rogue Titan
golem_l8_10_meadow: Elder Verdant Golem
golem_l8_10_forest: Woodland Elder Golem
golem_l11_12_forest: Young Woodland Golem
golem_l11_12_ruins: Forgotten Young Golem
golem_l13_14_ruins: Lost Forgotten Golem
golem_l13_14_canyon: Rift Lost Golem
golem_l15_16_canyon: Cursed Rift Golem
golem_l15_16_swamp: Bog Cursed Golem
golem_l17_18_volcanic: Rogue Ember Golem
golem_l17_18_astral: Astral Rogue Golem
wraith_l5_6_meadow: Elder Verdant Wraith
wraith_l5_6_forest: Woodland Elder Wraith
wraith_l7_8_forest: Young Woodland Wraith
wraith_l7_8_ruins: Forgotten Young Wraith
wraith_l9_10_ruins: Lost Forgotten Wraith
wraith_l9_10_canyon: Rift Lost Wraith
wraith_l11_12_canyon: Cursed Rift Wraith
wraith_l11_12_swamp: Bog Cursed Wraith
wraith_l13_14_volcanic: Rogue Ember Wraith
wraith_l13_14_astral: Astral Rogue Wraith
bandit_l4_5_meadow: Elder Verdant Bandit
bandit_l4_5_forest: Woodland Elder Bandit
bandit_l6_7_forest: Young Woodland Bandit
bandit_l6_7_ruins: Forgotten Young Bandit
bandit_l8_9_ruins: Lost Forgotten Bandit
bandit_l8_9_canyon: Rift Lost Bandit
bandit_l10_11_canyon: Cursed Rift Bandit
bandit_l10_11_swamp: Bog Cursed Bandit
bandit_l12_12_volcanic: Rogue Ember Bandit
bandit_l12_12_astral: Astral Rogue Bandit
cultist_l6_8_meadow: Elder Verdant Cultist
cultist_l6_8_forest: Woodland Elder Cultist
cultist_l9_10_forest: Young Woodland Cultist
cultist_l9_10_ruins: Forgotten Young Cultist
cultist_l11_12_ruins: Lost Forgotten Cultist
cultist_l11_12_canyon: Rift Lost Cultist
cultist_l13_14_canyon: Cursed Rift Cultist
cultist_l13_14_swamp: Bog Cursed Cultist
cultist_l15_16_volcanic: Rogue Ember Cultist
cultist_l15_16_astral: Astral Rogue Cultist
treant_l18_20_meadow: Elder Verdant Treant
treant_l18_20_forest: Woodland Elder Treant
treant_l21_23_forest: Young Woodland Treant
treant_l21_23_ruins: Forgotten Young Treant
treant_l24_26_ruins: Lost Forgotten Treant
treant_l24_26_canyon: Rift Lost Treant
treant_l27_28_canyon: Cursed Rift Treant
treant_l27_28_swamp: Bog Cursed Treant
treant_l29_30_volcanic: Rogue Ember Treant
treant_l29_30_astral: Astral Rogue Treant
basilisk_l9_11_meadow: Elder Verdant Basilisk
basilisk_l9_11_forest: Woodland Elder Basilisk
basilisk_l12_13_forest: Young Woodland Basilisk
basilisk_l12_13_ruins: Forgotten Young Basilisk
basilisk_l14_15_ruins: Lost Forgotten Basilisk
basilisk_l14_15_canyon: Rift Lost Basilisk
basilisk_l16_17_canyon: Cursed Rift Basilisk
basilisk_l16_17_swamp: Bog Cursed Basilisk
basilisk_l18_19_volcanic: Rogue Ember Basilisk
basilisk_l18_19_astral: Astral Rogue Basilisk
wyvern_l12_14_meadow: Elder Verdant Wyvern
wyvern_l12_14_forest: Woodland Elder Wyvern
wyvern_l15_17_forest: Young Woodland Wyvern
wyvern_l15_17_ruins: Forgotten Young Wyvern
wyvern_l18_20_ruins: Lost Forgotten Wyvern
wyvern_l18_20_canyon: Rift Lost Wyvern
wyvern_l21_22_canyon: Cursed Rift Wyvern
wyvern_l21_22_swamp: Bog Cursed Wyvern
wyvern_l23_24_volcanic: Rogue Ember Wyvern
wyvern_l23_24_astral: Astral Rogue Wyvern
harpy_l6_7_meadow: Elder Verdant Harpy
harpy_l6_7_forest: Woodland Elder Harpy
harpy_l8_9_forest: Young Woodland Harpy
harpy_l8_9_ruins: Forgotten Young Harpy
harpy_l10_11_ruins: Lost Forgotten Harpy
harpy_l10_11_canyon: Rift Lost Harpy
harpy_l12_13_canyon: Cursed Rift Harpy
harpy_l12_13_swamp: Bog Cursed Harpy
harpy_l14_15_volcanic: Rogue Ember Harpy
harpy_l14_15_astral: Astral Rogue Harpy
manticore_l14_16_meadow: Elder Verdant Manticore
manticore_l14_16_forest: Woodland Elder Manticore
manticore_l17_19_forest: Young Woodland Manticore
manticore_l17_19_ruins: Forgotten Young Manticore
manticore_l20_22_ruins: Lost Forgotten Manticore
manticore_l20_22_canyon: Rift Lost Manticore
manticore_l23_24_canyon: Cursed Rift Manticore
manticore_l23_24_swamp: Bog Cursed Manticore
manticore_l25_26_volcanic: Rogue Ember Manticore
manticore_l25_26_astral: Astral Rogue Manticore
shade_l10_12_meadow: Elder Verdant Shade
shade_l10_12_forest: Woodland Elder Shade
shade_l13_15_forest: Young Woodland Shade
shade_l13_15_ruins: Forgotten Young Shade
shade_l16_18_ruins: Lost Forgotten Shade
shade_l16_18_canyon: Rift Lost Shade
shade_l19_20_canyon: Cursed Rift Shade
shade_l19_20_swamp: Bog Cursed Shade
shade_l21_22_volcanic: Rogue Ember Shade
shade_l21_22_astral: Astral Rogue Shade
roadside:
nothing_matters_crit: If nothing matters, why does missing a crit sting so much?
road_chose_you: You wonder whether the road chose you or you chose the road.
coin_heavier_than_sword: A coin in your pocket feels heavier than your sword. Probably guilt.
consciousness_buff: If consciousness is a buff, who applied it and for how long?
grass_philosophical: The grass here looks philosophical. Or just wet.
braver_tomorrow: You resolve to be braver tomorrow. Today agrees to wait.
hero_job_or_tax: Is 'hero' a job title or a tax bracket?
scars_bookmarks: Every scar is a bookmark in a story nobody reads aloud.
breaths_on_purpose: You count your breaths and lose track on purpose.
universe_simulation_texture: If the universe is a simulation, the texture work on this ditch is impressive.
resting_cheating_alive: Resting feels like cheating until you remember you're still alive.
real_loot_npcs: Maybe the real loot was the NPCs we annoyed along the way.
memoir_soup: You consider writing a memoir titled 'I Stood Here and Thought About Soup.'
time_circle_hp: Time is a flat circle; your HP bar disagrees.
silence_loading: Silence isn't empty—it's just loading.
meaning_lunch_later: You decide the meaning of life is probably lunch, but later.
trees_gossip_breaks: If trees gossip, this one thinks you take too many breaks.
courage_silly_face: Courage is doing the next silly thing with a straight face.
miss_never_met: You miss someone you never met. Classic hero brain.
wind_advice_ignore: The wind offers advice you politely ignore.
gold_boots_happiness: Gold can't buy happiness, but it buys better boots, which is close.
gratitude_not_dummy: You practice gratitude for not being a training dummy.
legend_sat_tired: Every legend started with someone sitting down too tired to myth.
fear_debuff_curiosity: If fear is a debuff, curiosity might be the cleanse.
slimes_electric_sheep: You wonder if slimes dream of electric sheep. Probably not.
patience_skill_tree: Patience is a skill tree you forgot to spec into.
road_crooked_stand: The road will still be crooked when you stand up. That's fine.
narrate_life_xp: You narrate your own life badly and still get XP.
gods_patch_notes: Maybe gods are just very old patch notes.
rock_throne_dramatic: A small rock looks like a throne if you're dramatic enough.
forgive_panic_roll: You forgive yourself for yesterday's panic roll.
love_side_quest: Love is a side quest with unclear rewards.
thoughts_loot_encumbered: If thoughts were loot, you'd be over-encumbered by now.
sun_sets_optimize: The sun sets whether you optimize or not.
fate_bad_ui: You realize 'fate' might just be bad UI.
wounded_poetic_upgrade: Breathing deeply, you upgrade from 'wounded' to 'wounded but poetic.'
heroic_pose_nobody: Nobody's watching, so you strike a heroic pose anyway.
wisdom_stop_swinging: Wisdom is knowing when to stop swinging and start sitting.
endgame_good_chair: You suspect the real endgame is a good chair.
doubt_armor_unkillable: If doubt were armor, you'd be unkillable.
world_spinning_pause: The world keeps spinning; you're allowed to pause.
bird_screams_relate: A distant bird screams. You relate.
regrets_shorter_list: You catalogue your regrets; the list is shorter than expected.
hope_hp_cynical_patch: Hope is stubborn HP regeneration in a cynical patch.
courage_stubborn_pr: Maybe courage is just stubbornness with better PR.
merchants_fixed_prices: You wonder if merchants dream of fixed prices.
pause_rebellion_grind: Every pause is a tiny rebellion against the grind.
dirt_nails_showed_up: The dirt under your nails is proof you showed up.
meaning_hammer: If meaning is crafted, you're still holding the hammer.
smile_nothing_helps: You smile at nothing in particular. It helps.
tomorrow_walk_tonight_breathe: Tomorrow you'll walk again. Tonight you just breathe.
grind_volume_down: You admit the grind is loud, then turn the volume down.
town_npc_visit:
merchant:
crates_in_shade: You glance over crates and bundles stacked in the shade.
practiced_tired_smile: The merchant greets you with a practiced, tired smile.
chalk_prices_twice: Chalk prices are crossed out twice — the road tax of optimism.
rumors_bandits_carts: You swap rumors about bandits and broken cart wheels.
bell_traveler_pack: A bell tinkles as another traveler shoulders their pack.
step_back_tally_gold: You step back, mentally tallying what you can afford.
healer:
linens_herbs_tent: Clean linens and sharp herbs fill the small tent.
professional_frown_onceover: The healer looks you over with a professional frown.
slept_badly_nod: You admit to sleeping badly; they nod as if that explains everything.
tonic_steams_table: A tonic steams on the side table; you hope it is not meant for you.
blessings_salves_bandages: They mutter blessings while sorting salves and bandages.
lighter_under_canvas: You feel oddly lighter just standing under the canvas.
quest_giver:
scrolls_wax_desk: Scrolls and wax seals clutter the quest givers desk.
ink_stained_map_tap: They tap a map with an ink-stained finger.
busy_roads_noncommittal: “Busy roads,” they say — you agree, noncommittally.
draft_parchment_smell: A draft carries the smell of old parchment.
squint_spine_legend: They squint as if measuring your spine against a legend.
promise_listen_worth_it: You promise to listen; they promise it will be worth it.
generic:
town_noise_blanket: You pause; the town noise folds around you like a blanket.
grain_prices_argument: Someone nearby argues about grain prices in good humor.
dust_sunbeam_time: Dust motes hang in a sunbeam; time stretches a little.
strap_tighten_pretend: You tighten a strap and pretend you meant to stop here.
dog_boring_sleeps: A dog watches you, decides you are boring, and sleeps.
breathe_ready_move_on: You breathe out, ready to move on when the moment feels right.

@ -1,80 +0,0 @@
import { ENEMY_TYPE_NAME_ROWS } from './enemyTypeLabelsData';
export type EnemyTypeBilingual = { en: string; ru: string };
/** Longest English creature titles first (suffix match). */
const CREATURE_EN_RU: [string, string][] = [
['Bone Sovereign', 'владыка костей'],
['Elemental', 'элементаль'],
['Scaleback', 'чешуеспин'],
['Manticore', 'мантикора'],
['Basilisk', 'василиск'],
['Skeleton', 'скелет'],
['Wyvern', 'виверна'],
['Cultist', 'культист'],
['Treant', 'древень'],
['Harpy', 'гарпия'],
['Warden', 'страж'],
['Wraith', 'призрак'],
['Spider', 'паук'],
['Zombie', 'зомби'],
['Golem', 'голем'],
['Bandit', 'разбойник'],
['Demon', 'демон'],
['Shade', 'тень'],
['Titan', 'титан'],
['Orc', 'орк'],
['Boar', 'кабан'],
['Wolf', 'волк'],
];
function capitalizeTitleRu(s: string): string {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Russian gloss for procedural English names from `000006b_enemy_data.sql`. */
function ruFromEnglishDisplayName(en: string): string {
let creatureEn = '';
let creatureRu = '';
for (const [e, r] of CREATURE_EN_RU) {
if (en.endsWith(e)) {
creatureEn = e;
creatureRu = r;
break;
}
}
if (!creatureEn) return en;
const prefix = en.slice(0, en.length - creatureEn.length).trimEnd();
if (prefix === 'Rift Lost') {
return capitalizeTitleRu(creatureRu) + ' из разлома';
}
if (prefix === 'Cursed Rift') {
return capitalizeTitleRu('Проклятый ' + creatureRu + ' разлома');
}
const prefixMap: Record<string, string> = {
'Elder Verdant': 'Древний зелёный',
'Woodland Elder': 'Старый лесной',
'Young Woodland': 'Молодой лесной',
'Forgotten Young': 'Юный забытый',
'Lost Forgotten': 'Потерянный забытый',
'Bog Cursed': 'Болотный проклятый',
'Rogue Ember': 'Угольный бродячий',
'Astral Rogue': 'Астральный бродячий',
};
const p = prefixMap[prefix];
if (!p) return en;
return capitalizeTitleRu(p + ' ' + creatureRu);
}
function buildEnemyTypeLabels(): Record<string, EnemyTypeBilingual> {
const out: Record<string, EnemyTypeBilingual> = {};
for (const [slug, en] of ENEMY_TYPE_NAME_ROWS) {
out[slug] = { en, ru: ruFromEnglishDisplayName(en) };
}
return out;
}
/** Localized display per DB `enemies.type` (220 rows from migration seed). */
export const ENEMY_TYPE_LABELS: Record<string, EnemyTypeBilingual> = buildEnemyTypeLabels();

@ -1,226 +0,0 @@
/**
* `enemies.type` English display name from migration `000006b_enemy_data.sql`.
* Keep in sync when DB enemy names change.
*/
export const ENEMY_TYPE_NAME_ROWS = [
['wolf_l1_1_meadow', 'Elder Verdant Wolf'],
['wolf_l1_1_forest', 'Woodland Elder Wolf'],
['wolf_l2_2_forest', 'Young Woodland Wolf'],
['wolf_l2_2_ruins', 'Forgotten Young Wolf'],
['wolf_l3_3_ruins', 'Lost Forgotten Wolf'],
['wolf_l3_3_canyon', 'Rift Lost Wolf'],
['wolf_l4_4_canyon', 'Cursed Rift Wolf'],
['wolf_l4_4_swamp', 'Bog Cursed Wolf'],
['wolf_l5_5_volcanic', 'Rogue Ember Wolf'],
['wolf_l5_5_astral', 'Astral Rogue Wolf'],
['boar_l2_2_meadow', 'Elder Verdant Boar'],
['boar_l2_2_forest', 'Woodland Elder Boar'],
['boar_l3_3_forest', 'Young Woodland Boar'],
['boar_l3_3_ruins', 'Forgotten Young Boar'],
['boar_l4_4_ruins', 'Lost Forgotten Boar'],
['boar_l4_4_canyon', 'Rift Lost Boar'],
['boar_l5_5_canyon', 'Cursed Rift Boar'],
['boar_l5_5_swamp', 'Bog Cursed Boar'],
['boar_l6_6_volcanic', 'Rogue Ember Boar'],
['boar_l6_6_astral', 'Astral Rogue Boar'],
['zombie_l3_4_meadow', 'Elder Verdant Zombie'],
['zombie_l3_4_forest', 'Woodland Elder Zombie'],
['zombie_l5_5_forest', 'Young Woodland Zombie'],
['zombie_l5_5_ruins', 'Forgotten Young Zombie'],
['zombie_l6_6_ruins', 'Lost Forgotten Zombie'],
['zombie_l6_6_canyon', 'Rift Lost Zombie'],
['zombie_l7_7_canyon', 'Cursed Rift Zombie'],
['zombie_l7_7_swamp', 'Bog Cursed Zombie'],
['zombie_l8_8_volcanic', 'Rogue Ember Zombie'],
['zombie_l8_8_astral', 'Astral Rogue Zombie'],
['spider_l4_5_meadow', 'Elder Verdant Spider'],
['spider_l4_5_forest', 'Woodland Elder Spider'],
['spider_l6_6_forest', 'Young Woodland Spider'],
['spider_l6_6_ruins', 'Forgotten Young Spider'],
['spider_l7_7_ruins', 'Lost Forgotten Spider'],
['spider_l7_7_canyon', 'Rift Lost Spider'],
['spider_l8_8_canyon', 'Cursed Rift Spider'],
['spider_l8_8_swamp', 'Bog Cursed Spider'],
['spider_l9_9_volcanic', 'Rogue Ember Spider'],
['spider_l9_9_astral', 'Astral Rogue Spider'],
['orc_l5_6_meadow', 'Elder Verdant Orc'],
['orc_l5_6_forest', 'Woodland Elder Orc'],
['orc_l7_8_forest', 'Young Woodland Orc'],
['orc_l7_8_ruins', 'Forgotten Young Orc'],
['orc_l9_10_ruins', 'Lost Forgotten Orc'],
['orc_l9_10_canyon', 'Rift Lost Orc'],
['orc_l11_11_canyon', 'Cursed Rift Orc'],
['orc_l11_11_swamp', 'Bog Cursed Orc'],
['orc_l12_12_volcanic', 'Rogue Ember Orc'],
['orc_l12_12_astral', 'Astral Rogue Orc'],
['skeleton_l6_7_meadow', 'Elder Verdant Skeleton'],
['skeleton_l6_7_forest', 'Woodland Elder Skeleton'],
['skeleton_l8_9_forest', 'Young Woodland Skeleton'],
['skeleton_l8_9_ruins', 'Forgotten Young Skeleton'],
['skeleton_l10_11_ruins', 'Lost Forgotten Skeleton'],
['skeleton_l10_11_canyon', 'Rift Lost Skeleton'],
['skeleton_l12_13_canyon', 'Cursed Rift Skeleton'],
['skeleton_l12_13_swamp', 'Bog Cursed Skeleton'],
['skeleton_l14_14_volcanic', 'Rogue Ember Skeleton'],
['skeleton_l14_14_astral', 'Astral Rogue Skeleton'],
['battle_lizard_l7_8_meadow', 'Elder Verdant Scaleback'],
['battle_lizard_l7_8_forest', 'Woodland Elder Scaleback'],
['battle_lizard_l9_10_forest', 'Young Woodland Scaleback'],
['battle_lizard_l9_10_ruins', 'Forgotten Young Scaleback'],
['battle_lizard_l11_12_ruins', 'Lost Forgotten Scaleback'],
['battle_lizard_l11_12_canyon', 'Rift Lost Scaleback'],
['battle_lizard_l13_14_canyon', 'Cursed Rift Scaleback'],
['battle_lizard_l13_14_swamp', 'Bog Cursed Scaleback'],
['battle_lizard_l15_15_volcanic', 'Rogue Ember Scaleback'],
['battle_lizard_l15_15_astral', 'Astral Rogue Scaleback'],
['element_l18_20_meadow', 'Elder Verdant Elemental'],
['element_l12_14_forest', 'Woodland Elder Elemental'],
['element_l21_22_forest', 'Young Woodland Elemental'],
['element_l15_16_ruins', 'Forgotten Young Elemental'],
['element_l23_24_ruins', 'Lost Forgotten Elemental'],
['element_l17_18_canyon', 'Rift Lost Elemental'],
['element_l25_26_canyon', 'Cursed Rift Elemental'],
['element_l19_20_swamp', 'Bog Cursed Elemental'],
['element_l27_28_volcanic', 'Rogue Ember Elemental'],
['element_l21_22_astral', 'Astral Rogue Elemental'],
['demon_l10_12_meadow', 'Elder Verdant Demon'],
['demon_l10_12_forest', 'Woodland Elder Demon'],
['demon_l13_14_forest', 'Young Woodland Demon'],
['demon_l13_14_ruins', 'Forgotten Young Demon'],
['demon_l15_16_ruins', 'Lost Forgotten Demon'],
['demon_l15_16_canyon', 'Rift Lost Demon'],
['demon_l17_18_canyon', 'Cursed Rift Demon'],
['demon_l17_18_swamp', 'Bog Cursed Demon'],
['demon_l19_20_volcanic', 'Rogue Ember Demon'],
['demon_l19_20_astral', 'Astral Rogue Demon'],
['skeleton_king_l15_17_meadow', 'Elder Verdant Bone Sovereign'],
['skeleton_king_l15_17_forest', 'Woodland Elder Bone Sovereign'],
['skeleton_king_l18_19_forest', 'Young Woodland Bone Sovereign'],
['skeleton_king_l18_19_ruins', 'Forgotten Young Bone Sovereign'],
['skeleton_king_l20_21_ruins', 'Lost Forgotten Bone Sovereign'],
['skeleton_king_l20_21_canyon', 'Rift Lost Bone Sovereign'],
['skeleton_king_l22_23_canyon', 'Cursed Rift Bone Sovereign'],
['skeleton_king_l22_23_swamp', 'Bog Cursed Bone Sovereign'],
['skeleton_king_l24_25_volcanic', 'Rogue Ember Bone Sovereign'],
['skeleton_king_l24_25_astral', 'Astral Rogue Bone Sovereign'],
['forest_warden_l20_22_meadow', 'Elder Verdant Warden'],
['forest_warden_l20_22_forest', 'Woodland Elder Warden'],
['forest_warden_l23_24_forest', 'Young Woodland Warden'],
['forest_warden_l23_24_ruins', 'Forgotten Young Warden'],
['forest_warden_l25_26_ruins', 'Lost Forgotten Warden'],
['forest_warden_l25_26_canyon', 'Rift Lost Warden'],
['forest_warden_l27_28_canyon', 'Cursed Rift Warden'],
['forest_warden_l27_28_swamp', 'Bog Cursed Warden'],
['forest_warden_l29_30_volcanic', 'Rogue Ember Warden'],
['forest_warden_l29_30_astral', 'Astral Rogue Warden'],
['titan_l25_27_meadow', 'Elder Verdant Titan'],
['titan_l25_27_forest', 'Woodland Elder Titan'],
['titan_l28_29_forest', 'Young Woodland Titan'],
['titan_l28_29_ruins', 'Forgotten Young Titan'],
['titan_l30_31_ruins', 'Lost Forgotten Titan'],
['titan_l30_31_canyon', 'Rift Lost Titan'],
['titan_l32_33_canyon', 'Cursed Rift Titan'],
['titan_l32_33_swamp', 'Bog Cursed Titan'],
['titan_l34_35_volcanic', 'Rogue Ember Titan'],
['titan_l34_35_astral', 'Astral Rogue Titan'],
['golem_l8_10_meadow', 'Elder Verdant Golem'],
['golem_l8_10_forest', 'Woodland Elder Golem'],
['golem_l11_12_forest', 'Young Woodland Golem'],
['golem_l11_12_ruins', 'Forgotten Young Golem'],
['golem_l13_14_ruins', 'Lost Forgotten Golem'],
['golem_l13_14_canyon', 'Rift Lost Golem'],
['golem_l15_16_canyon', 'Cursed Rift Golem'],
['golem_l15_16_swamp', 'Bog Cursed Golem'],
['golem_l17_18_volcanic', 'Rogue Ember Golem'],
['golem_l17_18_astral', 'Astral Rogue Golem'],
['wraith_l5_6_meadow', 'Elder Verdant Wraith'],
['wraith_l5_6_forest', 'Woodland Elder Wraith'],
['wraith_l7_8_forest', 'Young Woodland Wraith'],
['wraith_l7_8_ruins', 'Forgotten Young Wraith'],
['wraith_l9_10_ruins', 'Lost Forgotten Wraith'],
['wraith_l9_10_canyon', 'Rift Lost Wraith'],
['wraith_l11_12_canyon', 'Cursed Rift Wraith'],
['wraith_l11_12_swamp', 'Bog Cursed Wraith'],
['wraith_l13_14_volcanic', 'Rogue Ember Wraith'],
['wraith_l13_14_astral', 'Astral Rogue Wraith'],
['bandit_l4_5_meadow', 'Elder Verdant Bandit'],
['bandit_l4_5_forest', 'Woodland Elder Bandit'],
['bandit_l6_7_forest', 'Young Woodland Bandit'],
['bandit_l6_7_ruins', 'Forgotten Young Bandit'],
['bandit_l8_9_ruins', 'Lost Forgotten Bandit'],
['bandit_l8_9_canyon', 'Rift Lost Bandit'],
['bandit_l10_11_canyon', 'Cursed Rift Bandit'],
['bandit_l10_11_swamp', 'Bog Cursed Bandit'],
['bandit_l12_12_volcanic', 'Rogue Ember Bandit'],
['bandit_l12_12_astral', 'Astral Rogue Bandit'],
['cultist_l6_8_meadow', 'Elder Verdant Cultist'],
['cultist_l6_8_forest', 'Woodland Elder Cultist'],
['cultist_l9_10_forest', 'Young Woodland Cultist'],
['cultist_l9_10_ruins', 'Forgotten Young Cultist'],
['cultist_l11_12_ruins', 'Lost Forgotten Cultist'],
['cultist_l11_12_canyon', 'Rift Lost Cultist'],
['cultist_l13_14_canyon', 'Cursed Rift Cultist'],
['cultist_l13_14_swamp', 'Bog Cursed Cultist'],
['cultist_l15_16_volcanic', 'Rogue Ember Cultist'],
['cultist_l15_16_astral', 'Astral Rogue Cultist'],
['treant_l18_20_meadow', 'Elder Verdant Treant'],
['treant_l18_20_forest', 'Woodland Elder Treant'],
['treant_l21_23_forest', 'Young Woodland Treant'],
['treant_l21_23_ruins', 'Forgotten Young Treant'],
['treant_l24_26_ruins', 'Lost Forgotten Treant'],
['treant_l24_26_canyon', 'Rift Lost Treant'],
['treant_l27_28_canyon', 'Cursed Rift Treant'],
['treant_l27_28_swamp', 'Bog Cursed Treant'],
['treant_l29_30_volcanic', 'Rogue Ember Treant'],
['treant_l29_30_astral', 'Astral Rogue Treant'],
['basilisk_l9_11_meadow', 'Elder Verdant Basilisk'],
['basilisk_l9_11_forest', 'Woodland Elder Basilisk'],
['basilisk_l12_13_forest', 'Young Woodland Basilisk'],
['basilisk_l12_13_ruins', 'Forgotten Young Basilisk'],
['basilisk_l14_15_ruins', 'Lost Forgotten Basilisk'],
['basilisk_l14_15_canyon', 'Rift Lost Basilisk'],
['basilisk_l16_17_canyon', 'Cursed Rift Basilisk'],
['basilisk_l16_17_swamp', 'Bog Cursed Basilisk'],
['basilisk_l18_19_volcanic', 'Rogue Ember Basilisk'],
['basilisk_l18_19_astral', 'Astral Rogue Basilisk'],
['wyvern_l12_14_meadow', 'Elder Verdant Wyvern'],
['wyvern_l12_14_forest', 'Woodland Elder Wyvern'],
['wyvern_l15_17_forest', 'Young Woodland Wyvern'],
['wyvern_l15_17_ruins', 'Forgotten Young Wyvern'],
['wyvern_l18_20_ruins', 'Lost Forgotten Wyvern'],
['wyvern_l18_20_canyon', 'Rift Lost Wyvern'],
['wyvern_l21_22_canyon', 'Cursed Rift Wyvern'],
['wyvern_l21_22_swamp', 'Bog Cursed Wyvern'],
['wyvern_l23_24_volcanic', 'Rogue Ember Wyvern'],
['wyvern_l23_24_astral', 'Astral Rogue Wyvern'],
['harpy_l6_7_meadow', 'Elder Verdant Harpy'],
['harpy_l6_7_forest', 'Woodland Elder Harpy'],
['harpy_l8_9_forest', 'Young Woodland Harpy'],
['harpy_l8_9_ruins', 'Forgotten Young Harpy'],
['harpy_l10_11_ruins', 'Lost Forgotten Harpy'],
['harpy_l10_11_canyon', 'Rift Lost Harpy'],
['harpy_l12_13_canyon', 'Cursed Rift Harpy'],
['harpy_l12_13_swamp', 'Bog Cursed Harpy'],
['harpy_l14_15_volcanic', 'Rogue Ember Harpy'],
['harpy_l14_15_astral', 'Astral Rogue Harpy'],
['manticore_l14_16_meadow', 'Elder Verdant Manticore'],
['manticore_l14_16_forest', 'Woodland Elder Manticore'],
['manticore_l17_19_forest', 'Young Woodland Manticore'],
['manticore_l17_19_ruins', 'Forgotten Young Manticore'],
['manticore_l20_22_ruins', 'Lost Forgotten Manticore'],
['manticore_l20_22_canyon', 'Rift Lost Manticore'],
['manticore_l23_24_canyon', 'Cursed Rift Manticore'],
['manticore_l23_24_swamp', 'Bog Cursed Manticore'],
['manticore_l25_26_volcanic', 'Rogue Ember Manticore'],
['manticore_l25_26_astral', 'Astral Rogue Manticore'],
['shade_l10_12_meadow', 'Elder Verdant Shade'],
['shade_l10_12_forest', 'Woodland Elder Shade'],
['shade_l13_15_forest', 'Young Woodland Shade'],
['shade_l13_15_ruins', 'Forgotten Young Shade'],
['shade_l16_18_ruins', 'Lost Forgotten Shade'],
['shade_l16_18_canyon', 'Rift Lost Shade'],
['shade_l19_20_canyon', 'Cursed Rift Shade'],
['shade_l19_20_swamp', 'Bog Cursed Shade'],
['shade_l21_22_volcanic', 'Rogue Ember Shade'],
['shade_l21_22_astral', 'Astral Rogue Shade'],
] as const;

@ -1,8 +1,9 @@
import { createContext, useContext } from 'react';
import { en, type TranslationKey, type Translations } from './en';
import { ru } from './ru';
import type { Locale } from './localeCodes';
export type Locale = 'en' | 'ru';
export type { Locale };
const bundles: Record<Locale, Translations> = { en, ru };

@ -0,0 +1,157 @@
import { parse } from 'yaml';
import type { Locale } from './localeCodes';
import type { LocaleYamlDoc, Translations } from './types';
import enRaw from './en.yml?raw';
import ruRaw from './ru.yml?raw';
/** Matches backend townVisitLineSlugs (legacy town_visit.type.N → Nth slug). */
export const TOWN_VISIT_SLUG_ORDER: Record<string, readonly string[]> = {
merchant: [
'crates_in_shade',
'practiced_tired_smile',
'chalk_prices_twice',
'rumors_bandits_carts',
'bell_traveler_pack',
'step_back_tally_gold',
],
healer: [
'linens_herbs_tent',
'professional_frown_onceover',
'slept_badly_nod',
'tonic_steams_table',
'blessings_salves_bandages',
'lighter_under_canvas',
],
quest_giver: [
'scrolls_wax_desk',
'ink_stained_map_tap',
'busy_roads_noncommittal',
'draft_parchment_smell',
'squint_spine_legend',
'promise_listen_worth_it',
],
generic: [
'town_noise_blanket',
'grain_prices_argument',
'dust_sunbeam_time',
'strap_tighten_pretend',
'dog_boring_sleeps',
'breathe_ready_move_on',
],
};
function assertRecordKeysMatch(name: string, a: Record<string, string>, b: Record<string, string>): void {
const ak = Object.keys(a).sort();
const bk = Object.keys(b).sort();
if (ak.length !== bk.length) {
throw new Error(`${name}: key count mismatch en=${ak.length} ru=${bk.length}`);
}
for (let i = 0; i < ak.length; i++) {
if (ak[i] !== bk[i]) {
throw new Error(`${name}: sorted keys differ at ${i}: en=${ak[i]} ru=${bk[i]}`);
}
}
}
function loadDoc(raw: string): LocaleYamlDoc {
const doc = parse(raw) as unknown;
if (!doc || typeof doc !== 'object') {
throw new Error('Invalid locale YAML root');
}
const d = doc as Record<string, unknown>;
const ui = d.ui as Translations | undefined;
const adventure_log = d.adventure_log as Record<string, string> | undefined;
const achievements = d.achievements as Record<string, string> | undefined;
const roadside = d.roadside as Record<string, string> | undefined;
const town_npc_visit = d.town_npc_visit as Record<string, Record<string, string>> | undefined;
const enemy_types = d.enemy_types as Record<string, string> | undefined;
if (!ui || !adventure_log || !achievements || !roadside || !town_npc_visit || !enemy_types) {
throw new Error(
'Locale YAML missing required sections (ui, adventure_log, achievements, roadside, town_npc_visit, enemy_types)',
);
}
for (const [k, v] of Object.entries(enemy_types)) {
if (typeof v !== 'string' || !v.trim()) {
throw new Error(`Locale YAML empty enemy_types.${k}`);
}
}
for (const [k, v] of Object.entries(roadside)) {
if (typeof v !== 'string' || !v.trim()) {
throw new Error(`Locale YAML empty roadside.${k}`);
}
}
for (const npcType of Object.keys(TOWN_VISIT_SLUG_ORDER)) {
const pack = town_npc_visit[npcType];
if (!pack || typeof pack !== 'object') {
throw new Error(`Locale YAML missing town_npc_visit.${npcType}`);
}
for (const slug of TOWN_VISIT_SLUG_ORDER[npcType]!) {
if (typeof pack[slug] !== 'string' || !pack[slug]) {
throw new Error(`Locale YAML missing town_npc_visit.${npcType}.${slug}`);
}
}
}
return { ui, adventure_log, achievements, roadside, town_npc_visit, enemy_types };
}
export const enDoc = loadDoc(enRaw);
export const ruDoc = loadDoc(ruRaw);
assertRecordKeysMatch('enemy_types', enDoc.enemy_types, ruDoc.enemy_types);
assertRecordKeysMatch('roadside', enDoc.roadside, ruDoc.roadside);
export const en: Translations = enDoc.ui;
export const ru: Translations = ruDoc.ui;
export function adventureLogTemplate(locale: Locale, code: string): string | undefined {
const map = locale === 'ru' ? ruDoc.adventure_log : enDoc.adventure_log;
return map[code];
}
export function achievementLogTitle(locale: Locale, achievementId: string): string {
if (!achievementId) return '';
const m = locale === 'ru' ? ruDoc.achievements : enDoc.achievements;
return m[achievementId] ?? '';
}
/** Localized display name for DB `enemies.type` slug; empty if unknown. */
export function enemyTypeLabel(locale: Locale, enemyTypeSlug: string): string {
const slug = enemyTypeSlug?.trim() ?? '';
if (!slug) return '';
const map = locale === 'ru' ? ruDoc.enemy_types : enDoc.enemy_types;
return map[slug] ?? '';
}
/** Phrase code `roadside.<slug>` → localized line. */
export function roadsidePhraseText(locale: Locale, phraseCode: string): string {
if (!phraseCode.startsWith('roadside.')) return '';
const slug = phraseCode.slice('roadside.'.length);
if (!slug || /^\d+$/.test(slug)) return '';
const map = locale === 'ru' ? ruDoc.roadside : enDoc.roadside;
return map[slug] ?? '';
}
/** Full phrase code `town_visit.<type>.<slug>` or legacy `town_visit.<type>.<lineIdx>`. */
export function townNpcVisitPhraseText(locale: Locale, phraseCode: string): string {
if (!phraseCode.startsWith('town_visit.')) return '';
const rest = phraseCode.slice('town_visit.'.length);
const lastDot = rest.lastIndexOf('.');
if (lastDot < 0) return '';
const codeNpcType = rest.slice(0, lastDot);
const tail = rest.slice(lastDot + 1);
const packAll = locale === 'ru' ? ruDoc.town_npc_visit : enDoc.town_npc_visit;
const pack = packAll[codeNpcType] ?? packAll.generic;
if (!pack) return '';
const line = parseInt(tail, 10);
if (Number.isFinite(line) && String(line) === tail) {
const slugOrderKey = packAll[codeNpcType] != null ? codeNpcType : 'generic';
const byType = TOWN_VISIT_SLUG_ORDER as Record<string, readonly string[] | undefined>;
const picked = byType[slugOrderKey];
const order: readonly string[] =
picked !== undefined ? picked : (TOWN_VISIT_SLUG_ORDER as { generic: readonly string[] }).generic;
const i = Math.max(0, Math.min(order.length - 1, line));
const slug = order[i] as string;
return pack[slug] ?? '';
}
return pack[tail] ?? '';
}

@ -0,0 +1 @@
export type Locale = 'en' | 'ru';

@ -1,4 +1,4 @@
import type { Locale } from './index';
import type { Locale } from './localeCodes';
/** Optional per-quest overrides; keys match `quest_key` from DB (e.g. quest.12). */
const BUNDLES: Record<

@ -1,110 +0,0 @@
/** Must match backend model.RoadsideThoughtCount (52). */
export const ROADSIDE_THOUGHTS_EN: string[] = [
'If nothing matters, why does missing a crit sting so much?',
'You wonder whether the road chose you or you chose the road.',
'A coin in your pocket feels heavier than your sword. Probably guilt.',
"If consciousness is a buff, who applied it and for how long?",
'The grass here looks philosophical. Or just wet.',
'You resolve to be braver tomorrow. Today agrees to wait.',
"Is 'hero' a job title or a tax bracket?",
'Every scar is a bookmark in a story nobody reads aloud.',
'You count your breaths and lose track on purpose.',
'If the universe is a simulation, the texture work on this ditch is impressive.',
"Resting feels like cheating until you remember you're still alive.",
'Maybe the real loot was the NPCs we annoyed along the way.',
"You consider writing a memoir titled 'I Stood Here and Thought About Soup.'",
'Time is a flat circle; your HP bar disagrees.',
"Silence isn't empty—it's just loading.",
'You decide the meaning of life is probably lunch, but later.',
'If trees gossip, this one thinks you take too many breaks.',
'Courage is doing the next silly thing with a straight face.',
'You miss someone you never met. Classic hero brain.',
'The wind offers advice you politely ignore.',
"Gold can't buy happiness, but it buys better boots, which is close.",
'You practice gratitude for not being a training dummy.',
'Every legend started with someone sitting down too tired to myth.',
'If fear is a debuff, curiosity might be the cleanse.',
'You wonder if slimes dream of electric sheep. Probably not.',
'Patience is a skill tree you forgot to spec into.',
"The road will still be crooked when you stand up. That's fine.",
"You narrate your own life badly and still get XP.",
'Maybe gods are just very old patch notes.',
"A small rock looks like a throne if you're dramatic enough.",
"You forgive yourself for yesterday's panic roll.",
'Love is a side quest with unclear rewards.',
"If thoughts were loot, you'd be over-encumbered by now.",
'The sun sets whether you optimize or not.',
"You realize 'fate' might just be bad UI.",
"Breathing deeply, you upgrade from 'wounded' to 'wounded but poetic.'",
"Nobody's watching, so you strike a heroic pose anyway.",
'Wisdom is knowing when to stop swinging and start sitting.',
'You suspect the real endgame is a good chair.',
"If doubt were armor, you'd be unkillable.",
"The world keeps spinning; you're allowed to pause.",
'A distant bird screams. You relate.',
'You catalogue your regrets; the list is shorter than expected.',
'Hope is stubborn HP regeneration in a cynical patch.',
'Maybe courage is just stubbornness with better PR.',
'You wonder if merchants dream of fixed prices.',
'Every pause is a tiny rebellion against the grind.',
'The dirt under your nails is proof you showed up.',
"If meaning is crafted, you're still holding the hammer.",
'You smile at nothing in particular. It helps.',
"Tomorrow you'll walk again. Tonight you just breathe.",
'You admit the grind is loud, then turn the volume down.',
];
export const ROADSIDE_THOUGHTS_RU: string[] = [
'Если ничто не важно, почему больно промахнуться по криту?',
'Ты гадаешь: дорога выбрала тебя или ты её.',
'Монета в кошельке тяжелее меча. Наверное, вина.',
'Если сознание — бафф, кто его наложил и на сколько?',
'Трава здесь выглядит философски. Или просто мокрой.',
'Ты решаешь быть храбрее завтра. Сегодня согласно подождать.',
'«Герой» — это должность или налоговая категория?',
'Каждый шрам — закладка в истории, которую не читают вслух.',
'Ты считаешь вдохи и нарочно сбиваешь счёт.',
'Если мир — симуляция, текстуры на этой канаве впечатляют.',
'Отдых кажется читерством, пока не вспомнишь, что ты жив.',
'Может, настоящий лут — это NPC, которых мы бесили.',
'Ты думаешь о мемуарах «Я стоял тут и думал о супе».',
'Время — плоский круг; полоска HP не согласна.',
'Тишина не пуста — она загружается.',
'Смысл жизни, наверное, в обеде. Но попозже.',
'Если деревья сплетничают, это считает, что ты слишком часто отдыхаешь.',
'Храбрость — делать следующую глупость с серьёзным лицом.',
'Тоска по человеку, которого не встречал. Классика геройского мозга.',
'Ветер советует; ты вежливо игнорируешь.',
'Золото не купит счастья, но купит лучшие сапоги — почти то же.',
'Ты благодарен, что не манекен для ударов.',
'Любая легенда начиналась с того, что кто-то сел, устав мифотворить.',
'Если страх — дебафф, любопытство может быть очищением.',
'Слаймы снятся электроовцы? Вряд ли.',
'Терпение — ветка навыков, в которую ты не вкачался.',
'Дорога всё равно будет кривой, когда встанешь. И ладно.',
'Ты плохо комментируешь свою жизнь и всё равно получаешь опыт.',
'Боги — просто очень старые патч-ноты?',
'Маленький камень похож на трон, если драматизировать.',
'Ты прощаешь себе вчерашнюю паническую кнопку.',
'Любовь — побочный квест с неясной наградой.',
'Если мысли — лут, ты уже перегружен.',
'Солнце садится и без твоей оптимизации.',
'«Судьба» — плохой UI?',
'Глубоко вдохнув, ты апгрейдишься с «ранен» до «ранен, но поэтичен».',
'Никто не смотрит — ты всё равно принимаешь героическую позу.',
'Мудрость — знать, когда перестать махать и сесть.',
'Настоящий эндгейм — хороший стул?',
'Если сомнение — броня, ты неуязвим.',
'Мир крутится; тебе можно на паузу.',
'Далёкая птица кричит. Ты понимаешь.',
'Ты перечисляешь сожаления; список короче, чем ожидал.',
'Надежда — упрямое восстановление HP в циничном патче.',
'Может, храбрость — упрямство с хорошим PR?',
'Интересно, снятся ли торговцам фиксированные цены.',
'Каждая пауза — маленький бунт против гринда.',
'Грязь под ногтями — доказательство, что ты был в игре.',
'Если смысл создаётся, молоток всё ещё у тебя.',
'Ты улыбаешься ни о чём. Помогает.',
'Завтра снова пойдёшь. Сегодня просто дышишь.',
'Ты признаёшь, что гринд громкий, и приглушаешь громкость.',
];

@ -1,226 +1 @@
import type { Translations } from './en';
export const ru: Translations = {
// General
loading: '\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u0433\u0435\u0440\u043e\u044f...',
close: '\u0417\u0430\u043a\u0440\u044b\u0442\u044c',
cancel: '\u041e\u0442\u043c\u0435\u043d\u0430',
confirm: '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c',
empty: '\u041f\u0443\u0441\u0442\u043e',
none: '\u041d\u0435\u0442',
error: '\u041e\u0448\u0438\u0431\u043a\u0430',
back: '\u041d\u0430\u0437\u0430\u0434',
// Stats
hp: 'HP',
atk: '\u0410\u0422\u041a',
def: '\u0417\u0410\u0429',
spd: '\u0421\u043a\u043e\u0440.',
moveSpd: '\u0421\u043a\u043e\u0440. \u0434\u0432\u0438\u0436.',
str: '\u0421\u0418\u041b',
con: '\u0412\u042b\u041d',
agi: '\u041b\u041e\u0412',
luck: '\u0423\u0414\u0410\u0427',
xp: '\u041e\u041f',
gold: '\u0417\u043e\u043b\u043e\u0442\u043e',
level: '\u0423\u0440',
stat: '\u0421\u0422\u0410\u0422',
// Hero Panel
heroStats: '\u0421\u0442\u0430\u0442\u044b \u0433\u0435\u0440\u043e\u044f',
experience: '\u041e\u043f\u044b\u0442',
activeBuffs: '\u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0435 \u0431\u0430\u0444\u044b',
activeDebuffs: '\u0410\u043a\u0442\u0438\u0432\u043d\u044b\u0435 \u0434\u0435\u0431\u0430\u0444\u044b',
// Equipment
equipment: '\u0421\u043d\u0430\u0440\u044f\u0436\u0435\u043d\u0438\u0435',
slotWeapon: '\u041e\u0440\u0443\u0436\u0438\u0435',
slotOffHand: '\u041b\u0435\u0432\u0430\u044f \u0440\u0443\u043a\u0430',
slotHead: '\u0413\u043e\u043b\u043e\u0432\u0430',
slotChest: '\u041d\u0430\u0433\u0440\u0443\u0434\u043d\u0438\u043a',
slotLegs: '\u041d\u043e\u0433\u0438',
slotFeet: '\u041e\u0431\u0443\u0432\u044c',
slotCloak: '\u041f\u043b\u0430\u0449',
slotNeck: '\u0428\u0435\u044f',
slotRing: '\u041a\u043e\u043b\u044c\u0446\u043e',
slotWrist: '\u0417\u0430\u043f\u044f\u0441\u0442\u044c\u0435',
slotHands: '\u0420\u0443\u043a\u0438',
slotQuiver: '\u041a\u043e\u043b\u0447\u0430\u043d',
inventory: '\u0418\u043d\u0432\u0435\u043d\u0442\u0430\u0440\u044c',
// Rarity
common: '\u041e\u0431\u044b\u0447\u043d\u043e\u0435',
uncommon: '\u041d\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0435',
rare: '\u0420\u0435\u0434\u043a\u043e\u0435',
epic: '\u042d\u043f\u0438\u0447\u0435\u0441\u043a\u043e\u0435',
legendary: '\u041b\u0435\u0433\u0435\u043d\u0434\u0430\u0440\u043d\u043e\u0435',
// Buff names
buffRush: '\u0420\u044b\u0432\u043e\u043a',
buffRage: '\u042f\u0440\u043e\u0441\u0442\u044c',
buffShield: '\u0429\u0438\u0442',
buffLuck: '\u0423\u0434\u0430\u0447\u0430',
buffResurrection: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435',
buffHeal: '\u0418\u0441\u0446\u0435\u043b\u0435\u043d\u0438\u0435',
buffPowerPotion: '\u0421\u0438\u043b\u0430',
buffWarCry: '\u041a\u043b\u0438\u0447',
// Buff descriptions
buffRushDesc: '+50% \u043a \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f',
buffRageDesc: '+100% \u043a \u0443\u0440\u043e\u043d\u0443',
buffShieldDesc: '-50% \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u0443\u0440\u043e\u043d\u0430',
buffLuckDesc: 'x2.5 \u0434\u0440\u043e\u043f \u043f\u0440\u0435\u0434\u043c\u0435\u0442\u043e\u0432',
buffResurrectionDesc: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0441 50% HP',
buffHealDesc: '+50% HP \u043c\u0433\u043d\u043e\u0432\u0435\u043d\u043d\u043e',
buffPowerPotionDesc: '+150% \u043a \u0443\u0440\u043e\u043d\u0443',
buffWarCryDesc: '+100% \u043a \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 \u0430\u0442\u0430\u043a\u0438',
// Buff UI
charges: '\u0417\u0430\u0440\u044f\u0434\u044b',
refillsAt: '\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0432',
refill: '\u041f\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u044c',
refillQuestion: '\u041f\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u044c {label}?',
noChargesLeft: '\u041d\u0435\u0442 \u0437\u0430\u0440\u044f\u0434\u043e\u0432 \u0434\u043b\u044f {label}',
// Debuff names
debuffPoison: '\u042f\u0434',
debuffFreeze: '\u0417\u0430\u043c\u043e\u0440\u043e\u0437\u043a\u0430',
debuffBurn: '\u041e\u0436\u043e\u0433',
debuffStun: '\u041e\u0433\u043b\u0443\u0448\u0435\u043d\u0438\u0435',
debuffSlow: '\u0417\u0430\u043c\u0435\u0434\u043b\u0435\u043d\u0438\u0435',
debuffWeaken: '\u041e\u0441\u043b\u0430\u0431\u043b\u0435\u043d\u0438\u0435',
debuffIceSlow: '\u041b\u0435\u0434\u044f\u043d\u043e\u0435 \u0437\u0430\u043c\u0435\u0434\u043b\u0435\u043d\u0438\u0435',
// Quest system
questLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u0437\u0430\u0434\u0430\u043d\u0438\u0439',
noActiveQuests: '\u041d\u0435\u0442 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0437\u0430\u0434\u0430\u043d\u0438\u0439. \u041f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u0442\u0435 \u0441 NPC!',
claimRewards: '\u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
claimRewardsDisabledDead:
'\u0412\u043e\u0441\u043a\u0440\u0435\u0441\u043d\u0438\u0442\u0435, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u044b \u0437\u0430 \u0437\u0430\u0434\u0430\u043d\u0438\u044f',
questDestination: '\u041f\u0443\u043d\u043a\u0442 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f',
abandon: '\u041e\u0442\u043a\u0430\u0437\u0430\u0442\u044c\u0441\u044f',
acceptQuest: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
questAccepted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u0440\u0438\u043d\u044f\u0442\u043e!',
inProgressQuests: '\u0412 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0435',
availableQuestsSection: '\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0435 \u043a\u0432\u0435\u0441\u0442\u044b',
loadingQuests: '\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430 \u043a\u0432\u0435\u0441\u0442\u043e\u0432...',
noQuestsRightNow: '\u0421\u0435\u0439\u0447\u0430\u0441 \u043d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u043a\u0432\u0435\u0441\u0442\u043e\u0432.',
yourGoldLabel: '\u0412\u0430\u0448\u0435 \u0437\u043e\u043b\u043e\u0442\u043e: {amount}',
servicesSection: '\u0423\u0441\u043b\u0443\u0433\u0438',
questRewardsClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
questAbandoned: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u043e',
failedToAcceptQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u0438\u043d\u044f\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
failedToClaimRewards: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
failedToAbandonQuest: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0437\u0430\u0434\u0430\u043d\u0438\u0435',
completed: '\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e',
// NPC
questGiver: '\u041a\u0432\u0435\u0441\u0442\u043e\u0434\u0430\u0442\u0435\u043b\u044c',
merchant: '\u0422\u043e\u0440\u0433\u043e\u0432\u0435\u0446',
healer: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
npc: 'NPC',
buyPotion: '\u041a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
buyPotionForGold: '\u041a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435 ({cost}\u0437)',
healToFull: '\u0418\u0441\u0446\u0435\u043b\u0438\u0442\u044c \u043f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e',
healToFullForGold: '\u041f\u043e\u043b\u043d\u043e\u0435 \u043b\u0435\u0447\u0435\u043d\u0438\u0435 ({cost}\u0437)',
viewQuests: '\u041a\u0432\u0435\u0441\u0442\u044b',
npcInteractTalk: '\u041f\u043e\u0433\u043e\u0432\u043e\u0440\u0438\u0442\u044c',
shopHealingPotionName: '\u0417\u0435\u043b\u044c\u0435 \u043b\u0435\u0447\u0435\u043d\u0438\u044f',
shopHealingPotionDesc: '\u0412\u043e\u0441\u0441\u0442\u0430\u043d\u0430\u0432\u043b\u0438\u0432\u0430\u0435\u0442 \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u0435. \u0412\u0441\u0435\u0433\u0434\u0430 \u043f\u043e\u043b\u0435\u0437\u043d\u043e.',
shopFullHealName: '\u041f\u043e\u043b\u043d\u043e\u0435 \u043b\u0435\u0447\u0435\u043d\u0438\u0435',
shopFullHealDesc: '\u0412\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0417\u0414 \u0434\u043e \u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c\u0430.',
boughtPotion: '\u041a\u0443\u043f\u043b\u0435\u043d\u043e \u0437\u0435\u043b\u044c\u0435 \u0437\u0430 {cost} \u0437\u043e\u043b\u043e\u0442\u0430',
healedToFull: '\u0417\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e!',
notEnoughGold: '\u041d\u0435\u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0437\u043e\u043b\u043e\u0442\u0430!',
failedToBuyPotion: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043a\u0443\u043f\u0438\u0442\u044c \u0437\u0435\u043b\u044c\u0435',
failedToHeal: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0438\u0441\u0446\u0435\u043b\u0438\u0442\u044c',
// Wandering NPC
giveGoldForItem: '\u041e\u0442\u0434\u0430\u0442\u044c {cost} \u0437\u043e\u043b\u043e\u0442\u0430 \u0437\u0430 \u0437\u0430\u0433\u0430\u0434\u043e\u0447\u043d\u044b\u0439 \u043f\u0440\u0435\u0434\u043c\u0435\u0442?',
accept: '\u041f\u0440\u0438\u043d\u044f\u0442\u044c',
decline: '\u041e\u0442\u043a\u043b\u043e\u043d\u0438\u0442\u044c',
giving: '\u041e\u0442\u0434\u0430\u044e...',
// Death screen
youDied: '\u0412\u042b \u041f\u041e\u0413\u0418\u0411\u041b\u0418',
reviveNow: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c',
freeRevivesLeft: '\u0411\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}',
revivesUnlimitedSubscription: '\u0411\u0435\u0437\u043b\u0438\u043c\u0438\u0442\u043d\u044b\u0435 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u044f (\u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430)',
reviveNowWithCount: '\u0412\u041e\u0421\u041a\u0420\u0415\u0421\u0418\u0422\u042c ({count})',
autoReviveIn: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 {timer}\u0441',
noFreeRevives: '\u041d\u0435\u0442 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u044b\u0445 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u2014 \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0430',
// Name entry
chooseHeroName: '\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u043c\u044f \u0433\u0435\u0440\u043e\u044f',
enterName: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f...',
continue: '\u041f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c',
saving: '\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435...',
nameTaken: '\u0418\u043c\u044f \u0443\u0436\u0435 \u0437\u0430\u043d\u044f\u0442\u043e, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0434\u0440\u0443\u0433\u043e\u0435',
invalidName: '\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u043e\u0435 \u0438\u043c\u044f',
serverError: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 ({status})',
connectionFailed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430',
// Offline report
whileYouWereAway: '\u041f\u043e\u043a\u0430 \u0432\u0430\u0441 \u043d\u0435 \u0431\u044b\u043b\u043e...',
killedMonsters: '\u0423\u0431\u0438\u0442\u043e \u043c\u043e\u043d\u0441\u0442\u0440\u043e\u0432: {count}',
gainedXP: '+{xp} \u041e\u041f',
gainedGold: '+{gold} \u0437\u043e\u043b\u043e\u0442\u0430',
gainedLevels: '\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0443\u0440\u043e\u0432\u043d\u0435\u0439: {levels}!',
offlineDeaths: '\u0421\u043c\u0435\u0440\u0442\u0435\u0439: {count}',
offlineRevives: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}',
offlineLootFound: '\u0414\u043e\u0431\u044b\u0447\u0430:',
tapToDismiss: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f',
// Toasts
achievementUnlockedToast: '\u0414\u043e\u0441\u0442\u0438\u0436\u0435\u043d\u0438\u0435: {title}!',
toastGainedXp: '+{xp} \u041e\u041f',
toastGainedGold: '+{gold} \u0437\u043e\u043b\u043e\u0442\u0430',
toastFoundItem: '\u041d\u0430\u0439\u0434\u0435\u043d\u043e: {name}',
levelUp: '\u041d\u043e\u0432\u044b\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c: {level}!',
heroRevived: '\u0413\u0435\u0440\u043e\u0439 \u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d!',
entering: '\u0412\u0445\u043e\u0434 \u0432 {townName}',
newEquipment: '\u041d\u043e\u0432\u043e\u0435 {slot}: {itemName}',
potionsCollected: '+{count} \u0437\u0435\u043b\u044c\u0435(\u0439)',
questProgress: '{title} ({current}/{target})',
questCompleted: '\u0417\u0430\u0434\u0430\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e: {title}!',
buffLimitReached: '\u041b\u0438\u043c\u0438\u0442 \u0431\u0430\u0444\u043e\u0432 \u0434\u043e\u0441\u0442\u0438\u0433\u043d\u0443\u0442',
reviveNotAllowed: '\u0412\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0435 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e',
dailyTaskClaimed: '\u041d\u0430\u0433\u0440\u0430\u0434\u0430 \u0437\u0430 \u0437\u0430\u0434\u0430\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0430!',
failedToClaimReward: '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430\u0433\u0440\u0430\u0434\u0443',
// Minimap
map: '\u041a\u0410\u0420\u0422\u0410',
// Adventure log
noEventsYet: '\u041f\u043e\u043a\u0430 \u043d\u0435\u0442 \u0441\u043e\u0431\u044b\u0442\u0438\u0439...',
combatLogTitle: '\u0411\u043e\u0439',
logEnteredTown: '\u0412\u0445\u043e\u0434 \u0432 {town}.',
logDeclinedWanderingMerchant: '\u0412\u044b \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u0438 \u0431\u0440\u043e\u0434\u044f\u0447\u0435\u0433\u043e \u0442\u043e\u0440\u0433\u043e\u0432\u0446\u0430.',
logMerchantMovedOn: '\u0411\u0440\u043e\u0434\u044f\u0447\u0438\u0439 \u0442\u043e\u0440\u0433\u043e\u0432\u0435\u0446 \u0443\u0448\u0451\u043b.',
// Misc
adventureLog: '\u0416\u0443\u0440\u043d\u0430\u043b \u043f\u0440\u0438\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439',
shopLabel: '\u041c\u0430\u0433\u0430\u0437\u0438\u043d',
healerLabel: '\u0426\u0435\u043b\u0438\u0442\u0435\u043b\u044c',
questLabel: '\u041a\u0432\u0435\u0441\u0442',
// Hero Sheet tabs
heroSheetQuestBadgeAria:
'\u041a\u0432\u0435\u0441\u0442\u044b \u043a \u0441\u0434\u0430\u0447\u0435: {count}',
stats: '\u0421\u0442\u0430\u0442\u044b',
character: '\u041f\u0435\u0440\u0441.',
journal: '\u0416\u0443\u0440\u043d\u0430\u043b',
quests: '\u041a\u0432\u0435\u0441\u0442\u044b',
hero: '\u0413\u0435\u0440\u043e\u0439',
// Changelog
changelogTitle: '\u0427\u0442\u043e \u043d\u043e\u0432\u043e\u0433\u043e',
changelogOk: '\u041f\u043e\u043d\u044f\u0442\u043d\u043e',
changelogVersion: '\u0412\u0435\u0440\u0441\u0438\u044f {version}',
// Settings
settings: '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438',
language: '\u042f\u0437\u044b\u043a',
english: 'English',
russian: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439',
};
export { ru } from './loadLocales';

@ -0,0 +1,530 @@
# Russian locale — same sections as en.yml (ui, adventure_log, achievements, enemy_types, roadside, town_npc_visit).
ui:
loading: 'Загрузка героя...'
close: 'Закрыть'
cancel: 'Отмена'
confirm: 'Подтвердить'
empty: 'Пусто'
none: 'Нет'
error: 'Ошибка'
back: 'Назад'
hp: 'HP'
atk: 'АТК'
def: 'ЗАЩ'
spd: 'Скор.'
moveSpd: 'Скор. движ.'
str: 'СИЛ'
con: 'ВЫН'
agi: 'ЛОВ'
luck: 'УДАЧ'
xp: 'ОП'
gold: 'Золото'
level: 'Ур'
stat: 'СТАТ'
heroStats: 'Статы героя'
experience: 'Опыт'
activeBuffs: 'Активные бафы'
activeDebuffs: 'Активные дебафы'
equipment: 'Снаряжение'
slotWeapon: 'Оружие'
slotOffHand: 'Левая рука'
slotHead: 'Голова'
slotChest: 'Нагрудник'
slotLegs: 'Ноги'
slotFeet: 'Обувь'
slotCloak: 'Плащ'
slotNeck: 'Шея'
slotRing: 'Кольцо'
slotWrist: 'Запястье'
slotHands: 'Руки'
slotQuiver: 'Колчан'
inventory: 'Инвентарь'
common: 'Обычное'
uncommon: 'Необычное'
rare: 'Редкое'
epic: 'Эпическое'
legendary: 'Легендарное'
buffRush: 'Рывок'
buffRage: 'Ярость'
buffShield: 'Щит'
buffLuck: 'Удача'
buffResurrection: 'Воскрешение'
buffHeal: 'Исцеление'
buffPowerPotion: 'Сила'
buffWarCry: 'Клич'
buffRushDesc: '+50% к скорости движения'
buffRageDesc: '+100% к урону'
buffShieldDesc: '-50% входящего урона'
buffLuckDesc: 'x2.5 дроп предметов'
buffResurrectionDesc: 'Воскрешение с 50% HP'
buffHealDesc: '+50% HP мгновенно'
buffPowerPotionDesc: '+150% к урону'
buffWarCryDesc: '+100% к скорости атаки'
charges: 'Заряды'
refillsAt: 'Обновление в'
refill: 'Пополнить'
refillQuestion: 'Пополнить {label}?'
noChargesLeft: 'Нет зарядов для {label}'
debuffPoison: 'Яд'
debuffFreeze: 'Заморозка'
debuffBurn: 'Ожог'
debuffStun: 'Оглушение'
debuffSlow: 'Замедление'
debuffWeaken: 'Ослабление'
debuffIceSlow: 'Ледяное замедление'
questLog: 'Журнал заданий'
noActiveQuests: 'Нет активных заданий. Поговорите с NPC!'
claimRewards: 'Получить награду'
claimRewardsDisabledDead: 'Воскресните, чтобы получить награды за задания'
questDestination: 'Пункт назначения'
abandon: 'Отказаться'
acceptQuest: 'Принять'
questAccepted: 'Задание принято!'
inProgressQuests: 'В процессе'
availableQuestsSection: 'Доступные квесты'
loadingQuests: 'Загрузка квестов...'
noQuestsRightNow: 'Сейчас нет доступных квестов.'
yourGoldLabel: 'Ваше золото: {amount}'
servicesSection: 'Услуги'
questRewardsClaimed: 'Награда получена!'
questAbandoned: 'Задание отменено'
failedToAcceptQuest: 'Не удалось принять задание'
failedToClaimRewards: 'Не удалось получить награду'
failedToAbandonQuest: 'Не удалось отменить задание'
completed: 'Завершено'
questGiver: 'Квестодатель'
merchant: 'Торговец'
healer: 'Целитель'
npc: 'NPC'
buyPotion: 'Купить зелье'
buyPotionForGold: 'Купить зелье ({cost}з)'
healToFull: 'Исцелить полностью'
healToFullForGold: 'Полное лечение ({cost}з)'
viewQuests: 'Квесты'
npcInteractTalk: 'Поговорить'
shopHealingPotionName: 'Зелье лечения'
shopHealingPotionDesc: 'Восстанавливает здоровье. Всегда полезно.'
shopFullHealName: 'Полное лечение'
shopFullHealDesc: 'Восстановить ЗД до максимума.'
boughtPotion: 'Куплено зелье за {cost} золота'
healedToFull: 'Здоровье восстановлено!'
notEnoughGold: 'Недостаточно золота!'
failedToBuyPotion: 'Не удалось купить зелье'
failedToHeal: 'Не удалось исцелить'
giveGoldForItem: 'Отдать {cost} золота за загадочный предмет?'
accept: 'Принять'
decline: 'Отклонить'
giving: 'Отдаю...'
youDied: 'ВЫ ПОГИБЛИ'
reviveNow: 'ВОСКРЕСИТЬ'
freeRevivesLeft: 'Бесплатных воскрешений: {count}'
revivesUnlimitedSubscription: 'Безлимитные воскрешения (подписка)'
reviveNowWithCount: 'ВОСКРЕСИТЬ ({count})'
autoReviveIn: 'Авто-воскрешение через {timer}с'
noFreeRevives: 'Нет бесплатных воскрешений — нужна подписка'
chooseHeroName: 'Выберите имя героя'
enterName: 'Введите имя...'
continue: 'Продолжить'
saving: 'Сохранение...'
nameTaken: 'Имя уже занято, попробуйте другое'
invalidName: 'Недопустимое имя'
serverError: 'Ошибка сервера ({status})'
connectionFailed: 'Ошибка соединения, попробуйте снова'
whileYouWereAway: 'Пока вас не было...'
killedMonsters: 'Убито монстров: {count}'
gainedXP: '+{xp} ОП'
gainedGold: '+{gold} золота'
gainedLevels: 'Получено уровней: {levels}!'
offlineDeaths: 'Смертей: {count}'
offlineRevives: 'Авто-воскрешений: {count}'
offlineLootFound: 'Добыча:'
tapToDismiss: 'Нажмите для продолжения'
achievementUnlockedToast: 'Достижение: {title}!'
toastGainedXp: '+{xp} ОП'
toastGainedGold: '+{gold} золота'
toastFoundItem: 'Найдено: {name}'
levelUp: 'Новый уровень: {level}!'
heroRevived: 'Герой воскрешен!'
entering: 'Вход в {townName}'
newEquipment: 'Новое {slot}: {itemName}'
potionsCollected: '+{count} зелье(й)'
questProgress: '{title} ({current}/{target})'
questCompleted: 'Задание выполнено: {title}!'
buffLimitReached: 'Лимит бафов достигнут'
reviveNotAllowed: 'Воскрешение недоступно'
dailyTaskClaimed: 'Награда за задание получена!'
failedToClaimReward: 'Не удалось получить награду'
map: 'КАРТА'
noEventsYet: 'Пока нет событий...'
combatLogTitle: 'Бой'
logEnteredTown: 'Вход в {town}.'
logDeclinedWanderingMerchant: 'Вы отклонили бродячего торговца.'
logMerchantMovedOn: 'Бродячий торговец ушёл.'
adventureLog: 'Журнал приключений'
shopLabel: 'Магазин'
healerLabel: 'Целитель'
questLabel: 'Квест'
heroSheetQuestBadgeAria: 'Квесты к сдаче: {count}'
stats: 'Статы'
character: 'Перс.'
journal: 'Журнал'
quests: 'Квесты'
hero: 'Герой'
changelogTitle: 'Что нового'
changelogOk: 'Понятно'
changelogVersion: 'Версия {version}'
settings: 'Настройки'
language: 'Язык'
english: 'English'
russian: 'Русский'
adventure_log:
log.defeated_enemy: 'Побеждён {enemy} (+{xp} опыта, +{gold} золота).'
log.leveled_up: 'Достигнут {level} уровень!'
log.equipped_new: 'Экипировано: {slot} — {item}.'
log.inventory_full_dropped: 'Инвентарь полон — выброшено {item}.'
log.buff_activated: 'Активирован бафф: {buff}.'
log.hero_revived: 'Вы воскрешены.'
log.encountered_enemy: 'Вы встречаете {enemy}.'
log.died_fighting: 'Вы погибли в бою с {enemy}.'
log.auto_revive_hours: 'Прошло время; вы воскресли в городе.'
log.auto_revive_after_sec: 'Авто-воскрешение оффлайн через {seconds} с.'
log.purchased_buff_refill: 'Пополнены заряды: {buff}.'
log.purchased_buff_refill_rub: 'Куплено пополнение {buff} ({price} ₽).'
log.subscribed: 'Оформлена подписка: {duration} ({price} ₽).'
log.used_healing_potion: 'Использовано зелье лечения (+{amount} HP).'
log.achievement_unlocked: 'Достижение: «{title}»{rewardSuffix}.'
log.met_npc: 'Встреча с {npc} в {town}.'
log.wandering_alms_equipped: 'Экипировано у купца: {item}.'
log.wandering_alms_dropped: 'Выброшено {item} — нет места.'
log.wandering_alms_stashed: '{item} убрано в инвентарь.'
log.healed_full_town: 'Оплачено полное лечение.'
log.bought_potion_town: 'Куплено зелье в городе.'
log.sold_items_merchant: 'Продано предметов: {count} торговцу {npc} (+{gold} золота).'
log.npc_skipped_visit: 'Пропущена встреча с {npc}.'
log.purchased_potion_from_npc: 'Куплено зелье у {npc}.'
log.paid_healer_full: 'Оплачено полное лечение у {npc}.'
log.quest_giver_checked: 'Заглянули к {npc} — новых квестов нет.'
log.quest_accepted: 'Принят квест: {title}.'
log.combat.hero_hit: 'Вы бьёте {enemy} на {damage} урона{crit}{debuffPart}'
log.combat.hero_dodge: '{enemy} уклонился от вашей атаки.{debuffPart}'
log.combat.hero_stun: 'Вы оглушены и не можете атаковать.{debuffPart}'
log.combat.enemy_hit: '{enemy} бьёт вас на {damage} урона{crit}{debuffPart}'
log.combat.enemy_block: 'Вы блокируете атаку {enemy}.{debuffPart}'
achievements:
first_blood: 'Первая кровь'
warrior: 'Воин'
legend: 'Легенда'
hunter: 'Охотник'
slayer: 'Истребитель'
rich: 'Богач'
lucky: 'Счастливчик'
undying: 'Неумирающий'
elite_hunter: 'Охотник на элиту'
enemy_types:
wolf_l1_1_meadow: Древний зелёный волк
wolf_l1_1_forest: Старый лесной волк
wolf_l2_2_forest: Молодой лесной волк
wolf_l2_2_ruins: Юный забытый волк
wolf_l3_3_ruins: Потерянный забытый волк
wolf_l3_3_canyon: Волк из разлома
wolf_l4_4_canyon: Проклятый волк разлома
wolf_l4_4_swamp: Болотный проклятый волк
wolf_l5_5_volcanic: Угольный бродячий волк
wolf_l5_5_astral: Астральный бродячий волк
boar_l2_2_meadow: Древний зелёный кабан
boar_l2_2_forest: Старый лесной кабан
boar_l3_3_forest: Молодой лесной кабан
boar_l3_3_ruins: Юный забытый кабан
boar_l4_4_ruins: Потерянный забытый кабан
boar_l4_4_canyon: Кабан из разлома
boar_l5_5_canyon: Проклятый кабан разлома
boar_l5_5_swamp: Болотный проклятый кабан
boar_l6_6_volcanic: Угольный бродячий кабан
boar_l6_6_astral: Астральный бродячий кабан
zombie_l3_4_meadow: Древний зелёный зомби
zombie_l3_4_forest: Старый лесной зомби
zombie_l5_5_forest: Молодой лесной зомби
zombie_l5_5_ruins: Юный забытый зомби
zombie_l6_6_ruins: Потерянный забытый зомби
zombie_l6_6_canyon: Зомби из разлома
zombie_l7_7_canyon: Проклятый зомби разлома
zombie_l7_7_swamp: Болотный проклятый зомби
zombie_l8_8_volcanic: Угольный бродячий зомби
zombie_l8_8_astral: Астральный бродячий зомби
spider_l4_5_meadow: Древний зелёный паук
spider_l4_5_forest: Старый лесной паук
spider_l6_6_forest: Молодой лесной паук
spider_l6_6_ruins: Юный забытый паук
spider_l7_7_ruins: Потерянный забытый паук
spider_l7_7_canyon: Паук из разлома
spider_l8_8_canyon: Проклятый паук разлома
spider_l8_8_swamp: Болотный проклятый паук
spider_l9_9_volcanic: Угольный бродячий паук
spider_l9_9_astral: Астральный бродячий паук
orc_l5_6_meadow: Древний зелёный орк
orc_l5_6_forest: Старый лесной орк
orc_l7_8_forest: Молодой лесной орк
orc_l7_8_ruins: Юный забытый орк
orc_l9_10_ruins: Потерянный забытый орк
orc_l9_10_canyon: Орк из разлома
orc_l11_11_canyon: Проклятый орк разлома
orc_l11_11_swamp: Болотный проклятый орк
orc_l12_12_volcanic: Угольный бродячий орк
orc_l12_12_astral: Астральный бродячий орк
skeleton_l6_7_meadow: Древний зелёный скелет
skeleton_l6_7_forest: Старый лесной скелет
skeleton_l8_9_forest: Молодой лесной скелет
skeleton_l8_9_ruins: Юный забытый скелет
skeleton_l10_11_ruins: Потерянный забытый скелет
skeleton_l10_11_canyon: Скелет из разлома
skeleton_l12_13_canyon: Проклятый скелет разлома
skeleton_l12_13_swamp: Болотный проклятый скелет
skeleton_l14_14_volcanic: Угольный бродячий скелет
skeleton_l14_14_astral: Астральный бродячий скелет
battle_lizard_l7_8_meadow: Древний зелёный чешуеспин
battle_lizard_l7_8_forest: Старый лесной чешуеспин
battle_lizard_l9_10_forest: Молодой лесной чешуеспин
battle_lizard_l9_10_ruins: Юный забытый чешуеспин
battle_lizard_l11_12_ruins: Потерянный забытый чешуеспин
battle_lizard_l11_12_canyon: Чешуеспин из разлома
battle_lizard_l13_14_canyon: Проклятый чешуеспин разлома
battle_lizard_l13_14_swamp: Болотный проклятый чешуеспин
battle_lizard_l15_15_volcanic: Угольный бродячий чешуеспин
battle_lizard_l15_15_astral: Астральный бродячий чешуеспин
element_l18_20_meadow: Древний зелёный элементаль
element_l12_14_forest: Старый лесной элементаль
element_l21_22_forest: Молодой лесной элементаль
element_l15_16_ruins: Юный забытый элементаль
element_l23_24_ruins: Потерянный забытый элементаль
element_l17_18_canyon: Элементаль из разлома
element_l25_26_canyon: Проклятый элементаль разлома
element_l19_20_swamp: Болотный проклятый элементаль
element_l27_28_volcanic: Угольный бродячий элементаль
element_l21_22_astral: Астральный бродячий элементаль
demon_l10_12_meadow: Древний зелёный демон
demon_l10_12_forest: Старый лесной демон
demon_l13_14_forest: Молодой лесной демон
demon_l13_14_ruins: Юный забытый демон
demon_l15_16_ruins: Потерянный забытый демон
demon_l15_16_canyon: Демон из разлома
demon_l17_18_canyon: Проклятый демон разлома
demon_l17_18_swamp: Болотный проклятый демон
demon_l19_20_volcanic: Угольный бродячий демон
demon_l19_20_astral: Астральный бродячий демон
skeleton_king_l15_17_meadow: Древний зелёный владыка костей
skeleton_king_l15_17_forest: Старый лесной владыка костей
skeleton_king_l18_19_forest: Молодой лесной владыка костей
skeleton_king_l18_19_ruins: Юный забытый владыка костей
skeleton_king_l20_21_ruins: Потерянный забытый владыка костей
skeleton_king_l20_21_canyon: Владыка костей из разлома
skeleton_king_l22_23_canyon: Проклятый владыка костей разлома
skeleton_king_l22_23_swamp: Болотный проклятый владыка костей
skeleton_king_l24_25_volcanic: Угольный бродячий владыка костей
skeleton_king_l24_25_astral: Астральный бродячий владыка костей
forest_warden_l20_22_meadow: Древний зелёный страж
forest_warden_l20_22_forest: Старый лесной страж
forest_warden_l23_24_forest: Молодой лесной страж
forest_warden_l23_24_ruins: Юный забытый страж
forest_warden_l25_26_ruins: Потерянный забытый страж
forest_warden_l25_26_canyon: Страж из разлома
forest_warden_l27_28_canyon: Проклятый страж разлома
forest_warden_l27_28_swamp: Болотный проклятый страж
forest_warden_l29_30_volcanic: Угольный бродячий страж
forest_warden_l29_30_astral: Астральный бродячий страж
titan_l25_27_meadow: Древний зелёный титан
titan_l25_27_forest: Старый лесной титан
titan_l28_29_forest: Молодой лесной титан
titan_l28_29_ruins: Юный забытый титан
titan_l30_31_ruins: Потерянный забытый титан
titan_l30_31_canyon: Титан из разлома
titan_l32_33_canyon: Проклятый титан разлома
titan_l32_33_swamp: Болотный проклятый титан
titan_l34_35_volcanic: Угольный бродячий титан
titan_l34_35_astral: Астральный бродячий титан
golem_l8_10_meadow: Древний зелёный голем
golem_l8_10_forest: Старый лесной голем
golem_l11_12_forest: Молодой лесной голем
golem_l11_12_ruins: Юный забытый голем
golem_l13_14_ruins: Потерянный забытый голем
golem_l13_14_canyon: Голем из разлома
golem_l15_16_canyon: Проклятый голем разлома
golem_l15_16_swamp: Болотный проклятый голем
golem_l17_18_volcanic: Угольный бродячий голем
golem_l17_18_astral: Астральный бродячий голем
wraith_l5_6_meadow: Древний зелёный призрак
wraith_l5_6_forest: Старый лесной призрак
wraith_l7_8_forest: Молодой лесной призрак
wraith_l7_8_ruins: Юный забытый призрак
wraith_l9_10_ruins: Потерянный забытый призрак
wraith_l9_10_canyon: Призрак из разлома
wraith_l11_12_canyon: Проклятый призрак разлома
wraith_l11_12_swamp: Болотный проклятый призрак
wraith_l13_14_volcanic: Угольный бродячий призрак
wraith_l13_14_astral: Астральный бродячий призрак
bandit_l4_5_meadow: Древний зелёный разбойник
bandit_l4_5_forest: Старый лесной разбойник
bandit_l6_7_forest: Молодой лесной разбойник
bandit_l6_7_ruins: Юный забытый разбойник
bandit_l8_9_ruins: Потерянный забытый разбойник
bandit_l8_9_canyon: Разбойник из разлома
bandit_l10_11_canyon: Проклятый разбойник разлома
bandit_l10_11_swamp: Болотный проклятый разбойник
bandit_l12_12_volcanic: Угольный бродячий разбойник
bandit_l12_12_astral: Астральный бродячий разбойник
cultist_l6_8_meadow: Древний зелёный культист
cultist_l6_8_forest: Старый лесной культист
cultist_l9_10_forest: Молодой лесной культист
cultist_l9_10_ruins: Юный забытый культист
cultist_l11_12_ruins: Потерянный забытый культист
cultist_l11_12_canyon: Культист из разлома
cultist_l13_14_canyon: Проклятый культист разлома
cultist_l13_14_swamp: Болотный проклятый культист
cultist_l15_16_volcanic: Угольный бродячий культист
cultist_l15_16_astral: Астральный бродячий культист
treant_l18_20_meadow: Древний зелёный древень
treant_l18_20_forest: Старый лесной древень
treant_l21_23_forest: Молодой лесной древень
treant_l21_23_ruins: Юный забытый древень
treant_l24_26_ruins: Потерянный забытый древень
treant_l24_26_canyon: Древень из разлома
treant_l27_28_canyon: Проклятый древень разлома
treant_l27_28_swamp: Болотный проклятый древень
treant_l29_30_volcanic: Угольный бродячий древень
treant_l29_30_astral: Астральный бродячий древень
basilisk_l9_11_meadow: Древний зелёный василиск
basilisk_l9_11_forest: Старый лесной василиск
basilisk_l12_13_forest: Молодой лесной василиск
basilisk_l12_13_ruins: Юный забытый василиск
basilisk_l14_15_ruins: Потерянный забытый василиск
basilisk_l14_15_canyon: Василиск из разлома
basilisk_l16_17_canyon: Проклятый василиск разлома
basilisk_l16_17_swamp: Болотный проклятый василиск
basilisk_l18_19_volcanic: Угольный бродячий василиск
basilisk_l18_19_astral: Астральный бродячий василиск
wyvern_l12_14_meadow: Древний зелёный виверна
wyvern_l12_14_forest: Старый лесной виверна
wyvern_l15_17_forest: Молодой лесной виверна
wyvern_l15_17_ruins: Юный забытый виверна
wyvern_l18_20_ruins: Потерянный забытый виверна
wyvern_l18_20_canyon: Виверна из разлома
wyvern_l21_22_canyon: Проклятый виверна разлома
wyvern_l21_22_swamp: Болотный проклятый виверна
wyvern_l23_24_volcanic: Угольный бродячий виверна
wyvern_l23_24_astral: Астральный бродячий виверна
harpy_l6_7_meadow: Древний зелёный гарпия
harpy_l6_7_forest: Старый лесной гарпия
harpy_l8_9_forest: Молодой лесной гарпия
harpy_l8_9_ruins: Юный забытый гарпия
harpy_l10_11_ruins: Потерянный забытый гарпия
harpy_l10_11_canyon: Гарпия из разлома
harpy_l12_13_canyon: Проклятый гарпия разлома
harpy_l12_13_swamp: Болотный проклятый гарпия
harpy_l14_15_volcanic: Угольный бродячий гарпия
harpy_l14_15_astral: Астральный бродячий гарпия
manticore_l14_16_meadow: Древний зелёный мантикора
manticore_l14_16_forest: Старый лесной мантикора
manticore_l17_19_forest: Молодой лесной мантикора
manticore_l17_19_ruins: Юный забытый мантикора
manticore_l20_22_ruins: Потерянный забытый мантикора
manticore_l20_22_canyon: Мантикора из разлома
manticore_l23_24_canyon: Проклятый мантикора разлома
manticore_l23_24_swamp: Болотный проклятый мантикора
manticore_l25_26_volcanic: Угольный бродячий мантикора
manticore_l25_26_astral: Астральный бродячий мантикора
shade_l10_12_meadow: Древний зелёный тень
shade_l10_12_forest: Старый лесной тень
shade_l13_15_forest: Молодой лесной тень
shade_l13_15_ruins: Юный забытый тень
shade_l16_18_ruins: Потерянный забытый тень
shade_l16_18_canyon: Тень из разлома
shade_l19_20_canyon: Проклятый тень разлома
shade_l19_20_swamp: Болотный проклятый тень
shade_l21_22_volcanic: Угольный бродячий тень
shade_l21_22_astral: Астральный бродячий тень
roadside:
nothing_matters_crit: 'Если ничто не важно, почему больно промахнуться по криту?'
road_chose_you: 'Ты гадаешь: дорога выбрала тебя или ты её.'
coin_heavier_than_sword: 'Монета в кошельке тяжелее меча. Наверное, вина.'
consciousness_buff: 'Если сознание — бафф, кто его наложил и на сколько?'
grass_philosophical: 'Трава здесь выглядит философски. Или просто мокрой.'
braver_tomorrow: 'Ты решаешь быть храбрее завтра. Сегодня согласно подождать.'
hero_job_or_tax: '«Герой» — это должность или налоговая категория?'
scars_bookmarks: 'Каждый шрам — закладка в истории, которую не читают вслух.'
breaths_on_purpose: 'Ты считаешь вдохи и нарочно сбиваешь счёт.'
universe_simulation_texture: 'Если мир — симуляция, текстуры на этой канаве впечатляют.'
resting_cheating_alive: 'Отдых кажется читерством, пока не вспомнишь, что ты жив.'
real_loot_npcs: 'Может, настоящий лут — это NPC, которых мы бесили.'
memoir_soup: 'Ты думаешь о мемуарах «Я стоял тут и думал о супе».'
time_circle_hp: 'Время — плоский круг; полоска HP не согласна.'
silence_loading: 'Тишина не пуста — она загружается.'
meaning_lunch_later: 'Смысл жизни, наверное, в обеде. Но попозже.'
trees_gossip_breaks: 'Если деревья сплетничают, это считает, что ты слишком часто отдыхаешь.'
courage_silly_face: 'Храбрость — делать следующую глупость с серьёзным лицом.'
miss_never_met: 'Тоска по человеку, которого не встречал. Классика геройского мозга.'
wind_advice_ignore: 'Ветер советует; ты вежливо игнорируешь.'
gold_boots_happiness: 'Золото не купит счастья, но купит лучшие сапоги — почти то же.'
gratitude_not_dummy: 'Ты благодарен, что не манекен для ударов.'
legend_sat_tired: 'Любая легенда начиналась с того, что кто-то сел, устав мифотворить.'
fear_debuff_curiosity: 'Если страх — дебафф, любопытство может быть очищением.'
slimes_electric_sheep: 'Слаймы снятся электроовцы? Вряд ли.'
patience_skill_tree: 'Терпение — ветка навыков, в которую ты не вкачался.'
road_crooked_stand: 'Дорога всё равно будет кривой, когда встанешь. И ладно.'
narrate_life_xp: 'Ты плохо комментируешь свою жизнь и всё равно получаешь опыт.'
gods_patch_notes: 'Боги — просто очень старые патч-ноты?'
rock_throne_dramatic: 'Маленький камень похож на трон, если драматизировать.'
forgive_panic_roll: 'Ты прощаешь себе вчерашнюю паническую кнопку.'
love_side_quest: 'Любовь — побочный квест с неясной наградой.'
thoughts_loot_encumbered: 'Если мысли — лут, ты уже перегружен.'
sun_sets_optimize: 'Солнце садится и без твоей оптимизации.'
fate_bad_ui: '«Судьба» — плохой UI?'
wounded_poetic_upgrade: 'Глубоко вдохнув, ты апгрейдишься с «ранен» до «ранен, но поэтичен».'
heroic_pose_nobody: 'Никто не смотрит — ты всё равно принимаешь героическую позу.'
wisdom_stop_swinging: 'Мудрость — знать, когда перестать махать и сесть.'
endgame_good_chair: 'Настоящий эндгейм — хороший стул?'
doubt_armor_unkillable: 'Если сомнение — броня, ты неуязвим.'
world_spinning_pause: 'Мир крутится; тебе можно на паузу.'
bird_screams_relate: 'Далёкая птица кричит. Ты понимаешь.'
regrets_shorter_list: 'Ты перечисляешь сожаления; список короче, чем ожидал.'
hope_hp_cynical_patch: 'Надежда — упрямое восстановление HP в циничном патче.'
courage_stubborn_pr: 'Может, храбрость — упрямство с хорошим PR?'
merchants_fixed_prices: 'Интересно, снятся ли торговцам фиксированные цены.'
pause_rebellion_grind: 'Каждая пауза — маленький бунт против гринда.'
dirt_nails_showed_up: 'Грязь под ногтями — доказательство, что ты был в игре.'
meaning_hammer: 'Если смысл создаётся, молоток всё ещё у тебя.'
smile_nothing_helps: 'Ты улыбаешься ни о чём. Помогает.'
tomorrow_walk_tonight_breathe: 'Завтра снова пойдёшь. Сегодня просто дышишь.'
grind_volume_down: 'Ты признаёшь, что гринд громкий, и приглушаешь громкость.'
town_npc_visit:
merchant:
crates_in_shade: 'Ты окинул взглядом ящики и узлы, сложенные в тени.'
practiced_tired_smile: 'Торговец здоровается отработанной, усталой улыбкой.'
chalk_prices_twice: 'Цены на меле перечёркнуты дважды — налог надежды на дороге.'
rumors_bandits_carts: 'Вы обмениваетесь слухами о разбойниках и сломанных осях.'
bell_traveler_pack: 'Звенит колокольчик: ещё один путник взваливает рюкзак.'
step_back_tally_gold: 'Ты отступаешь, устно подсчитывая, на что хватит золота.'
healer:
linens_herbs_tent: 'В палатке пахнет чистым бельём и резкими травами.'
professional_frown_onceover: 'Целитель окинул тебя взглядом с профессиональным хмурением.'
slept_badly_nod: 'Ты признаёшься, что плохо спал; кивают, будто это всё объясняет.'
tonic_steams_table: 'На столике дымится отвар; надеешься, он не для тебя.'
blessings_salves_bandages: 'Бормочут благословения, перекладывая мази и бинты.'
lighter_under_canvas: 'Стоя под пологом, чувствуешь себя странно легче.'
quest_giver:
scrolls_wax_desk: 'Стол завален свитками и сургучными печатями.'
ink_stained_map_tap: 'По карте стучит перстью в чернильных пятнах.'
busy_roads_noncommittal: '— Шумные дороги, — говорят они; ты без обязательств соглашаешься.'
draft_parchment_smell: 'Сквозняк несёт запах старой бумаги.'
squint_spine_legend: 'Щурятся, будто меряют тебя легендой напротив.'
promise_listen_worth_it: 'Ты обещаешь слушать; обещают, что оно того стоит.'
generic:
town_noise_blanket: 'Ты замираешь; городской шум обволакивает, как одеяло.'
grain_prices_argument: 'Рядом в шутку спорят о цене на зерно.'
dust_sunbeam_time: 'В луче солнца пляшет пыль; время чуть растягивается.'
strap_tighten_pretend: 'Подтягиваешь ремень и делаешь вид, что так и задумано.'
dog_boring_sleeps: 'Собака смотрит, решает, что ты скучен, и засыпает.'
breathe_ready_move_on: 'Выдыхаешь — готов идти дальше, когда будет пора.'

@ -1,95 +0,0 @@
import type { Locale } from './index';
/** Six timed lines per town NPC visit (backend townNPCVisitLogLines = 6). */
const MERCHANT_EN = [
'You glance over crates and bundles stacked in the shade.',
'The merchant greets you with a practiced, tired smile.',
'Chalk prices are crossed out twice — the road tax of optimism.',
'You swap rumors about bandits and broken cart wheels.',
'A bell tinkles as another traveler shoulders their pack.',
'You step back, mentally tallying what you can afford.',
];
const MERCHANT_RU = [
'Ты окинул взглядом ящики и узлы, сложенные в тени.',
'Торговец здоровается отработанной, усталой улыбкой.',
'Цены на меле перечёркнуты дважды — налог надежды на дороге.',
'Вы обмениваетесь слухами о разбойниках и сломанных осях.',
'Звенит колокольчик: ещё один путник взваливает рюкзак.',
'Ты отступаешь, устно подсчитывая, на что хватит золота.',
];
const HEALER_EN = [
'Clean linens and sharp herbs fill the small tent.',
'The healer looks you over with a professional frown.',
'You admit to sleeping badly; they nod as if that explains everything.',
'A tonic steams on the side table; you hope it is not meant for you.',
'They mutter blessings while sorting salves and bandages.',
'You feel oddly lighter just standing under the canvas.',
];
const HEALER_RU = [
'В палатке пахнет чистым бельём и резкими травами.',
'Целитель окинул тебя взглядом с профессиональным хмурением.',
'Ты признаёшься, что плохо спал; кивают, будто это всё объясняет.',
'На столике дымится отвар; надеешься, он не для тебя.',
'Бормочут благословения, перекладывая мази и бинты.',
'Стоя под пологом, чувствуешь себя странно легче.',
];
const QUEST_EN = [
'Scrolls and wax seals clutter the quest givers desk.',
'They tap a map with an ink-stained finger.',
'“Busy roads,” they say — you agree, noncommittally.',
'A draft carries the smell of old parchment.',
'They squint as if measuring your spine against a legend.',
'You promise to listen; they promise it will be worth it.',
];
const QUEST_RU = [
'Стол завален свитками и сургучными печатями.',
'По карте стучит перстью в чернильных пятнах.',
'— Шумные дороги, — говорят они; ты без обязательств соглашаешься.',
'Сквозняк несёт запах старой бумаги.',
'Щурятся, будто меряют тебя легендой напротив.',
'Ты обещаешь слушать; обещают, что оно того стоит.',
];
const GENERIC_EN = [
'You pause; the town noise folds around you like a blanket.',
'Someone nearby argues about grain prices in good humor.',
'Dust motes hang in a sunbeam; time stretches a little.',
'You tighten a strap and pretend you meant to stop here.',
'A dog watches you, decides you are boring, and sleeps.',
'You breathe out, ready to move on when the moment feels right.',
];
const GENERIC_RU = [
'Ты замираешь; городской шум обволакивает, как одеяло.',
'Рядом в шутку спорят о цене на зерно.',
'В луче солнца пляшет пыль; время чуть растягивается.',
'Подтягиваешь ремень и делаешь вид, что так и задумано.',
'Собака смотрит, решает, что ты скучен, и засыпает.',
'Выдыхаешь — готов идти дальше, когда будет пора.',
];
function linesForType(type: string, locale: Locale): string[] {
const ru = locale === 'ru';
switch (type) {
case 'merchant':
return ru ? MERCHANT_RU : MERCHANT_EN;
case 'healer':
return ru ? HEALER_RU : HEALER_EN;
case 'quest_giver':
return ru ? QUEST_RU : QUEST_EN;
default:
return ru ? GENERIC_RU : GENERIC_EN;
}
}
export function townNpcVisitLineText(locale: Locale, npcType: string, lineIdx: number): string {
const lines = linesForType(npcType, locale);
const i = Math.max(0, Math.min(lines.length - 1, lineIdx));
return lines[i] ?? '';
}

@ -0,0 +1,194 @@
/** UI strings loaded from en.yml / ru.yml (must match `ui` keys in those files). */
export interface Translations {
loading: string;
close: string;
cancel: string;
confirm: string;
empty: string;
none: string;
error: string;
back: string;
hp: string;
atk: string;
def: string;
spd: string;
moveSpd: string;
str: string;
con: string;
agi: string;
luck: string;
xp: string;
gold: string;
level: string;
stat: string;
heroStats: string;
experience: string;
activeBuffs: string;
activeDebuffs: string;
equipment: string;
slotWeapon: string;
slotOffHand: string;
slotHead: string;
slotChest: string;
slotLegs: string;
slotFeet: string;
slotCloak: string;
slotNeck: string;
slotRing: string;
slotWrist: string;
slotHands: string;
slotQuiver: string;
inventory: string;
common: string;
uncommon: string;
rare: string;
epic: string;
legendary: string;
buffRush: string;
buffRage: string;
buffShield: string;
buffLuck: string;
buffResurrection: string;
buffHeal: string;
buffPowerPotion: string;
buffWarCry: string;
buffRushDesc: string;
buffRageDesc: string;
buffShieldDesc: string;
buffLuckDesc: string;
buffResurrectionDesc: string;
buffHealDesc: string;
buffPowerPotionDesc: string;
buffWarCryDesc: string;
charges: string;
refillsAt: string;
refill: string;
refillQuestion: string;
noChargesLeft: string;
debuffPoison: string;
debuffFreeze: string;
debuffBurn: string;
debuffStun: string;
debuffSlow: string;
debuffWeaken: string;
debuffIceSlow: string;
questLog: string;
noActiveQuests: string;
claimRewards: string;
claimRewardsDisabledDead: string;
questDestination: string;
abandon: string;
acceptQuest: string;
questAccepted: string;
inProgressQuests: string;
availableQuestsSection: string;
loadingQuests: string;
noQuestsRightNow: string;
yourGoldLabel: string;
servicesSection: string;
questRewardsClaimed: string;
questAbandoned: string;
failedToAcceptQuest: string;
failedToClaimRewards: string;
failedToAbandonQuest: string;
completed: string;
questGiver: string;
merchant: string;
healer: string;
npc: string;
buyPotion: string;
buyPotionForGold: string;
healToFull: string;
healToFullForGold: string;
viewQuests: string;
npcInteractTalk: string;
shopHealingPotionName: string;
shopHealingPotionDesc: string;
shopFullHealName: string;
shopFullHealDesc: string;
boughtPotion: string;
healedToFull: string;
notEnoughGold: string;
failedToBuyPotion: string;
failedToHeal: string;
giveGoldForItem: string;
accept: string;
decline: string;
giving: string;
youDied: string;
reviveNow: string;
freeRevivesLeft: string;
revivesUnlimitedSubscription: string;
reviveNowWithCount: string;
autoReviveIn: string;
noFreeRevives: string;
chooseHeroName: string;
enterName: string;
continue: string;
saving: string;
nameTaken: string;
invalidName: string;
serverError: string;
connectionFailed: string;
whileYouWereAway: string;
killedMonsters: string;
gainedXP: string;
gainedGold: string;
gainedLevels: string;
offlineDeaths: string;
offlineRevives: string;
offlineLootFound: string;
tapToDismiss: string;
achievementUnlockedToast: string;
toastGainedXp: string;
toastGainedGold: string;
toastFoundItem: string;
levelUp: string;
heroRevived: string;
entering: string;
newEquipment: string;
potionsCollected: string;
questProgress: string;
questCompleted: string;
buffLimitReached: string;
reviveNotAllowed: string;
dailyTaskClaimed: string;
failedToClaimReward: string;
map: string;
noEventsYet: string;
combatLogTitle: string;
logEnteredTown: string;
logDeclinedWanderingMerchant: string;
logMerchantMovedOn: string;
adventureLog: string;
shopLabel: string;
healerLabel: string;
questLabel: string;
heroSheetQuestBadgeAria: string;
stats: string;
character: string;
journal: string;
quests: string;
hero: string;
changelogTitle: string;
changelogOk: string;
changelogVersion: string;
settings: string;
language: string;
english: string;
russian: string;
}
export type TranslationKey = keyof Translations;
export interface LocaleYamlDoc {
ui: Translations;
adventure_log: Record<string, string>;
achievements: Record<string, string>;
/** Slug → line text; phrase codes are roadside.<slug>. */
roadside: Record<string, string>;
/** npcType → slug → line text; matches backend town_visit.<type>.<slug>. */
town_npc_visit: Record<string, Record<string, string>>;
/** DB `enemies.type` slug → display name (en / ru). */
enemy_types: Record<string, string>;
}

@ -1,5 +1,5 @@
import { BuffType } from '../game/types';
import type { Translations } from '../i18n/en';
import type { Translations } from '../i18n/types';
/** Icons and colors for buff UI (labels/descriptions come from i18n like HeroPanel). */
export const BUFF_VISUAL: Record<BuffType, { icon: string; color: string }> = {

@ -1 +1,6 @@
/// <reference types="vite/client" />
declare module '*.yml?raw' {
const src: string;
export default src;
}

Loading…
Cancel
Save