localization

master
Denis Ranneft 1 month ago
parent 22e6b3fac4
commit 4f97cd2b98

@ -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.

@ -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) {

@ -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,9 +1910,14 @@ 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,
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{
@ -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,
@ -2341,7 +2318,9 @@ func ProcessSingleHeroMovementTick(
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0,
NPCName: "Wandering Merchant",
NPCNameKey: model.WanderingMerchantNPCKey,
Role: "alms",
DialogueKey: model.WanderingMerchantDialogueKey,
Cost: cost,
})
}

@ -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)
}
if len(out) == 0 {
return nil
}
return !now.Before(disconnect.Add(OfflineDigestGrace))
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,

@ -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

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

@ -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,
}

@ -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,7 +432,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
// 10% chance to encounter a wandering NPC instead of an enemy.
if rand.Float64() < cfg.RESTEncounterNPCChance {
cost := game.WanderingMerchantCost(hero.Level)
h.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()
@ -436,6 +440,7 @@ func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
Type: "npc_event",
NPC: model.NPCEventNPC{
Name: "Wandering Merchant",
NameKey: model.WanderingMerchantNPCKey,
Role: "alms",
},
Cost: cost,
@ -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)
}
@ -959,6 +969,7 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP
tw := model.TownWithNPCs{
ID: t.ID,
Name: t.Name,
NameKey: t.NameKey,
Biome: t.Biome,
WorldX: t.WorldX,
WorldY: t.WorldY,
@ -970,6 +981,7 @@ func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNP
tw.NPCs = append(tw.NPCs, model.NPCView{
ID: n.ID,
Name: n.Name,
NameKey: n.NameKey,
Type: n.Type,
WorldX: t.WorldX + n.OffsetX,
WorldY: t.WorldY + n.OffsetY,
@ -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,
},
},
})
}
}

@ -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,6 +212,7 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
_, healCost := tuning.EffectiveNPCShopCosts()
actions = append(actions, model.NPCInteractAction{
ActionType: "heal",
ItemKey: "shop.full_heal",
ItemName: "Full Heal",
ItemCost: healCost,
Description: "Restore hero to full HP.",
@ -213,12 +220,19 @@ func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
}
// 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,
NPCNameKey: npc.NameKey,
NPCType: npc.Type,
TownName: town.Name,
TownNameKey: town.NameKey,
Actions: actions,
}
if resp.Actions == nil {
@ -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)
}

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

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

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

@ -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"
)

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

