diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9e03c1d..6eee233 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -110,14 +110,14 @@ func main() { engine.SetHeroStore(heroStore) engine.SetTownSessionStore(storage.NewTownSessionStore(redisClient)) engine.SetQuestStore(questStore) - engine.SetAdventureLog(func(heroID int64, msg string) { + engine.SetAdventureLog(func(heroID int64, line model.AdventureLogLine) { logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if err := logStore.Add(logCtx, heroID, msg); err != nil { + if err := logStore.Add(logCtx, heroID, line); err != nil { logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) return } - hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: msg}) + hub.SendToHero(heroID, "adventure_log_line", line) }) // Hub callbacks: on connect, load hero and register movement; on disconnect, persist. diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index c3c3b70..fdab256 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -462,7 +462,12 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { } if e.adventureLog != nil { - e.adventureLog(msg.HeroID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount)) + e.adventureLog(msg.HeroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogUsedHealingPotion, + Args: map[string]any{"amount": healAmount}, + }, + }) } // Emit as an attack-like event so the client shows it. @@ -1004,7 +1009,12 @@ func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) { } if e.adventureLog != nil { - e.adventureLog(hero.ID, FormatEncounterLogLine(enemy.Name)) + e.adventureLog(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogEncounteredEnemy, + Args: map[string]any{"enemyType": enemy.Slug, "enemyName": enemy.Name}, + }, + }) } e.logger.Info("combat started", @@ -1474,47 +1484,23 @@ func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) { if e.adventureLog == nil || cs == nil { return } - enemyName := cs.Enemy.Name - critSuffix := "" - if evt.IsCrit { - critSuffix = " (crit)" - } - var msg string - switch evt.Source { - case "hero": - switch evt.Outcome { - case attackOutcomeStun: - msg = "You are stunned and cannot attack." - case attackOutcomeDodge: - msg = enemyName + " dodged your attack." - default: - msg = "You hit " + enemyName + " for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "." - } - case "enemy": - switch evt.Outcome { - case attackOutcomeBlock: - msg = "You block " + enemyName + "'s attack." - default: - msg = enemyName + " hits you for " + fmt.Sprintf("%d", evt.Damage) + " damage" + critSuffix + "." - } + args := map[string]any{ + "source": evt.Source, + "outcome": evt.Outcome, + "damage": evt.Damage, + "isCrit": evt.IsCrit, + "enemyType": cs.Enemy.Slug, + "enemyName": cs.Enemy.Name, } if evt.DebuffApplied != "" { - msg += " " + debuffDisplayName(evt.DebuffApplied) + " applied." - } - if msg != "" { - e.adventureLog(cs.HeroID, FormatBattleLogLine(msg)) + args["debuffType"] = evt.DebuffApplied } -} - -func debuffDisplayName(debuffType string) string { - dt, ok := model.ValidDebuffType(debuffType) - if !ok { - return debuffType - } - if def, ok := model.DebuffDefinition(dt); ok && def.Name != "" { - return def.Name - } - return debuffType + e.adventureLog(cs.HeroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogCombatSwing, + Args: args, + }, + }) } func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index bd7e7f1..cca0941 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -40,7 +40,7 @@ func townNPCLogInterval() time.Duration { } // AdventureLogWriter persists or pushes one adventure log line for a hero (optional). -type AdventureLogWriter func(heroID int64, message string) +type AdventureLogWriter func(heroID int64, line model.AdventureLogLine) // HeroMovement holds the live movement state for a single online hero. type HeroMovement struct { @@ -66,10 +66,14 @@ type HeroMovement struct { // Town NPC visit: adventure log lines until NextTownNPCRollAt (narration block) after town_npc_visit. TownVisitNPCName string + TownVisitNPCKey string TownVisitNPCType string TownVisitStartedAt time.Time TownVisitLogsEmitted int + // RoadsideThoughtNextAt schedules the next localized thought during roadside rest (ExcursionWild). + RoadsideThoughtNextAt time.Time + // Walk-to-NPC sub-state: hero moves toward the next NPC before the visit event fires. TownNPCWalkTargetID int64 // NPC id the hero is walking toward (0 = not walking) TownNPCWalkFromX float64 @@ -1443,83 +1447,21 @@ func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log Adv if now.Before(deadline) { break } - msg := townNPCVisitLogMessage(hm.TownVisitNPCType, hm.TownVisitNPCName, hm.TownVisitLogsEmitted) - if msg != "" { - log(heroID, msg) - } + 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, + }, + }, + }) hm.TownVisitLogsEmitted++ } } -func townNPCVisitLogMessage(npcType, npcName string, lineIndex int) string { - if lineIndex < 0 || lineIndex >= townNPCVisitLogLines { - return "" - } - switch npcType { - case "merchant": - switch lineIndex { - case 0: - return fmt.Sprintf("You stop at %s's stall.", npcName) - case 1: - return "Crates, pouches, and price tags blur together as you browse." - case 2: - return fmt.Sprintf("%s points out a few curious trinkets.", npcName) - case 3: - return "You weigh a potion against your coin purse." - case 4: - return "A short haggle ends in a reluctant nod." - case 5: - return fmt.Sprintf("You thank %s and step back from the counter.", npcName) - } - case "healer": - switch lineIndex { - case 0: - return fmt.Sprintf("You seek out %s.", npcName) - case 1: - return "The healer examines your wounds with a calm eye." - case 2: - return "Herbs steam gently; bandages are laid out in neat rows." - case 3: - return "A slow warmth spreads as salves are applied." - case 4: - return "You rest a moment on a bench, breathing easier." - case 5: - return fmt.Sprintf("You nod to %s and return to the street.", npcName) - } - case "quest_giver": - switch lineIndex { - case 0: - return fmt.Sprintf("You speak with %s about the road ahead.", npcName) - case 1: - return "Rumors of trouble and slim rewards fill the air." - case 2: - return "A worn map is smoothed flat between you." - case 3: - return "You mark targets and deadlines in your mind." - case 4: - return fmt.Sprintf("%s hints at better pay for the bold.", npcName) - case 5: - return "You part with a clearer picture of what must be done." - } - default: - switch lineIndex { - case 0: - return fmt.Sprintf("You spend time with %s.", npcName) - case 1: - return "Conversation drifts from weather to the wider world." - case 2: - return "A few practical details stick in your memory." - case 3: - return "You listen more than you speak." - case 4: - return "Promises and coin change hands—or almost do." - case 5: - return fmt.Sprintf("You say farewell to %s.", npcName) - } - } - return "" -} - // --- Excursion (mini-adventure) FSM helpers --- func smoothstep(t float64) float64 { @@ -1692,6 +1634,7 @@ func (hm *HeroMovement) beginRoadsideRest(now time.Time) { } // RestUntil tracks only the rest (wild) phase; travel out/return is separate. hm.RestUntil = wildUntil + hm.RoadsideThoughtNextAt = now.Add(time.Duration(25+rand.Intn(46)) * time.Second) } func (hm *HeroMovement) beginAdventureInlineRest(now time.Time) { @@ -1816,12 +1759,27 @@ func ProcessSingleHeroMovementTick( excursionEnded := hm.advanceExcursionPhases(now) if hm.Excursion.Phase == model.ExcursionWild { hm.applyRestHealTick(dt) + if adventureLog != nil { + if hm.RoadsideThoughtNextAt.IsZero() { + 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)}, + }, + }) + hm.RoadsideThoughtNextAt = now.Add(time.Duration(30+rand.Intn(61)) * time.Second) + } + } } if excursionEnded { hm.endExcursion(now) hm.ActiveRestKind = model.RestKindNone hm.RestUntil = time.Time{} hm.RestHealRemainder = 0 + hm.RoadsideThoughtNextAt = time.Time{} hm.State = model.StateWalking hm.Hero.State = model.StateWalking hm.refreshSpeed(now) @@ -1952,10 +1910,15 @@ func ProcessSingleHeroMovementTick( if npc, ok := graph.NPCByID[npcID]; ok { fullVisit := false + townNameKey := "" + if tt := graph.Towns[hm.CurrentTownID]; tt != nil { + townNameKey = tt.NameKey + } if sender != nil { sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ - NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, - WorldX: standX, WorldY: standY, + NPCID: npc.ID, Name: npc.Name, NameKey: npc.NameKey, Type: npc.Type, TownID: hm.CurrentTownID, + TownNameKey: townNameKey, + WorldX: standX, WorldY: standY, }) sender.SendToHero(heroID, "hero_move", model.HeroMovePayload{ X: hm.CurrentX, Y: hm.CurrentY, @@ -1971,6 +1934,7 @@ func ProcessSingleHeroMovementTick( if fullVisit { hm.TownVisitNPCName = npc.Name + hm.TownVisitNPCKey = npc.NameKey hm.TownVisitNPCType = npc.Type hm.TownVisitStartedAt = now hm.TownVisitLogsEmitted = 0 @@ -1982,14 +1946,24 @@ func ProcessSingleHeroMovementTick( } soldItems, soldGold := AutoSellRandomInventoryShare(hm.Hero, share, nil) if soldItems > 0 && adventureLog != nil { - adventureLog(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold)) + adventureLog(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogSoldItemsMerchant, + Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold}, + }, + }) } } emitTownNPCVisitLogs(heroID, hm, now, adventureLog) hm.NextTownNPCRollAt = now.Add(townNPCLogInterval() * (townNPCVisitLogLines - 1)) } else { if adventureLog != nil { - adventureLog(heroID, fmt.Sprintf("You notice %s but decide not to stop.", npc.Name)) + adventureLog(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogNPCSkippedVisit, + Args: map[string]any{"npcKey": npc.NameKey}, + }, + }) } hm.NextTownNPCRollAt = now.Add(time.Duration(cfg.TownNPCRetryMs) * time.Millisecond) } @@ -2031,6 +2005,7 @@ func ProcessSingleHeroMovementTick( // NPC visit pause ended: clear visit log state before the next roll. if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) { hm.TownVisitNPCName = "" + hm.TownVisitNPCKey = "" hm.TownVisitNPCType = "" hm.TownVisitStartedAt = time.Time{} hm.TownVisitLogsEmitted = 0 @@ -2260,7 +2235,8 @@ func ProcessSingleHeroMovementTick( if sender != nil { hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ - NPCID: 0, NPCName: "Wandering Merchant", Role: "alms", Cost: cost, + NPCID: 0, NPCName: "Wandering Merchant", NPCNameKey: model.WanderingMerchantNPCKey, + Role: "alms", DialogueKey: model.WanderingMerchantDialogueKey, Cost: cost, }) } if onMerchantEncounter != nil { @@ -2297,6 +2273,7 @@ func ProcessSingleHeroMovementTick( sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{ TownID: town.ID, TownName: town.Name, + TownNameKey: town.NameKey, Biome: town.Biome, NPCs: npcInfos, Buildings: buildingInfos, @@ -2339,10 +2316,12 @@ func ProcessSingleHeroMovementTick( if sender != nil { hm.WanderingMerchantDeadline = now.Add(time.Duration(cfg.WanderingMerchantPromptTimeoutMs) * time.Millisecond) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ - NPCID: 0, - NPCName: "Wandering Merchant", - Role: "alms", - Cost: cost, + NPCID: 0, + NPCName: "Wandering Merchant", + NPCNameKey: model.WanderingMerchantNPCKey, + Role: "alms", + DialogueKey: model.WanderingMerchantDialogueKey, + Cost: cost, }) } if onMerchantEncounter != nil { diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index e1c71ea..6eac82e 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -68,20 +68,29 @@ func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievement return s } -// WithDigestStore wires persistent offline digest accumulation (after disconnect grace). +// WithDigestStore wires persistent offline digest while the hero is processed by OfflineSimulator +// (no live WS session for that hero). Counters and loot are cleared when the client loads hero/init. func (s *OfflineSimulator) WithDigestStore(d *storage.OfflineDigestStore) *OfflineSimulator { s.digestStore = d return s } -// OfflineDigestGrace is the delay after the last WS disconnect before offline events count toward the digest. -const OfflineDigestGrace = 30 * time.Second - -func offlineDigestCollecting(disconnect *time.Time, now time.Time) bool { - if disconnect == nil { - return false +// nonGoldLootForDigest keeps equipment/potion lines only; gold belongs in gold_gained counter. +func nonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop { + if len(drops) == 0 { + return nil + } + out := make([]model.LootDrop, 0, len(drops)) + for _, d := range drops { + if d.ItemType == "gold" { + continue + } + out = append(out, d) } - return !now.Before(disconnect.Add(OfflineDigestGrace)) + if len(out) == 0 { + return nil + } + return out } // Run starts the offline simulation loop. It blocks until the context is cancelled. @@ -147,8 +156,13 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her } hero.State = model.StateWalking hero.Debuffs = nil - s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second))) - if s.digestStore != nil && offlineDigestCollecting(hero.WsDisconnectedAt, now) { + s.addLog(ctx, hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogAutoReviveAfterSec, + Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)}, + }, + }) + if s.digestStore != nil { _ = s.digestStore.ApplyDelta(ctx, hero.ID, storage.OfflineDigestDelta{Revives: 1}) } } @@ -175,11 +189,16 @@ 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, FormatEncounterLogLine(enemy.Name)) + s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogEncounteredEnemy, + Args: map[string]any{"enemyType": enemy.Slug, "enemyName": enemy.Name}, + }, + }) rewardDeps := s.rewardDeps(tickNow) levelBefore := hm.Hero.Level survived, en, xpGained, goldGained, drops := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps) - if s.digestStore != nil && offlineDigestCollecting(hm.Hero.WsDisconnectedAt, tickNow) { + if s.digestStore != nil { if survived { levelGain := hm.Hero.Level - levelBefore _ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{ @@ -187,17 +206,30 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her XPGained: xpGained, GoldGained: goldGained, LevelsGained: levelGain, - LootAppend: drops, + LootAppend: nonGoldLootForDigest(drops), }) } else { _ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{Deaths: 1}) } } if survived { - s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained)) + s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogDefeatedEnemy, + Args: map[string]any{ + "enemyType": en.Slug, "enemyName": en.Name, + "xp": xpGained, "gold": goldGained, + }, + }, + }) hm.ResumeWalking(tickNow) } else { - s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Died fighting %s", en.Name)) + s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogDiedFighting, + Args: map[string]any{"enemyType": en.Slug, "enemyName": en.Name}, + }, + }) hm.Die() } } @@ -217,10 +249,12 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her onMerchant := func(hm *HeroMovement, tickNow time.Time, cost int64) { _ = tickNow _ = cost - s.addLog(ctx, hm.Hero.ID, "Encountered a Wandering Merchant on the road") + s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{Code: model.LogWanderingMerchant}, + }) } - adventureLog := func(heroID int64, msg string) { - s.addLog(ctx, heroID, msg) + adventureLog := func(heroID int64, line model.AdventureLogLine) { + s.addLog(ctx, heroID, line) } ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineNPC) if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { @@ -260,10 +294,10 @@ func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps { QuestProgressor: s.questStore, AchievementCheck: s.achStore, TaskProgressor: s.taskStore, - LogWriter: func(heroID int64, msg string) { + LogWriter: func(heroID int64, line model.AdventureLogLine) { logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if err := s.logStore.Add(logCtx, heroID, msg); err != nil && s.logger != nil { + if err := s.logStore.Add(logCtx, heroID, line); err != nil && s.logger != nil { s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err) } }, @@ -304,14 +338,24 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID } soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil) if soldItems > 0 && al != nil { - al(heroID, fmt.Sprintf("Sold %d item(s) to %s for %d gold.", soldItems, npc.Name, soldGold)) + al(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogSoldItemsMerchant, + Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold}, + }, + }) } potionCost, _ := tuning.EffectiveNPCShopCosts() if potionCost > 0 && h.Gold >= potionCost && rand.Float64() < 0.55 { h.Gold -= potionCost h.Potions++ if al != nil { - al(heroID, fmt.Sprintf("Purchased a Healing Potion from %s.", npc.Name)) + al(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogPurchasedPotionFromNPC, + Args: map[string]any{"npcKey": npc.NameKey}, + }, + }) } } case "healer": @@ -320,7 +364,12 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID h.Gold -= healCost h.HP = h.MaxHP if al != nil { - al(heroID, fmt.Sprintf("Paid %s to restore full health.", npc.Name)) + al(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogPaidHealerFull, + Args: map[string]any{"npcKey": npc.NameKey}, + }, + }) } } case "quest_giver": @@ -349,7 +398,12 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID } if len(candidates) == 0 { if al != nil { - al(heroID, fmt.Sprintf("Checked in with %s — nothing new.", npc.Name)) + al(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogQuestGiverChecked, + Args: map[string]any{"npcKey": npc.NameKey}, + }, + }) } return true } @@ -360,7 +414,16 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID return true } if ok && al != nil { - al(heroID, fmt.Sprintf("Accepted quest: %s", pick.Title)) + qk := pick.QuestKey + if qk == "" { + qk = fmt.Sprintf("quest.%d", pick.ID) + } + al(heroID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogQuestAccepted, + Args: map[string]any{"questKey": qk, "title": pick.Title}, + }, + }) } default: // Other NPC types: treat as a social stop only. @@ -369,10 +432,10 @@ func (s *OfflineSimulator) applyOfflineTownNPCVisit(ctx context.Context, heroID } // addLog is a fire-and-forget helper that writes an adventure log entry. -func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message string) { +func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, line model.AdventureLogLine) { logCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - if err := s.logStore.Add(logCtx, heroID, message); err != nil { + if err := s.logStore.Add(logCtx, heroID, line); err != nil { s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err, diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index 5f5db3d..1991328 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -116,6 +116,24 @@ func TestOfflineAutoPotionHook_DoesNotTriggerWhenHealthy(t *testing.T) { } } +func TestNonGoldLootForDigest(t *testing.T) { + drops := []model.LootDrop{ + {ItemType: "gold", Rarity: model.RarityCommon, GoldAmount: 10}, + {ItemType: "potion", Rarity: model.RarityCommon}, + {ItemType: "gold", Rarity: model.RarityCommon, GoldAmount: 5}, + } + out := nonGoldLootForDigest(drops) + if len(out) != 1 || out[0].ItemType != "potion" { + t.Fatalf("want single potion line, got %#v", out) + } + if nonGoldLootForDigest(nil) != nil { + t.Fatal("nil in -> nil out") + } + if nonGoldLootForDigest([]model.LootDrop{{ItemType: "gold", GoldAmount: 1}}) != nil { + t.Fatal("gold-only -> nil") + } +} + func TestPickEnemyForLevel(t *testing.T) { tests := []struct { level int diff --git a/backend/internal/game/rewards.go b/backend/internal/game/rewards.go index 8abb232..7801838 100644 --- a/backend/internal/game/rewards.go +++ b/backend/internal/game/rewards.go @@ -3,7 +3,6 @@ package game import ( "context" "errors" - "fmt" "log/slog" "time" @@ -38,7 +37,7 @@ type VictoryRewardDeps struct { QuestProgressor QuestProgressor AchievementCheck AchievementChecker TaskProgressor TaskProgressor - LogWriter func(heroID int64, msg string) + LogWriter func(heroID int64, line model.AdventureLogLine) LootRecorder func(entry model.LootHistory) InTown func(ctx context.Context, posX, posY float64) bool Logger *slog.Logger @@ -138,7 +137,12 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de } } if deps.LogWriter != nil { - deps.LogWriter(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name)) + deps.LogWriter(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogEquippedNew, + Args: map[string]any{"slot": string(slot), "itemName": item.Name}, + }, + }) } if prev != nil && prev.ID != item.ID { hero.EnsureInventorySlice() @@ -160,7 +164,12 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de drop.ItemName = "" drop.GoldAmount = 0 if deps.LogWriter != nil { - deps.LogWriter(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity)) + deps.LogWriter(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogInventoryFullDropped, + Args: map[string]any{"itemName": item.Name, "rarity": string(item.Rarity)}, + }, + }) } } else { if deps.GearStore != nil { @@ -212,9 +221,22 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de } if deps.LogWriter != nil { - deps.LogWriter(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, goldGained)) + deps.LogWriter(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogDefeatedEnemy, + Args: map[string]any{ + "enemyType": enemy.Slug, "enemyName": enemy.Name, + "xp": enemy.XPReward, "gold": goldGained, + }, + }, + }) for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ { - deps.LogWriter(hero.ID, fmt.Sprintf("Leveled up to %d!", l)) + deps.LogWriter(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogLeveledUp, + Args: map[string]any{"level": l}, + }, + }) } } @@ -256,7 +278,17 @@ func ApplyVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time, de case "potion": hero.Potions += a.RewardAmount } - deps.LogWriter(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType)) + deps.LogWriter(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogAchievementUnlocked, + Args: map[string]any{ + "achievementId": a.ID, + "title": a.Title, + "rewardAmount": a.RewardAmount, + "rewardType": a.RewardType, + }, + }, + }) } } } diff --git a/backend/internal/game/road_graph.go b/backend/internal/game/road_graph.go index 35b0823..de63055 100644 --- a/backend/internal/game/road_graph.go +++ b/backend/internal/game/road_graph.go @@ -30,6 +30,7 @@ type Road struct { type TownNPC struct { ID int64 Name string + NameKey string Type string BuildingID *int64 OffsetX float64 @@ -73,7 +74,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) } // Load towns. - rows, err := pool.Query(ctx, `SELECT id, name, biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC`) + rows, err := pool.Query(ctx, `SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC`) if err != nil { return nil, fmt.Errorf("load towns: %w", err) } @@ -81,7 +82,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) for rows.Next() { var t model.Town - if err := rows.Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { return nil, fmt.Errorf("scan town: %w", err) } g.Towns[t.ID] = &t @@ -91,7 +92,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) return nil, fmt.Errorf("iterate towns: %w", err) } - npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, type, building_id, COALESCE(offset_x,0), COALESCE(offset_y,0) FROM npcs ORDER BY town_id, id`) + npcRows, err := pool.Query(ctx, `SELECT id, town_id, name, COALESCE(name_key, ''), type, building_id, COALESCE(offset_x,0), COALESCE(offset_y,0) FROM npcs ORDER BY town_id, id`) if err != nil { return nil, fmt.Errorf("load npcs: %w", err) } @@ -99,7 +100,7 @@ func LoadRoadGraph(ctx context.Context, pool *pgxpool.Pool) (*RoadGraph, error) for npcRows.Next() { var n TownNPC var townID int64 - if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil { + if err := npcRows.Scan(&n.ID, &townID, &n.Name, &n.NameKey, &n.Type, &n.BuildingID, &n.OffsetX, &n.OffsetY); err != nil { return nil, fmt.Errorf("scan npc: %w", err) } g.NPCByID[n.ID] = n @@ -191,6 +192,7 @@ func (g *RoadGraph) TownNPCInfos(townID int64) []model.TownNPCInfo { info := model.TownNPCInfo{ ID: n.ID, Name: n.Name, + NameKey: n.NameKey, Type: n.Type, BuildingID: n.BuildingID, } diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 47f6ec1..530c20d 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "log/slog" "math/rand" "net/http" @@ -77,19 +76,19 @@ func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *sto return h } -// addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. -func (h *GameHandler) addLog(heroID int64, message string) { +// addLogLine is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. +func (h *GameHandler) addLogLine(heroID int64, line model.AdventureLogLine) { if h.logStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if err := h.logStore.Add(ctx, heroID, message); err != nil { + if err := h.logStore.Add(ctx, heroID, line); err != nil { h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) return } if h.hub != nil { - h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message}) + h.hub.SendToHero(heroID, "adventure_log_line", line) } } @@ -114,7 +113,7 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy QuestProgressor: h.questStore, AchievementCheck: h.achievementStore, TaskProgressor: h.taskStore, - LogWriter: h.addLog, + LogWriter: h.addLogLine, InTown: func(ctx context.Context, posX, posY float64) bool { return h.isHeroInTown(ctx, posX, posY) }, @@ -269,7 +268,12 @@ func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) { "buff", bt, "expires_at", ab.ExpiresAt, ) - h.addLog(hero.ID, fmt.Sprintf("Activated %s", ab.Buff.Name)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogBuffActivated, + Args: map[string]any{"buffType": string(bt)}, + }, + }) // Daily/weekly task progress: use_buff. if h.taskStore != nil { @@ -353,7 +357,7 @@ func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) { } h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP) - h.addLog(hero.ID, "Hero revived") + h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogHeroRevived}}) hero.RefreshDerivedCombatStats(now) writeHeroJSON(w, http.StatusOK, hero) @@ -428,15 +432,16 @@ 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.addLog(hero.ID, "Encountered a Wandering Merchant on the road") + h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogWanderingMerchant}}) h.encounterMu.Lock() h.lastCombatEncounterAt[hero.ID] = now h.encounterMu.Unlock() writeJSON(w, http.StatusOK, model.NPCEventResponse{ Type: "npc_event", NPC: model.NPCEventNPC{ - Name: "Wandering Merchant", - Role: "alms", + Name: "Wandering Merchant", + NameKey: model.WanderingMerchantNPCKey, + Role: "alms", }, Cost: cost, Reward: "random_equipment", @@ -448,7 +453,12 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) { h.encounterMu.Lock() h.lastCombatEncounterAt[hero.ID] = now h.encounterMu.Unlock() - h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogEncounteredEnemy, + Args: map[string]any{"enemyType": enemy.Slug, "enemyName": enemy.Name}, + }, + }) writeJSON(w, http.StatusOK, encounterEnemyResponse{ ID: time.Now().UnixNano(), Name: enemy.Name, @@ -865,7 +875,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { } hero.State = model.StateWalking hero.Debuffs = nil - h.addLog(hero.ID, "Auto-revived after 1 hour") + h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogAutoReviveHours}}) 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) } @@ -957,9 +967,10 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP result := make([]model.TownWithNPCs, 0, len(towns)) for _, t := range towns { tw := model.TownWithNPCs{ - ID: t.ID, - Name: t.Name, - Biome: t.Biome, + ID: t.ID, + Name: t.Name, + NameKey: t.NameKey, + Biome: t.Biome, WorldX: t.WorldX, WorldY: t.WorldY, Radius: t.Radius, @@ -968,11 +979,12 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP } for _, n := range npcsByTown[t.ID] { tw.NPCs = append(tw.NPCs, model.NPCView{ - ID: n.ID, - Name: n.Name, - Type: n.Type, - WorldX: t.WorldX + n.OffsetX, - WorldY: t.WorldY + n.OffsetY, + ID: n.ID, + Name: n.Name, + NameKey: n.NameKey, + Type: n.Type, + WorldX: t.WorldX + n.OffsetX, + WorldY: t.WorldY + n.OffsetY, }) } result = append(result, tw) @@ -1209,7 +1221,9 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request) "buff_type", bt, "price_rub", priceRUB, ) - h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{Code: model.LogPurchasedBuffRefill, Args: map[string]any{"buffType": string(bt)}}, + }) hero.RefreshDerivedCombatStats(now) writeHeroJSON(w, http.StatusOK, hero) @@ -1271,7 +1285,15 @@ func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Reques } h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt) - h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice())) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogSubscribed, + Args: map[string]any{ + "durationKey": "subscription.week", + "priceRub": model.SubscriptionWeeklyPrice(), + }, + }, + }) hero.RefreshDerivedCombatStats(now) model.AttachDebuffCatalogForClient(hero) @@ -1373,7 +1395,9 @@ func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) { return } - h.addLog(hero.ID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{Code: model.LogUsedHealingPotion, Args: map[string]any{"amount": healAmount}}, + }) now := time.Now() hero.RefreshDerivedCombatStats(now) @@ -1565,7 +1589,17 @@ func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) { case "potion": hero.Potions += a.RewardAmount } - h.addLog(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogAchievementUnlocked, + 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 6958578..b215f3f 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -56,19 +56,19 @@ func (h *NPCHandler) sendMerchantLootWS(heroID int64, cost int64, drop *model.Lo }) } -// addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. -func (h *NPCHandler) addLog(heroID int64, message string) { +// addLogLine is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS. +func (h *NPCHandler) addLogLine(heroID int64, line model.AdventureLogLine) { if h.logStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if err := h.logStore.Add(ctx, heroID, message); err != nil { + if err := h.logStore.Add(ctx, heroID, line); err != nil { h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) return } if h.hub != nil { - h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message}) + h.hub.SendToHero(heroID, "adventure_log_line", line) } } @@ -185,9 +185,14 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { return } for _, q := range quests { + qk := q.QuestKey + if qk == "" { + qk = fmt.Sprintf("quest.%d", q.ID) + } actions = append(actions, model.NPCInteractAction{ ActionType: "quest", QuestID: q.ID, + QuestKey: qk, QuestTitle: q.Title, Description: q.Description, }) @@ -197,6 +202,7 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { potionCost, _ := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "shop_item", + ItemKey: "shop.healing_potion", ItemName: "Healing Potion", ItemCost: potionCost, Description: "Restores health. Always handy in a pinch.", @@ -206,20 +212,28 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) { _, healCost := tuning.EffectiveNPCShopCosts() actions = append(actions, model.NPCInteractAction{ ActionType: "heal", - ItemName: "Full Heal", - ItemCost: healCost, + ItemKey: "shop.full_heal", + ItemName: "Full Heal", + ItemCost: healCost, Description: "Restore hero to full HP.", }) } // Log the meeting. - h.addLog(hero.ID, fmt.Sprintf("Met %s in %s", npc.Name, town.Name)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogMetNPC, + Args: map[string]any{"npcKey": npc.NameKey, "townKey": town.NameKey}, + }, + }) resp := model.NPCInteractResponse{ - NPCName: npc.Name, - NPCType: npc.Type, - TownName: town.Name, - Actions: actions, + NPCName: npc.Name, + NPCNameKey: npc.NameKey, + NPCType: npc.Type, + TownName: town.Name, + TownNameKey: town.NameKey, + Actions: actions, } if resp.Actions == nil { resp.Actions = []model.NPCInteractAction{} @@ -293,6 +307,7 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) { result = append(result, model.NearbyNPCEntry{ ID: npc.ID, Name: npc.Name, + NameKey: npc.NameKey, Type: npc.Type, WorldX: npcWorldX, WorldY: npcWorldY, @@ -377,7 +392,12 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no hero.EnsureInventorySlice() hero.Inventory = append(hero.Inventory, prev) } - h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, equipped %s", item.Name)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogWanderingAlmsEquipped, + Args: map[string]any{"itemName": item.Name}, + }, + }) } } if !equipped { @@ -390,7 +410,12 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no } } cancelDel() - h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s) (wandering merchant)", item.Name, item.Rarity)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogWanderingAlmsDropped, + Args: map[string]any{"itemName": item.Name, "rarity": string(item.Rarity)}, + }, + }) } else { ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second) err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID) @@ -402,7 +427,12 @@ func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, no cancelDel() } else { hero.Inventory = append(hero.Inventory, item) - h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant; stashed %s", item.Name)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogWanderingAlmsStashed, + Args: map[string]any{"itemName": item.Name}, + }, + }) } } } @@ -619,7 +649,7 @@ func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) { return } - h.addLog(hero.ID, "Healed to full HP by a town healer") + h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogHealedFullTown}}) // Flat hero JSON — matches other /hero/* mutating endpoints (use-potion, quest claim) for the TS client. writeHeroJSON(w, http.StatusOK, hero) } @@ -669,6 +699,6 @@ func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) { return } - h.addLog(hero.ID, "Purchased a Healing Potion from a merchant") + h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogBoughtPotionTown}}) writeHeroJSON(w, http.StatusOK, hero) } diff --git a/backend/internal/handler/payments.go b/backend/internal/handler/payments.go index a7daad5..b28f644 100644 --- a/backend/internal/handler/payments.go +++ b/backend/internal/handler/payments.go @@ -276,7 +276,12 @@ func (h *PaymentsHandler) applySubscription(ctx context.Context, hero *model.Her return } - h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPrice())) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogSubscribed, + Args: map[string]any{"durationKey": "subscription.week", "priceRub": model.SubscriptionWeeklyPrice()}, + }, + }) h.logger.Info("subscription activated via Telegram Payment", "hero_id", hero.ID, "telegram_charge_id", sp.TelegramPaymentChargeID, @@ -325,7 +330,12 @@ func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero, return } - h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s (%d₽)", bt, priceRUB)) + h.addLogLine(hero.ID, model.AdventureLogLine{ + Event: &model.AdventureLogEvent{ + Code: model.LogPurchasedBuffRefillRub, + Args: map[string]any{"buffType": string(bt), "priceRub": priceRUB}, + }, + }) h.logger.Info("buff refill via Telegram Payment", "hero_id", hero.ID, "buff_type", bt, @@ -334,14 +344,14 @@ func (h *PaymentsHandler) applyBuffRefill(ctx context.Context, hero *model.Hero, ) } -// addLog writes an adventure log entry for the hero. -func (h *PaymentsHandler) addLog(heroID int64, message string) { +// addLogLine writes an adventure log entry for the hero (no WS mirror from payments webhook). +func (h *PaymentsHandler) addLogLine(heroID int64, line model.AdventureLogLine) { if h.logStore == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if err := h.logStore.Add(ctx, heroID, message); err != nil { + if err := h.logStore.Add(ctx, heroID, line); err != nil { h.logger.Warn("payments: failed to write adventure log", "hero_id", heroID, "error", err) } } diff --git a/backend/internal/model/adventure_log_english_fallback.go b/backend/internal/model/adventure_log_english_fallback.go new file mode 100644 index 0000000..ba9589b --- /dev/null +++ b/backend/internal/model/adventure_log_english_fallback.go @@ -0,0 +1,285 @@ +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 new file mode 100644 index 0000000..0337673 --- /dev/null +++ b/backend/internal/model/adventure_log_english_fallback_test.go @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..2141939 --- /dev/null +++ b/backend/internal/model/adventure_log_event.go @@ -0,0 +1,61 @@ +package model + +// AdventureLogEvent is a machine-readable log line; the client maps Code + Args to localized text. +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). +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. +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 new file mode 100644 index 0000000..fb386d5 --- /dev/null +++ b/backend/internal/model/adventure_log_json_test.go @@ -0,0 +1,44 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestAdventureLogLine_JSON_roundTrip(t *testing.T) { + line := AdventureLogLine{ + Message: "legacy", + Event: &AdventureLogEvent{ + Code: LogDefeatedEnemy, + Args: map[string]any{"enemyType": "wolf_l1_1_meadow", "xp": float64(10), "gold": float64(5)}, + }, + } + b, err := json.Marshal(line) + if err != nil { + t.Fatal(err) + } + var got AdventureLogLine + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.Message != line.Message { + t.Fatalf("message: got %q want %q", got.Message, line.Message) + } + if got.Event == nil || got.Event.Code != LogDefeatedEnemy { + t.Fatalf("event code: %+v", got.Event) + } + if got.Event.Args["enemyType"] != "wolf_l1_1_meadow" { + t.Fatalf("args: %+v", got.Event.Args) + } +} + +func TestAdventureLogLine_JSON_legacyMessageOnly(t *testing.T) { + raw := `{"message":"hello"}` + var got AdventureLogLine + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatal(err) + } + if got.Message != "hello" || got.Event != nil { + t.Fatalf("got %+v", got) + } +} diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 3c52097..4039488 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -71,7 +71,7 @@ type Hero struct { TownPause *TownPausePersisted `json:"-"` LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"` - // WsDisconnectedAt is when the last WebSocket session ended (DB only; used for offline digest grace). + // WsDisconnectedAt is when the last WebSocket session ended (DB only; optional telemetry). WsDisconnectedAt *time.Time `json:"-"` // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only). ChangelogAckVersion string `json:"-"` diff --git a/backend/internal/model/quest.go b/backend/internal/model/quest.go index 1f21e8e..b4814ab 100644 --- a/backend/internal/model/quest.go +++ b/backend/internal/model/quest.go @@ -6,6 +6,7 @@ import "time" type Town struct { ID int64 `json:"id"` Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` Biome string `json:"biome"` WorldX float64 `json:"worldX"` WorldY float64 `json:"worldY"` @@ -19,6 +20,7 @@ type NPC struct { ID int64 `json:"id"` TownID int64 `json:"townId"` Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` Type string `json:"type"` // quest_giver, merchant, healer OffsetX float64 `json:"offsetX"` OffsetY float64 `json:"offsetY"` @@ -29,6 +31,7 @@ type NPC struct { type Quest struct { ID int64 `json:"id"` NPCID int64 `json:"npcId"` + QuestKey string `json:"questKey,omitempty"` Title string `json:"title"` Description string `json:"description"` Type string `json:"type"` // kill_count, visit_town, collect_item @@ -69,6 +72,7 @@ type QuestReward struct { type TownWithNPCs struct { ID int64 `json:"id"` Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` Biome string `json:"biome"` WorldX float64 `json:"worldX"` WorldY float64 `json:"worldY"` @@ -82,6 +86,7 @@ type TownWithNPCs struct { type NPCView struct { ID int64 `json:"id"` Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` Type string `json:"type"` WorldX float64 `json:"worldX"` WorldY float64 `json:"worldY"` @@ -103,25 +108,31 @@ func TownSizeFromRadius(radius float64) string { // NPCInteractAction represents a single action the player can take with an NPC. type NPCInteractAction struct { ActionType string `json:"actionType"` // "quest", "shop_item", "heal" - QuestID int64 `json:"questId,omitempty"` // for quest_giver - QuestTitle string `json:"questTitle,omitempty"` // for quest_giver - ItemName string `json:"itemName,omitempty"` // for merchant - ItemCost int64 `json:"itemCost,omitempty"` // for merchant / healer + QuestID int64 `json:"questId,omitempty"` // for quest_giver + QuestKey string `json:"questKey,omitempty"` + QuestTitle string `json:"questTitle,omitempty"` // for quest_giver (fallback EN) + ItemName string `json:"itemName,omitempty"` // for merchant + ItemCost int64 `json:"itemCost,omitempty"` // for merchant / healer Description string `json:"description,omitempty"` + // ItemKey identifies the shop/heal offer for client localization (e.g. shop.healing_potion). + ItemKey string `json:"itemKey,omitempty"` } // NPCInteractResponse is the response for POST /api/v1/hero/npc-interact. type NPCInteractResponse struct { - NPCName string `json:"npcName"` - NPCType string `json:"npcType"` - TownName string `json:"townName"` - Actions []NPCInteractAction `json:"actions"` + NPCName string `json:"npcName"` + NPCNameKey string `json:"npcNameKey,omitempty"` + NPCType string `json:"npcType"` + TownName string `json:"townName"` + TownNameKey string `json:"townNameKey,omitempty"` + Actions []NPCInteractAction `json:"actions"` } // NearbyNPCEntry is returned by the nearby-npcs endpoint. type NearbyNPCEntry struct { ID int64 `json:"id"` Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` Type string `json:"type"` WorldX float64 `json:"worldX"` WorldY float64 `json:"worldY"` @@ -138,8 +149,9 @@ type NPCEventResponse struct { // NPCEventNPC describes the wandering NPC in a random event. type NPCEventNPC struct { - Name string `json:"name"` - Role string `json:"role"` + Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` + Role string `json:"role"` } // AlmsResponse is the response for POST /api/v1/hero/npc-alms. diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index ad9e4c6..04a990e 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -132,6 +132,7 @@ type HeroRevivedPayload struct { type TownNPCInfo struct { ID int64 `json:"id"` Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` Type string `json:"type"` BuildingID *int64 `json:"buildingId,omitempty"` WorldX float64 `json:"worldX"` @@ -153,6 +154,7 @@ type TownBuildingInfo struct { type TownEnterPayload struct { TownID int64 `json:"townId"` TownName string `json:"townName"` + TownNameKey string `json:"townNameKey,omitempty"` Biome string `json:"biome"` NPCs []TownNPCInfo `json:"npcs"` Buildings []TownBuildingInfo `json:"buildings"` @@ -162,18 +164,18 @@ type TownEnterPayload struct { // TownNPCVisitPayload is sent when the hero finishes walking to an NPC visit (quest/shop/healer). // WorldX/WorldY are the hero's stand position (near the NPC), not the NPC tile center. type TownNPCVisitPayload struct { - NPCID int64 `json:"npcId"` - Name string `json:"name"` - Type string `json:"type"` - TownID int64 `json:"townId"` - WorldX float64 `json:"worldX"` - WorldY float64 `json:"worldY"` + NPCID int64 `json:"npcId"` + Name string `json:"name"` + NameKey string `json:"nameKey,omitempty"` + Type string `json:"type"` + TownID int64 `json:"townId"` + TownNameKey string `json:"townNameKey,omitempty"` + WorldX float64 `json:"worldX"` + WorldY float64 `json:"worldY"` } // AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log. -type AdventureLogLinePayload struct { - Message string `json:"message"` -} +type AdventureLogLinePayload = AdventureLogLine // TownExitPayload is sent when the hero leaves a town. type TownExitPayload struct{} @@ -189,11 +191,12 @@ type MerchantLootPayload struct { // NPCEncounterPayload is sent when the hero meets a wandering NPC on the road (e.g. merchant). type NPCEncounterPayload struct { - NPCID int64 `json:"npcId"` - NPCName string `json:"npcName"` - Role string `json:"role"` - Dialogue string `json:"dialogue,omitempty"` - Cost int64 `json:"cost"` + NPCID int64 `json:"npcId"` + NPCName string `json:"npcName"` + NPCNameKey string `json:"npcNameKey,omitempty"` + Role string `json:"role"` + DialogueKey string `json:"dialogueKey,omitempty"` + Cost int64 `json:"cost"` } // NPCEncounterEndPayload is sent when the wandering merchant prompt ends (e.g. timeout). diff --git a/backend/internal/storage/log_store.go b/backend/internal/storage/log_store.go index 9e91bac..f413796 100644 --- a/backend/internal/storage/log_store.go +++ b/backend/internal/storage/log_store.go @@ -2,18 +2,23 @@ package storage import ( "context" + "encoding/json" "fmt" + "strings" "time" "github.com/jackc/pgx/v5/pgxpool" + + "github.com/denisovdennis/autohero/internal/model" ) -// LogEntry represents a single adventure log message. +// LogEntry represents a single adventure log row for API/JSON. type LogEntry struct { - ID int64 `json:"id"` - HeroID int64 `json:"heroId"` - Message string `json:"message"` - CreatedAt time.Time `json:"createdAt"` + ID int64 `json:"id"` + HeroID int64 `json:"heroId"` + Message string `json:"message"` + CreatedAt time.Time `json:"createdAt"` + Event *model.AdventureLogEvent `json:"event,omitempty"` } // LogStore handles adventure log CRUD operations against PostgreSQL. @@ -27,10 +32,27 @@ func NewLogStore(pool *pgxpool.Pool) *LogStore { } // Add inserts a new adventure log entry for the given hero. -func (s *LogStore) Add(ctx context.Context, heroID int64, message string) error { - _, err := s.pool.Exec(ctx, - `INSERT INTO adventure_log (hero_id, message) VALUES ($1, $2)`, - heroID, message, +func (s *LogStore) Add(ctx context.Context, heroID int64, line model.AdventureLogLine) error { + var code *string + var argsJSON []byte + var err error + if line.Event != nil { + c := line.Event.Code + code = &c + if line.Event.Args != nil { + argsJSON, err = json.Marshal(line.Event.Args) + if err != nil { + return fmt.Errorf("marshal event args: %w", err) + } + } + } + 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, ) if err != nil { return fmt.Errorf("add log entry: %w", err) @@ -38,6 +60,20 @@ func (s *LogStore) Add(ctx context.Context, heroID int64, message string) error return nil } +func logEntryFromScan(id int64, heroID int64, message string, createdAt time.Time, code *string, argsBytes []byte) LogEntry { + e := LogEntry{ID: id, HeroID: heroID, Message: message, CreatedAt: createdAt} + if code != nil && *code != "" { + ev := model.AdventureLogEvent{Code: *code} + if len(argsBytes) > 0 { + if err := json.Unmarshal(argsBytes, &ev.Args); err != nil { + ev.Args = map[string]any{"_raw": string(argsBytes)} + } + } + e.Event = &ev + } + return e +} + // GetSince returns log entries for a hero created after the given timestamp, // ordered oldest-first (chronological). Used to build offline reports from // real adventure log entries written by the offline simulator. @@ -50,7 +86,7 @@ func (s *LogStore) GetSince(ctx context.Context, heroID int64, since time.Time, } rows, err := s.pool.Query(ctx, ` - SELECT id, hero_id, message, created_at + SELECT id, hero_id, message, created_at, event_code, event_args FROM adventure_log WHERE hero_id = $1 AND created_at > $2 ORDER BY created_at ASC @@ -64,10 +100,12 @@ func (s *LogStore) GetSince(ctx context.Context, heroID int64, since time.Time, var entries []LogEntry for rows.Next() { var e LogEntry - if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt); err != nil { + var code *string + var argsBytes []byte + if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt, &code, &argsBytes); err != nil { return nil, fmt.Errorf("scan log entry: %w", err) } - entries = append(entries, e) + entries = append(entries, logEntryFromScan(e.ID, e.HeroID, e.Message, e.CreatedAt, code, argsBytes)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("log since rows: %w", err) @@ -88,7 +126,7 @@ func (s *LogStore) GetRecent(ctx context.Context, heroID int64, limit int) ([]Lo } rows, err := s.pool.Query(ctx, ` - SELECT id, hero_id, message, created_at + SELECT id, hero_id, message, created_at, event_code, event_args FROM adventure_log WHERE hero_id = $1 ORDER BY created_at DESC @@ -102,10 +140,12 @@ func (s *LogStore) GetRecent(ctx context.Context, heroID int64, limit int) ([]Lo var entries []LogEntry for rows.Next() { var e LogEntry - if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt); err != nil { + var code *string + var argsBytes []byte + if err := rows.Scan(&e.ID, &e.HeroID, &e.Message, &e.CreatedAt, &code, &argsBytes); err != nil { return nil, fmt.Errorf("scan log entry: %w", err) } - entries = append(entries, e) + entries = append(entries, logEntryFromScan(e.ID, e.HeroID, e.Message, e.CreatedAt, code, argsBytes)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("log entries rows: %w", err) diff --git a/backend/internal/storage/quest_store.go b/backend/internal/storage/quest_store.go index 1dd5db7..21eb73c 100644 --- a/backend/internal/storage/quest_store.go +++ b/backend/internal/storage/quest_store.go @@ -26,7 +26,7 @@ func NewQuestStore(pool *pgxpool.Pool) *QuestStore { // ListTowns returns all towns ordered by level_min ascending. func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, name, biome, world_x, world_y, radius, level_min, level_max + SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max FROM towns ORDER BY level_min ASC `) @@ -38,7 +38,7 @@ func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) { var towns []model.Town for rows.Next() { var t model.Town - if err := rows.Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax); err != nil { return nil, fmt.Errorf("scan town: %w", err) } towns = append(towns, t) @@ -56,9 +56,9 @@ func (s *QuestStore) ListTowns(ctx context.Context) ([]model.Town, error) { func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, error) { var t model.Town err := s.pool.QueryRow(ctx, ` - SELECT id, name, biome, world_x, world_y, radius, level_min, level_max + SELECT id, name, COALESCE(name_key, ''), biome, world_x, world_y, radius, level_min, level_max FROM towns WHERE id = $1 - `, townID).Scan(&t.ID, &t.Name, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax) + `, townID).Scan(&t.ID, &t.Name, &t.NameKey, &t.Biome, &t.WorldX, &t.WorldY, &t.Radius, &t.LevelMin, &t.LevelMax) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -71,7 +71,7 @@ func (s *QuestStore) GetTown(ctx context.Context, townID int64) (*model.Town, er // ListNPCsByTown returns all NPCs in the given town. func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model.NPC, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, town_id, name, type, offset_x, offset_y, building_id + SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id FROM npcs WHERE town_id = $1 ORDER BY id ASC @@ -84,7 +84,7 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model. var npcs []model.NPC for rows.Next() { var n model.NPC - if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { + if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { return nil, fmt.Errorf("scan npc: %w", err) } npcs = append(npcs, n) @@ -102,9 +102,9 @@ func (s *QuestStore) ListNPCsByTown(ctx context.Context, townID int64) ([]model. func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, error) { var n model.NPC err := s.pool.QueryRow(ctx, ` - SELECT id, town_id, name, type, offset_x, offset_y, building_id + SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id FROM npcs WHERE id = $1 - `, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID) + `, npcID).Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -117,7 +117,7 @@ func (s *QuestStore) GetNPCByID(ctx context.Context, npcID int64) (*model.NPC, e // ListAllNPCs returns every NPC across all towns. func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, town_id, name, type, offset_x, offset_y, building_id + SELECT id, town_id, name, COALESCE(name_key, ''), type, offset_x, offset_y, building_id FROM npcs ORDER BY town_id ASC, id ASC `) @@ -129,7 +129,7 @@ func (s *QuestStore) ListAllNPCs(ctx context.Context) ([]model.NPC, error) { var npcs []model.NPC for rows.Next() { var n model.NPC - if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { + if err := rows.Scan(&n.ID, &n.TownID, &n.Name, &n.NameKey, &n.Type, &n.OffsetX, &n.OffsetY, &n.BuildingID); err != nil { return nil, fmt.Errorf("scan npc: %w", err) } npcs = append(npcs, n) @@ -205,7 +205,7 @@ func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding // ListQuestsByNPCForHeroLevel returns quests offered by an NPC that match the hero level range. func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int64, heroLevel int) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, npc_id, title, description, type, target_count, + SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions FROM quests @@ -221,7 +221,7 @@ func (s *QuestStore) ListQuestsByNPCForHeroLevel(ctx context.Context, npcID int6 for rows.Next() { var q model.Quest if err := rows.Scan( - &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, + &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { @@ -282,7 +282,7 @@ func (s *QuestStore) ListOfferableQuestsForNPC(ctx context.Context, heroID, npcI // ListQuestsByNPC returns all quest templates offered by the given NPC. func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, npc_id, title, description, type, target_count, + SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions FROM quests @@ -298,7 +298,7 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model. for rows.Next() { var q model.Quest if err := rows.Scan( - &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, + &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { @@ -318,7 +318,7 @@ func (s *QuestStore) ListQuestsByNPC(ctx context.Context, npcID int64) ([]model. // ListAllQuestTemplates returns every quest template row (content catalog). func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, npc_id, title, description, type, target_count, + SELECT id, npc_id, COALESCE(quest_key, ''), title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions FROM quests @@ -333,7 +333,7 @@ func (s *QuestStore) ListAllQuestTemplates(ctx context.Context) ([]model.Quest, for rows.Next() { var q model.Quest if err := rows.Scan( - &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, + &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { @@ -357,12 +357,12 @@ func (s *QuestStore) UpdateQuestTemplate(ctx context.Context, q *model.Quest) er } cmd, err := s.pool.Exec(ctx, ` UPDATE quests SET - npc_id = $2, title = $3, description = $4, type = $5, target_count = $6, - target_enemy_type = $7, target_enemy_archetype = $8, target_town_id = $9, drop_chance = $10, - min_level = $11, max_level = $12, reward_xp = $13, reward_gold = $14, reward_potions = $15 + npc_id = $2, quest_key = NULLIF($3, ''), title = $4, description = $5, type = $6, target_count = $7, + target_enemy_type = $8, target_enemy_archetype = $9, target_town_id = $10, drop_chance = $11, + min_level = $12, max_level = $13, reward_xp = $14, reward_gold = $15, reward_potions = $16 WHERE id = $1 `, - q.ID, q.NPCID, q.Title, q.Description, q.Type, q.TargetCount, + q.ID, q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount, q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, ) @@ -384,19 +384,23 @@ func (s *QuestStore) CreateQuestTemplate(ctx context.Context, q *model.Quest) er return fmt.Errorf("npcId, title and type are required") } err := s.pool.QueryRow(ctx, ` - INSERT INTO quests (npc_id, title, description, type, target_count, + INSERT INTO quests (npc_id, quest_key, title, description, type, target_count, target_enemy_type, target_enemy_archetype, target_town_id, drop_chance, min_level, max_level, reward_xp, reward_gold, reward_potions) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + VALUES ($1, NULLIF($2, ''), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id `, - q.NPCID, q.Title, q.Description, q.Type, q.TargetCount, + q.NPCID, q.QuestKey, q.Title, q.Description, q.Type, q.TargetCount, q.TargetEnemyType, q.TargetEnemyArchetype, q.TargetTownID, q.DropChance, q.MinLevel, q.MaxLevel, q.RewardXP, q.RewardGold, q.RewardPotions, ).Scan(&q.ID) if err != nil { return fmt.Errorf("create quest: %w", err) } + if q.QuestKey == "" { + q.QuestKey = fmt.Sprintf("quest.%d", q.ID) + _, _ = s.pool.Exec(ctx, `UPDATE quests SET quest_key = $2 WHERE id = $1`, q.ID, q.QuestKey) + } return nil } @@ -431,7 +435,7 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model. rows, err := s.pool.Query(ctx, ` SELECT hq.id, hq.hero_id, hq.quest_id, hq.status, hq.progress, hq.accepted_at, hq.completed_at, hq.claimed_at, - q.id, q.npc_id, q.title, q.description, q.type, q.target_count, + q.id, q.npc_id, COALESCE(q.quest_key, ''), q.title, q.description, q.type, q.target_count, q.target_enemy_type, q.target_enemy_archetype, q.target_town_id, COALESCE(tt.name, '') AS target_town_name, q.drop_chance, @@ -454,7 +458,7 @@ func (s *QuestStore) ListHeroQuests(ctx context.Context, heroID int64) ([]model. if err := rows.Scan( &hq.ID, &hq.HeroID, &hq.QuestID, &hq.Status, &hq.Progress, &hq.AcceptedAt, &hq.CompletedAt, &hq.ClaimedAt, - &q.ID, &q.NPCID, &q.Title, &q.Description, &q.Type, &q.TargetCount, + &q.ID, &q.NPCID, &q.QuestKey, &q.Title, &q.Description, &q.Type, &q.TargetCount, &q.TargetEnemyType, &q.TargetEnemyArchetype, &q.TargetTownID, &q.TargetTownName, &q.DropChance, &q.MinLevel, &q.MaxLevel, &q.RewardXP, &q.RewardGold, &q.RewardPotions, ); err != nil { diff --git a/backend/migrations/000026_localization_keys_and_log_events.sql b/backend/migrations/000026_localization_keys_and_log_events.sql new file mode 100644 index 0000000..3b93777 --- /dev/null +++ b/backend/migrations/000026_localization_keys_and_log_events.sql @@ -0,0 +1,48 @@ +-- Adventure log structured events (client-localized). +ALTER TABLE adventure_log + ADD COLUMN IF NOT EXISTS event_code TEXT, + ADD COLUMN IF NOT EXISTS event_args JSONB; + +-- Stable keys for UI localization (towns / NPCs). +ALTER TABLE towns + ADD COLUMN IF NOT EXISTS name_key TEXT; + +UPDATE towns SET name_key = v.k +FROM (VALUES + (1, 'town.willowdale.v1'), + (2, 'town.thornwatch.v1'), + (3, 'town.ashengard.v1'), + (4, 'town.redcliff.v1'), + (5, 'town.boghollow.v1'), + (6, 'town.cinderkeep.v1'), + (7, 'town.starfall.v1'), + (8, 'town.mossharbor.v1'), + (9, 'town.emberwell.v1'), + (10, 'town.frostmark.v1'), + (11, 'town.duskwatch.v1') +) AS v(id, k) +WHERE towns.id = v.id AND towns.name_key IS NULL; + +ALTER TABLE npcs + ADD COLUMN IF NOT EXISTS name_key TEXT; + +UPDATE npcs SET name_key = v.k +FROM (VALUES + (1, 'npc.elder_maren.v1'), + (2, 'npc.peddler_finn.v1'), + (3, 'npc.sister_asha.v1'), + (4, 'npc.guard_halric.v1'), + (5, 'npc.trader_wynn.v1'), + (6, 'npc.scholar_orin.v1'), + (7, 'npc.bone_merchant.v1'), + (8, 'npc.priestess_liora.v1') +) AS v(id, k) +WHERE npcs.id = v.id AND npcs.name_key IS NULL; + +-- Quest localization keys (fallback: quest. set in app if null). +ALTER TABLE quests + ADD COLUMN IF NOT EXISTS quest_key TEXT; + +UPDATE quests SET quest_key = 'quest.' || id::text WHERE quest_key IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_quests_quest_key ON quests (quest_key); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b933c7a..a0c88c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,9 +50,14 @@ import type { HeroState, BuffChargeState } from './game/types'; import { useUiClock } from './hooks/useUiClock'; import { adventureEntriesFromServerLog, - appendAdventureLogMessage, + appendAdventureLogRawRow, + buildAdventureLogEntriesFromRaw, + type AdventureLogRawRow, } from './game/adventureLogMap'; -import { parseAdventureLogLine } from './game/adventureLogMarkers'; +import { parseAdventureLogLine, shouldSuppressThoughtBubblePayload } from './game/adventureLogMarkers'; +import { formatAdventureLogPayload, formatClientLogLine } from './game/adventureLogFormat'; +import { townLabel, npcLabel, dialogueText } from './i18n/contentLabels'; +import type { AdventureLogLinePayload } from './game/types'; import { HUD } from './ui/HUD'; import { DeathScreen } from './ui/DeathScreen'; import { CombatOverlay, type CombatOverlayEvent } from './ui/CombatOverlay'; @@ -216,10 +221,16 @@ function mapEquipment( } /** Convert Town (from /towns API) to engine-facing TownData, optionally with NPCs and buildings */ -function townToTownData(town: Town, npcs?: NPC[], buildings?: BuildingData[]): TownData { +function townToTownData( + town: Town, + npcs?: NPC[], + buildings?: BuildingData[], + locale: Locale = 'en', +): TownData { const npcData: NPCData[] | undefined = npcs?.map((n) => ({ id: n.id, - name: n.name, + name: n.nameKey ? npcLabel(locale, n.nameKey, n.name) : n.name, + nameKey: n.nameKey, type: n.type, worldX: town.worldX + n.offsetX, worldY: town.worldY + n.offsetY, @@ -227,7 +238,8 @@ function townToTownData(town: Town, npcs?: NPC[], buildings?: BuildingData[]): T })); return { id: town.id, - name: town.name, + name: town.nameKey ? townLabel(locale, town.nameKey, town.name) : town.name, + nameKey: town.nameKey, centerX: town.worldX, centerY: town.worldY, radius: town.radius, @@ -339,6 +351,9 @@ export function App() { const pendingChangelogRef = useRef(null); const [changelogOpen, setChangelogOpen] = useState(null); const logIdCounter = useRef(0); + const logRawRef = useRef([]); + const i18nForLogRef = useRef({ locale, tr: translations }); + i18nForLogRef.current = { locale, tr: translations }; const nearbyIntervalRef = useRef | null>(null); // Quest system state @@ -365,13 +380,47 @@ export function App() { const sheetNowMs = useUiClock(100); - const appendLogLine = useCallback((rawMessage: string) => { - setLogEntries((prev) => - appendAdventureLogMessage(prev, rawMessage, () => { - logIdCounter.current += 1; - return logIdCounter.current; - }), + const appendLogPayload = useCallback((p: AdventureLogLinePayload) => { + const { locale: loc, tr: bundle } = i18nForLogRef.current; + logIdCounter.current += 1; + const id = logIdCounter.current; + logRawRef.current = appendAdventureLogRawRow( + logRawRef.current, + { id, message: p.message ?? '', timestamp: Date.now(), event: p.event }, + () => 0, ); + setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle)); + + const thoughtText = formatAdventureLogPayload(loc, bundle, p); + const eng = engineRef.current; + if (thoughtText && eng && !shouldSuppressThoughtBubblePayload(p)) { + eng.applyAdventureLogLine(thoughtText); + } + + if (p.event?.code === 'encountered_enemy' && thoughtText) { + setCombatLogLines([thoughtText]); + } else { + const parsed = parseAdventureLogLine(p.message ?? ''); + if (parsed.type === 'encounter') { + setCombatLogLines([parsed.title]); + } else if (parsed.type === 'battle') { + setCombatLogLines((prev) => [...prev, parsed.text].slice(-5)); + } else if (p.event?.code === 'combat_swing' && thoughtText) { + setCombatLogLines((prev) => [...prev, thoughtText].slice(-5)); + } + } + }, []); + + const appendLogClientMessage = useCallback((rawMessage: string) => { + const { locale: loc, tr: bundle } = i18nForLogRef.current; + logIdCounter.current += 1; + const id = logIdCounter.current; + logRawRef.current = appendAdventureLogRawRow( + logRawRef.current, + { id, message: rawMessage, timestamp: Date.now() }, + () => 0, + ); + setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle)); const parsed = parseAdventureLogLine(rawMessage); if (parsed.type === 'encounter') { setCombatLogLines([parsed.title]); @@ -380,6 +429,49 @@ export function App() { } }, []); + useEffect(() => { + const { locale: loc, tr: bundle } = i18nForLogRef.current; + setLogEntries(buildAdventureLogEntriesFromRaw([...logRawRef.current], loc, bundle)); + }, [locale, translations]); + + useEffect(() => { + const eng = engineRef.current; + if (!eng || towns.length === 0) return; + let cancelled = false; + (async () => { + const townNPCMap = new Map(); + const townBuildingMap = new Map(); + try { + const [npcResults, buildingResults] = await Promise.all([ + Promise.allSettled(towns.map((town) => getTownNPCs(town.id).then((npcs) => ({ townId: town.id, npcs })))), + Promise.allSettled(towns.map((town) => getTownBuildings(town.id).then((b) => ({ townId: town.id, buildings: b })))), + ]); + for (const result of npcResults) { + if (result.status === 'fulfilled') townNPCMap.set(result.value.townId, result.value.npcs); + } + for (const result of buildingResults) { + if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); + } + } catch { + /* ignore */ + } + if (cancelled) return; + const loc = locale; + const townDataList = towns.map((town) => + townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), + ); + eng.setTowns(townDataList); + const allNPCs: NPCData[] = []; + for (const td of townDataList) { + if (td.npcs) allNPCs.push(...td.npcs); + } + eng.setNPCs(allNPCs); + })(); + return () => { + cancelled = true; + }; + }, [locale, towns]); + const appendCombatEvent = useCallback((evt: CombatOverlayEvent) => { setCombatEvents((prev) => [...prev, evt]); }, []); @@ -567,7 +659,10 @@ export function App() { } catch { /* ignore */ } - const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id))); + const loc = i18nForLogRef.current.locale; + const townDataList = t.map((town) => + townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), + ); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { @@ -621,7 +716,10 @@ export function App() { } catch { console.warn('[App] Error fetching town NPCs/buildings'); } - const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id))); + const loc = i18nForLogRef.current.locale; + const townDataList = t.map((town) => + townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), + ); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { @@ -666,7 +764,9 @@ export function App() { // Fetch adventure log (same source as server DB; response shape { log: [...] }) try { const serverLog = await getAdventureLog(telegramId, 50); - const { entries, maxId } = adventureEntriesFromServerLog(serverLog); + const { locale: loc, tr: bundle } = i18nForLogRef.current; + const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle); + logRawRef.current = rawRows; logIdCounter.current = Math.max(logIdCounter.current, maxId); setLogEntries(entries); } catch { @@ -716,6 +816,7 @@ export function App() { }, onCombatEnd: (p) => { + const bundle = i18nForLogRef.current.tr; setCombatLogLines([]); clearCombatEvents(); const loot = buildLootFromCombatEnd(p); @@ -723,16 +824,16 @@ export function App() { hapticNotification('success'); const parts: string[] = []; - if (p.xpGained > 0) parts.push(`+${p.xpGained} XP`); - if (p.goldGained > 0) parts.push(`+${p.goldGained} gold`); + if (p.xpGained > 0) parts.push(t(bundle.toastGainedXp, { xp: p.xpGained })); + if (p.goldGained > 0) parts.push(t(bundle.toastGainedGold, { gold: p.goldGained })); const equipDrop = (p.loot ?? []).find( (l) => l.itemType !== 'gold' && l.itemType !== 'potion', ); - if (equipDrop?.name) parts.push(`found ${equipDrop.name}`); + if (equipDrop?.name) parts.push(t(bundle.toastFoundItem, { name: equipDrop.name })); // Victory line comes from server adventure log (Defeated …) + WS adventure_log_line if (p.leveledUp && p.newLevel) { - setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); + setToast({ message: t(bundle.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); } @@ -744,7 +845,7 @@ export function App() { const prevIds = new Set(prevAchievementsRef.current.filter((x) => x.unlocked).map((x) => x.id)); const newlyUnlocked = a.filter((x) => x.unlocked && !prevIds.has(x.id)); for (const ach of newlyUnlocked) { - setToast({ message: `Achievement unlocked: ${ach.title}!`, color: '#ffd700' }); + setToast({ message: t(bundle.achievementUnlockedToast, { title: ach.title }), color: '#ffd700' }); hapticNotification('success'); } prevAchievementsRef.current = a; @@ -760,7 +861,7 @@ export function App() { onHeroRevived: () => { setCombatLogLines([]); clearCombatEvents(); - setToast({ message: tr.heroRevived, color: '#44cc44' }); + setToast({ message: i18nForLogRef.current.tr.heroRevived, color: '#44cc44' }); // "Hero revived" comes from server log + WS }, @@ -769,10 +870,12 @@ export function App() { }, onTownEnter: (p) => { + const { locale: loc, tr: bundle } = i18nForLogRef.current; const town = townsRef.current.find((t) => t.id === p.townId) ?? null; setCurrentTown(town); - setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); - appendLogLine(`Entered ${p.townName}`); + const townDisp = townLabel(loc, p.townNameKey, p.townName); + setToast({ message: t(bundle.entering, { townName: townDisp }), color: '#daa520' }); + appendLogClientMessage(formatClientLogLine(bundle, 'enteredTown', { town: townDisp })); setNearestNPC(null); setNpcVisitAwaitingProximity(null); setSelectedNPC(null); @@ -780,15 +883,18 @@ export function App() { }, onAdventureLogLine: (p) => { - appendLogLine(p.message); + appendLogPayload(p); }, onTownNPCVisit: (p) => { + const loc = i18nForLogRef.current.locale; setNearestNPC(null); setNpcInteractionDismissed(null); + const displayName = p.nameKey ? npcLabel(loc, p.nameKey, p.name) : p.name; setNpcVisitAwaitingProximity({ id: p.npcId, - name: p.name, + name: displayName, + nameKey: p.nameKey, type: p.type as NPCData['type'], worldX: p.worldX ?? 0, worldY: p.worldY ?? 0, @@ -802,10 +908,17 @@ export function App() { }, onNPCEncounter: (p) => { + const loc = i18nForLogRef.current.locale; + const name = p.npcNameKey ? npcLabel(loc, p.npcNameKey, p.npcName) : p.npcName; + const msg = p.dialogueKey + ? dialogueText(loc, p.dialogueKey, p.dialogue ?? `${name} approaches!`) + : (p.dialogue ?? `${name} approaches!`); const npcEvent: NPCEncounterEvent = { type: 'npc_event', - npcName: p.npcName, - message: `${p.npcName} approaches!`, + npcName: name, + npcNameKey: p.npcNameKey, + dialogueKey: p.dialogueKey, + message: msg, cost: p.cost, }; setWanderingNPC(npcEvent); @@ -813,28 +926,31 @@ export function App() { onNPCEncounterEnd: (p) => { if (p.reason === 'timeout') { - appendLogLine('Wandering merchant moved on'); + appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'merchantMovedOn')); } setWanderingNPC(null); }, onLevelUp: (p) => { - setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); + setToast({ message: t(i18nForLogRef.current.tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); // Level-up lines come from server log + WS }, onEquipmentChange: (p) => { - setToast({ message: t(tr.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' }); + const bundle = i18nForLogRef.current.tr; + setToast({ message: t(bundle.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' }); // Equipment line comes from server log + WS refreshEquipment(); }, onPotionCollected: (p) => { - setToast({ message: t(tr.potionsCollected, { count: p.count }), color: '#44cc44' }); + const bundle = i18nForLogRef.current.tr; + setToast({ message: t(bundle.potionsCollected, { count: p.count }), color: '#44cc44' }); }, onQuestProgress: (p) => { + const bundle = i18nForLogRef.current.tr; setHeroQuests((prev) => prev.map((hq) => hq.questId === p.questId @@ -844,13 +960,14 @@ export function App() { ); if (p.title) { setToast({ - message: t(tr.questProgress, { title: p.title, current: p.current, target: p.target }), + message: t(bundle.questProgress, { title: p.title, current: p.current, target: p.target }), color: '#44aaff', }); } }, onQuestComplete: (p) => { + const bundle = i18nForLogRef.current.tr; setHeroQuests((prev) => prev.map((hq) => hq.questId === p.questId @@ -858,7 +975,7 @@ export function App() { : hq, ), ); - setToast({ message: t(tr.questCompleted, { title: p.title }), color: '#ffd700' }); + setToast({ message: t(bundle.questCompleted, { title: p.title }), color: '#ffd700' }); hapticNotification('success'); }, @@ -1141,7 +1258,10 @@ export function App() { if (result.status === 'fulfilled') townBuildingMap.set(result.value.townId, result.value.buildings); } } catch { /* ignore */ } - const townDataList = t.map((town) => townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id))); + const loc = i18nForLogRef.current.locale; + const townDataList = t.map((town) => + townToTownData(town, townNPCMap.get(town.id), townBuildingMap.get(town.id), loc), + ); engine.setTowns(townDataList); const allNPCs: NPCData[] = []; for (const td of townDataList) { @@ -1155,7 +1275,9 @@ export function App() { .catch(() => console.warn('[App] Could not fetch hero quests')); getAdventureLog(telegramId, 50) .then((serverLog) => { - const { entries, maxId } = adventureEntriesFromServerLog(serverLog); + const { locale: loc, tr: bundle } = i18nForLogRef.current; + const { entries, rawRows, maxId } = adventureEntriesFromServerLog(serverLog, loc, bundle); + logRawRef.current = rawRows; logIdCounter.current = Math.max(logIdCounter.current, maxId); setLogEntries(entries); }) @@ -1199,6 +1321,7 @@ export function App() { id: npc.id, townId: 0, name: npc.name, + nameKey: npc.nameKey, type: npc.type, offsetX: 0, offsetY: 0, @@ -1274,8 +1397,8 @@ export function App() { sendNPCAlmsDecline(ws); } setWanderingNPC(null); - appendLogLine('Declined wandering merchant'); - }, [appendLogLine]); + appendLogClientMessage(formatClientLogLine(i18nForLogRef.current.tr, 'declinedWanderingMerchant')); + }, [appendLogClientMessage]); // Show NPC interaction when near an NPC and not dismissed const showNPCInteraction = diff --git a/frontend/src/game/adventureLogFormat.ts b/frontend/src/game/adventureLogFormat.ts new file mode 100644 index 0000000..28f9ab6 --- /dev/null +++ b/frontend/src/game/adventureLogFormat.ts @@ -0,0 +1,387 @@ +import type { Translations } from '../i18n/en'; +import type { Locale } from '../i18n/index'; +import { t } from '../i18n/index'; +import { + dialogueText, + enemyFamilyLabel, + npcLabel, + townLabel, + WANDERING_MERCHANT_DIALOGUE_KEY, +} from '../i18n/contentLabels'; +import { ROADSIDE_THOUGHTS_EN, ROADSIDE_THOUGHTS_RU } from '../i18n/roadsideThoughts'; +import { townNpcVisitLineText } from '../i18n/townNpcVisitLines'; + +export interface AdventureLogEventWire { + code: string; + args?: Record; +} + +function strArg(args: Record | undefined, key: string): string { + if (!args) return ''; + const v = args[key]; + if (v == null) return ''; + return String(v); +} + +function numArg(args: Record | undefined, key: string): number { + if (!args) return 0; + const v = args[key]; + if (typeof v === 'number') return v; + if (typeof v === 'string') { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +function intArg(args: Record | undefined, key: string): number { + return Math.floor(numArg(args, key)); +} + +function boolArg(args: Record | undefined, key: string): boolean { + if (!args) return false; + const v = args[key]; + if (typeof v === 'boolean') return v; + if (v === 'true') return true; + return false; +} + +function debuffName(tr: Translations, raw: string): string { + const m: Record = { + poison: 'debuffPoison', + freeze: 'debuffFreeze', + burn: 'debuffBurn', + stun: 'debuffStun', + slow: 'debuffSlow', + weaken: 'debuffWeaken', + ice_slow: 'debuffIceSlow', + }; + const k = m[raw.toLowerCase()]; + return k ? tr[k] : raw; +} + +function buffName(tr: Translations, raw: string): string { + const m: Record = { + rush: 'buffRush', + rage: 'buffRage', + shield: 'buffShield', + luck: 'buffLuck', + resurrection: 'buffResurrection', + heal: 'buffHeal', + power_potion: 'buffPowerPotion', + war_cry: 'buffWarCry', + }; + const k = m[raw.toLowerCase()]; + return k ? tr[k] : raw; +} + +function slotName(tr: Translations, raw: string): string { + const m: Record = { + main_hand: 'slotWeapon', + off_hand: 'slotOffHand', + head: 'slotHead', + chest: 'slotChest', + legs: 'slotLegs', + feet: 'slotFeet', + cloak: 'slotCloak', + neck: 'slotNeck', + finger: 'slotRing', + wrist: 'slotWrist', + hands: 'slotHands', + quiver: 'slotQuiver', + }; + const k = m[raw.toLowerCase()]; + return k ? tr[k] : raw; +} + +function rarityName(tr: Translations, raw: string): string { + const m: Record = { + common: 'common', + uncommon: 'uncommon', + rare: 'rare', + epic: 'epic', + legendary: 'legendary', + }; + const k = m[raw.toLowerCase()]; + return k ? tr[k] : raw; +} + +function subscriptionDuration(locale: Locale, key: string): string { + if (key === 'subscription.week') { + return locale === 'ru' ? 'неделю подписки' : 'one week of subscription'; + } + 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] ?? ''; +} + +/** 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 a = args ?? {}; + + switch (code) { + case 'defeated_enemy': { + 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}!`; + } + case 'equipped_new': { + const slot = slotName(tr, strArg(a, 'slot')); + const itemName = strArg(a, 'itemName'); + return locale === 'ru' + ? `Экипировано: ${slot} — ${itemName}.` + : `Equipped new ${slot}: ${itemName}.`; + } + case 'inventory_full_dropped': { + const itemName = strArg(a, 'itemName'); + const rarity = rarityName(tr, strArg(a, 'rarity')); + return locale === 'ru' + ? `Инвентарь полон — выброшено ${itemName} (${rarity}).` + : `Inventory full — dropped ${itemName} (${rarity}).`; + } + case 'buff_activated': + return locale === 'ru' + ? `Активирован бафф: ${buffName(tr, strArg(a, 'buffType'))}.` + : `${buffName(tr, strArg(a, 'buffType'))} activated.`; + case 'hero_revived': + return locale === 'ru' ? 'Вы воскрешены.' : 'You revived.'; + case 'wandering_merchant_encounter': + return dialogueText(locale, WANDERING_MERCHANT_DIALOGUE_KEY, legacyMessage ?? ''); + case 'encountered_enemy': { + const slug = strArg(a, 'enemyType'); + const dbName = strArg(a, 'enemyName'); + const enemy = enemyFamilyLabel(locale, slug, dbName || slug); + return locale === 'ru' ? `Вы встречаете ${enemy}.` : `You encounter ${enemy}.`; + } + case 'died_fighting': { + const slug = strArg(a, 'enemyType'); + const dbName = strArg(a, 'enemyName'); + const enemy = enemyFamilyLabel(locale, slug, dbName || slug); + return locale === 'ru' ? `Вы погибли в бою с ${enemy}.` : `You died fighting ${enemy}.`; + } + case 'auto_revive_hours': + return locale === 'ru' + ? 'Прошло время; вы воскресли в городе.' + : 'Hours passed; you revived in town.'; + case 'auto_revive_after_sec': { + const sec = intArg(a, 'seconds'); + return locale === 'ru' + ? `Авто-воскрешение оффлайн через ${sec} с.` + : `Auto-revived after ${sec}s offline.`; + } + case 'purchased_buff_refill': + return locale === 'ru' + ? `Пополнены заряды: ${buffName(tr, strArg(a, 'buffType'))}.` + : `Refilled charges: ${buffName(tr, strArg(a, 'buffType'))}.`; + case 'purchased_buff_refill_rub': { + const price = intArg(a, 'priceRub'); + return locale === 'ru' + ? `Куплено пополнение ${buffName(tr, strArg(a, 'buffType'))} (${price} ₽).` + : `Purchased refill for ${buffName(tr, strArg(a, 'buffType'))} (${price} RUB).`; + } + case 'subscribed': { + const dk = strArg(a, 'durationKey'); + const price = intArg(a, 'priceRub'); + const dur = subscriptionDuration(locale, dk); + return locale === 'ru' + ? `Оформлена подписка: ${dur} (${price} ₽).` + : `Subscribed: ${dur} (${price} RUB).`; + } + case 'used_healing_potion': { + const amount = intArg(a, 'amount'); + return locale === 'ru' + ? `Использовано зелье лечения (+${amount} HP).` + : `Used healing potion (+${amount} HP).`; + } + case 'achievement_unlocked': { + const title = strArg(a, 'title'); + const rt = strArg(a, 'rewardType'); + const ra = intArg(a, 'rewardAmount'); + if (rt === 'gold') { + return locale === 'ru' + ? `Достижение: «${title}» (+${ra} золота).` + : `Achievement: ${title} (+${ra} gold).`; + } + if (rt === 'potion') { + return locale === 'ru' + ? `Достижение: «${title}» (+${ra} зелий).` + : `Achievement: ${title} (+${ra} potions).`; + } + return locale === 'ru' ? `Достижение: «${title}».` : `Achievement: ${title}.`; + } + case 'met_npc': { + const nk = strArg(a, 'npcKey'); + const tk = strArg(a, 'townKey'); + const npc = npcLabel(locale, nk, nk); + const town = townLabel(locale, tk, tk); + return locale === 'ru' ? `Встреча с ${npc} в ${town}.` : `Met ${npc} in ${town}.`; + } + case 'wandering_alms_equipped': { + const itemName = strArg(a, 'itemName'); + return locale === 'ru' + ? `Экипировано сделанное у купца: ${itemName}.` + : `Equipped from the merchant: ${itemName}.`; + } + case 'wandering_alms_dropped': { + const itemName = strArg(a, 'itemName'); + const rarity = rarityName(tr, strArg(a, 'rarity')); + return locale === 'ru' + ? `Выброшено ${itemName} (${rarity}) — нет места.` + : `Dropped ${itemName} (${rarity}) — no room.`; + } + case 'wandering_alms_stashed': { + const itemName = strArg(a, 'itemName'); + return locale === 'ru' + ? `${itemName} убрано в инвентарь.` + : `Stashed ${itemName} in your inventory.`; + } + case 'healed_full_town': + return locale === 'ru' ? 'Оплачено полное лечение.' : 'Paid for a full heal.'; + case 'bought_potion_town': + return locale === 'ru' ? 'Куплено зелье в городе.' : 'Bought a potion in town.'; + case 'sold_items_merchant': { + const count = intArg(a, 'count'); + const gold = numArg(a, 'gold'); + const nk = strArg(a, 'npcKey'); + const npc = npcLabel(locale, nk, legacyMessage ?? ''); + return locale === 'ru' + ? `Продано предметов: ${count} торговцу ${npc} (+${gold} золота).` + : `Sold ${count} items to ${npc} (+${gold} gold).`; + } + case 'npc_skipped_visit': { + const nk = strArg(a, 'npcKey'); + const npc = npcLabel(locale, nk, nk); + return locale === 'ru' ? `Пропущена встреча с ${npc}.` : `Skipped visiting ${npc}.`; + } + case 'thought_roadside': { + const idx = intArg(a, 'idx'); + return roadsideThoughtText(locale, idx); + } + case 'purchased_potion_from_npc': { + const nk = strArg(a, 'npcKey'); + const npc = npcLabel(locale, nk, nk); + return locale === 'ru' ? `Куплено зелье у ${npc}.` : `Bought a potion from ${npc}.`; + } + case 'paid_healer_full': { + const nk = strArg(a, 'npcKey'); + const npc = npcLabel(locale, nk, nk); + return locale === 'ru' ? `Оплачено полное лечение у ${npc}.` : `Paid ${npc} for a full heal.`; + } + case 'quest_giver_checked': { + const nk = strArg(a, 'npcKey'); + const npc = npcLabel(locale, nk, nk); + return locale === 'ru' + ? `Заглянули к ${npc} — новых квестов нет.` + : `Checked in with ${npc} — no new quests.`; + } + case 'quest_accepted': { + const title = strArg(a, 'title'); + return locale === 'ru' ? `Принят квест: ${title}.` : `Accepted quest: ${title}.`; + } + 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); + 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 ?? ''); + } + default: + return legacyMessage ?? ''; + } +} + +export function formatAdventureLogPayload( + locale: Locale, + tr: Translations, + payload: { message?: string; event?: AdventureLogEventWire }, +): string { + const legacy = payload.message?.trim() ? payload.message : ''; + if (payload.event?.code) { + const formatted = formatAdventureLogEvent( + locale, + tr, + payload.event.code, + payload.event.args, + legacy, + ); + if (formatted) return formatted; + } + return legacy; +} + +/** Client-only log lines (no server event). */ +export function formatClientLogLine( + tr: Translations, + templateKey: 'enteredTown' | 'declinedWanderingMerchant' | 'merchantMovedOn', + vars?: { town?: string }, +): string { + switch (templateKey) { + case 'enteredTown': + return t(tr.logEnteredTown, { town: vars?.town ?? '' }); + case 'declinedWanderingMerchant': + return tr.logDeclinedWanderingMerchant; + case 'merchantMovedOn': + return tr.logMerchantMovedOn; + default: + return ''; + } +} diff --git a/frontend/src/game/adventureLogMap.ts b/frontend/src/game/adventureLogMap.ts index 026a168..e323c5f 100644 --- a/frontend/src/game/adventureLogMap.ts +++ b/frontend/src/game/adventureLogMap.ts @@ -1,24 +1,73 @@ import type { AdventureLogBattleGroup, AdventureLogEntry, AdventureLogPlainEntry } from './types'; import type { LogEntry } from '../network/api'; +import type { Translations } from '../i18n/en'; +import type { Locale } from '../i18n/index'; import { parseAdventureLogLine } from './adventureLogMarkers'; +import type { AdventureLogEventWire } from './adventureLogFormat'; +import { formatAdventureLogEvent, formatAdventureLogPayload } from './adventureLogFormat'; -/** Group server log rows (oldest first) into plain lines + battle groups. */ -export function groupAdventureLogFromServer( - sortedOldestFirst: Array<{ id: number; message: string; timestamp: number }>, +export interface AdventureLogRawRow { + id: number; + message: string; + timestamp: number; + event?: AdventureLogEventWire; +} + +function isEncounterStart(row: AdventureLogRawRow): boolean { + if (row.event?.code === 'encountered_enemy') return true; + return parseAdventureLogLine(row.message).type === 'encounter'; +} + +function isBattleLine(row: AdventureLogRawRow): boolean { + if (row.event?.code === 'combat_swing') 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 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); + if (formatted) return formatted; + } + const p = parseAdventureLogLine(row.message); + if (p.type === 'battle') return p.text; + return row.message; +} + +function plainLineText(locale: Locale, tr: Translations, row: AdventureLogRawRow): string { + const parsed = parseAdventureLogLine(row.message); + if (parsed.type === 'battle') return parsed.text; + if (parsed.type === 'encounter') { + return encounterTitle(locale, tr, row); + } + const formatted = formatAdventureLogPayload(locale, tr, { message: row.message, event: row.event }); + return formatted || (parsed.type === 'plain' ? parsed.text : row.message); +} + +/** Group raw rows (oldest first) into plain lines + battle groups. */ +export function buildAdventureLogEntriesFromRaw( + sortedOldestFirst: AdventureLogRawRow[], + locale: Locale, + tr: Translations, ): AdventureLogEntry[] { const out: AdventureLogEntry[] = []; let i = 0; while (i < sortedOldestFirst.length) { const row = sortedOldestFirst[i]!; - const parsed = parseAdventureLogLine(row.message); - if (parsed.type === 'encounter') { + if (isEncounterStart(row)) { const lines: { id: number; message: string }[] = []; i++; while (i < sortedOldestFirst.length) { const inner = sortedOldestFirst[i]!; - const innerParsed = parseAdventureLogLine(inner.message); - if (innerParsed.type === 'battle') { - lines.push({ id: inner.id, message: innerParsed.text }); + if (isBattleLine(inner)) { + lines.push({ id: inner.id, message: battleLineText(locale, tr, inner) }); i++; } else { break; @@ -27,18 +76,16 @@ export function groupAdventureLogFromServer( const group: AdventureLogBattleGroup = { kind: 'battle_group', id: row.id, - title: parsed.title, + title: encounterTitle(locale, tr, row), timestamp: row.timestamp, lines, }; out.push(group); } else { - const text = - parsed.type === 'plain' ? parsed.text : parsed.type === 'battle' ? parsed.text : row.message; const plain: AdventureLogPlainEntry = { kind: 'line', id: row.id, - message: text, + message: plainLineText(locale, tr, row), timestamp: row.timestamp, }; out.push(plain); @@ -48,65 +95,64 @@ export function groupAdventureLogFromServer( return out; } -export function appendAdventureLogMessage( - prev: AdventureLogEntry[], +/** Append a server/client log row immutably. */ +export function appendAdventureLogRawRow( + rows: AdventureLogRawRow[], + row: Omit & { id?: number }, + nextId: () => number, +): AdventureLogRawRow[] { + const id = row.id ?? nextId(); + const full: AdventureLogRawRow = { + id, + message: row.message, + timestamp: row.timestamp, + event: row.event, + }; + return [...rows, full]; +} + +/** Legacy: append by raw message string only (client or old server). */ +export function appendAdventureLogMessageToRows( + rows: AdventureLogRawRow[], rawMessage: string, nextId: () => number, -): AdventureLogEntry[] { +): AdventureLogRawRow[] { const parsed = parseAdventureLogLine(rawMessage); + const ts = Date.now(); + if (parsed.type === 'encounter') { - const group: AdventureLogBattleGroup = { - kind: 'battle_group', - id: nextId(), - title: parsed.title, - timestamp: Date.now(), - lines: [], - }; - return [...prev, group]; + return appendAdventureLogRawRow(rows, { message: rawMessage, timestamp: ts }, nextId); } if (parsed.type === 'battle') { - const last = prev[prev.length - 1]; - if (last?.kind === 'battle_group') { - const lineId = nextId(); - return [ - ...prev.slice(0, -1), - { - ...last, - lines: [...last.lines, { id: lineId, message: parsed.text }], - }, - ]; + const last = rows[rows.length - 1]; + if (last && isEncounterStart(last)) { + return appendAdventureLogRawRow(rows, { message: rawMessage, timestamp: ts }, nextId); } - const line: AdventureLogPlainEntry = { - kind: 'line', - id: nextId(), - message: parsed.text, - timestamp: Date.now(), - }; - return [...prev, line]; + return appendAdventureLogRawRow(rows, { message: rawMessage, timestamp: ts }, nextId); } - const line: AdventureLogPlainEntry = { - kind: 'line', - id: nextId(), - message: parsed.text, - timestamp: Date.now(), - }; - return [...prev, line]; + return appendAdventureLogRawRow(rows, { message: rawMessage, timestamp: ts }, nextId); } -/** Map GET /hero/log lines to UI entries (oldest first, stable ids from DB). */ -export function adventureEntriesFromServerLog(serverLog: LogEntry[]): { - entries: AdventureLogEntry[]; - maxId: number; -} { +export function adventureRowsFromServerLog(serverLog: LogEntry[]): { rows: AdventureLogRawRow[]; maxId: number } { const sorted = [...serverLog].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); - const flat = sorted.map((entry) => ({ + const rows: AdventureLogRawRow[] = sorted.map((entry) => ({ id: Number(entry.id), - message: entry.message, + message: entry.message ?? '', timestamp: new Date(entry.createdAt).getTime(), + event: entry.event, })); - const entries = groupAdventureLogFromServer(flat); - const maxId = flat.reduce((m, e) => Math.max(m, e.id), 0); - return { entries, maxId }; + const maxId = rows.reduce((m, e) => Math.max(m, e.id), 0); + return { rows, maxId }; +} + +export function adventureEntriesFromServerLog( + serverLog: LogEntry[], + locale: Locale, + tr: Translations, +): { entries: AdventureLogEntry[]; rawRows: AdventureLogRawRow[]; maxId: number } { + const { rows, maxId } = adventureRowsFromServerLog(serverLog); + const entries = buildAdventureLogEntriesFromRaw(rows, locale, tr); + return { entries, rawRows: rows, maxId }; } diff --git a/frontend/src/game/adventureLogMarkers.ts b/frontend/src/game/adventureLogMarkers.ts index 465bc26..c9998b7 100644 --- a/frontend/src/game/adventureLogMarkers.ts +++ b/frontend/src/game/adventureLogMarkers.ts @@ -20,3 +20,14 @@ export function parseAdventureLogLine(raw: string): ParsedAdventureLine { export function shouldSuppressThoughtBubble(raw: string): boolean { return raw.startsWith(AH_ENC_PREFIX) || raw.startsWith(AH_BAT_PREFIX); } + +/** Thought bubble: hide for combat grouping lines; show roadside thoughts and plain social lines. */ +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; + return false; +} diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 0301b3c..af87c0a 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -277,6 +277,7 @@ export type AdventureLogEntry = AdventureLogPlainEntry | AdventureLogBattleGroup export interface Town { id: number; name: string; + nameKey?: string; biome: string; worldX: number; worldY: number; @@ -289,6 +290,7 @@ export interface NPC { id: number; townId: number; name: string; + nameKey?: string; type: 'quest_giver' | 'merchant' | 'healer'; offsetX: number; offsetY: number; @@ -298,6 +300,7 @@ export interface NPC { export interface Quest { id: number; npcId: number; + questKey?: string; title: string; description: string; type: 'kill_count' | 'visit_town' | 'collect_item'; @@ -316,6 +319,9 @@ export interface Quest { export interface HeroQuest { id: number; questId: number; + questKey?: string; + /** Quest template owner — matches `NPC.id` for filtering by open dialog. */ + questNpcId?: number; title: string; description: string; type: string; @@ -364,6 +370,7 @@ export type EquipmentSlot = export interface NPCData { id: number; name: string; + nameKey?: string; type: 'quest_giver' | 'merchant' | 'healer'; worldX: number; worldY: number; @@ -385,6 +392,7 @@ export interface BuildingData { export interface TownData { id: number; name: string; + nameKey?: string; centerX: number; centerY: number; radius: number; @@ -400,6 +408,8 @@ export interface TownData { export interface NPCEncounterEvent { type: 'npc_event'; npcName: string; + npcNameKey?: string; + dialogueKey?: string; message: string; cost: number; } @@ -548,8 +558,17 @@ export interface DebuffAppliedPayload { export interface TownEnterPayload { townId: number; townName: string; + townNameKey?: string; biome?: string; - npcs?: Array<{ id: number; name: string; type: string; buildingId?: number; worldX: number; worldY: number }>; + npcs?: Array<{ + id: number; + name: string; + nameKey?: string; + type: string; + buildingId?: number; + worldX: number; + worldY: number; + }>; buildings?: Array<{ id: number; buildingType: string; @@ -566,15 +585,18 @@ export interface TownEnterPayload { export interface TownNPCVisitPayload { npcId: number; name: string; + nameKey?: string; type: string; townId: number; + townNameKey?: string; worldX: number; worldY: number; } -/** Server-persisted adventure log line (e.g. town NPC visit narration). */ +/** Server-persisted adventure log line (legacy uses message; new rows use event). */ export interface AdventureLogLinePayload { - message: string; + message?: string; + event?: { code: string; args?: Record }; } export interface TownExitPayload {} @@ -582,8 +604,10 @@ export interface TownExitPayload {} export interface NPCEncounterPayload { npcId: number; npcName: string; + npcNameKey?: string; role: string; dialogue?: string; + dialogueKey?: string; cost: number; } diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index efecd1b..b4a425f 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -30,8 +30,6 @@ import type { DebuffAppliedPayload, } from './types'; import { DebuffType, Rarity } from './types'; -import { shouldSuppressThoughtBubble } from './adventureLogMarkers'; - // ---- Callback types for UI layer (App.tsx) ---- export interface WSHandlerCallbacks { @@ -204,9 +202,6 @@ export function wireWSHandler( ws.on('adventure_log_line', (msg: ServerMessage) => { const p = msg.payload as AdventureLogLinePayload; - if (!shouldSuppressThoughtBubble(p.message)) { - engine.applyAdventureLogLine(p.message); - } callbacks.onAdventureLogLine?.(p); }); diff --git a/frontend/src/i18n/contentLabels.ts b/frontend/src/i18n/contentLabels.ts new file mode 100644 index 0000000..9996bdc --- /dev/null +++ b/frontend/src/i18n/contentLabels.ts @@ -0,0 +1,77 @@ +import type { Locale } from './index'; +import { ENEMY_TYPE_LABELS } from './enemyTypeLabels'; + +/** Stable keys aligned with backend migrations / model constants. */ +export const WANDERING_MERCHANT_NPC_KEY = 'npc.wandering_merchant.v1'; +export const WANDERING_MERCHANT_DIALOGUE_KEY = 'npc.wandering_merchant.dialogue.v1'; + +type Bilingual = { en: string; ru: string }; + +const TOWNS: Record = { + 'town.willowdale.v1': { en: 'Willowdale', ru: 'Ивадол' }, + 'town.thornwatch.v1': { en: 'Thornwatch', ru: 'Тернозорь' }, + 'town.ashengard.v1': { en: 'Ashengard', ru: 'Пепельный гард' }, + 'town.redcliff.v1': { en: 'Redcliff', ru: 'Красная скала' }, + 'town.boghollow.v1': { en: 'Boghollow', ru: 'Торфяная низина' }, + 'town.cinderkeep.v1': { en: 'Cinderkeep', ru: 'Зола-крепость' }, + 'town.starfall.v1': { en: 'Starfall', ru: 'Звездопад' }, + 'town.mossharbor.v1': { en: 'Mossharbor', ru: 'Мшистая гавань' }, + 'town.emberwell.v1': { en: 'Emberwell', ru: 'Угольный колодец' }, + 'town.frostmark.v1': { en: 'Frostmark', ru: 'Морозная метка' }, + 'town.duskwatch.v1': { en: 'Duskwatch', ru: 'Сумеречный дозор' }, +}; + +const NPCS: Record = { + 'npc.elder_maren.v1': { en: 'Elder Maren', ru: 'Старейшина Марен' }, + 'npc.peddler_finn.v1': { en: 'Peddler Finn', ru: 'Бродячий торговец Финн' }, + 'npc.sister_asha.v1': { en: 'Sister Asha', ru: 'Сестра Аша' }, + 'npc.guard_halric.v1': { en: 'Guard Halric', ru: 'Страж Халрик' }, + 'npc.trader_wynn.v1': { en: 'Trader Wynn', ru: 'Торговец Винн' }, + 'npc.scholar_orin.v1': { en: 'Scholar Orin', ru: 'Учёный Орин' }, + 'npc.bone_merchant.v1': { en: 'Bone Merchant', ru: 'Торговец костями' }, + 'npc.priestess_liora.v1': { en: 'Priestess Liora', ru: 'Жрица Лиора' }, + [WANDERING_MERCHANT_NPC_KEY]: { en: 'Wandering Merchant', ru: 'Бродячий торговец' }, +}; + +const DIALOGUES: Record = { + [WANDERING_MERCHANT_DIALOGUE_KEY]: { + en: 'A hooded merchant blocks your path, jingling a pouch of odd trinkets.', + ru: 'В капюшоне торговец преграждает путь, звеня мешочком с безделушками.', + }, +}; + +function pick(locale: Locale, b: Bilingual): string { + return locale === 'ru' ? b.ru : b.en; +} + +export function townLabel(locale: Locale, key: string | undefined, fallback: string): string { + if (!key) return fallback; + const b = TOWNS[key]; + return b ? pick(locale, b) : fallback; +} + +export function npcLabel(locale: Locale, key: string | undefined, fallback: string): string { + if (!key) return fallback; + const b = NPCS[key]; + return b ? pick(locale, b) : fallback; +} + +export function dialogueText(locale: Locale, key: string | undefined, fallback: string): string { + if (!key) return fallback; + const b = DIALOGUES[key]; + return b ? pick(locale, b) : 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 + */ +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 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 61b6b65..791aa78 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -98,6 +98,12 @@ export const en = { 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', @@ -111,7 +117,15 @@ export const en = { 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!', @@ -155,6 +169,10 @@ export const en = { 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}', @@ -173,6 +191,9 @@ export const en = { // 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', diff --git a/frontend/src/i18n/enemyTypeLabels.ts b/frontend/src/i18n/enemyTypeLabels.ts new file mode 100644 index 0000000..a12b823 --- /dev/null +++ b/frontend/src/i18n/enemyTypeLabels.ts @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..891100a --- /dev/null +++ b/frontend/src/i18n/enemyTypeLabelsData.ts @@ -0,0 +1,226 @@ +/** + * `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/questCopy.ts b/frontend/src/i18n/questCopy.ts new file mode 100644 index 0000000..e621a3b --- /dev/null +++ b/frontend/src/i18n/questCopy.ts @@ -0,0 +1,20 @@ +import type { Locale } from './index'; + +/** Optional per-quest overrides; keys match `quest_key` from DB (e.g. quest.12). */ +const BUNDLES: Record< + string, + { title: { en: string; ru: string }; description: { en: string; ru: string } } +> = {}; + +export function localizedQuestText( + locale: Locale, + questKey: string | undefined, + part: 'title' | 'description', + fallback: string, +): string { + if (!questKey) return fallback; + const b = BUNDLES[questKey]; + if (!b) return fallback; + const piece = b[part]; + return locale === 'ru' ? (piece.ru || fallback) : (piece.en || fallback); +} diff --git a/frontend/src/i18n/roadsideThoughts.ts b/frontend/src/i18n/roadsideThoughts.ts new file mode 100644 index 0000000..e56b15f --- /dev/null +++ b/frontend/src/i18n/roadsideThoughts.ts @@ -0,0 +1,110 @@ +/** 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 a5b70af..b7c2583 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -101,6 +101,12 @@ export const ru: Translations = { 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', @@ -114,7 +120,15 @@ export const ru: Translations = { 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!', @@ -158,6 +172,10 @@ export const ru: Translations = { 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}', @@ -176,6 +194,9 @@ export const ru: Translations = { // 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', diff --git a/frontend/src/i18n/townNpcVisitLines.ts b/frontend/src/i18n/townNpcVisitLines.ts new file mode 100644 index 0000000..59d231a --- /dev/null +++ b/frontend/src/i18n/townNpcVisitLines.ts @@ -0,0 +1,95 @@ +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/network/api.ts b/frontend/src/network/api.ts index 11183cb..4cb3e0e 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -197,6 +197,12 @@ export interface OfflineReport { message?: string; } +/** Digest loot list is items only; gold is shown via goldGained (filters legacy rows). */ +export function offlineLootLinesWithoutGold(lines: OfflineLootLine[] | undefined): OfflineLootLine[] { + if (!lines?.length) return []; + return lines.filter((l) => l.itemType !== 'gold'); +} + /** True when init should show the offline summary overlay (server sends non-null only when meaningful). */ export function offlineReportHasActivity(r: OfflineReport): boolean { return ( @@ -206,15 +212,12 @@ export function offlineReportHasActivity(r: OfflineReport): boolean { r.levelsGained > 0 || (r.deaths ?? 0) > 0 || (r.revives ?? 0) > 0 || - (r.loot?.length ?? 0) > 0 || + offlineLootLinesWithoutGold(r.loot).length > 0 || Boolean(r.message?.trim()) ); } export function formatOfflineLootLine(line: OfflineLootLine): string { - if (line.itemType === 'gold' || (line.goldAmount ?? 0) > 0) { - return `+${line.goldAmount ?? 0} gold`; - } const name = line.itemName?.trim() || line.itemType; return `${name} (${line.rarity})`; } @@ -487,6 +490,7 @@ export interface LogEntry { id: number; message: string; createdAt: string; + event?: { code: string; args?: Record }; } /** Fetch recent adventure log entries (offline events, etc.) */ @@ -548,6 +552,7 @@ interface HeroQuestRaw { quest?: { id: number; npcId: number; + questKey?: string; title: string; description: string; type: string; @@ -579,6 +584,8 @@ function flattenHeroQuest(raw: HeroQuestRaw): HeroQuest { return { id: raw.id, questId: raw.questId, + questKey: q?.questKey, + questNpcId: q?.npcId, title: raw.title ?? q?.title ?? '', description: raw.description ?? q?.description ?? '', type: raw.type ?? q?.type ?? '', diff --git a/frontend/src/ui/BuffBar.tsx b/frontend/src/ui/BuffBar.tsx index c92e16d..cdbdd5e 100644 --- a/frontend/src/ui/BuffBar.tsx +++ b/frontend/src/ui/BuffBar.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; import { BuffType, type ActiveBuff, type BuffChargeState } from '../game/types'; import { BUFF_COOLDOWN_MS, buffMaxChargesForHero } from '../network/buffMap'; -import { BUFF_META } from './buffMeta'; +import { BUFF_VISUAL, buffUiStrings } from './buffMeta'; import { purchaseBuffRefill } from '../network/api'; import type { HeroResponse } from '../network/api'; import { getTelegramUserId } from '../shared/telegram'; @@ -27,7 +27,6 @@ interface BuffBarProps { interface BuffButtonProps { buff: ActiveBuff; - meta: { icon: string; label: string; color: string; desc: string }; charge: BuffChargeState | undefined; maxCharges: number; onActivate: () => void; @@ -169,8 +168,11 @@ const buttonBase: CSSProperties = { WebkitTapHighlightColor: 'transparent', }; -function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowMs, buffsLocked }: BuffButtonProps) { +function BuffButton({ buff, charge, maxCharges, onActivate, onRefill, nowMs, buffsLocked }: BuffButtonProps) { const tr = useT(); + const visual = BUFF_VISUAL[buff.type]; + const { label, desc } = buffUiStrings(tr, buff.type); + const meta = { ...visual, label, desc }; const [pressed, setPressed] = useState(false); const [showTooltip, setShowTooltip] = useState(false); const [showRefillConfirm, setShowRefillConfirm] = useState(false); @@ -565,14 +567,12 @@ export function BuffBar({
{ALL_BUFF_TYPES.map((type) => { const buff = getBuffEntry(type, buffs, cooldownEndsAt, nowMs); - const meta = BUFF_META[type]; const charge = buffCharges[type]; const maxCharges = buffMaxChargesForHero(type, subscriptionActive); return ( ({ ...b, remainingMs: liveRemainingMs(b, nowMs) })) .filter((b) => b.remainingMs > 0); @@ -49,8 +51,9 @@ export function BuffStatusStrip({ buffs, nowMs }: BuffStatusStripProps) { return (
{live.map((buff) => { - const meta = BUFF_META[buff.type]; + const meta = BUFF_VISUAL[buff.type]; if (!meta) return null; + const { label, desc } = buffUiStrings(tr, buff.type); const sec = Math.ceil(buff.remainingMs / 1000); const justApplied = buff.durationMs > 0 && buff.remainingMs / buff.durationMs > 0.8; @@ -61,7 +64,7 @@ export function BuffStatusStrip({ buffs, nowMs }: BuffStatusStripProps) { animation: justApplied ? 'buff-status-pulse 0.55s ease-out' : undefined, }; return ( - + {meta.icon} {sec}s diff --git a/frontend/src/ui/HUD.tsx b/frontend/src/ui/HUD.tsx index 4db3c42..d8d4f85 100644 --- a/frontend/src/ui/HUD.tsx +++ b/frontend/src/ui/HUD.tsx @@ -9,7 +9,8 @@ import type { GameState } from '../game/types'; import { GamePhase, BuffType } from '../game/types'; import { useUiClock } from '../hooks/useUiClock'; import type { HeroResponse } from '../network/api'; -import { t, useT } from '../i18n'; +import { t, useT, useLocale } from '../i18n'; +import { enemyFamilyLabel } from '../i18n/contentLabels'; // FREE_BUFF_ACTIVATIONS_PER_PERIOD removed — per-buff charges are now shown on each button interface HUDProps { @@ -185,6 +186,7 @@ export function HUD({ onOpenHeroSheet, completedQuestCount = 0, }: HUDProps) { + const { locale } = useLocale(); const { hero, enemy, phase, lastVictoryLoot } = gameState; const nowMs = useUiClock(100); const tr = useT(); @@ -310,7 +312,9 @@ export function HUD({ (phase === GamePhase.Fighting || (phase === GamePhase.Dead && enemy)) && (
-
{enemy.name}
+
+ {enemyFamilyLabel(locale, enemy.enemySlug, enemy.name)} +
([]); const [loading, setLoading] = useState(false); @@ -349,10 +353,10 @@ export function NPCDialog({ ); // Filter to quests from this NPC based on npcName matching const npcInProgressQuests = npcHeroQuests.filter( - (hq) => hq.npcName === npc.name && hq.status === 'accepted', + (hq) => hq.questNpcId === npc.id && hq.status === 'accepted', ); const npcCompletedQuests = npcHeroQuests.filter( - (hq) => hq.npcName === npc.name && hq.status === 'completed', + (hq) => hq.questNpcId === npc.id && hq.status === 'completed', ); return ( @@ -374,7 +378,7 @@ export function NPCDialog({
{npcTypeIcon(npc.type)} - {npc.name} + {npcDisplayName} {npcTypeLabel(npc.type, tr)}
@@ -399,7 +403,9 @@ export function NPCDialog({ >
{questTypeIcon(hq.type)} - {hq.title} + + {localizedQuestText(locale, hq.questKey, 'title', hq.title)} + {hq.progress}/{hq.targetCount} @@ -441,7 +447,7 @@ export function NPCDialog({ {/* In-progress quests */} {npcInProgressQuests.length > 0 && ( <> -
In Progress
+
{tr.inProgressQuests}
{npcInProgressQuests.map((hq) => { const pct = hq.targetCount > 0 ? Math.min(100, (hq.progress / hq.targetCount) * 100) @@ -450,7 +456,9 @@ export function NPCDialog({
{questTypeIcon(hq.type)} - {hq.title} + + {localizedQuestText(locale, hq.questKey, 'title', hq.title)} + {hq.progress}/{hq.targetCount} @@ -475,18 +483,22 @@ export function NPCDialog({ {/* Available quests */} {loading ? (
- Loading quests... + {tr.loadingQuests}
) : (availableQuests?.length ?? 0) > 0 ? ( <> -
Available Quests
+
{tr.availableQuestsSection}
{(availableQuests ?? []).map((q) => (
{questTypeIcon(q.type)} - {q.title} + + {localizedQuestText(locale, q.questKey, 'title', q.title)} + +
+
+ {localizedQuestText(locale, q.questKey, 'description', q.description)}
-
{q.description}
{q.rewardXp > 0 && ( @@ -517,7 +529,7 @@ export function NPCDialog({ npcInProgressQuests.length === 0 && npcCompletedQuests.length === 0 && (
- No quests available right now. + {tr.noQuestsRightNow}
) )} @@ -527,7 +539,7 @@ export function NPCDialog({ {/* ---- Merchant ---- */} {npc.type === 'merchant' && ( <> -
Shop
+
{tr.shopLabel}
- Your gold: {heroGold} + {t(tr.yourGoldLabel, { amount: heroGold })}
)} @@ -548,7 +560,7 @@ export function NPCDialog({ {/* ---- Healer ---- */} {npc.type === 'healer' && ( <> -
Services
+
{tr.servicesSection}
- Your gold: {heroGold} + {t(tr.yourGoldLabel, { amount: heroGold })}
)} diff --git a/frontend/src/ui/NPCInteraction.tsx b/frontend/src/ui/NPCInteraction.tsx index 4fe10f9..ca204de 100644 --- a/frontend/src/ui/NPCInteraction.tsx +++ b/frontend/src/ui/NPCInteraction.tsx @@ -1,5 +1,6 @@ import { useCallback, type CSSProperties } from 'react'; import type { NPCData } from '../game/types'; +import { useT, t } from '../i18n'; // ---- Types ---- @@ -81,16 +82,19 @@ const actionBtnStyle: CSSProperties = { // ---- NPC appearance ---- -function npcColor(type: string): { bg: string; icon: string; text: string } { +function npcColor( + type: string, + tr: ReturnType, +): { bg: string; icon: string; text: string } { switch (type) { case 'quest_giver': - return { bg: 'rgba(218, 165, 32, 0.2)', icon: '!', text: 'Quest Giver' }; + return { bg: 'rgba(218, 165, 32, 0.2)', icon: '!', text: tr.questGiver }; case 'merchant': - return { bg: 'rgba(68, 170, 85, 0.2)', icon: '$', text: 'Merchant' }; + return { bg: 'rgba(68, 170, 85, 0.2)', icon: '$', text: tr.merchant }; case 'healer': - return { bg: 'rgba(220, 80, 80, 0.2)', icon: '+', text: 'Healer' }; + return { bg: 'rgba(220, 80, 80, 0.2)', icon: '+', text: tr.healer }; default: - return { bg: 'rgba(136, 136, 170, 0.2)', icon: '?', text: 'NPC' }; + return { bg: 'rgba(136, 136, 170, 0.2)', icon: '?', text: tr.npc }; } } @@ -106,7 +110,8 @@ export function NPCInteraction({ onHeal, onDismiss, }: NPCInteractionProps) { - const info = npcColor(npc.type); + const tr = useT(); + const info = npcColor(npc.type, tr); const handleAction = useCallback(() => { switch (npc.type) { @@ -125,13 +130,13 @@ export function NPCInteraction({ const actionLabel = (() => { switch (npc.type) { case 'quest_giver': - return 'View Quests'; + return tr.viewQuests; case 'merchant': - return `Buy Potion (${potionCost}g)`; + return t(tr.buyPotionForGold, { cost: potionCost }); case 'healer': - return `Heal to Full (${healCost}g)`; + return t(tr.healToFullForGold, { cost: healCost }); default: - return 'Talk'; + return tr.npcInteractTalk; } })(); diff --git a/frontend/src/ui/OfflineReport.tsx b/frontend/src/ui/OfflineReport.tsx index 67bf27a..39f335f 100644 --- a/frontend/src/ui/OfflineReport.tsx +++ b/frontend/src/ui/OfflineReport.tsx @@ -1,6 +1,10 @@ import { useEffect, useState, type CSSProperties } from 'react'; import { useT, t } from '../i18n'; -import { formatOfflineLootLine, type OfflineLootLine } from '../network/api'; +import { + formatOfflineLootLine, + offlineLootLinesWithoutGold, + type OfflineLootLine, +} from '../network/api'; interface OfflineReportProps { monstersKilled: number; @@ -84,6 +88,7 @@ export function OfflineReport({ }: OfflineReportProps) { const tr = useT(); const [fading, setFading] = useState(false); + const itemLoot = offlineLootLinesWithoutGold(loot); useEffect(() => { const fadeTimer = setTimeout(() => setFading(true), 4600); @@ -137,11 +142,11 @@ export function OfflineReport({ {revives > 0 && (
{t(tr.offlineRevives, { count: revives })}
)} - {loot.length > 0 && ( + {itemLoot.length > 0 && ( <>
{tr.offlineLootFound}
- {loot.map((line, i) => ( + {itemLoot.map((line, i) => (
{formatOfflineLootLine(line)}
))}
diff --git a/frontend/src/ui/QuestLog.tsx b/frontend/src/ui/QuestLog.tsx index 0765e81..fc58cf3 100644 --- a/frontend/src/ui/QuestLog.tsx +++ b/frontend/src/ui/QuestLog.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect, type CSSProperties } from 'react'; import type { HeroQuest } from '../game/types'; -import { useT } from '../i18n'; +import { useT, useLocale } from '../i18n'; +import { localizedQuestText } from '../i18n/questCopy'; // ---- Types ---- @@ -206,6 +207,7 @@ interface QuestLogListProps { /** Quest list body (embedded in Hero sheet or standalone panel). */ export function QuestLogList({ quests, onClaim, onAbandon, claimDisabled = false }: QuestLogListProps) { const tr = useT(); + const { locale } = useLocale(); const [expandedId, setExpandedId] = useState(null); const activeQuests = quests.filter((q) => q.status !== 'claimed'); @@ -250,7 +252,9 @@ export function QuestLogList({ quests, onClaim, onAbandon, claimDisabled = false >
{questTypeIcon(q.type)} - {q.title} + + {localizedQuestText(locale, q.questKey, 'title', q.title)} + -
{q.description}
+
+ {localizedQuestText(locale, q.questKey, 'description', q.description)} +
{q.type === 'visit_town' && q.targetTownName ? (
{tr.questDestination}: {q.targetTownName} diff --git a/frontend/src/ui/buffMeta.ts b/frontend/src/ui/buffMeta.ts index a78adca..cc03505 100644 --- a/frontend/src/ui/buffMeta.ts +++ b/frontend/src/ui/buffMeta.ts @@ -1,16 +1,29 @@ import { BuffType } from '../game/types'; +import type { Translations } from '../i18n/en'; -/** Icons and colors for buff UI (BuffBar buttons + BuffStatusStrip). */ -export const BUFF_META: Record< - BuffType, - { icon: string; label: string; color: string; desc: string } -> = { - [BuffType.Rush]: { icon: '\u26A1', label: 'Rush', color: '#44aaff', desc: '+50% movement speed' }, - [BuffType.Rage]: { icon: '\u2694\uFE0F', label: 'Rage', color: '#ff4444', desc: '+100% damage' }, - [BuffType.Shield]: { icon: '\uD83D\uDEE1\uFE0F', label: 'Shield', color: '#aaaaff', desc: '-50% incoming damage' }, - [BuffType.Luck]: { icon: '\uD83C\uDF40', label: 'Luck', color: '#44ff44', desc: 'x2.5 loot drops' }, - [BuffType.Resurrection]: { icon: '\uD83D\uDD2E', label: 'Resurrect', color: '#ffaa44', desc: 'Revive at 50% HP' }, - [BuffType.Heal]: { icon: '\u2764\uFE0F', label: 'Heal', color: '#ff6699', desc: '+50% HP instant' }, - [BuffType.PowerPotion]: { icon: '\uD83E\uDDEA', label: 'Power', color: '#dd44ff', desc: '+150% damage' }, - [BuffType.WarCry]: { icon: '\uD83D\uDCE3', label: 'WarCry', color: '#ffcc00', desc: '+100% attack speed' }, +/** Icons and colors for buff UI (labels/descriptions come from i18n like HeroPanel). */ +export const BUFF_VISUAL: Record = { + [BuffType.Rush]: { icon: '\u26A1', color: '#44aaff' }, + [BuffType.Rage]: { icon: '\u2694\uFE0F', color: '#ff4444' }, + [BuffType.Shield]: { icon: '\uD83D\uDEE1\uFE0F', color: '#aaaaff' }, + [BuffType.Luck]: { icon: '\uD83C\uDF40', color: '#44ff44' }, + [BuffType.Resurrection]: { icon: '\uD83D\uDD2E', color: '#ffaa44' }, + [BuffType.Heal]: { icon: '\u2764\uFE0F', color: '#ff6699' }, + [BuffType.PowerPotion]: { icon: '\uD83E\uDDEA', color: '#dd44ff' }, + [BuffType.WarCry]: { icon: '\uD83D\uDCE3', color: '#ffcc00' }, }; + +export function buffUiStrings(tr: Translations, type: BuffType): { label: string; desc: string } { + const keys: Record = { + [BuffType.Rush]: ['buffRush', 'buffRushDesc'], + [BuffType.Rage]: ['buffRage', 'buffRageDesc'], + [BuffType.Shield]: ['buffShield', 'buffShieldDesc'], + [BuffType.Luck]: ['buffLuck', 'buffLuckDesc'], + [BuffType.Resurrection]: ['buffResurrection', 'buffResurrectionDesc'], + [BuffType.Heal]: ['buffHeal', 'buffHealDesc'], + [BuffType.PowerPotion]: ['buffPowerPotion', 'buffPowerPotionDesc'], + [BuffType.WarCry]: ['buffWarCry', 'buffWarCryDesc'], + }; + const [lk, dk] = keys[type]; + return { label: tr[lk], desc: tr[dk] }; +}