From a9b452e71389eb2058c99315e9215801a7759494 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 13:37:17 +0300 Subject: [PATCH] updated localization --- backend/internal/game/combat_log_phrase.go | 25 + backend/internal/game/engine.go | 19 +- backend/internal/game/engine_bootstrap.go | 82 +++ backend/internal/game/engine_unified_test.go | 63 +++ backend/internal/game/movement.go | 24 +- backend/internal/game/offline.go | 30 +- backend/internal/game/rewards.go | 23 +- backend/internal/handler/game.go | 21 +- backend/internal/handler/npc.go | 20 +- backend/internal/handler/payments.go | 4 +- .../model/adventure_log_english_fallback.go | 285 ---------- .../adventure_log_english_fallback_test.go | 34 -- backend/internal/model/adventure_log_event.go | 49 +- .../internal/model/adventure_log_json_test.go | 4 +- .../model/adventure_log_phrase_keys.go | 91 +++ .../model/adventure_log_phrase_keys_test.go | 31 + .../internal/model/roadside_phrase_keys.go | 62 ++ backend/internal/storage/log_store.go | 3 - frontend/package.json | 3 +- frontend/src/App.tsx | 11 +- frontend/src/game/adventureLogFormat.ts | 473 +++++++++------- frontend/src/game/adventureLogMap.ts | 22 +- frontend/src/game/adventureLogMarkers.ts | 13 +- frontend/src/i18n/contentLabels.ts | 10 +- frontend/src/i18n/en.ts | 227 +------- frontend/src/i18n/en.yml | 530 ++++++++++++++++++ frontend/src/i18n/enemyTypeLabels.ts | 80 --- frontend/src/i18n/enemyTypeLabelsData.ts | 226 -------- frontend/src/i18n/index.ts | 3 +- frontend/src/i18n/loadLocales.ts | 157 ++++++ frontend/src/i18n/localeCodes.ts | 1 + frontend/src/i18n/questCopy.ts | 2 +- frontend/src/i18n/roadsideThoughts.ts | 110 ---- frontend/src/i18n/ru.ts | 227 +------- frontend/src/i18n/ru.yml | 530 ++++++++++++++++++ frontend/src/i18n/townNpcVisitLines.ts | 95 ---- frontend/src/i18n/types.ts | 194 +++++++ frontend/src/ui/buffMeta.ts | 2 +- frontend/src/vite-env.d.ts | 5 + 39 files changed, 2161 insertions(+), 1630 deletions(-) create mode 100644 backend/internal/game/combat_log_phrase.go create mode 100644 backend/internal/game/engine_bootstrap.go create mode 100644 backend/internal/game/engine_unified_test.go delete mode 100644 backend/internal/model/adventure_log_english_fallback.go delete mode 100644 backend/internal/model/adventure_log_english_fallback_test.go create mode 100644 backend/internal/model/adventure_log_phrase_keys.go create mode 100644 backend/internal/model/adventure_log_phrase_keys_test.go create mode 100644 backend/internal/model/roadside_phrase_keys.go create mode 100644 frontend/src/i18n/en.yml delete mode 100644 frontend/src/i18n/enemyTypeLabels.ts delete mode 100644 frontend/src/i18n/enemyTypeLabelsData.ts create mode 100644 frontend/src/i18n/loadLocales.ts create mode 100644 frontend/src/i18n/localeCodes.ts delete mode 100644 frontend/src/i18n/roadsideThoughts.ts create mode 100644 frontend/src/i18n/ru.yml delete mode 100644 frontend/src/i18n/townNpcVisitLines.ts create mode 100644 frontend/src/i18n/types.ts diff --git a/backend/internal/game/combat_log_phrase.go b/backend/internal/game/combat_log_phrase.go new file mode 100644 index 0000000..459c2df --- /dev/null +++ b/backend/internal/game/combat_log_phrase.go @@ -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 + } +} diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index f8bb07c..06914d4 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -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, + "damage": evt.Damage, + "isCrit": evt.IsCrit, + "enemyType": cs.Enemy.Slug, } 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)}, }, }) diff --git a/backend/internal/game/engine_bootstrap.go b/backend/internal/game/engine_bootstrap.go new file mode 100644 index 0000000..dc700c1 --- /dev/null +++ b/backend/internal/game/engine_bootstrap.go @@ -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) + } + } +} diff --git a/backend/internal/game/engine_unified_test.go b/backend/internal/game/engine_unified_test.go new file mode 100644 index 0000000..bbf94fc --- /dev/null +++ b/backend/internal/game/engine_unified_test.go @@ -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) + } +} diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index cca0941..abdac3d 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -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) { - adventureLog(heroID, model.AdventureLogLine{ - Event: &model.AdventureLogEvent{ - Code: model.LogThoughtRoadside, - Args: map[string]any{"idx": rand.Intn(model.RoadsideThoughtCount)}, - }, - }) + if n := len(model.RoadsideSlugs); n > 0 { + adventureLog(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + 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}, }, }) diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 8cd51ae..0b4c318 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -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,10 +184,10 @@ 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, - "xp": xpGained, "gold": goldGained, + "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}, }, }) } diff --git a/backend/internal/game/rewards.go b/backend/internal/game/rewards.go index 7801838..32bbc2c 100644 --- a/backend/internal/game/rewards.go +++ b/backend/internal/game/rewards.go @@ -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,17 +227,17 @@ 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, - "xp": enemy.XPReward, "gold": goldGained, + "enemyType": enemy.Slug, + "xp": enemy.XPReward, "gold": goldGained, }, }, }) 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, }, diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 1550d2f..84cc83f 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -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, }, diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index b215f3f..a2244ee 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -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) } diff --git a/backend/internal/handler/payments.go b/backend/internal/handler/payments.go index b28f644..21758d1 100644 --- a/backend/internal/handler/payments.go +++ b/backend/internal/handler/payments.go @@ -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}, }, }) diff --git a/backend/internal/model/adventure_log_english_fallback.go b/backend/internal/model/adventure_log_english_fallback.go deleted file mode 100644 index ba9589b..0000000 --- a/backend/internal/model/adventure_log_english_fallback.go +++ /dev/null @@ -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 - } -} diff --git a/backend/internal/model/adventure_log_english_fallback_test.go b/backend/internal/model/adventure_log_english_fallback_test.go deleted file mode 100644 index 0337673..0000000 --- a/backend/internal/model/adventure_log_english_fallback_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/model/adventure_log_event.go b/backend/internal/model/adventure_log_event.go index 2141939..0607299 100644 --- a/backend/internal/model/adventure_log_event.go +++ b/backend/internal/model/adventure_log_event.go @@ -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" diff --git a/backend/internal/model/adventure_log_json_test.go b/backend/internal/model/adventure_log_json_test.go index fb386d5..5a5170b 100644 --- a/backend/internal/model/adventure_log_json_test.go +++ b/backend/internal/model/adventure_log_json_test.go @@ -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" { diff --git a/backend/internal/model/adventure_log_phrase_keys.go b/backend/internal/model/adventure_log_phrase_keys.go new file mode 100644 index 0000000..aa87dcf --- /dev/null +++ b/backend/internal/model/adventure_log_phrase_keys.go @@ -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] +} diff --git a/backend/internal/model/adventure_log_phrase_keys_test.go b/backend/internal/model/adventure_log_phrase_keys_test.go new file mode 100644 index 0000000..15482a3 --- /dev/null +++ b/backend/internal/model/adventure_log_phrase_keys_test.go @@ -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) + } +} diff --git a/backend/internal/model/roadside_phrase_keys.go b/backend/internal/model/roadside_phrase_keys.go new file mode 100644 index 0000000..f06bbf2 --- /dev/null +++ b/backend/internal/model/roadside_phrase_keys.go @@ -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 +} diff --git a/backend/internal/storage/log_store.go b/backend/internal/storage/log_store.go index f413796..fb6f2d0 100644 --- a/backend/internal/storage/log_store.go +++ b/backend/internal/storage/log_store.go @@ -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, diff --git a/frontend/package.json b/frontend/package.json index 3309151..fd9b3c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0c88c1..01644b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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)); } } diff --git a/frontend/src/game/adventureLogFormat.ts b/frontend/src/game/adventureLogFormat.ts index 28f9ab6..4ea3770 100644 --- a/frontend/src/game/adventureLogFormat.ts +++ b/frontend/src/game/adventureLogFormat.ts @@ -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 = { + 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., town_visit.type.). + * Legacy rows without dots are mapped; phrase keys pass through. + */ +export function normalizeAdventureLogCode(code: string, args?: Record): 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; +} + +function dynamicRoadsideLine(locale: Locale, code: string): string | undefined { + if (!code.startsWith('roadside.')) return undefined; + const text = roadsidePhraseText(locale, code); + return text || undefined; } -/** Localized single log line from structured event (+ optional legacy message). */ -export function formatAdventureLogEvent( +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, + args: Record, 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 = { + 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, + legacyMessage?: string, +): Record | 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}).`; + const legacyName = strArg(a, 'itemName'); + const item = + legacyName || + (locale === 'ru' ? `${rarity} (${slot})` : `${rarity} — ${slot}`); + return { slot, item }; } - 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 '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 'achievement_unlocked': { - const title = strArg(a, 'title'); + 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).`; + rewardSuffix = locale === 'ru' ? ` (+${ra} золота)` : ` (+${ra} gold)`; + } else if (rt === 'potion') { + rewardSuffix = locale === 'ru' ? ` (+${ra} зелий)` : ` (+${ra} potions)`; } - if (rt === 'potion') { - return locale === 'ru' - ? `Достижение: «${title}» (+${ra} зелий).` - : `Achievement: ${title} (+${ra} potions).`; - } - return locale === 'ru' ? `Достижение: «${title}».` : `Achievement: ${title}.`; + 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}.`; + return { + npc: npcLabel(locale, nk, nk), + town: townLabel(locale, tk, tk), + }; } - 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}.`; - } - 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.`; + return { npc: npcLabel(locale, nk, nk) }; } - case 'quest_accepted': { - const title = strArg(a, 'title'); - return locale === 'ru' ? `Принят квест: ${title}.` : `Accepted quest: ${title}.`; + 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 'town_npc_visit_line': { - const npcType = strArg(a, 'npcType'); - const line = intArg(a, 'line'); - return townNpcVisitLineText(locale, npcType, line); - } - 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); + 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 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}.`; - } - } - if (debuff) { - const dn = debuffName(tr, debuff); - base += locale === 'ru' ? ` ${dn} применён.` : ` ${dn} applied.`; - } - return base || (legacyMessage ?? ''); + 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, + }; } default: - return legacyMessage ?? ''; + return null; + } +} + +/** Localized single log line from structured event (+ optional legacy message). */ +export function formatAdventureLogEvent( + locale: Locale, + tr: Translations, + code: string, + args?: Record, + 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 ?? ''); + } + + 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 legacyMessage?.trim() ? legacyMessage : ''; + } + + const vars = resolveAdventureLogVars(locale, tr, phraseKey, rawArgs, legacyMessage); + if (vars === null) { + return legacyMessage?.trim() ? legacyMessage : ''; } + return t(template, vars); } export function formatAdventureLogPayload( diff --git a/frontend/src/game/adventureLogMap.ts b/frontend/src/game/adventureLogMap.ts index e323c5f..0eba720 100644 --- a/frontend/src/game/adventureLogMap.ts +++ b/frontend/src/game/adventureLogMap.ts @@ -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); diff --git a/frontend/src/game/adventureLogMarkers.ts b/frontend/src/game/adventureLogMarkers.ts index c9998b7..0e8b6e1 100644 --- a/frontend/src/game/adventureLogMarkers.ts +++ b/frontend/src/game/adventureLogMarkers.ts @@ -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; } diff --git a/frontend/src/i18n/contentLabels.ts b/frontend/src/i18n/contentLabels.ts index 9996bdc..0e3416c 100644 --- a/frontend/src/i18n/contentLabels.ts +++ b/frontend/src/i18n/contentLabels.ts @@ -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; diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 791aa78..6c0ec9a 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -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; +export { en } from './loadLocales'; +export type { TranslationKey, Translations } from './types'; diff --git a/frontend/src/i18n/en.yml b/frontend/src/i18n/en.yml new file mode 100644 index 0000000..3d9c78b --- /dev/null +++ b/frontend/src/i18n/en.yml @@ -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 giver’s 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. diff --git a/frontend/src/i18n/enemyTypeLabels.ts b/frontend/src/i18n/enemyTypeLabels.ts deleted file mode 100644 index a12b823..0000000 --- a/frontend/src/i18n/enemyTypeLabels.ts +++ /dev/null @@ -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 = { - '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 { - const out: Record = {}; - 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 = buildEnemyTypeLabels(); diff --git a/frontend/src/i18n/enemyTypeLabelsData.ts b/frontend/src/i18n/enemyTypeLabelsData.ts deleted file mode 100644 index 891100a..0000000 --- a/frontend/src/i18n/enemyTypeLabelsData.ts +++ /dev/null @@ -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; diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index ce857a6..ad21fa9 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -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 = { en, ru }; diff --git a/frontend/src/i18n/loadLocales.ts b/frontend/src/i18n/loadLocales.ts new file mode 100644 index 0000000..7e237a0 --- /dev/null +++ b/frontend/src/i18n/loadLocales.ts @@ -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 = { + 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, b: Record): 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; + const ui = d.ui as Translations | undefined; + const adventure_log = d.adventure_log as Record | undefined; + const achievements = d.achievements as Record | undefined; + const roadside = d.roadside as Record | undefined; + const town_npc_visit = d.town_npc_visit as Record> | undefined; + const enemy_types = d.enemy_types as Record | 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.` → 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..` or legacy `town_visit..`. */ +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; + 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] ?? ''; +} diff --git a/frontend/src/i18n/localeCodes.ts b/frontend/src/i18n/localeCodes.ts new file mode 100644 index 0000000..dd5bb10 --- /dev/null +++ b/frontend/src/i18n/localeCodes.ts @@ -0,0 +1 @@ +export type Locale = 'en' | 'ru'; diff --git a/frontend/src/i18n/questCopy.ts b/frontend/src/i18n/questCopy.ts index e621a3b..6704b3f 100644 --- a/frontend/src/i18n/questCopy.ts +++ b/frontend/src/i18n/questCopy.ts @@ -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< diff --git a/frontend/src/i18n/roadsideThoughts.ts b/frontend/src/i18n/roadsideThoughts.ts deleted file mode 100644 index e56b15f..0000000 --- a/frontend/src/i18n/roadsideThoughts.ts +++ /dev/null @@ -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?', - 'Интересно, снятся ли торговцам фиксированные цены.', - 'Каждая пауза — маленький бунт против гринда.', - 'Грязь под ногтями — доказательство, что ты был в игре.', - 'Если смысл создаётся, молоток всё ещё у тебя.', - 'Ты улыбаешься ни о чём. Помогает.', - 'Завтра снова пойдёшь. Сегодня просто дышишь.', - 'Ты признаёшь, что гринд громкий, и приглушаешь громкость.', -]; diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts index b7c2583..b1ca3da 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -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'; diff --git a/frontend/src/i18n/ru.yml b/frontend/src/i18n/ru.yml new file mode 100644 index 0000000..d70db23 --- /dev/null +++ b/frontend/src/i18n/ru.yml @@ -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: 'Выдыхаешь — готов идти дальше, когда будет пора.' diff --git a/frontend/src/i18n/townNpcVisitLines.ts b/frontend/src/i18n/townNpcVisitLines.ts deleted file mode 100644 index 59d231a..0000000 --- a/frontend/src/i18n/townNpcVisitLines.ts +++ /dev/null @@ -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 giver’s 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] ?? ''; -} diff --git a/frontend/src/i18n/types.ts b/frontend/src/i18n/types.ts new file mode 100644 index 0000000..4ae6a11 --- /dev/null +++ b/frontend/src/i18n/types.ts @@ -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; + achievements: Record; + /** Slug → line text; phrase codes are roadside.. */ + roadside: Record; + /** npcType → slug → line text; matches backend town_visit... */ + town_npc_visit: Record>; + /** DB `enemies.type` slug → display name (en / ru). */ + enemy_types: Record; +} diff --git a/frontend/src/ui/buffMeta.ts b/frontend/src/ui/buffMeta.ts index cc03505..b615ee3 100644 --- a/frontend/src/ui/buffMeta.ts +++ b/frontend/src/ui/buffMeta.ts @@ -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 = { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..e6ee51a 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +declare module '*.yml?raw' { + const src: string; + export default src; +}