@ -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:"-"`

@ -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"`
@ -104,17 +109,22 @@ func TownSizeFromRadius(radius float64) string {
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
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"`
NPCNameKey string `json:"npcNameKey,omitempty"`
NPCType string `json:"npcType"`
TownName string `json:"townName"`
TownNameKey string `json:"townNameKey,omitempty"`
Actions []NPCInteractAction `json:"actions"`
}
@ -122,6 +132,7 @@ type NPCInteractResponse struct {
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"`
@ -139,6 +150,7 @@ type NPCEventResponse struct {
// NPCEventNPC describes the wandering NPC in a random event.
type NPCEventNPC struct {
Name string `json:"name"`
NameKey string `json:"nameKey,omitempty"`
Role string `json:"role"`
}

@ -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"`
@ -164,16 +166,16 @@ type TownEnterPayload struct {
type TownNPCVisitPayload struct {
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{}
@ -191,8 +193,9 @@ type MerchantLootPayload struct {
type NPCEncounterPayload struct {
NPCID int64 `json:"npcId"`
NPCName string `json:"npcName"`
NPCNameKey string `json:"npcNameKey,omitempty"`
Role string `json:"role"`
Dialogue string `json:"dialogue,omitempty"`
DialogueKey string `json:"dialogueKey,omitempty"`
Cost int64 `json:"cost"`
}

@ -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"`
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)

@ -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 {

@ -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.<id> 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);

@ -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<ChangelogOpen | null>(null);
const [changelogOpen, setChangelogOpen] = useState<ChangelogOpen | null>(null);
const logIdCounter = useRef(0);
const logRawRef = useRef<AdventureLogRawRow[]>([]);
const i18nForLogRef = useRef({ locale, tr: translations });
i18nForLogRef.current = { locale, tr: translations };
const nearbyIntervalRef = useRef<ReturnType<typeof setInterval> | 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, () => {
const appendLogPayload = useCallback((p: AdventureLogLinePayload) => {
const { locale: loc, tr: bundle } = i18nForLogRef.current;
logIdCounter.current += 1;
return logIdCounter.current;
}),
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<number, NPC[]>();
const townBuildingMap = new Map<number, BuildingData[]>();
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 =

@ -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<string, unknown>;
}
function strArg(args: Record<string, unknown> | undefined, key: string): string {
if (!args) return '';
const v = args[key];
if (v == null) return '';
return String(v);
}
function numArg(args: Record<string, unknown> | 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<string, unknown> | undefined, key: string): number {
return Math.floor(numArg(args, key));
}
function boolArg(args: Record<string, unknown> | 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<string, keyof Translations> = {
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<string, keyof Translations> = {
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<string, keyof Translations> = {
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<string, keyof Translations> = {
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<string, unknown>,
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 '';
}
}

@ -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<AdventureLogRawRow, 'id'> & { 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 };
}

@ -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;
}

@ -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<string, unknown> };
}
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;
}

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

@ -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<string, Bilingual> = {
'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<string, Bilingual> = {
'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<string, Bilingual> = {
[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;
}

@ -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',

@ -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<string, string> = {
'Elder Verdant': 'Древний зелёный',
'Woodland Elder': 'Старый лесной',
'Young Woodland': 'Молодой лесной',
'Forgotten Young': 'Юный забытый',
'Lost Forgotten': 'Потерянный забытый',
'Bog Cursed': 'Болотный проклятый',
'Rogue Ember': 'Угольный бродячий',
'Astral Rogue': 'Астральный бродячий',
};
const p = prefixMap[prefix];
if (!p) return en;
return capitalizeTitleRu(p + ' ' + creatureRu);
}
function buildEnemyTypeLabels(): Record<string, EnemyTypeBilingual> {
const out: Record<string, EnemyTypeBilingual> = {};
for (const [slug, en] of ENEMY_TYPE_NAME_ROWS) {
out[slug] = { en, ru: ruFromEnglishDisplayName(en) };
}
return out;
}
/** Localized display per DB `enemies.type` (220 rows from migration seed). */
export const ENEMY_TYPE_LABELS: Record<string, EnemyTypeBilingual> = buildEnemyTypeLabels();

@ -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;

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

@ -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?',
'Интересно, снятся ли торговцам фиксированные цены.',
'Каждая пауза — маленький бунт против гринда.',
'Грязь под ногтями — доказательство, что ты был в игре.',
'Если смысл создаётся, молоток всё ещё у тебя.',
'Ты улыбаешься ни о чём. Помогает.',
'Завтра снова пойдёшь. Сегодня просто дышишь.',
'Ты признаёшь, что гринд громкий, и приглушаешь громкость.',
];

@ -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',

@ -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 givers desk.',
'They tap a map with an ink-stained finger.',
'“Busy roads,” they say — you agree, noncommittally.',
'A draft carries the smell of old parchment.',
'They squint as if measuring your spine against a legend.',
'You promise to listen; they promise it will be worth it.',
];
const QUEST_RU = [
'Стол завален свитками и сургучными печатями.',
'По карте стучит перстью в чернильных пятнах.',
'— Шумные дороги, — говорят они; ты без обязательств соглашаешься.',
'Сквозняк несёт запах старой бумаги.',
'Щурятся, будто меряют тебя легендой напротив.',
'Ты обещаешь слушать; обещают, что оно того стоит.',
];
const GENERIC_EN = [
'You pause; the town noise folds around you like a blanket.',
'Someone nearby argues about grain prices in good humor.',
'Dust motes hang in a sunbeam; time stretches a little.',
'You tighten a strap and pretend you meant to stop here.',
'A dog watches you, decides you are boring, and sleeps.',
'You breathe out, ready to move on when the moment feels right.',
];
const GENERIC_RU = [
'Ты замираешь; городской шум обволакивает, как одеяло.',
'Рядом в шутку спорят о цене на зерно.',
'В луче солнца пляшет пыль; время чуть растягивается.',
'Подтягиваешь ремень и делаешь вид, что так и задумано.',
'Собака смотрит, решает, что ты скучен, и засыпает.',
'Выдыхаешь — готов идти дальше, когда будет пора.',
];
function linesForType(type: string, locale: Locale): string[] {
const ru = locale === 'ru';
switch (type) {
case 'merchant':
return ru ? MERCHANT_RU : MERCHANT_EN;
case 'healer':
return ru ? HEALER_RU : HEALER_EN;
case 'quest_giver':
return ru ? QUEST_RU : QUEST_EN;
default:
return ru ? GENERIC_RU : GENERIC_EN;
}
}
export function townNpcVisitLineText(locale: Locale, npcType: string, lineIdx: number): string {
const lines = linesForType(npcType, locale);
const i = Math.max(0, Math.min(lines.length - 1, lineIdx));
return lines[i] ?? '';
}

@ -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<string, unknown> };
}
/** 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 ?? '',

@ -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({
<div style={barStyle}>
{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 (
<BuffButton
key={type}
buff={buff}
meta={meta}
charge={charge}
maxCharges={maxCharges}
onActivate={handleActivate(type)}

@ -1,6 +1,7 @@
import { type CSSProperties } from 'react';
import type { ActiveBuff } from '../game/types';
import { BUFF_META } from './buffMeta';
import { BUFF_VISUAL, buffUiStrings } from './buffMeta';
import { useT } from '../i18n';
interface BuffStatusStripProps {
buffs: ActiveBuff[];
@ -40,6 +41,7 @@ function liveRemainingMs(buff: ActiveBuff, nowMs: number): number {
/** Read-only active buff indicators (no activation controls). */
export function BuffStatusStrip({ buffs, nowMs }: BuffStatusStripProps) {
const tr = useT();
const live = buffs
.map((b) => ({ ...b, remainingMs: liveRemainingMs(b, nowMs) }))
.filter((b) => b.remainingMs > 0);
@ -49,8 +51,9 @@ export function BuffStatusStrip({ buffs, nowMs }: BuffStatusStripProps) {
return (
<div style={rowStyle} role="status" aria-label="Active buffs">
{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 (
<span key={buff.type} style={style} title={`${meta.label}${meta.desc}`}>
<span key={buff.type} style={style} title={`${label}${desc}`}>
<span style={{ fontSize: 14, lineHeight: 1 }}>{meta.icon}</span>
<span>{sec}s</span>
</span>

@ -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)) && (
<div style={enemySection}>
<div style={enemyNameStyle}>{enemy.name}</div>
<div style={enemyNameStyle}>
{enemyFamilyLabel(locale, enemy.enemySlug, enemy.name)}
</div>
<HPBar
current={enemy.hp}
max={enemy.maxHp}

@ -4,7 +4,9 @@ import { getNPCQuests, acceptQuest, claimQuest, buyPotion, healAtNPC } from '../
import type { HeroResponse } from '../network/api';
import { getTelegramUserId } from '../shared/telegram';
import { hapticImpact } from '../shared/telegram';
import { useT, t } from '../i18n';
import { useT, t, useLocale } from '../i18n';
import { localizedQuestText } from '../i18n/questCopy';
import { npcLabel } from '../i18n/contentLabels';
// ---- Types ----
@ -247,6 +249,8 @@ export function NPCDialog({
questClaimDisabled = false,
}: NPCDialogProps) {
const tr = useT();
const { locale } = useLocale();
const npcDisplayName = npcLabel(locale, npc.nameKey, npc.name);
const [availableQuests, setAvailableQuests] = useState<Quest[]>([]);
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({
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: 18, marginRight: 8 }}>{npcTypeIcon(npc.type)}</span>
<span style={npcNameStyle}>{npc.name}</span>
<span style={npcNameStyle}>{npcDisplayName}</span>
<span style={npcTypeTag}>{npcTypeLabel(npc.type, tr)}</span>
</div>
<button style={closeBtnStyle} onClick={onClose}>{'\u2715'}</button>
@ -399,7 +403,9 @@ export function NPCDialog({
>
<div style={questTitleRow}>
<span style={{ fontSize: 14 }}>{questTypeIcon(hq.type)}</span>
<span style={questTitleText}>{hq.title}</span>
<span style={questTitleText}>
{localizedQuestText(locale, hq.questKey, 'title', hq.title)}
</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#ffd700' }}>
{hq.progress}/{hq.targetCount}
</span>
@ -441,7 +447,7 @@ export function NPCDialog({
{/* In-progress quests */}
{npcInProgressQuests.length > 0 && (
<>
<div style={sectionTitleStyle}>In Progress</div>
<div style={sectionTitleStyle}>{tr.inProgressQuests}</div>
{npcInProgressQuests.map((hq) => {
const pct = hq.targetCount > 0
? Math.min(100, (hq.progress / hq.targetCount) * 100)
@ -450,7 +456,9 @@ export function NPCDialog({
<div key={hq.id} style={questCardStyle}>
<div style={questTitleRow}>
<span style={{ fontSize: 14 }}>{questTypeIcon(hq.type)}</span>
<span style={questTitleText}>{hq.title}</span>
<span style={questTitleText}>
{localizedQuestText(locale, hq.questKey, 'title', hq.title)}
</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#aaa' }}>
{hq.progress}/{hq.targetCount}
</span>
@ -475,18 +483,22 @@ export function NPCDialog({
{/* Available quests */}
{loading ? (
<div style={{ color: '#666', fontSize: 12, textAlign: 'center', padding: 16 }}>
Loading quests...
{tr.loadingQuests}
</div>
) : (availableQuests?.length ?? 0) > 0 ? (
<>
<div style={sectionTitleStyle}>Available Quests</div>
<div style={sectionTitleStyle}>{tr.availableQuestsSection}</div>
{(availableQuests ?? []).map((q) => (
<div key={q.id} style={questCardStyle}>
<div style={questTitleRow}>
<span style={{ fontSize: 14 }}>{questTypeIcon(q.type)}</span>
<span style={questTitleText}>{q.title}</span>
<span style={questTitleText}>
{localizedQuestText(locale, q.questKey, 'title', q.title)}
</span>
</div>
<div style={questDescStyle}>
{localizedQuestText(locale, q.questKey, 'description', q.description)}
</div>
<div style={questDescStyle}>{q.description}</div>
<div style={rewardsRow}>
{q.rewardXp > 0 && (
<span style={{ ...rewardChip, color: '#44aaff' }}>
@ -517,7 +529,7 @@ export function NPCDialog({
npcInProgressQuests.length === 0 &&
npcCompletedQuests.length === 0 && (
<div style={{ color: '#666', fontSize: 12, textAlign: 'center', padding: 16 }}>
No quests available right now.
{tr.noQuestsRightNow}
</div>
)
)}
@ -527,7 +539,7 @@ export function NPCDialog({
{/* ---- Merchant ---- */}
{npc.type === 'merchant' && (
<>
<div style={sectionTitleStyle}>Shop</div>
<div style={sectionTitleStyle}>{tr.shopLabel}</div>
<button
style={
heroGold >= potionCost
@ -540,7 +552,7 @@ export function NPCDialog({
{'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {potionCost} {tr.gold}
</button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold}
{t(tr.yourGoldLabel, { amount: heroGold })}
</div>
</>
)}
@ -548,7 +560,7 @@ export function NPCDialog({
{/* ---- Healer ---- */}
{npc.type === 'healer' && (
<>
<div style={sectionTitleStyle}>Services</div>
<div style={sectionTitleStyle}>{tr.servicesSection}</div>
<button
style={
heroGold >= healCost
@ -561,7 +573,7 @@ export function NPCDialog({
{'\u2764\uFE0F'} {tr.healToFull} &mdash; {healCost} {tr.gold}
</button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold}
{t(tr.yourGoldLabel, { amount: heroGold })}
</div>
</>
)}

@ -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<typeof useT>,
): { 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;
}
})();

@ -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 && (
<div style={lineStyle}>{t(tr.offlineRevives, { count: revives })}</div>
)}
{loot.length > 0 && (
{itemLoot.length > 0 && (
<>
<div style={{ ...lineStyle, marginTop: 10 }}>{tr.offlineLootFound}</div>
<div style={lootListStyle}>
{loot.map((line, i) => (
{itemLoot.map((line, i) => (
<div key={i}>{formatOfflineLootLine(line)}</div>
))}
</div>

@ -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<number | null>(null);
const activeQuests = quests.filter((q) => q.status !== 'claimed');
@ -250,7 +252,9 @@ export function QuestLogList({ quests, onClaim, onAbandon, claimDisabled = false
>
<div style={questHeaderRow}>
<span style={{ fontSize: 16 }}>{questTypeIcon(q.type)}</span>
<span style={questTitleStyle}>{q.title}</span>
<span style={questTitleStyle}>
{localizedQuestText(locale, q.questKey, 'title', q.title)}
</span>
<span
style={{
...progressTextStyle,
@ -277,7 +281,9 @@ export function QuestLogList({ quests, onClaim, onAbandon, claimDisabled = false
{/* Expanded details */}
{isExpanded && (
<>
<div style={descriptionStyle}>{q.description}</div>
<div style={descriptionStyle}>
{localizedQuestText(locale, q.questKey, 'description', q.description)}
</div>
{q.type === 'visit_town' && q.targetTownName ? (
<div style={{ ...descriptionStyle, color: '#9bdcff', fontSize: 11 }}>
{tr.questDestination}: {q.targetTownName}

@ -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, { icon: string; color: string }> = {
[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, [keyof Translations, keyof Translations]> = {
[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] };
}

Loading…
Cancel
Save