From e8de1d62c15532525092ecf797eb53783862dfff Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Mon, 30 Mar 2026 01:05:56 +0300 Subject: [PATCH] Huge refactor, i18n, payments, stats, inventory, logic --- backend/cmd/server/main.go | 21 +- backend/internal/config/config.go | 18 +- backend/internal/game/engine.go | 367 ++++++++++++++++++- backend/internal/game/movement.go | 470 ++++++++++++++++++++++++- backend/internal/game/offline.go | 57 +-- backend/internal/game/offline_test.go | 8 +- backend/internal/game/road_graph.go | 18 + backend/internal/handler/admin.go | 304 +++++++++++++++- backend/internal/handler/game.go | 196 ++++++++--- backend/internal/handler/npc.go | 187 ++++++++-- backend/internal/model/buff_quota.go | 80 ++++- backend/internal/model/hero.go | 22 +- backend/internal/model/ws_message.go | 19 + backend/internal/router/router.go | 24 +- backend/internal/storage/gear_store.go | 120 +++++++ backend/internal/storage/hero_store.go | 64 +++- docker-compose.yml | 1 + frontend/src/App.tsx | 305 +++++++--------- frontend/src/game/engine.ts | 19 +- frontend/src/game/renderer.ts | 15 +- frontend/src/game/types.ts | 22 ++ frontend/src/game/ws-handler.ts | 46 +++ frontend/src/network/api.ts | 19 +- frontend/src/ui/AdventureLog.tsx | 130 ++----- frontend/src/ui/BuffBar.tsx | 14 +- frontend/src/ui/DeathScreen.tsx | 10 +- frontend/src/ui/EquipmentPanel.tsx | 177 ---------- frontend/src/ui/HUD.tsx | 86 ++++- frontend/src/ui/HeroPanel.tsx | 128 +++---- frontend/src/ui/InventoryStrip.tsx | 27 +- frontend/src/ui/Minimap.tsx | 14 +- frontend/src/ui/NPCDialog.tsx | 44 +-- frontend/src/ui/NameEntryScreen.tsx | 18 +- frontend/src/ui/OfflineReport.tsx | 19 +- frontend/src/ui/QuestLog.tsx | 75 ++-- frontend/src/ui/WanderingNPCPopup.tsx | 10 +- scripts/admin-tool.ps1 | 35 +- 37 files changed, 2354 insertions(+), 835 deletions(-) delete mode 100644 frontend/src/ui/EquipmentPanel.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6b11826..b571e70 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -79,6 +79,15 @@ func main() { engine.SetSender(hub) // Hub implements game.MessageSender engine.SetRoadGraph(roadGraph) engine.SetHeroStore(heroStore) + engine.SetAdventureLog(func(heroID int64, msg string) { + logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := logStore.Add(logCtx, heroID, msg); 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 callbacks: on connect, load hero and register movement; on disconnect, persist. hub.OnConnect = func(heroID int64) { @@ -100,6 +109,13 @@ func main() { case <-ctx.Done(): return case msg := <-hub.Incoming: + if engine.IsTimePaused() { + hub.SendToHero(msg.HeroID, "error", model.ErrorPayload{ + Code: "time_paused", + Message: "server time is paused", + }) + continue + } engine.IncomingCh() <- game.IncomingMessage{ HeroID: msg.HeroID, Type: msg.Type, @@ -132,7 +148,9 @@ func main() { // Record server start time for catch-up gap calculation. serverStartedAt := time.Now() - offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger) + offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger, func() bool { + return engine.IsTimePaused() + }) go func() { if err := offlineSim.Run(ctx); err != nil && err != context.Canceled { logger.Error("offline simulator error", "error", err) @@ -145,6 +163,7 @@ func main() { Hub: hub, PgPool: pgPool, BotToken: cfg.BotToken, + PaymentProviderToken: cfg.PaymentProviderToken, AdminBasicAuthUsername: cfg.Admin.BasicAuthUsername, AdminBasicAuthPassword: cfg.Admin.BasicAuthPassword, AdminBasicAuthRealm: cfg.Admin.BasicAuthRealm, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 280ddaa..0fb88b0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -6,12 +6,13 @@ import ( ) type Config struct { - ServerPort string - BotToken string - DB DBConfig - Redis RedisConfig - Game GameConfig - Admin AdminConfig + ServerPort string + BotToken string + PaymentProviderToken string + DB DBConfig + Redis RedisConfig + Game GameConfig + Admin AdminConfig } type DBConfig struct { @@ -42,8 +43,9 @@ type AdminConfig struct { func Load() *Config { return &Config{ - ServerPort: envOrDefault("SERVER_PORT", "8080"), - BotToken: os.Getenv("BOT_TOKEN"), + ServerPort: envOrDefault("SERVER_PORT", "8080"), + BotToken: os.Getenv("BOT_TOKEN"), + PaymentProviderToken: os.Getenv("PAYMENT_PROVIDER_TOKEN"), DB: DBConfig{ Host: envOrDefault("DB_HOST", "localhost"), Port: envOrDefault("DB_PORT", "5432"), diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 293d372..b40bf07 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -31,6 +31,7 @@ type EngineStatus struct { ActiveCombats int `json:"activeCombats"` ActiveMovements int `json:"activeMovements"` UptimeMs int64 `json:"uptimeMs"` + TimePaused bool `json:"timePaused"` } // CombatInfo is a read-only snapshot of a single active combat. @@ -64,8 +65,16 @@ type Engine struct { eventCh chan model.CombatEvent logger *slog.Logger onEnemyDeath EnemyDeathCallback + adventureLog AdventureLogWriter startedAt time.Time running bool + // timePaused: when true, combat/movement/sync ticks and WS game commands are no-ops. + timePaused bool + // pauseStartedAt is wall clock when global pause began (zero when running). + pauseStartedAt time.Time + + // npcAlmsHandler runs when the client accepts a wandering merchant offer (WS). + npcAlmsHandler func(context.Context, int64) error } const minAttackInterval = 250 * time.Millisecond @@ -93,6 +102,98 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement { return e.movements[heroId] } +// RoadGraph returns the loaded world graph (for admin tools), or nil. +func (e *Engine) RoadGraph() *RoadGraph { + e.mu.RLock() + defer e.mu.RUnlock() + return e.roadGraph +} + +// SetTimePaused freezes all engine simulation ticks and client command handling. +// On resume, movement/combat timers are shifted so wall time spent paused does not advance the sim +// (no invisible travel or burst combat). +func (e *Engine) SetTimePaused(paused bool) { + e.mu.Lock() + defer e.mu.Unlock() + + if paused { + if !e.timePaused { + e.pauseStartedAt = time.Now() + e.timePaused = true + if e.logger != nil { + e.logger.Info("game time paused") + } + } + return + } + + if !e.timePaused { + return + } + + now := time.Now() + var pauseDur time.Duration + if !e.pauseStartedAt.IsZero() { + pauseDur = now.Sub(e.pauseStartedAt) + } + e.timePaused = false + e.pauseStartedAt = time.Time{} + + if pauseDur > 0 { + for _, hm := range e.movements { + hm.ShiftGameDeadlines(pauseDur, now) + if hm.Hero != nil { + model.ShiftHeroEffectDeadlines(hm.Hero, pauseDur) + } + } + e.resyncCombatAfterPauseLocked(now, pauseDur) + if e.logger != nil { + e.logger.Info("game time resumed", "paused_wall_ms", pauseDur.Milliseconds()) + } + } +} + +// resyncCombatAfterPauseLocked shifts combat scheduling by pauseDur and rebuilds the attack heap. +// Caller must hold e.mu (write lock). +func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Duration) { + if len(e.combats) == 0 { + return + } + for _, cs := range e.combats { + cs.LastTickAt = now + cs.StartedAt = cs.StartedAt.Add(pauseDur) + + hna := cs.HeroNextAttack.Add(pauseDur) + ena := cs.EnemyNextAttack.Add(pauseDur) + if cs.Hero != nil { + if hna.Before(now) { + hna = now.Add(attackInterval(cs.Hero.EffectiveSpeed())) + } + } else if hna.Before(now) { + hna = now.Add(minAttackInterval * combatPaceMultiplier) + } + if ena.Before(now) { + ena = now.Add(attackInterval(cs.Enemy.Speed)) + } + cs.HeroNextAttack = hna + cs.EnemyNextAttack = ena + } + + e.queue = make(model.AttackQueue, 0) + for heroID, cs := range e.combats { + heap.Push(&e.queue, &model.AttackEvent{NextAttackAt: cs.HeroNextAttack, IsHero: true, CombatID: heroID}) + heap.Push(&e.queue, &model.AttackEvent{NextAttackAt: cs.EnemyNextAttack, IsHero: false, CombatID: heroID}) + } + heap.Init(&e.queue) +} + +// IsTimePaused reports whether global simulation time is frozen. +func (e *Engine) IsTimePaused() bool { + e.mu.RLock() + defer e.mu.RUnlock() + return e.timePaused +} + // SetSender sets the WS message sender (typically handler.Hub). func (e *Engine) SetSender(s MessageSender) { e.mu.Lock() @@ -121,6 +222,20 @@ func (e *Engine) SetOnEnemyDeath(cb EnemyDeathCallback) { e.onEnemyDeath = cb } +// SetNPCAlmsHandler registers the handler for npc_alms_accept WebSocket commands. +func (e *Engine) SetNPCAlmsHandler(h func(context.Context, int64) error) { + e.mu.Lock() + defer e.mu.Unlock() + e.npcAlmsHandler = h +} + +// SetAdventureLog registers a writer for town NPC visit lines (optional). +func (e *Engine) SetAdventureLog(w AdventureLogWriter) { + e.mu.Lock() + defer e.mu.Unlock() + e.adventureLog = w +} + // IncomingCh returns the channel for routing client WS commands into the engine. func (e *Engine) IncomingCh() chan<- IncomingMessage { return e.incomingCh @@ -154,11 +269,17 @@ func (e *Engine) Run(ctx context.Context) error { e.logger.Info("game engine shutting down") return ctx.Err() case now := <-combatTicker.C: - e.processCombatTick(now) + if !e.IsTimePaused() { + e.processCombatTick(now) + } case now := <-moveTicker.C: - e.processMovementTick(now) + if !e.IsTimePaused() { + e.processMovementTick(now) + } case now := <-syncTicker.C: - e.processPositionSync(now) + if !e.IsTimePaused() { + e.processPositionSync(now) + } case msg := <-e.incomingCh: e.handleClientMessage(msg) } @@ -167,6 +288,10 @@ func (e *Engine) Run(ctx context.Context) error { // handleClientMessage routes a single inbound client command. func (e *Engine) handleClientMessage(msg IncomingMessage) { + if e.IsTimePaused() { + e.sendError(msg.HeroID, "time_paused", "server time is paused") + return + } switch msg.Type { case "activate_buff": e.handleActivateBuff(msg) @@ -174,6 +299,10 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) { e.handleUsePotion(msg) case "revive": e.handleRevive(msg) + case "npc_alms_accept": + e.handleNPCAlmsAccept(msg) + case "npc_alms_decline": + e.handleNPCAlmsDecline(msg) default: // Commands like accept_quest, claim_quest, npc_interact etc. // are handled by their respective REST handlers for now. @@ -266,6 +395,35 @@ func (e *Engine) handleUsePotion(msg IncomingMessage) { } // handleRevive processes the revive client command. +func (e *Engine) handleNPCAlmsAccept(msg IncomingMessage) { + e.mu.RLock() + h := e.npcAlmsHandler + e.mu.RUnlock() + if h == nil { + e.sendError(msg.HeroID, "not_supported", "wandering merchant is not available") + return + } + if err := h(context.Background(), msg.HeroID); err != nil { + e.sendError(msg.HeroID, "alms_failed", err.Error()) + } +} + +func (e *Engine) handleNPCAlmsDecline(msg IncomingMessage) { + e.mu.Lock() + defer e.mu.Unlock() + hm, ok := e.movements[msg.HeroID] + if !ok { + return + } + if hm.WanderingMerchantDeadline.IsZero() { + return + } + hm.WanderingMerchantDeadline = time.Time{} + if e.sender != nil { + e.sender.SendToHero(msg.HeroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "declined"}) + } +} + func (e *Engine) handleRevive(msg IncomingMessage) { e.mu.Lock() defer e.mu.Unlock() @@ -305,8 +463,10 @@ func (e *Engine) handleRevive(msg IncomingMessage) { } if e.sender != nil { - e.sender.SendToHero(msg.HeroID, "hero_revived", model.HeroRevivedPayload{HP: hero.HP}) + hero.EnsureGearMap() + hero.RefreshDerivedCombatStats(time.Now()) e.sender.SendToHero(msg.HeroID, "hero_state", hero) + e.sender.SendToHero(msg.HeroID, "hero_revived", model.HeroRevivedPayload{HP: hero.HP}) } } @@ -333,6 +493,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { // Reconnect while the previous socket is still tearing down: keep live movement so we // do not replace (x,y) and route with a stale DB snapshot. if existing, ok := e.movements[hero.ID]; ok { + existing.Hero.EnsureGearMap() existing.Hero.RefreshDerivedCombatStats(now) e.logger.Info("hero movement reattached (existing session)", "hero_id", hero.ID, @@ -367,6 +528,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) { // Send initial state via WS. if e.sender != nil { + hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) @@ -428,7 +590,94 @@ func (e *Engine) Status() EngineStatus { ActiveCombats: len(e.combats), ActiveMovements: len(e.movements), UptimeMs: uptimeMs, + TimePaused: e.timePaused, + } +} + +// ApplyAdminStartAdventure forces an off-road adventure for an online hero (walking on a road). +func (e *Engine) ApplyAdminStartAdventure(heroID int64) (*model.Hero, bool) { + e.mu.Lock() + defer e.mu.Unlock() + hm, ok := e.movements[heroID] + if !ok || e.roadGraph == nil { + return nil, false + } + now := time.Now() + if !hm.StartAdventureForced(now) { + return nil, false + } + hm.SyncToHero() + h := hm.Hero + if e.sender != nil { + h.EnsureGearMap() + h.RefreshDerivedCombatStats(now) + e.sender.SendToHero(heroID, "hero_state", h) + e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) + } + return h, true +} + +// ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival). +func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) { + e.mu.Lock() + defer e.mu.Unlock() + hm, ok := e.movements[heroID] + if !ok || e.roadGraph == nil { + return nil, false + } + now := time.Now() + if err := hm.AdminPlaceInTown(e.roadGraph, townID, now); err != nil { + return nil, false + } + delete(e.combats, heroID) + hm.SyncToHero() + h := hm.Hero + if e.sender != nil { + h.EnsureGearMap() + h.RefreshDerivedCombatStats(now) + e.sender.SendToHero(heroID, "hero_state", h) + town := e.roadGraph.Towns[hm.CurrentTownID] + if town != nil { + npcInfos := make([]model.TownNPCInfo, 0, len(e.roadGraph.TownNPCs[hm.CurrentTownID])) + for _, n := range e.roadGraph.TownNPCs[hm.CurrentTownID] { + npcInfos = append(npcInfos, model.TownNPCInfo{ID: n.ID, Name: n.Name, Type: n.Type}) + } + var restMs int64 + if hm.State == model.StateResting { + restMs = hm.RestUntil.Sub(now).Milliseconds() + } + e.sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{ + TownID: town.ID, + TownName: town.Name, + Biome: town.Biome, + NPCs: npcInfos, + RestDurationMs: restMs, + }) + } + } + return h, true +} + +// ApplyAdminStartRest puts an online hero into town-style rest at the current location. +func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) { + e.mu.Lock() + defer e.mu.Unlock() + hm, ok := e.movements[heroID] + if !ok || e.roadGraph == nil { + return nil, false + } + now := time.Now() + if !hm.AdminStartRest(now, e.roadGraph) { + return nil, false + } + hm.SyncToHero() + h := hm.Hero + if e.sender != nil { + h.EnsureGearMap() + h.RefreshDerivedCombatStats(now) + e.sender.SendToHero(heroID, "hero_state", h) } + return h, true } // ListActiveCombats returns a snapshot of all active combat sessions. @@ -464,6 +713,13 @@ func (e *Engine) StartCombat(hero *model.Hero, enemy *model.Enemy) { func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) { now := time.Now() + if hm, ok := e.movements[hero.ID]; ok { + if hm.State == model.StateResting || hm.State == model.StateInTown { + e.logger.Debug("skip combat start: hero in town", "hero_id", hero.ID) + return + } + } + cs := &model.CombatState{ HeroID: hero.ID, Hero: hero, @@ -529,10 +785,83 @@ func (e *Engine) SyncHeroState(hero *model.Hero) { } e.mu.Lock() defer e.mu.Unlock() - + if e.sender == nil { + return + } + hero.EnsureGearMap() e.sender.SendToHero(hero.ID, "hero_state", hero) } +// ApplyAdminHeroSnapshot merges a persisted hero (e.g. after admin set-hp) into the live +// movement session and pushes hero_state (+ route_assigned when a new road was bound). +func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) { + if hero == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + + hm, ok := e.movements[hero.ID] + if !ok || e.roadGraph == nil { + return + } + + now := time.Now() + *hm.Hero = *hero + hm.State = hero.State + hm.CurrentX = hero.PositionX + hm.CurrentY = hero.PositionY + hm.LastMoveTick = now + hm.refreshSpeed(now) + + routeAssigned := false + if hm.State == model.StateWalking && hm.Road == nil { + hm.pickDestination(e.roadGraph) + hm.assignRoad(e.roadGraph) + routeAssigned = true + } + + if e.sender == nil { + return + } + hm.Hero.EnsureGearMap() + hm.Hero.RefreshDerivedCombatStats(now) + e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) + if routeAssigned { + if route := hm.RoutePayload(); route != nil { + e.sender.SendToHero(hero.ID, "route_assigned", route) + } + } +} + +// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into +// the live movement session and pushes hero_state when a sender is configured. +func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) { + if hero == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + + hm, ok := e.movements[hero.ID] + if ok { + now := time.Now() + hm.WanderingMerchantDeadline = time.Time{} + *hm.Hero = *hero + hm.Hero.EnsureGearMap() + hm.Hero.RefreshDerivedCombatStats(now) + } + if e.sender == nil { + return + } + if ok { + e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) + } else { + hero.EnsureGearMap() + e.sender.SendToHero(hero.ID, "hero_state", hero) + } +} + // ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted // the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online), // restores movement/route when needed, and pushes WS events so the client matches the DB. @@ -570,9 +899,11 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) { if e.sender == nil { return } + hm.Hero.EnsureGearMap() hm.Hero.RefreshDerivedCombatStats(now) - e.sender.SendToHero(hero.ID, "hero_revived", model.HeroRevivedPayload{HP: hm.Hero.HP}) + // Full snapshot first so clients never briefly drop gear after hero_revived. e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) + e.sender.SendToHero(hero.ID, "hero_revived", model.HeroRevivedPayload{HP: hm.Hero.HP}) if routeAssigned { if route := hm.RoutePayload(); route != nil { e.sender.SendToHero(hero.ID, "route_assigned", route) @@ -593,6 +924,22 @@ func (e *Engine) processCombatTick(now time.Time) { e.mu.Lock() defer e.mu.Unlock() + // Heroes resting or touring town must not keep fighting in the background. + var purgeCombat []int64 + for heroID := range e.combats { + if hm, ok := e.movements[heroID]; ok { + if hm.State == model.StateResting || hm.State == model.StateInTown { + purgeCombat = append(purgeCombat, heroID) + } + } + } + for _, heroID := range purgeCombat { + delete(e.combats, heroID) + if hm, ok := e.movements[heroID]; ok { + hm.Hero.State = hm.State + } + } + // Apply periodic effects (debuff DoT, enemy regen, summon damage) for all active combats. for heroID, cs := range e.combats { if cs.Hero == nil { @@ -777,9 +1124,11 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) { e.sender.SendToHero(cs.HeroID, "level_up", model.LevelUpPayload{ NewLevel: hero.Level, }) - hero.RefreshDerivedCombatStats(now) - e.sender.SendToHero(cs.HeroID, "hero_state", hero) } + hero.EnsureGearMap() + hero.EnsureInventorySlice() + hero.RefreshDerivedCombatStats(now) + e.sender.SendToHero(cs.HeroID, "hero_state", hero) } delete(e.combats, cs.HeroID) @@ -810,7 +1159,7 @@ func (e *Engine) processMovementTick(now time.Time) { } for heroID, hm := range e.movements { - ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil) + ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog) } } diff --git a/backend/internal/game/movement.go b/backend/internal/game/movement.go index 7211987..1ae84f0 100644 --- a/backend/internal/game/movement.go +++ b/backend/internal/game/movement.go @@ -1,6 +1,7 @@ package game import ( + "fmt" "math" "math/rand" "time" @@ -21,19 +22,43 @@ const ( // EncounterCooldownBase is the minimum gap between road encounters (monster or merchant). EncounterCooldownBase = 12 * time.Second + // WanderingMerchantPromptTimeout is how long the hero stays stopped for the wandering merchant dialog (online). + WanderingMerchantPromptTimeout = 15 * time.Second + // EncounterActivityBase scales per-tick chance to roll an encounter after cooldown. // Effective activity is higher deep off-road (see rollRoadEncounter). EncounterActivityBase = 0.035 // StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion. - StartAdventurePerTick = 0.0021 + StartAdventurePerTick = 0.000030 // AdventureDurationMin/Max bound how long an off-road excursion lasts. AdventureDurationMin = 15 * time.Minute AdventureDurationMax = 20 * time.Minute // AdventureMaxLateral is max perpendicular offset from the road spine (world units) at peak wilderness. - AdventureMaxLateral = 3.5 + AdventureMaxLateral = 20.0 + + // AdventureWildernessRampFraction is the share of excursion time spent easing off the road at the start + // and easing back at the end. The middle (1 - 2*ramp) stays at full lateral offset so the hero + // visibly walks beside the road for most of a long excursion. + AdventureWildernessRampFraction = 0.12 + + // LowHPThreshold: below this HP fraction (of MaxHP) the hero seeks a short roadside rest. + LowHPThreshold = 0.35 + // RoadsideRestExitHP: leave roadside rest when HP reaches this fraction of MaxHP (or max duration). + RoadsideRestExitHP = 0.92 + // RoadsideRestDurationMin/Max cap how long a roadside rest can last (hero may leave earlier if healed). + RoadsideRestDurationMin = 40 * time.Second + RoadsideRestDurationMax = 100 * time.Second + // RoadsideRestLateral is perpendicular offset from the road while resting (smaller than adventure). + RoadsideRestLateral = 1.15 + // RoadsideRestHPPerSecond is MaxHP fraction restored per second while roadside resting (0.1%). + RoadsideRestHPPerSecond = 0.001 + + // RoadsideRestThoughtMinInterval / MaxInterval between adventure log lines while resting. + RoadsideRestThoughtMinInterval = 4 * time.Second + RoadsideRestThoughtMaxInterval = 11 * time.Second // TownRestMin is the minimum rest duration when arriving at a town. TownRestMin = 5 * 60 * time.Second @@ -50,8 +75,20 @@ const ( townNPCRollMin = 800 * time.Millisecond townNPCRollMax = 2600 * time.Millisecond townNPCRetryAfterMiss = 450 * time.Millisecond + // TownNPCVisitTownPause is how long the hero stays in town after the last NPC (whole town) before leaving. + TownNPCVisitTownPause = 30 * time.Second + // TownNPCVisitLogInterval is how often a line is written to the adventure log during a visit. + TownNPCVisitLogInterval = 5 * time.Second + // townNPCVisitLogLines is how many log lines to emit per NPC (every TownNPCVisitLogInterval). + townNPCVisitLogLines = 6 ) +// TownNPCVisitNarrationBlock is the minimum gap before visiting the next town NPC (first line through last line). +var TownNPCVisitNarrationBlock = TownNPCVisitLogInterval * (townNPCVisitLogLines - 1) + +// AdventureLogWriter persists or pushes one adventure log line for a hero (optional). +type AdventureLogWriter func(heroID int64, message string) + // HeroMovement holds the live movement state for a single online hero. type HeroMovement struct { HeroID int64 @@ -74,11 +111,28 @@ type HeroMovement struct { TownNPCQueue []int64 NextTownNPCRollAt time.Time + // Town NPC visit: adventure log lines until NextTownNPCRollAt (narration block) after town_npc_visit. + TownVisitNPCName string + TownVisitNPCType string + TownVisitStartedAt time.Time + TownVisitLogsEmitted int + + // TownLeaveAt: after all town NPCs are visited (queue empty), leave only once now >= TownLeaveAt (TownNPCVisitTownPause). + TownLeaveAt time.Time + // Off-road excursion ("looking for trouble"): not persisted; cleared on town enter and when it ends. AdventureStartAt time.Time AdventureEndAt time.Time AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring + // Roadside rest (low HP): step off the road and recover HP; not persisted. + RoadsideRestEndAt time.Time + RoadsideRestSide int // +1 / -1 perpendicular; 0 = not resting + RoadsideRestNextLog time.Time + + // WanderingMerchantDeadline: non-zero while the hero is frozen for wandering merchant UI (online WS only). + WanderingMerchantDeadline time.Time + // spawnAtRoadStart: DB had no world position yet — place at first waypoint after assignRoad // instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0. spawnAtRoadStart bool @@ -349,6 +403,32 @@ func (hm *HeroMovement) snapProgressToNearestPointOnRoad() { hm.CurrentY = bestY } +// ShiftGameDeadlines advances movement-related deadlines by d (wall time spent paused) so +// simulation does not “catch up” after resume. LastMoveTick is set to now to avoid a huge dt on the next tick. +func (hm *HeroMovement) ShiftGameDeadlines(d time.Duration, now time.Time) { + if d <= 0 { + hm.LastMoveTick = now + return + } + shift := func(t time.Time) time.Time { + if t.IsZero() { + return t + } + return t.Add(d) + } + hm.LastEncounterAt = shift(hm.LastEncounterAt) + hm.RestUntil = shift(hm.RestUntil) + hm.NextTownNPCRollAt = shift(hm.NextTownNPCRollAt) + hm.TownVisitStartedAt = shift(hm.TownVisitStartedAt) + hm.TownLeaveAt = shift(hm.TownLeaveAt) + hm.AdventureStartAt = shift(hm.AdventureStartAt) + hm.AdventureEndAt = shift(hm.AdventureEndAt) + hm.RoadsideRestEndAt = shift(hm.RoadsideRestEndAt) + hm.RoadsideRestNextLog = shift(hm.RoadsideRestNextLog) + hm.WanderingMerchantDeadline = shift(hm.WanderingMerchantDeadline) + hm.LastMoveTick = now +} + // refreshSpeed recalculates the effective movement speed using hero buffs/debuffs. func (hm *HeroMovement) refreshSpeed(now time.Time) { // Per-hero speed variation: ±10% based on hero ID for natural spread. @@ -453,6 +533,81 @@ func (hm *HeroMovement) expireAdventureIfNeeded(now time.Time) { hm.AdventureSide = 0 } +func (hm *HeroMovement) roadsideRestInProgress() bool { + return !hm.RoadsideRestEndAt.IsZero() +} + +func (hm *HeroMovement) endRoadsideRest() { + hm.RoadsideRestEndAt = time.Time{} + hm.RoadsideRestSide = 0 + hm.RoadsideRestNextLog = time.Time{} +} + +func (hm *HeroMovement) applyRoadsideRestHeal(dt float64) { + if dt <= 0 || hm.Hero == nil || hm.Hero.MaxHP <= 0 { + return + } + gain := int(math.Ceil(float64(hm.Hero.MaxHP) * RoadsideRestHPPerSecond * dt)) + if gain < 1 { + gain = 1 + } + hm.Hero.HP += gain + if hm.Hero.HP > hm.Hero.MaxHP { + hm.Hero.HP = hm.Hero.MaxHP + } +} + +// tryStartRoadsideRest pulls the hero off the road when HP is low; cancels an active adventure. +func (hm *HeroMovement) tryStartRoadsideRest(now time.Time) { + if hm.roadsideRestInProgress() { + return + } + if hm.Hero == nil || hm.Hero.MaxHP <= 0 { + return + } + if float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) > LowHPThreshold { + return + } + hm.AdventureStartAt = time.Time{} + hm.AdventureEndAt = time.Time{} + hm.AdventureSide = 0 + spanNs := (RoadsideRestDurationMax - RoadsideRestDurationMin).Nanoseconds() + if spanNs < 1 { + spanNs = 1 + } + hm.RoadsideRestEndAt = now.Add(RoadsideRestDurationMin + time.Duration(rand.Int63n(spanNs+1))) + if rand.Float64() < 0.5 { + hm.RoadsideRestSide = 1 + } else { + hm.RoadsideRestSide = -1 + } + hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) +} + +func randomRoadsideRestThoughtDelay() time.Duration { + span := RoadsideRestThoughtMaxInterval - RoadsideRestThoughtMinInterval + if span < 0 { + span = 0 + } + return RoadsideRestThoughtMinInterval + time.Duration(rand.Int63n(int64(span)+1)) +} + +// emitRoadsideRestThoughts appends occasional journal lines while the hero rests off the road. +func emitRoadsideRestThoughts(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { + if log == nil || !hm.roadsideRestInProgress() { + return + } + if hm.RoadsideRestNextLog.IsZero() { + hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) + return + } + if now.Before(hm.RoadsideRestNextLog) { + return + } + log(heroID, randomRoadsideRestThought()) + hm.RoadsideRestNextLog = now.Add(randomRoadsideRestThoughtDelay()) +} + // tryStartAdventure begins a timed off-road excursion with small probability. func (hm *HeroMovement) tryStartAdventure(now time.Time) { if hm.adventureActive(now) { @@ -474,7 +629,93 @@ func (hm *HeroMovement) tryStartAdventure(now time.Time) { } } -// wildernessFactor is 0 on the road, then 0→1→0 over the excursion (triangle: out, then back). +// StartAdventureForced starts an off-road adventure immediately (admin). +func (hm *HeroMovement) StartAdventureForced(now time.Time) bool { + if hm.Hero == nil || hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { + return false + } + if hm.State != model.StateWalking { + return false + } + if hm.Road == nil || len(hm.Road.Waypoints) < 2 { + return false + } + if hm.adventureActive(now) { + return true + } + spanNs := (AdventureDurationMax - AdventureDurationMin).Nanoseconds() + if spanNs < 1 { + spanNs = 1 + } + hm.AdventureStartAt = now + hm.AdventureEndAt = now.Add(AdventureDurationMin + time.Duration(rand.Int63n(spanNs+1))) + if rand.Float64() < 0.5 { + hm.AdventureSide = 1 + } else { + hm.AdventureSide = -1 + } + return true +} + +// AdminPlaceInTown moves the hero to a town center and applies EnterTown logic (NPC tour or rest). +func (hm *HeroMovement) AdminPlaceInTown(graph *RoadGraph, townID int64, now time.Time) error { + if graph == nil || townID == 0 { + return fmt.Errorf("invalid town") + } + if _, ok := graph.Towns[townID]; !ok { + return fmt.Errorf("unknown town") + } + hm.Road = nil + hm.WaypointIndex = 0 + hm.WaypointFraction = 0 + hm.DestinationTownID = townID + hm.spawnAtRoadStart = false + hm.AdventureStartAt = time.Time{} + hm.AdventureEndAt = time.Time{} + hm.AdventureSide = 0 + hm.endRoadsideRest() + hm.WanderingMerchantDeadline = time.Time{} + hm.TownVisitNPCName = "" + hm.TownVisitNPCType = "" + hm.TownVisitStartedAt = time.Time{} + hm.TownVisitLogsEmitted = 0 + t := graph.Towns[townID] + hm.CurrentX = t.WorldX + hm.CurrentY = t.WorldY + hm.EnterTown(now, graph) + return nil +} + +// AdminStartRest forces a resting period (same duration model as town rest). +func (hm *HeroMovement) AdminStartRest(now time.Time, graph *RoadGraph) bool { + if hm.Hero == nil || hm.Hero.HP <= 0 || hm.Hero.State == model.StateDead { + return false + } + if hm.State == model.StateFighting { + return false + } + hm.endRoadsideRest() + hm.AdventureStartAt = time.Time{} + hm.AdventureEndAt = time.Time{} + hm.AdventureSide = 0 + hm.WanderingMerchantDeadline = time.Time{} + hm.TownNPCQueue = nil + hm.NextTownNPCRollAt = time.Time{} + hm.TownVisitNPCName = "" + hm.TownVisitNPCType = "" + hm.TownVisitStartedAt = time.Time{} + hm.TownVisitLogsEmitted = 0 + if graph != nil && hm.CurrentTownID == 0 { + hm.CurrentTownID = graph.NearestTown(hm.CurrentX, hm.CurrentY) + } + hm.State = model.StateResting + hm.Hero.State = model.StateResting + hm.RestUntil = now.Add(randomRestDuration()) + return true +} + +// wildernessFactor is 0 on the road, then ramps to 1, stays at 1 for most of the excursion, then ramps back. +// (Trapezoid, not a triangle — so "off-road" reads as a long stretch, not a brief peak at the midpoint.) func (hm *HeroMovement) wildernessFactor(now time.Time) float64 { if !hm.adventureActive(now) { return 0 @@ -490,10 +731,20 @@ func (hm *HeroMovement) wildernessFactor(now time.Time) float64 { } else if p > 1 { p = 1 } - if p < 0.5 { - return p * 2 + r := AdventureWildernessRampFraction + if r < 1e-6 { + r = 1e-6 + } + if r > 0.49 { + r = 0.49 + } + if p < r { + return p / r } - return (1 - p) * 2 + if p > 1-r { + return (1 - p) / r + } + return 1 } func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) { @@ -519,6 +770,14 @@ func (hm *HeroMovement) roadPerpendicularUnit() (float64, float64) { } func (hm *HeroMovement) displayOffset(now time.Time) (float64, float64) { + if hm.roadsideRestInProgress() { + if hm.RoadsideRestSide == 0 { + return 0, 0 + } + px, py := hm.roadPerpendicularUnit() + mag := float64(hm.RoadsideRestSide) * RoadsideRestLateral + return px * mag, py * mag + } w := hm.wildernessFactor(now) if w <= 0 || hm.AdventureSide == 0 { return 0, 0 @@ -568,9 +827,15 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { hm.Road = nil hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} + hm.TownVisitNPCName = "" + hm.TownVisitNPCType = "" + hm.TownVisitStartedAt = time.Time{} + hm.TownVisitLogsEmitted = 0 + hm.TownLeaveAt = time.Time{} hm.AdventureStartAt = time.Time{} hm.AdventureEndAt = time.Time{} hm.AdventureSide = 0 + hm.endRoadsideRest() ids := graph.TownNPCIDs(destID) if len(ids) == 0 { @@ -593,8 +858,15 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) { func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) { hm.TownNPCQueue = nil hm.NextTownNPCRollAt = time.Time{} + hm.TownVisitNPCName = "" + hm.TownVisitNPCType = "" + hm.TownVisitStartedAt = time.Time{} + hm.TownVisitLogsEmitted = 0 + hm.TownLeaveAt = time.Time{} hm.State = model.StateWalking hm.Hero.State = model.StateWalking + // Prevent a huge movement step on the first tick after town: AdvanceTick uses now - LastMoveTick. + hm.LastMoveTick = now hm.pickDestination(graph) hm.assignRoad(graph) hm.refreshSpeed(now) @@ -608,6 +880,8 @@ func randomTownNPCDelay() time.Duration { // StartFighting pauses movement for combat. func (hm *HeroMovement) StartFighting() { hm.State = model.StateFighting + hm.endRoadsideRest() + hm.WanderingMerchantDeadline = time.Time{} } // ResumWalking resumes movement after combat. @@ -620,6 +894,7 @@ func (hm *HeroMovement) ResumeWalking(now time.Time) { // Die sets the movement state to dead. func (hm *HeroMovement) Die() { hm.State = model.StateDead + hm.endRoadsideRest() } // SyncToHero writes movement state back to the hero model for persistence. @@ -698,12 +973,100 @@ type EncounterStarter func(hm *HeroMovement, enemy *model.Enemy, now time.Time) // MerchantEncounterHook is called for wandering-merchant road events when there is no WS sender (offline). type MerchantEncounterHook func(hm *HeroMovement, now time.Time, cost int64) +func emitTownNPCVisitLogs(heroID int64, hm *HeroMovement, now time.Time, log AdventureLogWriter) { + if log == nil || hm.TownVisitStartedAt.IsZero() { + return + } + for hm.TownVisitLogsEmitted < townNPCVisitLogLines { + deadline := hm.TownVisitStartedAt.Add(time.Duration(hm.TownVisitLogsEmitted) * TownNPCVisitLogInterval) + if now.Before(deadline) { + break + } + msg := townNPCVisitLogMessage(hm.TownVisitNPCType, hm.TownVisitNPCName, hm.TownVisitLogsEmitted) + if msg != "" { + log(heroID, msg) + } + 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 "" +} + // ProcessSingleHeroMovementTick applies one movement-system step as of logical time now. // It mirrors the online engine's 500ms cadence: callers should advance now in MovementTickRate // steps (plus a final partial step to real time) for catch-up simulation. // // sender may be nil to suppress all WebSocket payloads (offline ticks). // onEncounter is required for walking encounter rolls; if nil, encounters are not triggered. +// adventureLog may be nil; when set, town NPC visits append timed lines (per NPC narration block), +// and roadside rest emits occasional thoughts. func ProcessSingleHeroMovementTick( heroID int64, hm *HeroMovement, @@ -712,6 +1075,7 @@ func ProcessSingleHeroMovementTick( sender MessageSender, onEncounter EncounterStarter, onMerchantEncounter MerchantEncounterHook, + adventureLog AdventureLogWriter, ) { if graph == nil { return @@ -722,6 +1086,8 @@ func ProcessSingleHeroMovementTick( return case model.StateResting: + // Advance logical movement time while idle so leaving town does not apply a huge dt (teleport). + hm.LastMoveTick = now if now.After(hm.RestUntil) { hm.LeaveTown(graph, now) hm.SyncToHero() @@ -734,7 +1100,25 @@ func ProcessSingleHeroMovementTick( } case model.StateInTown: + // Same as resting: no road simulation here, but keep LastMoveTick aligned with wall time. + hm.LastMoveTick = now + // NPC visit pause ended: clear visit log state before the next roll. + if !hm.TownVisitStartedAt.IsZero() && !now.Before(hm.NextTownNPCRollAt) { + hm.TownVisitNPCName = "" + hm.TownVisitNPCType = "" + hm.TownVisitStartedAt = time.Time{} + hm.TownVisitLogsEmitted = 0 + } + emitTownNPCVisitLogs(heroID, hm, now, adventureLog) + if len(hm.TownNPCQueue) == 0 { + if hm.TownLeaveAt.IsZero() { + hm.TownLeaveAt = now.Add(TownNPCVisitTownPause) + } + if now.Before(hm.TownLeaveAt) { + return + } + hm.TownLeaveAt = time.Time{} hm.LeaveTown(graph, now) hm.SyncToHero() if sender != nil { @@ -751,12 +1135,19 @@ func ProcessSingleHeroMovementTick( if rand.Float64() < townNPCVisitChance { npcID := hm.TownNPCQueue[0] hm.TownNPCQueue = hm.TownNPCQueue[1:] - if npc, ok := graph.NPCByID[npcID]; ok && sender != nil { - sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ - NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, - }) + if npc, ok := graph.NPCByID[npcID]; ok { + if sender != nil { + sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ + NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, + }) + } + hm.TownVisitNPCName = npc.Name + hm.TownVisitNPCType = npc.Type + hm.TownVisitStartedAt = now + hm.TownVisitLogsEmitted = 0 + emitTownNPCVisitLogs(heroID, hm, now, adventureLog) } - hm.NextTownNPCRollAt = now.Add(randomTownNPCDelay()) + hm.NextTownNPCRollAt = now.Add(TownNPCVisitNarrationBlock) } else { hm.NextTownNPCRollAt = now.Add(townNPCRetryAfterMiss) } @@ -768,6 +1159,62 @@ func ProcessSingleHeroMovementTick( hm.pickDestination(graph) hm.assignRoad(graph) } + + // Wandering merchant dialog (online): freeze movement and encounter rolls until accept/decline or timeout. + if !hm.WanderingMerchantDeadline.IsZero() { + if !now.Before(hm.WanderingMerchantDeadline) { + hm.WanderingMerchantDeadline = time.Time{} + if sender != nil { + sender.SendToHero(heroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "timeout"}) + } + } else { + hm.LastMoveTick = now + if sender != nil { + sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) + } + if hm.Hero != nil { + hm.Hero.PositionX = hm.CurrentX + hm.Hero.PositionY = hm.CurrentY + } + return + } + } + + if hm.roadsideRestInProgress() { + dt := now.Sub(hm.LastMoveTick).Seconds() + if dt <= 0 { + dt = MovementTickRate.Seconds() + } + hm.LastMoveTick = now + hm.applyRoadsideRestHeal(dt) + emitRoadsideRestThoughts(heroID, hm, now, adventureLog) + timeUp := !now.Before(hm.RoadsideRestEndAt) + hpOk := hm.Hero != nil && hm.Hero.MaxHP > 0 && + float64(hm.Hero.HP)/float64(hm.Hero.MaxHP) >= RoadsideRestExitHP + if timeUp || hpOk { + hm.endRoadsideRest() + } else { + if sender != nil { + sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) + } + hm.Hero.PositionX = hm.CurrentX + hm.Hero.PositionY = hm.CurrentY + return + } + } + + hm.tryStartRoadsideRest(now) + if hm.roadsideRestInProgress() { + hm.LastMoveTick = now + emitRoadsideRestThoughts(heroID, hm, now, adventureLog) + if sender != nil { + sender.SendToHero(heroID, "hero_move", hm.MovePayload(now)) + } + hm.Hero.PositionX = hm.CurrentX + hm.Hero.PositionY = hm.CurrentY + return + } + hm.tryStartAdventure(now) reachedTown := hm.AdvanceTick(now, graph) @@ -815,6 +1262,7 @@ func ProcessSingleHeroMovementTick( if sender != nil || onMerchantEncounter != nil { hm.LastEncounterAt = now if sender != nil { + hm.WanderingMerchantDeadline = now.Add(WanderingMerchantPromptTimeout) sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ NPCID: 0, NPCName: "Wandering Merchant", diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 6a9f64a..81aee28 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -21,16 +21,20 @@ type OfflineSimulator struct { graph *RoadGraph interval time.Duration logger *slog.Logger + // isPaused, when set, skips simulation ticks while global server time is frozen. + isPaused func() bool } // NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds. -func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger) *OfflineSimulator { +// isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause). +func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool) *OfflineSimulator { return &OfflineSimulator{ store: store, logStore: logStore, graph: graph, interval: 30 * time.Second, logger: logger, + isPaused: isPaused, } } @@ -54,6 +58,9 @@ func (s *OfflineSimulator) Run(ctx context.Context) error { // processTick finds all offline heroes and simulates one fight for each. func (s *OfflineSimulator) processTick(ctx context.Context) { + if s.isPaused != nil && s.isPaused() { + return + } heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100) if err != nil { s.logger.Error("offline simulator: failed to list offline heroes", "error", err) @@ -99,6 +106,14 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her return nil } + // Do not simulate roads/encounters while the hero is in town (rest or NPC tour). + if hero.State == model.StateInTown || hero.State == model.StateResting { + return nil + } + if hero.MoveState == string(model.StateInTown) || hero.MoveState == string(model.StateResting) { + return nil + } + if s.graph == nil { s.logger.Warn("offline simulator: road graph nil, skipping movement tick", "hero_id", hero.ID) return nil @@ -113,7 +128,9 @@ 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, fmt.Sprintf("Encountered %s", enemy.Name)) - survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy) + survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, func(msg string) { + s.addLog(ctx, hm.Hero.ID, msg) + }) if survived { s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained)) hm.ResumeWalking(tickNow) @@ -139,7 +156,10 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her _ = cost s.addLog(ctx, hm.Hero.ID, "Encountered a Wandering Merchant on the road") } - ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant) + adventureLog := func(heroID int64, msg string) { + s.addLog(ctx, heroID, msg) + } + ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog) if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 { break } @@ -174,14 +194,17 @@ func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message str // It mutates the hero (HP, XP, gold, potions, level, equipment, state). // If encounterEnemy is non-nil, that enemy is used (same as movement encounter roll); // otherwise a new enemy is picked for the hero's level. +// onInventoryDiscard is called when a gear drop cannot be equipped and the backpack is full (may be nil). // Returns whether the hero survived, the enemy fought, XP gained, and gold gained. -func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) { +func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, onInventoryDiscard func(string)) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) { if encounterEnemy != nil { enemy = *encounterEnemy } else { enemy = PickEnemyForLevel(hero.Level) } + allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY) + heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense if heroDmgPerHit < 1 { heroDmgPerHit = 1 @@ -245,8 +268,8 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene if family != nil { ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite) item := model.NewGearItem(family, ilvl, drop.Rarity) - AutoEquipGear(hero, item, now) - } else { + TryEquipOrStashOffline(hero, item, now, onInventoryDiscard) + } else if allowSell { hero.Gold += model.AutoSellPrices[drop.Rarity] goldGained += model.AutoSellPrices[drop.Rarity] } @@ -327,25 +350,3 @@ func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy { return picked } -const autoEquipThreshold = 1.03 // 3% improvement required - -// AutoEquipGear equips the gear item if the slot is empty or the new item -// improves combat rating by >= 3%; otherwise auto-sells it. -func AutoEquipGear(hero *model.Hero, item *model.GearItem, now time.Time) { - if hero.Gear == nil { - hero.Gear = make(map[model.EquipmentSlot]*model.GearItem) - } - current := hero.Gear[item.Slot] - if current == nil { - hero.Gear[item.Slot] = item - return - } - oldRating := hero.CombatRatingAt(now) - hero.Gear[item.Slot] = item - if hero.CombatRatingAt(now) >= oldRating*autoEquipThreshold { - return - } - // Revert: new item is not an upgrade. - hero.Gear[item.Slot] = current - hero.Gold += model.AutoSellPrices[item.Rarity] -} diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index 5e87c1d..c387936 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) { } now := time.Now() - survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil) + survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, nil) if !survived { t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name) @@ -42,7 +42,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) { } now := time.Now() - survived, _, _, _ := SimulateOneFight(hero, now, nil) + survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, nil) if survived { t.Fatal("1 HP hero should die to any enemy") @@ -66,7 +66,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) { } now := time.Now() - survived, _, xpGained, _ := SimulateOneFight(hero, now, nil) + survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, nil) if !survived { t.Fatal("overpowered hero should survive") @@ -98,7 +98,7 @@ func TestSimulateOneFight_PotionUsage(t *testing.T) { break } hero.HP = 25 // force low HP to trigger potion usage - SimulateOneFight(hero, now, nil) + SimulateOneFight(hero, now, nil, nil, nil) } if hero.Potions >= startPotions { diff --git a/backend/internal/game/road_graph.go b/backend/internal/game/road_graph.go index 3772912..8ddd4d6 100644 --- a/backend/internal/game/road_graph.go +++ b/backend/internal/game/road_graph.go @@ -165,6 +165,24 @@ func (g *RoadGraph) NextTownInChain(currentTownID int64) int64 { return g.TownOrder[(idx+1)%n] } +// HeroInTownAt returns true if (x, y) lies inside any town's radius (vendor / sell zone). +func (g *RoadGraph) HeroInTownAt(x, y float64) bool { + if g == nil { + return false + } + for _, t := range g.Towns { + if t == nil { + continue + } + dx := x - t.WorldX + dy := y - t.WorldY + if dx*dx+dy*dy <= t.Radius*t.Radius { + return true + } + } + return false +} + // NearestTown returns the town ID closest to the given world position. func (g *RoadGraph) NearestTown(x, y float64) int64 { bestDist := math.MaxFloat64 diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index c3b8c67..b9ef532 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -1,7 +1,9 @@ package handler import ( + "context" "encoding/json" + "fmt" "log/slog" "net/http" "runtime" @@ -384,7 +386,9 @@ func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) { } h.logger.Info("admin: hero HP set", "hero_id", heroID, "hp", hero.HP) + hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) + h.engine.ApplyAdminHeroSnapshot(hero) writeJSON(w, http.StatusOK, hero) } @@ -430,6 +434,7 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) { h.engine.ApplyAdminHeroRevive(hero) h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP) + hero.EnsureGearMap() hero.RefreshDerivedCombatStats(time.Now()) writeJSON(w, http.StatusOK, hero) } @@ -599,13 +604,304 @@ func (h *AdminHandler) DeleteHero(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) EngineStatus(w http.ResponseWriter, r *http.Request) { status := h.engine.Status() writeJSON(w, http.StatusOK, map[string]any{ - "running": status.Running, - "tickRateMs": status.TickRate.Milliseconds(), - "activeCombats": status.ActiveCombats, - "uptimeMs": status.UptimeMs, + "running": status.Running, + "tickRateMs": status.TickRate.Milliseconds(), + "activeCombats": status.ActiveCombats, + "activeMovements": status.ActiveMovements, + "timePaused": status.TimePaused, + "uptimeMs": status.UptimeMs, }) } +type teleportTownRequest struct { + TownID int64 `json:"townId"` +} + +// ListTowns returns town ids from the loaded road graph (for admin teleport). +// GET /admin/towns +func (h *AdminHandler) ListTowns(w http.ResponseWriter, r *http.Request) { + rg := h.engine.RoadGraph() + if rg == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "road graph not loaded", + }) + return + } + type row struct { + ID int64 `json:"id"` + Name string `json:"name"` + WorldX float64 `json:"worldX"` + WorldY float64 `json:"worldY"` + } + out := make([]row, 0, len(rg.TownOrder)) + for _, id := range rg.TownOrder { + if t := rg.Towns[id]; t != nil { + out = append(out, row{ID: t.ID, Name: t.Name, WorldX: t.WorldX, WorldY: t.WorldY}) + } + } + writeJSON(w, http.StatusOK, map[string]any{"towns": out}) +} + +// StartHeroAdventure forces off-road adventure for a hero (online or offline). +// POST /admin/heroes/{heroId}/start-adventure +func (h *AdminHandler) StartHeroAdventure(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for start-adventure", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + var hm = h.engine.GetMovements(heroID); + hero = hm.Hero; + if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "hero must be alive and not in combat", + "hero": hero, + }) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminStartAdventure(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]any{ + "error": "cannot start adventure (hero must be walking on a road)", + }) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: start adventure", "hero_id", heroID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + if !hm.StartAdventureForced(now) { + return fmt.Errorf("cannot start adventure (hero must be walking on a road)") + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: start adventure (offline)", "hero_id", heroID) + writeJSON(w, http.StatusOK, hero2) +} + +// TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest). +// POST /admin/heroes/{heroId}/teleport-town +func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + var req teleportTownRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid request body: " + err.Error(), + }) + return + } + if req.TownID == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "townId is required", + }) + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for teleport", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "hero must be alive and not in combat", + }) + return + } + + townID := req.TownID + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminTeleportTown(heroID, townID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "teleport failed (unknown town or hero not online with movement)", + }) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after teleport", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: teleport town", "hero_id", heroID, "town_id", townID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + return hm.AdminPlaceInTown(rg, townID, now) + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: teleport town (offline)", "hero_id", heroID, "town_id", townID) + writeJSON(w, http.StatusOK, hero2) +} + +// StartHeroRest forces resting state (duration same as town rest). +// POST /admin/heroes/{heroId}/start-rest +func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + if h.isHeroInCombat(w, heroID) { + return + } + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for start-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + if hero.State == model.StateFighting || hero.State == model.StateDead || hero.HP <= 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "hero must be alive and not in combat", + }) + return + } + + if hm := h.engine.GetMovements(heroID); hm != nil { + out, ok := h.engine.ApplyAdminStartRest(heroID) + if !ok || out == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "cannot start rest", + }) + return + } + if err := h.store.Save(r.Context(), out); err != nil { + h.logger.Error("admin: save after start-rest", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + out.RefreshDerivedCombatStats(time.Now()) + h.logger.Info("admin: start rest", "hero_id", heroID) + writeJSON(w, http.StatusOK, out) + return + } + + hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error { + if !hm.AdminStartRest(now, rg) { + return fmt.Errorf("cannot start rest") + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.logger.Info("admin: start rest (offline)", "hero_id", heroID) + writeJSON(w, http.StatusOK, hero2) +} + +// PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls. +// POST /admin/time/pause +func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) { + h.engine.SetTimePaused(true) + h.logger.Info("admin: global time paused") + writeJSON(w, http.StatusOK, map[string]any{"timePaused": true}) +} + +// ResumeTime resumes engine and offline simulation. +// POST /admin/time/resume +func (h *AdminHandler) ResumeTime(w http.ResponseWriter, r *http.Request) { + h.engine.SetTimePaused(false) + h.logger.Info("admin: global time resumed") + writeJSON(w, http.StatusOK, map[string]any{"timePaused": false}) +} + +// adminMovementOffline rebuilds movement from DB, applies fn, persists. +func (h *AdminHandler) adminMovementOffline(ctx context.Context, hero *model.Hero, fn func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error) (*model.Hero, error) { + rg := h.engine.RoadGraph() + if rg == nil { + return nil, fmt.Errorf("road graph not loaded") + } + now := time.Now() + hm := game.NewHeroMovement(hero, rg, now) + if err := fn(hm, rg, now); err != nil { + return nil, err + } + hm.SyncToHero() + if err := h.store.Save(ctx, hero); err != nil { + return nil, fmt.Errorf("failed to save hero: %w", err) + } + hero.RefreshDerivedCombatStats(now) + return hero, nil +} + // ActiveCombats returns all active combat sessions. // GET /admin/engine/combats func (h *AdminHandler) ActiveCombats(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index d8913f4..98b258f 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -31,6 +31,7 @@ type GameHandler struct { engine *game.Engine store *storage.HeroStore logStore *storage.LogStore + hub *Hub questStore *storage.QuestStore gearStore *storage.GearStore achievementStore *storage.AchievementStore @@ -56,11 +57,12 @@ type encounterEnemyResponse struct { EnemyType model.EnemyType `json:"enemyType"` } -func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore) *GameHandler { +func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore, hub *Hub) *GameHandler { h := &GameHandler{ engine: engine, store: store, logStore: logStore, + hub: hub, questStore: questStore, gearStore: gearStore, achievementStore: achievementStore, @@ -75,7 +77,7 @@ 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. +// 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) { if h.logStore == nil { return @@ -84,6 +86,10 @@ func (h *GameHandler) addLog(heroID int64, message string) { defer cancel() if err := h.logStore.Add(ctx, heroID, message); 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}) } } @@ -95,8 +101,9 @@ func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now tim // processVictoryRewards is the single source of truth for post-kill rewards. // It awards XP, generates loot (gold is guaranteed via GenerateLoot — no separate -// enemy.GoldReward add), processes equipment drops with nil-fallback auto-sell, -// runs the level-up loop, sets hero state to walking, and records loot history. +// enemy.GoldReward add), processes equipment drops (auto-equip, else stash up to +// MaxInventorySlots, else discard + adventure log), runs the level-up loop, +// sets hero state to walking, and records loot history. // Returns the drops for API response building. func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop { oldLevel := hero.Level @@ -110,6 +117,10 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy luckMult := game.LuckMultiplier(hero, now) drops := model.GenerateLoot(enemy.Type, luckMult) + ctxTown, cancelTown := context.WithTimeout(context.Background(), 2*time.Second) + inTown := h.isHeroInTown(ctxTown, hero.PositionX, hero.PositionY) + cancelTown() + h.lootMu.Lock() defer h.lootMu.Unlock() @@ -137,9 +148,13 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy if err := h.gearStore.CreateItem(ctx, item); err != nil { h.logger.Warn("failed to create gear item", "slot", slot, "error", err) cancel() - sellPrice := model.AutoSellPrices[drop.Rarity] - hero.Gold += sellPrice - drop.GoldAmount = sellPrice + if inTown { + sellPrice := model.AutoSellPrices[drop.Rarity] + hero.Gold += sellPrice + drop.GoldAmount = sellPrice + } else { + drop.GoldAmount = 0 + } goto recordLoot } cancel() @@ -152,11 +167,43 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy if equipped { h.addLog(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name)) } else { - sellPrice := model.AutoSellPrices[drop.Rarity] - hero.Gold += sellPrice - drop.GoldAmount = sellPrice + hero.EnsureInventorySlice() + if len(hero.Inventory) >= model.MaxInventorySlots { + ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second) + if h.gearStore != nil && item.ID != 0 { + if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil { + h.logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err) + } + } + cancelDel() + drop.ItemID = 0 + drop.ItemName = "" + drop.GoldAmount = 0 + h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity)) + } else { + ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second) + var err error + if h.gearStore != nil { + err = h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID) + } + cancelInv() + if err != nil { + h.logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err) + ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second) + if h.gearStore != nil && item.ID != 0 { + _ = h.gearStore.DeleteGearItem(ctxDel, item.ID) + } + cancelDel() + drop.ItemID = 0 + drop.ItemName = "" + drop.GoldAmount = 0 + } else { + hero.Inventory = append(hero.Inventory, item) + drop.GoldAmount = 0 + } + } } - } else { + } else if inTown { sellPrice := model.AutoSellPrices[drop.Rarity] hero.Gold += sellPrice drop.GoldAmount = sellPrice @@ -229,7 +276,7 @@ func resolveTelegramID(r *http.Request) (int64, bool) { } // Localhost fallback: default to telegram_id 1 for testing. host := r.Host - if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "[::1]") { + if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "192.168.0.53") { return 1, true } return 0, false @@ -262,9 +309,12 @@ func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) { } now := time.Now() - needsSave := hero.EnsureBuffChargesPopulated(now) - if hero.ApplyBuffQuotaRollover(now) { - needsSave = true + needsSave := false + if h.engine == nil || !h.engine.IsTimePaused() { + needsSave = hero.EnsureBuffChargesPopulated(now) + if hero.ApplyBuffQuotaRollover(now) { + needsSave = true + } } if needsSave { if err := h.store.Save(r.Context(), hero); err != nil { @@ -567,23 +617,11 @@ func pickEnemyForLevel(level int) model.Enemy { // to equip a new gear item. If it improves combat rating by >= 3%, equips it // (persisting to DB if gearStore is available). Returns true if equipped. func (h *GameHandler) tryAutoEquipGear(hero *model.Hero, item *model.GearItem, now time.Time) bool { - if hero.Gear == nil { - hero.Gear = make(map[model.EquipmentSlot]*model.GearItem) - } - current := hero.Gear[item.Slot] - if current == nil { - hero.Gear[item.Slot] = item - h.persistGearEquip(hero.ID, item) - return true - } - oldRating := hero.CombatRatingAt(now) - hero.Gear[item.Slot] = item - if hero.CombatRatingAt(now) >= oldRating*1.03 { - h.persistGearEquip(hero.ID, item) - return true - } - hero.Gear[item.Slot] = current - return false + if !game.TryAutoEquipInMemory(hero, item, now) { + return false + } + h.persistGearEquip(hero.ID, item) + return true } // persistGearEquip saves the equip to the hero_gear table if gearStore is available. @@ -826,6 +864,9 @@ func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero, // This covers the period when the server was down and the offline simulator wasn't running. // Returns true if any simulation was performed. func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) bool { + if h.engine != nil && h.engine.IsTimePaused() { + return false + } gapDuration := h.serverStartedAt.Sub(hero.UpdatedAt) if gapDuration < 30*time.Second { return false @@ -860,7 +901,13 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b break } - survived, enemy, xpGained, goldGained := game.SimulateOneFight(hero, now, nil) + var rg *game.RoadGraph + if h.engine != nil { + rg = h.engine.RoadGraph() + } + survived, enemy, xpGained, goldGained := game.SimulateOneFight(hero, now, nil, rg, func(msg string) { + h.addLog(hero.ID, msg) + }) performed = true h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) @@ -942,11 +989,14 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { } now := time.Now() - chargesInit := hero.EnsureBuffChargesPopulated(now) - quotaRolled := hero.ApplyBuffQuotaRollover(now) - if chargesInit || quotaRolled { - if err := h.store.Save(r.Context(), hero); err != nil { - h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err) + simFrozen := h.engine != nil && h.engine.IsTimePaused() + if !simFrozen { + chargesInit := hero.EnsureBuffChargesPopulated(now) + quotaRolled := hero.ApplyBuffQuotaRollover(now) + if chargesInit || quotaRolled { + if err := h.store.Save(r.Context(), hero); err != nil { + h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err) + } } } @@ -954,7 +1004,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { // (the period when the server was down and the offline simulator wasn't running). offlineDuration := time.Since(hero.UpdatedAt) var catchUpPerformed bool - if hero.UpdatedAt.Before(h.serverStartedAt) && hero.State == model.StateWalking && hero.HP > 0 { + if !simFrozen && hero.UpdatedAt.Before(h.serverStartedAt) && hero.State == model.StateWalking && hero.HP > 0 { catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero) } @@ -969,7 +1019,7 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) { } // Auto-revive if hero has been dead for more than 1 hour (spec section 3.3). - if (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > 1*time.Hour { + if !simFrozen && (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > 1*time.Hour { hero.HP = hero.MaxHP / 2 if hero.HP < 1 { hero.HP = 1 @@ -1259,6 +1309,72 @@ func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, hero) } +// PurchaseSubscription purchases a weekly subscription (x2 buffs, x2 revives). +// POST /api/v1/hero/purchase-subscription +func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Request) { + telegramID, ok := resolveTelegramID(r) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"}) + return + } + + hero, err := h.store.GetByTelegramID(r.Context(), telegramID) + if err != nil { + h.logger.Error("subscription: load hero failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"}) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"}) + return + } + + now := time.Now() + + payment := &model.Payment{ + HeroID: hero.ID, + Type: "subscription_weekly", + AmountRUB: model.SubscriptionWeeklyPriceRUB, + Status: model.PaymentCompleted, + CreatedAt: now, + CompletedAt: &now, + } + if err := h.store.CreatePayment(r.Context(), payment); err != nil { + h.logger.Error("subscription: payment failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to process payment"}) + return + } + + hero.ActivateSubscription(now) + + // Upgrade buff charges to subscriber limits immediately. + hero.EnsureBuffChargesPopulated(now) + for bt := range model.BuffFreeChargesPerType { + state := hero.GetBuffCharges(bt, now) + subMax := hero.MaxBuffCharges(bt) + if state.Remaining < subMax { + state.Remaining = subMax + hero.BuffCharges[string(bt)] = state + } + } + + if err := h.store.Save(r.Context(), hero); err != nil { + h.logger.Error("subscription: save failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + + h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt) + h.addLog(hero.ID, fmt.Sprintf("Subscribed for 7 days (%d₽) — x2 buffs & revives!", model.SubscriptionWeeklyPriceRUB)) + + hero.RefreshDerivedCombatStats(now) + writeJSON(w, http.StatusOK, map[string]any{ + "hero": hero, + "expiresAt": hero.SubscriptionExpiresAt, + "priceRub": model.SubscriptionWeeklyPriceRUB, + }) +} + // GetLoot returns the hero's recent loot history. // GET /api/v1/hero/loot func (h *GameHandler) GetLoot(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/handler/npc.go b/backend/internal/handler/npc.go index acb2cdb..50d3419 100644 --- a/backend/internal/handler/npc.go +++ b/backend/internal/handler/npc.go @@ -13,6 +13,7 @@ import ( "strconv" "time" + "github.com/denisovdennis/autohero/internal/game" "github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/storage" ) @@ -24,20 +25,37 @@ type NPCHandler struct { gearStore *storage.GearStore logStore *storage.LogStore logger *slog.Logger + engine *game.Engine + hub *Hub } // NewNPCHandler creates a new NPCHandler. -func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger) *NPCHandler { +func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger, eng *game.Engine, hub *Hub) *NPCHandler { return &NPCHandler{ questStore: questStore, heroStore: heroStore, gearStore: gearStore, logStore: logStore, logger: logger, + engine: eng, + hub: hub, } } -// addLog is a fire-and-forget helper that writes an adventure log entry. +func (h *NPCHandler) sendMerchantLootWS(heroID int64, cost int64, drop *model.LootDrop) { + if h.hub == nil || drop == nil { + return + } + h.hub.SendToHero(heroID, "merchant_loot", model.MerchantLootPayload{ + GoldSpent: cost, + ItemType: drop.ItemType, + ItemName: drop.ItemName, + Rarity: string(drop.Rarity), + GoldAmount: drop.GoldAmount, + }) +} + +// 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) { if h.logStore == nil { return @@ -46,6 +64,10 @@ func (h *NPCHandler) addLog(heroID int64, message string) { defer cancel() if err := h.logStore.Add(ctx, heroID, message); 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}) } } @@ -275,8 +297,120 @@ func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } +// npcPersistGearEquip writes hero_gear when a merchant drop is equipped. +func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) { + if h.gearStore == nil || item == nil || item.ID == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID); err != nil { + h.logger.Warn("failed to persist merchant gear equip", "hero_id", heroID, "slot", item.Slot, "error", err) + } +} + +// grantMerchantLoot rolls one random gear piece; auto-equips if better. +// Outside town, unwanted pieces are discarded (gold for sells only in town). +// Cost must already be deducted from hero.Gold. +func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time) (*model.LootDrop, error) { + slots := model.AllEquipmentSlots + slot := slots[rand.Intn(len(slots))] + family := model.PickGearFamily(slot) + if family == nil || h.gearStore == nil { + return nil, errors.New("failed to roll gear") + } + + rarity := model.RollRarity() + ilvl := model.RollIlvl(hero.Level, false) + item := model.NewGearItem(family, ilvl, rarity) + + ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second) + err := h.gearStore.CreateItem(ctxCreate, item) + cancel() + if err != nil { + h.logger.Warn("failed to create alms gear item", "slot", slot, "error", err) + return nil, err + } + + equipped := game.TryAutoEquipInMemory(hero, item, now) + if equipped { + h.npcPersistGearEquip(hero.ID, item) + h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, equipped %s", item.Name)) + } else { + hero.EnsureInventorySlice() + if len(hero.Inventory) >= model.MaxInventorySlots { + ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second) + if item.ID != 0 { + if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil { + h.logger.Warn("failed to delete merchant gear (inventory full)", "gear_id", item.ID, "error", err) + } + } + cancelDel() + h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s) (wandering merchant)", item.Name, item.Rarity)) + } else { + ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second) + err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID) + cancelInv() + if err != nil { + h.logger.Warn("failed to stash merchant gear", "hero_id", hero.ID, "error", err) + ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second) + _ = h.gearStore.DeleteGearItem(ctxDel, item.ID) + cancelDel() + } else { + hero.Inventory = append(hero.Inventory, item) + h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant; stashed %s", item.Name)) + } + } + } + + drop := &model.LootDrop{ + ItemType: string(item.Slot), + ItemID: item.ID, + ItemName: item.Name, + Rarity: rarity, + } + return drop, nil +} + +// ProcessAlmsByHeroID applies wandering merchant rewards for a DB hero id (WebSocket npc_alms_accept). +func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) error { + hero, err := h.heroStore.GetByID(ctx, heroID) + if err != nil { + h.logger.Error("failed to get hero for ws npc alms", "hero_id", heroID, "error", err) + return errors.New("failed to load hero") + } + if hero == nil { + return errors.New("hero not found") + } + + cost := int64(20 + hero.Level*5) + if hero.Gold < cost { + return fmt.Errorf("not enough gold (need %d, have %d)", cost, hero.Gold) + } + + hero.Gold -= cost + now := time.Now() + drop, err := h.grantMerchantLoot(ctx, hero, now) + if err != nil { + hero.Gold += cost + return err + } + + hero.RefreshDerivedCombatStats(now) + if err := h.heroStore.Save(ctx, hero); err != nil { + h.logger.Error("failed to save hero after alms", "hero_id", hero.ID, "error", err) + return errors.New("failed to save hero") + } + + if h.engine != nil { + h.engine.ApplyHeroAlmsUpdate(hero) + } + h.sendMerchantLootWS(hero.ID, cost, drop) + return nil +} + // NPCAlms handles POST /api/v1/hero/npc-alms. -// The hero gives alms to a wandering merchant in exchange for random equipment. +// The hero pays for one random equipment roll; better items equip, worse are sold for gold. func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) { telegramID, ok := resolveTelegramID(r) if !ok { @@ -324,7 +458,6 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) { return } - // Compute cost: 20 + level * 5. cost := int64(20 + hero.Level*5) if hero.Gold < cost { writeJSON(w, http.StatusBadRequest, map[string]string{ @@ -334,39 +467,9 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) { } hero.Gold -= cost - - // Generate random equipment drop. - slots := model.AllEquipmentSlots - slot := slots[rand.Intn(len(slots))] - family := model.PickGearFamily(slot) - - var drop *model.LootDrop - - if family != nil { - rarity := model.RollRarity() - ilvl := model.RollIlvl(hero.Level, false) - item := model.NewGearItem(family, ilvl, rarity) - - if h.gearStore != nil { - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) - err := h.gearStore.CreateItem(ctx, item) - cancel() - if err != nil { - h.logger.Warn("failed to create alms gear item", "slot", slot, "error", err) - } else { - drop = &model.LootDrop{ - ItemType: string(slot), - ItemID: item.ID, - ItemName: item.Name, - Rarity: rarity, - } - h.addLog(hero.ID, fmt.Sprintf("Gave alms to wandering merchant, received %s", item.Name)) - } - } - } - - if drop == nil { - // Fallback: gold refund if we couldn't generate equipment. + now := time.Now() + drop, err := h.grantMerchantLoot(r.Context(), hero, now) + if err != nil { hero.Gold += cost writeJSON(w, http.StatusInternalServerError, map[string]string{ "error": "failed to generate reward", @@ -374,6 +477,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) { return } + hero.RefreshDerivedCombatStats(now) if err := h.heroStore.Save(r.Context(), hero); err != nil { h.logger.Error("failed to save hero after alms", "hero_id", hero.ID, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{ @@ -382,12 +486,19 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) { return } + if h.engine != nil { + h.engine.ApplyHeroAlmsUpdate(hero) + } + h.sendMerchantLootWS(hero.ID, cost, drop) + + msg := fmt.Sprintf("You gave %d gold to the wandering merchant and received %s.", cost, drop.ItemName) + writeJSON(w, http.StatusOK, model.AlmsResponse{ Accepted: true, GoldSpent: cost, ItemDrop: drop, Hero: hero, - Message: fmt.Sprintf("You gave %d gold to the wandering merchant and received %s.", cost, drop.ItemName), + Message: msg, }) } diff --git a/backend/internal/model/buff_quota.go b/backend/internal/model/buff_quota.go index c0f6fd1..101ff3f 100644 --- a/backend/internal/model/buff_quota.go +++ b/backend/internal/model/buff_quota.go @@ -8,6 +8,12 @@ import ( // FreeBuffActivationsPerPeriod is the legacy shared limit. Kept for backward compatibility. const FreeBuffActivationsPerPeriod = 2 +// SubscriptionWeeklyPriceRUB is the price for a 7-day subscription in rubles. +const SubscriptionWeeklyPriceRUB = 299 + +// SubscriptionDuration is how long a subscription lasts. +const SubscriptionDuration = 7 * 24 * time.Hour + // BuffFreeChargesPerType defines the per-buff free charge limits per 24h window. var BuffFreeChargesPerType = map[BuffType]int{ BuffRush: 3, @@ -20,6 +26,67 @@ var BuffFreeChargesPerType = map[BuffType]int{ BuffWarCry: 2, } +// BuffSubscriberChargesPerType defines the per-buff charge limits for subscribers (x2). +var BuffSubscriberChargesPerType = map[BuffType]int{ + BuffRush: 6, + BuffRage: 4, + BuffShield: 4, + BuffLuck: 2, + BuffResurrection: 2, + BuffHeal: 6, + BuffPowerPotion: 2, + BuffWarCry: 4, +} + +// RefreshSubscription checks if the subscription has expired and updates SubscriptionActive. +// Returns true if the hero state was changed (caller should persist). +func (h *Hero) RefreshSubscription(now time.Time) bool { + if !h.SubscriptionActive { + return false + } + if h.SubscriptionExpiresAt != nil && now.After(*h.SubscriptionExpiresAt) { + h.SubscriptionActive = false + h.SubscriptionExpiresAt = nil + return true + } + return false +} + +// ActivateSubscription sets the hero as a subscriber for SubscriptionDuration. +// If already subscribed, extends from current expiry. +func (h *Hero) ActivateSubscription(now time.Time) { + if h.SubscriptionActive && h.SubscriptionExpiresAt != nil && h.SubscriptionExpiresAt.After(now) { + // Extend from current expiry. + extended := h.SubscriptionExpiresAt.Add(SubscriptionDuration) + h.SubscriptionExpiresAt = &extended + } else { + expires := now.Add(SubscriptionDuration) + h.SubscriptionExpiresAt = &expires + } + h.SubscriptionActive = true +} + +// MaxBuffCharges returns the max charges for a buff type, considering subscription status. +func (h *Hero) MaxBuffCharges(bt BuffType) int { + if h.SubscriptionActive { + if v, ok := BuffSubscriberChargesPerType[bt]; ok { + return v + } + } + if v, ok := BuffFreeChargesPerType[bt]; ok { + return v + } + return FreeBuffActivationsPerPeriod +} + +// MaxRevives returns the max free revives per period (1 free, 2 for subscribers). +func (h *Hero) MaxRevives() int { + if h.SubscriptionActive { + return 2 + } + return 1 +} + // ApplyBuffQuotaRollover refills free buff charges when the 24h window has passed. // Returns true if the hero was mutated (caller may persist). // Deprecated: kept for backward compat with the shared counter. New code should @@ -48,10 +115,7 @@ func (h *Hero) GetBuffCharges(bt BuffType, now time.Time) BuffChargeState { h.BuffCharges = make(map[string]BuffChargeState) } - maxCharges := BuffFreeChargesPerType[bt] - if maxCharges == 0 { - maxCharges = FreeBuffActivationsPerPeriod // fallback - } + maxCharges := h.MaxBuffCharges(bt) state, exists := h.BuffCharges[string(bt)] if !exists { @@ -151,9 +215,9 @@ func (h *Hero) ResetBuffCharges(bt *BuffType, now time.Time) { } // Reset ALL buff types. - for buffType, maxCharges := range BuffFreeChargesPerType { + for buffType := range BuffFreeChargesPerType { h.BuffCharges[string(buffType)] = BuffChargeState{ - Remaining: maxCharges, + Remaining: h.MaxBuffCharges(buffType), PeriodEnd: &pe, } } @@ -174,9 +238,9 @@ func (h *Hero) EnsureBuffChargesPopulated(now time.Time) bool { if h.BuffQuotaPeriodEnd != nil { pe = *h.BuffQuotaPeriodEnd } - for bt, maxCharges := range BuffFreeChargesPerType { + for bt := range BuffFreeChargesPerType { h.BuffCharges[string(bt)] = BuffChargeState{ - Remaining: maxCharges, + Remaining: h.MaxBuffCharges(bt), PeriodEnd: &pe, } } diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index 1b38e2a..21293e5 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -10,6 +10,8 @@ const ( AgilityCoef = 0.03 // MaxAttackSpeed enforces the target cap of ~4 attacks/sec. MaxAttackSpeed = 4.0 + // MaxInventorySlots is the maximum unequipped gear items carried at once. + MaxInventorySlots = 40 ) type Hero struct { @@ -29,6 +31,8 @@ type Hero struct { WeaponID *int64 `json:"weaponId,omitempty"` // Deprecated: kept for DB backward compat ArmorID *int64 `json:"armorId,omitempty"` // Deprecated: kept for DB backward compat Gear map[EquipmentSlot]*GearItem `json:"gear"` + // Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots. + Inventory []*GearItem `json:"inventory,omitempty"` Buffs []ActiveBuff `json:"buffs,omitempty"` Debuffs []ActiveDebuff `json:"debuffs,omitempty"` Gold int64 `json:"gold"` @@ -43,7 +47,8 @@ type Hero struct { PositionY float64 `json:"positionY"` Potions int `json:"potions"` ReviveCount int `json:"reviveCount"` - SubscriptionActive bool `json:"subscriptionActive"` + SubscriptionActive bool `json:"subscriptionActive"` + SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"` // Deprecated: BuffFreeChargesRemaining is the legacy shared counter. Use BuffCharges instead. BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"` // Deprecated: BuffQuotaPeriodEnd is the legacy shared period end. Use BuffCharges instead. @@ -319,6 +324,21 @@ func (h *Hero) MovementSpeedMultiplier(now time.Time) float64 { return mult } +// EnsureGearMap guarantees Gear is a non-nil map so JSON encodes "gear":{} instead of null +// (clients treat null as missing equipment). +func (h *Hero) EnsureGearMap() { + if h.Gear == nil { + h.Gear = make(map[EquipmentSlot]*GearItem) + } +} + +// EnsureInventorySlice guarantees Inventory is a non-nil slice for append/count. +func (h *Hero) EnsureInventorySlice() { + if h.Inventory == nil { + h.Inventory = []*GearItem{} + } +} + // RefreshDerivedCombatStats updates exported derived combat fields for API/state usage. func (h *Hero) RefreshDerivedCombatStats(now time.Time) { h.XPToNext = XPToNextLevel(h.Level) diff --git a/backend/internal/model/ws_message.go b/backend/internal/model/ws_message.go index 426bb1e..f1cf170 100644 --- a/backend/internal/model/ws_message.go +++ b/backend/internal/model/ws_message.go @@ -138,9 +138,23 @@ type TownNPCVisitPayload struct { TownID int64 `json:"townId"` } +// AdventureLogLinePayload is sent when a new line is appended to the hero's adventure log. +type AdventureLogLinePayload struct { + Message string `json:"message"` +} + // TownExitPayload is sent when the hero leaves a town. type TownExitPayload struct{} +// MerchantLootPayload is sent after a successful wandering merchant trade (WS or REST when online). +type MerchantLootPayload struct { + GoldSpent int64 `json:"goldSpent"` + ItemType string `json:"itemType"` // "potion", equipment slot key, etc. + ItemName string `json:"itemName,omitempty"` + Rarity string `json:"rarity,omitempty"` + GoldAmount int64 `json:"goldAmount,omitempty"` // auto-sell gold +} + // NPCEncounterPayload is sent when the hero meets a wandering NPC on the road (e.g. merchant). type NPCEncounterPayload struct { NPCID int64 `json:"npcId"` @@ -150,6 +164,11 @@ type NPCEncounterPayload struct { Cost int64 `json:"cost"` } +// NPCEncounterEndPayload is sent when the wandering merchant prompt ends (e.g. timeout). +type NPCEncounterEndPayload struct { + Reason string `json:"reason"` // "timeout" +} + // LevelUpPayload is sent on level-up. type LevelUpPayload struct { NewLevel int `json:"newLevel"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 980765b..d7c7e0c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1,6 +1,7 @@ package router import ( + "context" "log/slog" "net/http" "time" @@ -21,6 +22,7 @@ type Deps struct { Hub *handler.Hub PgPool *pgxpool.Pool BotToken string + PaymentProviderToken string AdminBasicAuthUsername string AdminBasicAuthPassword string AdminBasicAuthRealm string @@ -60,6 +62,11 @@ func New(deps Deps) *chi.Mux { authH := handler.NewAuthHandler(deps.BotToken, heroStore, deps.Logger) r.Post("/api/v1/auth/telegram", authH.TelegramAuth) + // Telegram Payments. + paymentsH := handler.NewPaymentsHandler(deps.BotToken, deps.PaymentProviderToken, heroStore, logStore, deps.Logger) + r.Post("/api/v1/payments/create-invoice", paymentsH.CreateInvoice) + r.Post("/api/v1/payments/telegram-webhook", paymentsH.TelegramWebhook) + // Admin routes protected with HTTP Basic authentication. adminH := handler.NewAdminHandler(heroStore, deps.Engine, deps.Hub, deps.PgPool, deps.Logger) r.Route("/admin", func(r chi.Router) { @@ -77,18 +84,28 @@ func New(deps Deps) *chi.Mux { r.Post("/heroes/{heroId}/revive", adminH.ReviveHero) r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) + r.Post("/heroes/{heroId}/start-adventure", adminH.StartHeroAdventure) + r.Post("/heroes/{heroId}/teleport-town", adminH.TeleportHeroTown) + r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest) r.Delete("/heroes/{heroId}", adminH.DeleteHero) + r.Get("/towns", adminH.ListTowns) + r.Post("/time/pause", adminH.PauseTime) + r.Post("/time/resume", adminH.ResumeTime) r.Get("/engine/status", adminH.EngineStatus) r.Get("/engine/combats", adminH.ActiveCombats) r.Get("/ws/connections", adminH.WSConnections) r.Get("/info", adminH.ServerInfo) + r.Post("/payments/set-webhook", paymentsH.SetWebhook) }) // API v1 (authenticated routes). - gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore) + gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore, deps.Hub) mapsH := handler.NewMapsHandler(worldSvc, deps.Logger) questH := handler.NewQuestHandler(questStore, heroStore, logStore, deps.Logger) - npcH := handler.NewNPCHandler(questStore, heroStore, gearStore, logStore, deps.Logger) + npcH := handler.NewNPCHandler(questStore, heroStore, gearStore, logStore, deps.Logger, deps.Engine, deps.Hub) + deps.Engine.SetNPCAlmsHandler(func(ctx context.Context, heroID int64) error { + return npcH.ProcessAlmsByHeroID(ctx, heroID) + }) achieveH := handler.NewAchievementHandler(achievementStore, heroStore, deps.Logger) taskH := handler.NewDailyTaskHandler(taskStore, heroStore, deps.Logger) r.Route("/api/v1", func(r chi.Router) { @@ -96,6 +113,8 @@ func New(deps Deps) *chi.Mux { // Disabled for now to allow development without a bot token. // r.Use(handler.TelegramAuthMiddleware(deps.BotToken)) + r.Use(handler.APITimePausedMiddleware(deps.Engine)) + r.Get("/hero", gameH.GetHero) r.Get("/hero/init", gameH.InitHero) r.Post("/hero/name", gameH.SetHeroName) @@ -104,6 +123,7 @@ func New(deps Deps) *chi.Mux { r.Post("/hero/victory", gameH.ReportVictory) r.Post("/hero/revive", gameH.ReviveHero) r.Post("/hero/purchase-buff-refill", gameH.PurchaseBuffRefill) + r.Post("/hero/purchase-subscription", gameH.PurchaseSubscription) r.Get("/hero/loot", gameH.GetLoot) r.Get("/hero/log", gameH.GetAdventureLog) r.Post("/hero/use-potion", gameH.UsePotion) diff --git a/backend/internal/storage/gear_store.go b/backend/internal/storage/gear_store.go index 1510b1e..5e2a1e1 100644 --- a/backend/internal/storage/gear_store.go +++ b/backend/internal/storage/gear_store.go @@ -120,6 +120,18 @@ func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.Equi return nil } +// DeleteGearItem removes a gear row by id (e.g. discarded drop not sold). Fails if still equipped. +func (s *GearStore) DeleteGearItem(ctx context.Context, id int64) error { + cmd, err := s.pool.Exec(ctx, `DELETE FROM gear WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete gear item: %w", err) + } + if cmd.RowsAffected() == 0 { + return fmt.Errorf("delete gear item: no row for id %d", id) + } + return nil +} + // UnequipSlot removes the gear from the given slot for a hero. func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.EquipmentSlot) error { _, err := s.pool.Exec(ctx, ` @@ -130,3 +142,111 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq } return nil } + +// GetHeroInventory loads unequipped gear ordered by slot_index. +func (s *GearStore) GetHeroInventory(ctx context.Context, heroID int64) ([]*model.GearItem, error) { + rows, err := s.pool.Query(ctx, ` + SELECT g.id, g.slot, g.form_id, g.name, g.subtype, g.rarity, g.ilvl, + g.base_primary, g.primary_stat, g.stat_type, + g.speed_modifier, g.crit_chance, g.agility_bonus, + g.set_name, g.special_effect + FROM hero_inventory hi + JOIN gear g ON hi.gear_id = g.id + WHERE hi.hero_id = $1 + ORDER BY hi.slot_index ASC + `, heroID) + if err != nil { + return nil, fmt.Errorf("get hero inventory: %w", err) + } + defer rows.Close() + + var out []*model.GearItem + for rows.Next() { + var item model.GearItem + var slot, rarity string + if err := rows.Scan( + &item.ID, &slot, &item.FormID, &item.Name, &item.Subtype, + &rarity, &item.Ilvl, + &item.BasePrimary, &item.PrimaryStat, &item.StatType, + &item.SpeedModifier, &item.CritChance, &item.AgilityBonus, + &item.SetName, &item.SpecialEffect, + ); err != nil { + return nil, fmt.Errorf("scan inventory gear: %w", err) + } + item.Slot = model.EquipmentSlot(slot) + item.Rarity = model.Rarity(rarity) + out = append(out, &item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("hero inventory rows: %w", err) + } + return out, nil +} + +// AddToInventory inserts gear into the next free backpack slot. Fails if inventory is full. +func (s *GearStore) AddToInventory(ctx context.Context, heroID, gearID int64) error { + var n int + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM hero_inventory WHERE hero_id = $1 + `, heroID).Scan(&n); err != nil { + return fmt.Errorf("inventory count: %w", err) + } + if n >= model.MaxInventorySlots { + return fmt.Errorf("inventory full") + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO hero_inventory (hero_id, slot_index, gear_id) + VALUES ($1, $2, $3) + `, heroID, n, gearID) + if err != nil { + return fmt.Errorf("add to inventory: %w", err) + } + return nil +} + +// ReplaceHeroInventory deletes all backpack rows for the hero and re-inserts from the slice. +// Used when persisting offline-simulated heroes with in-memory-only gear rows (id may be 0). +func (s *GearStore) ReplaceHeroInventory(ctx context.Context, heroID int64, items []*model.GearItem) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("replace inventory begin: %w", err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, `DELETE FROM hero_inventory WHERE hero_id = $1`, heroID); err != nil { + return fmt.Errorf("replace inventory delete: %w", err) + } + for i, item := range items { + if item == nil || i >= model.MaxInventorySlots { + continue + } + gid := item.ID + if gid == 0 { + err := tx.QueryRow(ctx, ` + INSERT INTO gear (slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, + stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id + `, + string(item.Slot), item.FormID, item.Name, item.Subtype, + string(item.Rarity), item.Ilvl, item.BasePrimary, item.PrimaryStat, + item.StatType, item.SpeedModifier, item.CritChance, item.AgilityBonus, + item.SetName, item.SpecialEffect, + ).Scan(&gid) + if err != nil { + return fmt.Errorf("replace inventory create gear: %w", err) + } + item.ID = gid + } + if _, err := tx.Exec(ctx, ` + INSERT INTO hero_inventory (hero_id, slot_index, gear_id) + VALUES ($1, $2, $3) + `, heroID, i, gid); err != nil { + return fmt.Errorf("replace inventory insert: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("replace inventory commit: %w", err) + } + return nil +} diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index 0ec4f0f..27ed23d 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -22,7 +22,7 @@ const heroSelectQuery = ` h.strength, h.constitution, h.agility, h.luck, h.state, h.weapon_id, h.armor_id, h.gold, h.xp, h.level, - h.revive_count, h.subscription_active, + h.revive_count, h.subscription_active, h.subscription_expires_at, h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges, h.position_x, h.position_y, h.potions, h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops, @@ -77,6 +77,9 @@ func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*mod if err := s.loadHeroGear(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by telegram_id gear: %w", err) } + if err := s.loadHeroInventory(ctx, hero); err != nil { + return nil, fmt.Errorf("get hero by telegram_id inventory: %w", err) + } if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by telegram_id buffs: %w", err) } @@ -118,6 +121,9 @@ func (s *HeroStore) ListHeroes(ctx context.Context, limit, offset int) ([]*model if err := s.loadHeroGear(ctx, h); err != nil { return nil, fmt.Errorf("list heroes load gear: %w", err) } + if err := s.loadHeroInventory(ctx, h); err != nil { + return nil, fmt.Errorf("list heroes load inventory: %w", err) + } if err := s.loadHeroBuffsAndDebuffs(ctx, h); err != nil { return nil, fmt.Errorf("list heroes load buffs: %w", err) } @@ -147,6 +153,9 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) if err := s.loadHeroGear(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by id gear: %w", err) } + if err := s.loadHeroInventory(ctx, hero); err != nil { + return nil, fmt.Errorf("get hero by id inventory: %w", err) + } if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil { return nil, fmt.Errorf("get hero by id buffs: %w", err) } @@ -176,7 +185,7 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { strength, constitution, agility, luck, state, weapon_id, armor_id, gold, xp, level, - revive_count, subscription_active, + revive_count, subscription_active, subscription_expires_at, buff_free_charges_remaining, buff_quota_period_end, buff_charges, position_x, position_y, potions, total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops, @@ -203,7 +212,7 @@ func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error { hero.Strength, hero.Constitution, hero.Agility, hero.Luck, string(hero.State), hero.WeaponID, hero.ArmorID, hero.Gold, hero.XP, hero.Level, - hero.ReviveCount, hero.SubscriptionActive, + hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.PositionX, hero.PositionY, hero.Potions, hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops, @@ -280,17 +289,17 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error { strength = $6, constitution = $7, agility = $8, luck = $9, state = $10, weapon_id = $11, armor_id = $12, gold = $13, xp = $14, level = $15, - revive_count = $16, subscription_active = $17, - buff_free_charges_remaining = $18, buff_quota_period_end = $19, buff_charges = $20, - position_x = $21, position_y = $22, potions = $23, - total_kills = $24, elite_kills = $25, total_deaths = $26, - kills_since_death = $27, legendary_drops = $28, - last_online_at = $29, - updated_at = $30, + revive_count = $16, subscription_active = $17, subscription_expires_at = $18, + buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21, + position_x = $22, position_y = $23, potions = $24, + total_kills = $25, elite_kills = $26, total_deaths = $27, + kills_since_death = $28, legendary_drops = $29, + last_online_at = $30, + updated_at = $31, destination_town_id = $32, current_town_id = $33, move_state = $34 - WHERE id = $31 + WHERE id = $35 ` tag, err := s.pool.Exec(ctx, query, @@ -299,17 +308,17 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error { hero.Strength, hero.Constitution, hero.Agility, hero.Luck, string(hero.State), hero.WeaponID, hero.ArmorID, hero.Gold, hero.XP, hero.Level, - hero.ReviveCount, hero.SubscriptionActive, + hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.PositionX, hero.PositionY, hero.Potions, hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops, hero.LastOnlineAt, hero.UpdatedAt, - hero.ID, hero.DestinationTownID, hero.CurrentTownID, hero.MoveState, + hero.ID, ) if err != nil { return fmt.Errorf("update hero: %w", err) @@ -323,6 +332,14 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error { return fmt.Errorf("update hero buffs/debuffs: %w", err) } + inv := hero.Inventory + if inv == nil { + inv = []*model.GearItem{} + } + if err := s.gearStore.ReplaceHeroInventory(ctx, hero.ID, inv); err != nil { + return fmt.Errorf("update hero inventory: %w", err) + } + s.logger.Info("saved hero", "hero", hero) return nil @@ -400,6 +417,7 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time query := heroSelectQuery + ` WHERE h.state = 'walking' AND h.hp > 0 AND h.updated_at < $1 + AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')) ORDER BY h.updated_at ASC LIMIT $2 ` @@ -425,6 +443,9 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time if err := s.loadHeroGear(ctx, h); err != nil { return nil, fmt.Errorf("list offline heroes load gear: %w", err) } + if err := s.loadHeroInventory(ctx, h); err != nil { + return nil, fmt.Errorf("list offline heroes load inventory: %w", err) + } } return heroes, nil } @@ -442,7 +463,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) { &h.Strength, &h.Constitution, &h.Agility, &h.Luck, &state, &h.WeaponID, &h.ArmorID, &h.Gold, &h.XP, &h.Level, - &h.ReviveCount, &h.SubscriptionActive, + &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.PositionX, &h.PositionY, &h.Potions, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, @@ -474,7 +495,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) { &h.Strength, &h.Constitution, &h.Agility, &h.Luck, &state, &h.WeaponID, &h.ArmorID, &h.Gold, &h.XP, &h.Level, - &h.ReviveCount, &h.SubscriptionActive, + &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.PositionX, &h.PositionY, &h.Potions, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, @@ -505,6 +526,19 @@ func (s *HeroStore) loadHeroGear(ctx context.Context, hero *model.Hero) error { return nil } +// loadHeroInventory populates the hero's backpack from hero_inventory. +func (s *HeroStore) loadHeroInventory(ctx context.Context, hero *model.Hero) error { + inv, err := s.gearStore.GetHeroInventory(ctx, hero.ID) + if err != nil { + return fmt.Errorf("load hero inventory: %w", err) + } + hero.Inventory = inv + if hero.Inventory == nil { + hero.Inventory = []*model.GearItem{} + } + return nil +} + // loadHeroBuffsAndDebuffs populates the hero's Buffs and Debuffs from the // hero_active_buffs / hero_active_debuffs tables, filtering out expired entries. func (s *HeroStore) loadHeroBuffsAndDebuffs(ctx context.Context, hero *model.Hero) error { diff --git a/docker-compose.yml b/docker-compose.yml index 581e618..cd6f3cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: DB_NAME: ${DB_NAME:-autohero} REDIS_ADDR: redis:6379 BOT_TOKEN: ${BOT_TOKEN:-} + PAYMENT_PROVIDER_TOKEN: ${PAYMENT_PROVIDER_TOKEN:-} ADMIN_BASIC_AUTH_USERNAME: ${ADMIN_BASIC_AUTH_USERNAME:-} ADMIN_BASIC_AUTH_PASSWORD: ${ADMIN_BASIC_AUTH_PASSWORD:-} ADMIN_BASIC_AUTH_REALM: ${ADMIN_BASIC_AUTH_REALM:-AutoHero Admin} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4645fe9..d732d71 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback, type CSSProperties } from 'react'; +import { useEffect, useRef, useState, useCallback, useMemo, type CSSProperties } from 'react'; import { GameEngine } from './game/engine'; import { GamePhase, BuffType, type GameState, type FloatingDamageData, type ActiveBuff, type NPCData } from './game/types'; import type { NPCEncounterEvent } from './game/types'; @@ -11,6 +11,7 @@ import { sendNPCAlmsAccept, sendNPCAlmsDecline, buildLootFromCombatEnd, + buildMerchantLootDrop, } from './game/ws-handler'; import { ApiError, @@ -24,13 +25,11 @@ import { abandonQuest, getAchievements, getNearbyHeroes, - getDailyTasks, - claimDailyTask, buyPotion, healAtNPC, requestRevive, } from './network/api'; -import type { HeroResponse, Achievement, DailyTaskResponse } from './network/api'; +import type { HeroResponse, Achievement } from './network/api'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem } from './game/types'; import type { OfflineReport as OfflineReportData } from './network/api'; import { @@ -42,20 +41,21 @@ import { import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } from './shared/telegram'; import { Rarity } from './game/types'; import type { HeroState, BuffChargeState } from './game/types'; +import { useUiClock } from './hooks/useUiClock'; +import { adventureEntriesFromServerLog } from './game/adventureLogMap'; import { HUD } from './ui/HUD'; import { DeathScreen } from './ui/DeathScreen'; import { FloatingDamage } from './ui/FloatingDamage'; import { GameToast } from './ui/GameToast'; -import { AdventureLog } from './ui/AdventureLog'; import { OfflineReport } from './ui/OfflineReport'; -import { QuestLog } from './ui/QuestLog'; +import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal'; import { NPCDialog } from './ui/NPCDialog'; import { NameEntryScreen } from './ui/NameEntryScreen'; -import { DailyTasks } from './ui/DailyTasks'; import { AchievementsPanel } from './ui/AchievementsPanel'; import { Minimap } from './ui/Minimap'; import { NPCInteraction } from './ui/NPCInteraction'; import { WanderingNPCPopup } from './ui/WanderingNPCPopup'; +import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n'; const appStyle: CSSProperties = { width: '100%', @@ -152,8 +152,13 @@ function mapEquipment( ): Record { const out: Record = {}; - if (raw) { - for (const [slot, item] of Object.entries(raw)) { + // REST uses `equipment`, WS hero_state from Go uses `gear`. Treat JSON null like missing. + const merged = raw ?? res.gear; + const rawSlots = + merged != null && typeof merged === 'object' ? merged : undefined; + + if (rawSlots) { + for (const [slot, item] of Object.entries(rawSlots)) { out[slot] = { id: item.id, slot: item.slot ?? slot, @@ -255,11 +260,34 @@ function heroResponseToState(res: HeroResponse): HeroState { potions: res.potions ?? 0, moveSpeed: res.moveSpeed, equipment: mapEquipment(res.equipment, res), + inventory: mapInventoryFromResponse(res.inventory), }; } +function mapInventoryFromResponse(raw: HeroResponse['inventory']): EquipmentItem[] | undefined { + if (!raw?.length) return undefined; + return raw.map((it) => ({ + id: it.id, + slot: it.slot, + formId: it.formId ?? '', + name: it.name, + rarity: it.rarity as Rarity, + ilvl: it.ilvl ?? 1, + primaryStat: it.primaryStat ?? 0, + statType: it.statType ?? 'mixed', + })); +} + export function App() { + const [locale, setLocale] = useState(() => detectLocale()); + const translations = useMemo(() => getTranslations(locale), [locale]); + const handleSetLocale = useCallback((l: Locale) => { + setLocale(l); + try { localStorage.setItem('autohero_locale', l); } catch { /* ignore */ } + }, []); + const tr = translations; + const canvasRef = useRef(null); const engineRef = useRef(null); const wsRef = useRef(null); @@ -295,7 +323,8 @@ export function App() { const [heroQuests, setHeroQuests] = useState([]); const [currentTown, setCurrentTown] = useState(null); const [selectedNPC, setSelectedNPC] = useState(null); - const [questLogOpen, setQuestLogOpen] = useState(false); + const [heroSheetOpen, setHeroSheetOpen] = useState(false); + const [heroSheetInitialTab, setHeroSheetInitialTab] = useState('stats'); // NPC interaction state (server-driven via town_enter) const [nearestNPC, setNearestNPC] = useState(null); @@ -303,12 +332,12 @@ export function App() { // Wandering NPC encounter state const [wanderingNPC, setWanderingNPC] = useState(null); - // Daily tasks (backend-driven) - const [dailyTasks, setDailyTasks] = useState([]); // Achievements const [achievements, setAchievements] = useState([]); const prevAchievementsRef = useRef([]); + const sheetNowMs = useUiClock(100); + const addLogEntry = useCallback((message: string) => { logIdCounter.current += 1; const entry: AdventureLogEntry = { @@ -444,10 +473,6 @@ export function App() { .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => console.warn('[App] Could not fetch achievements')); - getDailyTasks(telegramId) - .then((t) => setDailyTasks(t)) - .catch(() => console.warn('[App] Could not fetch daily tasks')); - // Poll nearby heroes every 5 seconds const nearbyInterval = setInterval(() => { getNearbyHeroes(telegramId) @@ -471,18 +496,12 @@ export function App() { .catch(() => {}); nearbyIntervalRef.current = nearbyInterval; - // Fetch adventure log + // Fetch adventure log (same source as server DB; response shape { log: [...] }) try { const serverLog = await getAdventureLog(telegramId, 50); - if (serverLog.length > 0) { - const mapped: AdventureLogEntry[] = serverLog.map((entry, i) => ({ - id: i + 1, - message: entry.message, - timestamp: new Date(entry.createdAt).getTime(), - })); - logIdCounter.current = mapped.length; - setLogEntries(mapped); - } + const { entries, maxId } = adventureEntriesFromServerLog(serverLog); + logIdCounter.current = Math.max(logIdCounter.current, maxId); + setLogEntries(entries); } catch { console.warn('[App] Could not fetch adventure log'); } @@ -529,18 +548,17 @@ export function App() { if (p.goldGained > 0) parts.push(`+${p.goldGained} gold`); const equipDrop = p.loot.find((l) => l.itemType === 'weapon' || l.itemType === 'armor'); if (equipDrop?.name) parts.push(`found ${equipDrop.name}`); - if (parts.length > 0) addLogEntry(`Victory: ${parts.join(', ')}`); + // Victory line comes from server adventure log (Defeated …) + WS adventure_log_line if (p.leveledUp && p.newLevel) { - setToast({ message: `Level up! Now level ${p.newLevel}`, color: '#ffd700' }); + setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); } - // Refresh quests, equipment, daily tasks, achievements after combat + // Refresh quests, equipment, achievements after combat const tid = getTelegramUserId() ?? 1; getHeroQuests(tid).then((q) => setHeroQuests(q)).catch(() => {}); refreshEquipment(); - getDailyTasks(tid).then((t) => setDailyTasks(t)).catch(() => {}); getAchievements(tid).then((a) => { 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)); @@ -553,24 +571,24 @@ export function App() { }).catch(() => {}); }, - onHeroDied: (p) => { + onHeroDied: (_p) => { hapticNotification('error'); - addLogEntry(`Hero was slain by ${p.killedBy}`); + // Death line comes from server log + WS }, onHeroRevived: () => { - addLogEntry('Hero revived!'); - setToast({ message: 'Hero revived!', color: '#44cc44' }); + setToast({ message: tr.heroRevived, color: '#44cc44' }); + // "Hero revived" comes from server log + WS }, - onBuffApplied: (p) => { - addLogEntry(`Buff applied: ${p.buffType}`); + onBuffApplied: (_p) => { + // Buff activation comes from server log + WS }, onTownEnter: (p) => { const town = townsRef.current.find((t) => t.id === p.townId) ?? null; setCurrentTown(town); - setToast({ message: `Entering ${p.townName}`, color: '#daa520' }); + setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' }); addLogEntry(`Entered ${p.townName}`); const npcs = p.npcs ?? []; if (npcs.length > 0) { @@ -586,10 +604,13 @@ export function App() { } }, + onAdventureLogLine: (p) => { + addLogEntry(p.message); + }, + onTownNPCVisit: (p) => { const role = - p.type === 'merchant' ? 'Shop' : p.type === 'healer' ? 'Healer' : 'Quest'; - addLogEntry(`${role}: ${p.name}`); + p.type === 'merchant' ? tr.shopLabel : p.type === 'healer' ? tr.healerLabel : tr.questLabel; setToast({ message: `${role}: ${p.name}`, color: '#c9a227' }); setNearestNPC({ id: p.npcId, @@ -616,20 +637,27 @@ export function App() { setWanderingNPC(npcEvent); }, + onNPCEncounterEnd: (p) => { + if (p.reason === 'timeout') { + addLogEntry('Wandering merchant moved on'); + } + setWanderingNPC(null); + }, + onLevelUp: (p) => { - setToast({ message: `Level up! Now level ${p.newLevel}`, color: '#ffd700' }); + setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' }); hapticNotification('success'); - addLogEntry(`Reached level ${p.newLevel}`); + // Level-up lines come from server log + WS }, onEquipmentChange: (p) => { - setToast({ message: `New ${p.slot}: ${p.item.name}`, color: '#cc88ff' }); - addLogEntry(`Equipped ${p.item.name} (${p.slot})`); + setToast({ message: t(tr.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' }); + // Equipment line comes from server log + WS refreshEquipment(); }, onPotionCollected: (p) => { - setToast({ message: `+${p.count} potion${p.count > 1 ? 's' : ''}`, color: '#44cc44' }); + setToast({ message: t(tr.potionsCollected, { count: p.count }), color: '#44cc44' }); }, onQuestProgress: (p) => { @@ -642,7 +670,7 @@ export function App() { ); if (p.title) { setToast({ - message: `${p.title} (${p.current}/${p.target})`, + message: t(tr.questProgress, { title: p.title, current: p.current, target: p.target }), color: '#44aaff', }); } @@ -656,13 +684,23 @@ export function App() { : hq, ), ); - setToast({ message: `Quest completed: ${p.title}!`, color: '#ffd700' }); + setToast({ message: t(tr.questCompleted, { title: p.title }), color: '#ffd700' }); hapticNotification('success'); }, onError: (p) => { setToast({ message: p.message, color: '#ff4444' }); }, + + onMerchantLoot: (p) => { + const loot = buildMerchantLootDrop(p); + const eng = engineRef.current; + if (loot && eng) { + eng.applyLoot(loot); + } + hapticNotification('success'); + refreshEquipment(); + }, }); ws.connect(); @@ -706,7 +744,7 @@ export function App() { const charge = hero.buffCharges?.[type]; if (charge != null && charge.remaining <= 0) { const label = type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' '); - setToast({ message: `No charges left for ${label}`, color: '#ff8844' }); + setToast({ message: t(tr.noChargesLeft, { label }), color: '#ff8844' }); return; } } @@ -781,14 +819,14 @@ export function App() { } hapticImpact('medium'); - addLogEntry(`Activated ${type} buff`); + // Server logs buff activation + sends adventure_log_line } // Send command to server via WebSocket if (ws) { sendActivateBuff(ws, type); } - }, [addLogEntry]); + }, []); const handleRevive = useCallback(() => { const ws = wsRef.current; @@ -824,13 +862,13 @@ export function App() { if (pos) merged.position = pos; engine.applyHeroState(merged); } - setToast({ message: 'Quest rewards claimed!', color: '#ffd700' }); + setToast({ message: tr.questRewardsClaimed, color: '#ffd700' }); hapticNotification('success'); refreshHeroQuests(); }) .catch((err) => { console.warn('[App] Failed to claim quest:', err); - setToast({ message: 'Failed to claim rewards', color: '#ff4444' }); + setToast({ message: tr.failedToClaimRewards, color: '#ff4444' }); }); }, [refreshHeroQuests], @@ -841,12 +879,12 @@ export function App() { const telegramId = getTelegramUserId() ?? 1; abandonQuest(heroQuestId, telegramId) .then(() => { - setToast({ message: 'Quest abandoned', color: '#ff8844' }); + setToast({ message: tr.questAbandoned, color: '#ff8844' }); refreshHeroQuests(); }) .catch((err) => { console.warn('[App] Failed to abandon quest:', err); - setToast({ message: 'Failed to abandon quest', color: '#ff4444' }); + setToast({ message: tr.failedToAbandonQuest, color: '#ff4444' }); }); }, [refreshHeroQuests], @@ -902,47 +940,17 @@ export function App() { .catch(() => console.warn('[App] Could not fetch hero quests')); getAdventureLog(telegramId, 50) .then((serverLog) => { - if (serverLog.length > 0) { - const mapped: AdventureLogEntry[] = serverLog.map((entry, i) => ({ - id: i + 1, - message: entry.message, - timestamp: new Date(entry.createdAt).getTime(), - })); - logIdCounter.current = mapped.length; - setLogEntries(mapped); - } + const { entries, maxId } = adventureEntriesFromServerLog(serverLog); + logIdCounter.current = Math.max(logIdCounter.current, maxId); + setLogEntries(entries); }) .catch(() => console.warn('[App] Could not fetch adventure log')); getAchievements(telegramId) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .catch(() => {}); - getDailyTasks(telegramId) - .then((t) => setDailyTasks(t)) - .catch(() => {}); } }, []); - const handleClaimDailyTask = useCallback((taskId: string) => { - const telegramId = getTelegramUserId() ?? 1; - claimDailyTask(taskId, telegramId) - .then((hero) => { - const merged = heroResponseToState(hero); - const engine = engineRef.current; - if (engine) { - const pos = engine.gameState.hero?.position; - if (pos) merged.position = pos; - engine.applyHeroState(merged); - } - setToast({ message: 'Daily task reward claimed!', color: '#ffd700' }); - hapticNotification('success'); - getDailyTasks(telegramId).then((t) => setDailyTasks(t)).catch(() => {}); - }) - .catch((err) => { - console.warn('[App] Failed to claim daily task:', err); - setToast({ message: 'Failed to claim reward', color: '#ff4444' }); - }); - }, []); - const handleUsePotion = useCallback(() => { const ws = wsRef.current; const hero = engineRef.current?.gameState.hero; @@ -954,9 +962,9 @@ export function App() { } hero.potions-- - addLogEntry('Used healing potion'); hapticImpact('medium'); - }, [addLogEntry]); + // Server logs potion use + sends adventure_log_line + }, []); // ---- NPC Interaction Handlers ---- @@ -973,49 +981,49 @@ export function App() { setNpcInteractionDismissed(npc.id); }, []); - const handleNPCBuyPotion = useCallback((npc: NPCData) => { + const handleNPCBuyPotion = useCallback((_npc: NPCData) => { const telegramId = getTelegramUserId() ?? 1; buyPotion(telegramId) .then((hero) => { hapticImpact('medium'); - setToast({ message: 'Bought a potion for 50 gold', color: '#88dd88' }); + setToast({ message: t(tr.boughtPotion, { cost: 50 }), color: '#88dd88' }); handleNPCHeroUpdated(hero); - addLogEntry(`Bought potion from ${npc.name}`); + // Server logs purchase + WS }) .catch((err) => { console.warn('[App] Failed to buy potion:', err); if (err instanceof ApiError) { try { const j = JSON.parse(err.body) as { error?: string }; - setToast({ message: j.error ?? 'Failed to buy potion', color: '#ff4444' }); + setToast({ message: j.error ?? tr.failedToBuyPotion, color: '#ff4444' }); } catch { - setToast({ message: 'Failed to buy potion', color: '#ff4444' }); + setToast({ message: tr.failedToBuyPotion, color: '#ff4444' }); } } }); - }, [handleNPCHeroUpdated, addLogEntry]); + }, [handleNPCHeroUpdated]); - const handleNPCHeal = useCallback((npc: NPCData) => { + const handleNPCHeal = useCallback((_npc: NPCData) => { const telegramId = getTelegramUserId() ?? 1; healAtNPC(telegramId) .then((hero) => { hapticImpact('medium'); - setToast({ message: 'Healed to full HP!', color: '#44cc44' }); + setToast({ message: tr.healedToFull, color: '#44cc44' }); handleNPCHeroUpdated(hero); - addLogEntry(`Healed at ${npc.name}`); + // Server logs heal + WS }) .catch((err) => { console.warn('[App] Failed to heal:', err); if (err instanceof ApiError) { try { const j = JSON.parse(err.body) as { error?: string }; - setToast({ message: j.error ?? 'Failed to heal', color: '#ff4444' }); + setToast({ message: j.error ?? tr.failedToHeal, color: '#ff4444' }); } catch { - setToast({ message: 'Failed to heal', color: '#ff4444' }); + setToast({ message: tr.failedToHeal, color: '#ff4444' }); } } }); - }, [handleNPCHeroUpdated, addLogEntry]); + }, [handleNPCHeroUpdated]); const handleNPCInteractionDismiss = useCallback(() => { if (nearestNPC) { @@ -1031,8 +1039,8 @@ export function App() { sendNPCAlmsAccept(ws); } setWanderingNPC(null); - addLogEntry('Accepted wandering merchant offer'); - }, [addLogEntry]); + // Alms outcome is logged on server + WS when trade completes + }, []); const handleWanderingDecline = useCallback(() => { const ws = wsRef.current; @@ -1051,6 +1059,7 @@ export function App() { !selectedNPC; return ( +
{/* PixiJS Canvas */}
@@ -1062,8 +1071,27 @@ export function App() { buffCooldownEndsAt={buffCooldownEndsAt} onUsePotion={handleUsePotion} onHeroUpdated={handleNPCHeroUpdated} + onOpenHeroSheet={() => { + setHeroSheetInitialTab('stats'); + setHeroSheetOpen(true); + }} /> + {gameState.hero && ( + setHeroSheetOpen(false)} + initialTab={heroSheetInitialTab} + hero={gameState.hero} + nowMs={sheetNowMs} + equipment={gameState.hero.equipment ?? {}} + logEntries={logEntries} + quests={heroQuests} + onQuestClaim={handleQuestClaim} + onQuestAbandon={handleQuestAbandon} + /> + )} + {/* Floating Damage Numbers */} @@ -1114,70 +1142,6 @@ export function App() {
)} - {/* Quest Log Toggle Button */} - {gameState.hero && ( - - )} - - {/* Quest Log Panel */} - {questLogOpen && ( - setQuestLogOpen(false)} - /> - )} - {/* NPC Proximity Interaction */} {showNPCInteraction && nearestNPC && ( )} - {/* Daily Tasks (top-right) */} - {gameState.hero && } - - {/* Minimap (below daily tasks, top-right) */} + {/* Minimap (top-right) */} {gameState.hero && ( } - {/* Adventure Log */} - - {/* Offline Report Overlay */} {offlineReport && ( )}
+
); } diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 8ff0712..d662b32 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -477,15 +477,16 @@ export class GameEngine { this._notifyStateChange(); } - /** Server simulated approach to a town NPC (quest / shop / healer). */ - applyTownNPCVisit(npcName: string, npcType: string): void { - const label = - npcType === 'merchant' - ? 'Shop' - : npcType === 'healer' - ? 'Healer' - : 'Quest'; - this._thoughtText = `${label}: ${npcName}`; + /** Server simulated approach to a town NPC (quest / shop / healer). Narration uses adventure_log_line. */ + applyTownNPCVisit(_npcName: string, _npcType: string): void { + void _npcName; + void _npcType; + } + + /** Same text as adventure log line — shown in the thought bubble above the hero (town NPC visits). */ + applyAdventureLogLine(message: string): void { + if (!message) return; + this._thoughtText = message; this._thoughtStartMs = performance.now(); this._notifyStateChange(); } diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts index fbfffb7..a26c456 100644 --- a/frontend/src/game/renderer.ts +++ b/frontend/src/game/renderer.ts @@ -304,7 +304,7 @@ export class GameRenderer { fontFamily: 'system-ui, sans-serif', fill: 0x333333, wordWrap: true, - wordWrapWidth: 130, + wordWrapWidth: 210, align: 'center', }), }); @@ -562,8 +562,14 @@ export class GameRenderer { const bx = iso.x; const by = iso.y - 52; // above hero head - const w = 140; - const h = 28; + txt.text = text; + txt.style.wordWrapWidth = 210; + const padX = 10; + const padY = 8; + const rawW = txt.width; + const rawH = txt.height; + const w = Math.min(240, Math.max(100, Math.ceil(rawW) + padX * 2)); + const h = Math.max(30, Math.ceil(rawH) + padY * 2); const left = bx - w / 2; const top = by - h / 2; @@ -584,8 +590,7 @@ export class GameRenderer { gfx.zIndex = by + 200; // above hero - // Text - txt.text = text; + // Text position (centered in bubble) txt.x = bx; txt.y = by; txt.alpha = alpha; diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 2327bbd..e3a653a 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -153,6 +153,8 @@ export interface HeroState { moveSpeed?: number; /** Extended equipment slots (§6.3): slot key -> equipped item */ equipment?: Record; + /** Backpack items (server max 40) */ + inventory?: EquipmentItem[]; } export interface ActiveBuff { @@ -380,12 +382,14 @@ export type ServerMessageType = | 'town_exit' | 'town_npc_visit' | 'npc_encounter' + | 'npc_encounter_end' | 'level_up' | 'equipment_change' | 'potion_collected' | 'quest_available' | 'quest_progress' | 'quest_complete' + | 'merchant_loot' | 'error' | 'pong'; @@ -443,6 +447,15 @@ export interface CombatEndPayload { newLevel?: number; } +/** Wandering merchant trade result (after npc alms). */ +export interface MerchantLootPayload { + goldSpent: number; + itemType: string; + itemName?: string; + rarity?: string; + goldAmount?: number; +} + export interface HeroDiedPayload { killedBy: string; } @@ -472,6 +485,11 @@ export interface TownNPCVisitPayload { townId: number; } +/** Server-persisted adventure log line (e.g. town NPC visit narration). */ +export interface AdventureLogLinePayload { + message: string; +} + export interface TownExitPayload {} export interface NPCEncounterPayload { @@ -482,6 +500,10 @@ export interface NPCEncounterPayload { cost: number; } +export interface NPCEncounterEndPayload { + reason: 'timeout' | 'declined' | string; +} + export interface LevelUpPayload { newLevel: number; statChanges: { diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index 996b6ee..e541065 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -12,7 +12,9 @@ import type { BuffAppliedPayload, TownEnterPayload, TownNPCVisitPayload, + AdventureLogLinePayload, NPCEncounterPayload, + NPCEncounterEndPayload, LevelUpPayload, EquipmentChangePayload, PotionCollectedPayload, @@ -22,6 +24,7 @@ import type { ServerErrorPayload, EnemyState, LootDrop, + MerchantLootPayload, } from './types'; import { EnemyType, Rarity } from './types'; @@ -34,8 +37,10 @@ export interface WSHandlerCallbacks { onBuffApplied?: (payload: BuffAppliedPayload) => void; onTownEnter?: (payload: TownEnterPayload) => void; onTownNPCVisit?: (payload: TownNPCVisitPayload) => void; + onAdventureLogLine?: (payload: AdventureLogLinePayload) => void; onTownExit?: () => void; onNPCEncounter?: (payload: NPCEncounterPayload) => void; + onNPCEncounterEnd?: (payload: NPCEncounterEndPayload) => void; onLevelUp?: (payload: LevelUpPayload) => void; onEquipmentChange?: (payload: EquipmentChangePayload) => void; onPotionCollected?: (payload: PotionCollectedPayload) => void; @@ -44,6 +49,7 @@ export interface WSHandlerCallbacks { onQuestAvailable?: (payload: QuestAvailablePayload) => void; onError?: (payload: ServerErrorPayload) => void; onHeroStateReceived?: (hero: Record) => void; + onMerchantLoot?: (payload: MerchantLootPayload) => void; } /** @@ -111,6 +117,11 @@ export function wireWSHandler( callbacks.onCombatEnd?.(p); }); + ws.on('merchant_loot', (msg: ServerMessage) => { + const p = msg.payload as MerchantLootPayload; + callbacks.onMerchantLoot?.(p); + }); + // ---- Server -> Client: Death / Revive ---- ws.on('hero_died', (msg: ServerMessage) => { @@ -151,6 +162,12 @@ export function wireWSHandler( callbacks.onTownNPCVisit?.(p); }); + ws.on('adventure_log_line', (msg: ServerMessage) => { + const p = msg.payload as AdventureLogLinePayload; + engine.applyAdventureLogLine(p.message); + callbacks.onAdventureLogLine?.(p); + }); + // ---- Server -> Client: NPC Encounter ---- ws.on('npc_encounter', (msg: ServerMessage) => { @@ -158,6 +175,11 @@ export function wireWSHandler( callbacks.onNPCEncounter?.(p); }); + ws.on('npc_encounter_end', (msg: ServerMessage) => { + const p = msg.payload as NPCEncounterEndPayload; + callbacks.onNPCEncounterEnd?.(p); + }); + // ---- Server -> Client: Progression ---- ws.on('level_up', (msg: ServerMessage) => { @@ -261,3 +283,27 @@ export function buildLootFromCombatEnd(p: CombatEndPayload): LootDrop | null { : undefined, }; } + +/** + * Build a LootDrop for the popup after a wandering merchant trade (gear only; equip or auto-sell). + */ +export function buildMerchantLootDrop(p: MerchantLootPayload): LootDrop | null { + const rarity = (p.rarity?.toLowerCase() ?? 'common') as Rarity; + if (p.goldAmount != null && p.goldAmount > 0) { + return { + itemType: 'gold', + rarity, + goldAmount: p.goldAmount, + itemName: p.itemName ? `Sold: ${p.itemName}` : undefined, + }; + } + if (p.itemName) { + return { + itemType: 'gold', + rarity, + goldAmount: 0, + itemName: p.itemName, + }; + } + return null; +} diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index c163d9e..6183367 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -137,6 +137,19 @@ export interface HeroResponse { primaryStat?: number; statType?: string; }>; + /** Same slot data as `equipment`; WebSocket `hero_state` from Go uses `gear` */ + gear?: HeroResponse['equipment']; + /** Unequipped gear (backpack), max 40 — mirrors Go `Hero.Inventory` */ + inventory?: Array<{ + id: number; + slot: string; + formId?: string; + name: string; + rarity: string; + ilvl?: number; + primaryStat?: number; + statType?: string; + }>; } export interface AuthResponse { @@ -403,7 +416,11 @@ export async function getAdventureLog(telegramId?: number, limit?: number): Prom if (telegramId != null) params.set('telegramId', String(telegramId)); if (limit != null) params.set('limit', String(limit)); const query = params.toString() ? `?${params.toString()}` : ''; - return apiGet(`/hero/log${query}`); + const raw = await apiGet<{ log?: LogEntry[] } | LogEntry[]>(`/hero/log${query}`); + if (Array.isArray(raw)) { + return raw; + } + return raw.log ?? []; } // ---- Potions ---- diff --git a/frontend/src/ui/AdventureLog.tsx b/frontend/src/ui/AdventureLog.tsx index 1ac67ba..a7f01cf 100644 --- a/frontend/src/ui/AdventureLog.tsx +++ b/frontend/src/ui/AdventureLog.tsx @@ -1,9 +1,6 @@ -import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { useEffect, useRef, type CSSProperties, type RefObject } from 'react'; import type { AdventureLogEntry } from '../game/types'; - -interface AdventureLogProps { - entries: AdventureLogEntry[]; -} +import { useT } from '../i18n'; function formatTime(timestamp: number): string { const d = new Date(timestamp); @@ -12,62 +9,14 @@ function formatTime(timestamp: number): string { return `${hh}:${mm}`; } -const buttonStyle: CSSProperties = { - position: 'fixed', - bottom: 60, - left: 12, - zIndex: 50, - display: 'flex', - alignItems: 'center', - gap: 4, - padding: '6px 12px', - borderRadius: 8, - border: '1px solid rgba(255,255,255,0.15)', - backgroundColor: 'rgba(0,0,0,0.55)', - color: '#ccc', - fontSize: 12, - fontWeight: 600, - cursor: 'pointer', - pointerEvents: 'auto', - userSelect: 'none', -}; - -const panelStyle: CSSProperties = { - position: 'fixed', - bottom: 60, - left: 12, - zIndex: 50, - width: 'calc(100vw - 24px)', - maxWidth: 360, - maxHeight: '40vh', - display: 'flex', - flexDirection: 'column', - borderRadius: 10, - border: '1px solid rgba(255,255,255,0.12)', - backgroundColor: 'rgba(10, 10, 20, 0.82)', - backdropFilter: 'blur(6px)', - overflow: 'hidden', - pointerEvents: 'auto', -}; - -const panelHeaderStyle: CSSProperties = { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: '8px 12px', - borderBottom: '1px solid rgba(255,255,255,0.08)', - fontSize: 13, - fontWeight: 700, - color: '#ddd', -}; - const scrollAreaStyle: CSSProperties = { flex: 1, overflowY: 'auto', - padding: '6px 10px', + padding: '4px 2px', fontSize: 12, lineHeight: 1.6, color: '#bbb', + maxHeight: 'min(52vh, 420px)', }; const entryStyle: CSSProperties = { @@ -83,56 +32,37 @@ const timestampStyle: CSSProperties = { fontSize: 11, }; -const closeButtonStyle: CSSProperties = { - background: 'none', - border: 'none', - color: '#999', - fontSize: 16, - cursor: 'pointer', - padding: '0 4px', - lineHeight: 1, -}; - -export function AdventureLog({ entries }: AdventureLogProps) { - const [expanded, setExpanded] = useState(false); - const scrollRef = useRef(null); +/** Scrollable adventure log list (Hero sheet Journal tab). */ +export function AdventureLogEntries({ + entries, + scrollRef, +}: { + entries: AdventureLogEntry[]; + scrollRef?: RefObject; +}) { + const tr = useT(); + const innerRef = useRef(null); + const ref = scrollRef ?? innerRef; - // Auto-scroll to bottom when new entries arrive while expanded useEffect(() => { - if (expanded && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (ref.current) { + ref.current.scrollTop = ref.current.scrollHeight; } - }, [entries.length, expanded]); - - if (!expanded) { - return ( - - ); - } + }, [entries.length, ref]); return ( -
-
- 📜 Adventure Log - -
-
- {entries.length === 0 && ( -
- No events yet... -
- )} - {entries.map((entry) => ( -
- [{formatTime(entry.timestamp)}] - {entry.message} -
- ))} -
+
+ {entries.length === 0 && ( +
+ {tr.noEventsYet} +
+ )} + {entries.map((entry) => ( +
+ [{formatTime(entry.timestamp)}] + {entry.message} +
+ ))}
); } diff --git a/frontend/src/ui/BuffBar.tsx b/frontend/src/ui/BuffBar.tsx index 9b812f6..5892b25 100644 --- a/frontend/src/ui/BuffBar.tsx +++ b/frontend/src/ui/BuffBar.tsx @@ -5,6 +5,7 @@ import { BUFF_META } from './buffMeta'; import { purchaseBuffRefill } from '../network/api'; import type { HeroResponse } from '../network/api'; import { getTelegramUserId } from '../shared/telegram'; +import { useT, t } from '../i18n'; // ---- Types ---- @@ -161,6 +162,7 @@ const buttonBase: CSSProperties = { }; function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowMs }: BuffButtonProps) { + const tr = useT(); const [pressed, setPressed] = useState(false); const [showTooltip, setShowTooltip] = useState(false); const [showRefillConfirm, setShowRefillConfirm] = useState(false); @@ -332,12 +334,12 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM color: isOutOfCharges ? '#ff8844' : '#aaccff', }} > - Charges: {remaining}/{maxCharges} + {tr.charges}: {remaining}/{maxCharges}
)} {isOutOfCharges && charge?.periodEnd && (
- Refills at {formatTimeHHMM(charge.periodEnd)} + {tr.refillsAt} {formatTimeHHMM(charge.periodEnd)}
)} {isOutOfCharges && onRefill && ( @@ -361,7 +363,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM pointerEvents: 'auto', }} > - Refill + {tr.refill} )}
@@ -390,7 +392,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM }} >
- Refill {meta.label}? + {t(tr.refillQuestion, { label: meta.label })}
{buff.type === BuffType.Resurrection ? '150\u20BD' : '50\u20BD'} @@ -414,7 +416,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM cursor: 'pointer', }} > - Refill + {tr.refill}
diff --git a/frontend/src/ui/DeathScreen.tsx b/frontend/src/ui/DeathScreen.tsx index 6b41d86..b9a8b86 100644 --- a/frontend/src/ui/DeathScreen.tsx +++ b/frontend/src/ui/DeathScreen.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type CSSProperties } from 'react'; import { REVIVE_TIMER_SECONDS } from '../shared/constants'; +import { useT, t } from '../i18n'; interface DeathScreenProps { visible: boolean; @@ -52,6 +53,7 @@ const buttonStyle: CSSProperties = { }; export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreenProps) { + const tr = useT(); const [timer, setTimer] = useState(REVIVE_TIMER_SECONDS); const canRevive = revivesRemaining === undefined || revivesRemaining > 0; @@ -86,11 +88,11 @@ export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreen return (
-
YOU DIED
+
{tr.youDied}
{canRevive ? timer : '—'}
{revivesRemaining !== undefined && (
- Free revives left: {Math.max(0, revivesRemaining)} + {t(tr.freeRevivesLeft, { count: Math.max(0, revivesRemaining) })}
)}
- {canRevive ? `Auto-revive in ${timer}s` : 'No free revives left — subscription required'} + {canRevive ? t(tr.autoReviveIn, { timer }) : tr.noFreeRevives}
); diff --git a/frontend/src/ui/EquipmentPanel.tsx b/frontend/src/ui/EquipmentPanel.tsx deleted file mode 100644 index 7d65014..0000000 --- a/frontend/src/ui/EquipmentPanel.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useState, type CSSProperties } from 'react'; -import type { EquipmentItem } from '../game/types'; -import { RARITY_COLORS, RARITY_GLOW } from '../shared/constants'; - -/** Ordered slot definitions for display */ -const SLOT_DEFS: Array<{ key: string; icon: string; label: string }> = [ - { key: 'main_hand', icon: '\u2694\uFE0F', label: 'Weapon' }, - { key: 'chest', icon: '\uD83D\uDEE1\uFE0F', label: 'Chest' }, - { key: 'head', icon: '\u26D1\uFE0F', label: 'Head' }, - { key: 'feet', icon: '\uD83D\uDC62', label: 'Feet' }, - { key: 'neck', icon: '\uD83D\uDCBF', label: 'Neck' }, - { key: 'hands', icon: '\uD83D\uDC42', label: 'Hands' }, - { key: 'legs', icon: '\uD83D\uDC62', label: 'Legs' }, - { key: 'cloak', icon: '\uD83D\uDEE1\uFE0F', label: 'Cloak' }, - { key: 'finger', icon: '\uD83D\uDCBF', label: 'Finger' }, - { key: 'wrist', icon: '\uD83D\uDC42', label: 'Wrist' }, -]; - -function rarityColor(rarity: string): string { - return RARITY_COLORS[rarity.toLowerCase()] ?? '#9d9d9d'; -} - -function rarityGlow(rarity: string): string { - return RARITY_GLOW[rarity.toLowerCase()] ?? 'none'; -} - -function statLabel(statType: string): string { - switch (statType) { - case 'attack': return 'ATK'; - case 'defense': return 'DEF'; - case 'speed': return 'SPD'; - default: return 'STAT'; - } -} - -const wrapStyle: CSSProperties = { - marginTop: 6, - borderRadius: 8, - backgroundColor: 'rgba(0,0,0,0.45)', - border: '1px solid rgba(255,255,255,0.12)', - overflow: 'hidden', - flex: 1, - minWidth: 0, -}; - -const headerStyle: CSSProperties = { - width: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '6px 10px', - fontSize: 12, - fontWeight: 700, - color: '#e8e8e8', - cursor: 'pointer', - userSelect: 'none', - WebkitTapHighlightColor: 'transparent', - background: 'rgba(255,255,255,0.04)', - border: 'none', - fontFamily: 'inherit', -}; - -const bodyStyle: CSSProperties = { - padding: '8px 10px 10px', - fontSize: 11, - color: '#ccc', -}; - -const slotRow: CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '4px 0', - borderBottom: '1px solid rgba(255,255,255,0.06)', -}; - -const slotLeftStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 6, - minWidth: 0, - overflow: 'hidden', -}; - -const slotIconStyle: CSSProperties = { - fontSize: 13, - flexShrink: 0, - width: 18, - textAlign: 'center', -}; - -const emptyLabelStyle: CSSProperties = { - color: '#555', - fontStyle: 'italic', - fontSize: 10, -}; - -const itemNameStyle: CSSProperties = { - fontWeight: 600, - fontSize: 11, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', -}; - -const statValStyle: CSSProperties = { - fontSize: 10, - color: '#999', - flexShrink: 0, - textAlign: 'right', - marginLeft: 8, -}; - -interface EquipmentPanelProps { - equipment: Record; -} - -export function EquipmentPanel({ equipment }: EquipmentPanelProps) { - const [open, setOpen] = useState(false); - - const equippedCount = equipment ? Object.keys(equipment).length : 0; - - return ( -
- - {open && ( -
- {SLOT_DEFS.map((def) => { - const item = equipment?.[def.key]; - if (!item) { - return ( -
-
- {def.icon} - {def.label} -
- Empty -
- ); - } - - const color = rarityColor(item.rarity); - const glow = rarityGlow(item.rarity); - - return ( -
-
- {def.icon} - {item.name} -
- - {statLabel(item.statType)} {item.primaryStat} - {' \u00B7 '}ilvl {item.ilvl} - -
- ); - })} -
- )} -
- ); -} diff --git a/frontend/src/ui/HUD.tsx b/frontend/src/ui/HUD.tsx index f20213e..b156e6b 100644 --- a/frontend/src/ui/HUD.tsx +++ b/frontend/src/ui/HUD.tsx @@ -4,13 +4,12 @@ import { BuffBar } from './BuffBar'; import { BuffStatusStrip } from './BuffStatusStrip'; import { DebuffBar } from './DebuffBar'; import { LootPopup } from './LootPopup'; -import { HeroPanel } from './HeroPanel'; -import { EquipmentPanel } from './EquipmentPanel'; import { InventoryStrip } from './InventoryStrip'; import type { GameState } from '../game/types'; import { GamePhase, BuffType } from '../game/types'; import { useUiClock } from '../hooks/useUiClock'; import type { HeroResponse } from '../network/api'; +import { useT } from '../i18n'; // FREE_BUFF_ACTIVATIONS_PER_PERIOD removed — per-buff charges are now shown on each button interface HUDProps { @@ -19,6 +18,7 @@ interface HUDProps { buffCooldownEndsAt: Partial>; onUsePotion?: () => void; onHeroUpdated?: (hero: HeroResponse) => void; + onOpenHeroSheet: () => void; } const containerStyle: CSSProperties = { @@ -126,9 +126,69 @@ const potionButtonDisabledStyle: CSSProperties = { cursor: 'default', }; -export function HUD({ gameState, onBuffActivate, buffCooldownEndsAt, onUsePotion, onHeroUpdated }: HUDProps) { +const sheetBtnStyle: CSSProperties = { + flexShrink: 0, + minWidth: 44, + height: 40, + padding: '3px 6px', + borderRadius: 6, + border: '1px solid rgba(140, 170, 220, 0.35)', + backgroundColor: 'rgba(25, 35, 55, 0.75)', + color: '#b8c8e8', + cursor: 'pointer', + pointerEvents: 'auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 1, + userSelect: 'none', + WebkitTapHighlightColor: 'transparent', +}; + +const sheetBtnIconStyle: CSSProperties = { + lineHeight: 1, + fontSize: 14, +}; + +const sheetBtnLabelStyle: CSSProperties = { + fontSize: 9, + fontWeight: 700, + letterSpacing: 0.4, + textTransform: 'uppercase', + color: 'rgba(200, 215, 240, 0.95)', + lineHeight: 1, +}; + +/** Gold next to Hero sheet button — matches inventory tab styling */ +const hudGoldChipStyle: CSSProperties = { + flexShrink: 0, + display: 'inline-flex', + alignItems: 'center', + gap: 4, + padding: '4px 8px', + borderRadius: 6, + background: 'rgba(255, 215, 0, 0.08)', + border: '1px solid rgba(255, 215, 0, 0.22)', + color: '#ffd700', + fontWeight: 700, + fontSize: 12, + pointerEvents: 'none', + userSelect: 'none', + whiteSpace: 'nowrap', +}; + +export function HUD({ + gameState, + onBuffActivate, + buffCooldownEndsAt, + onUsePotion, + onHeroUpdated, + onOpenHeroSheet, +}: HUDProps) { const { hero, enemy, phase, lastVictoryLoot } = gameState; const nowMs = useUiClock(100); + const tr = useT(); const handleBuffActivate = useCallback( (type: BuffType) => { @@ -170,6 +230,20 @@ export function HUD({ gameState, onBuffActivate, buffCooldownEndsAt, onUsePotion
+ +
+ {'\uD83E\uDE99'} + {hero.gold.toLocaleString()} +
@@ -189,10 +263,6 @@ export function HUD({ gameState, onBuffActivate, buffCooldownEndsAt, onUsePotion {/* Debuff indicators */} {debuffsLive.length > 0 && } -
- - -
)} diff --git a/frontend/src/ui/HeroPanel.tsx b/frontend/src/ui/HeroPanel.tsx index d09a0eb..ab81f3b 100644 --- a/frontend/src/ui/HeroPanel.tsx +++ b/frontend/src/ui/HeroPanel.tsx @@ -1,55 +1,35 @@ -import { useState, type CSSProperties } from 'react'; +import { type CSSProperties } from 'react'; import type { HeroState } from '../game/types'; import { BuffType, DebuffType, type ActiveBuff, type ActiveDebuff } from '../game/types'; import { DEBUFF_COLORS } from '../shared/constants'; import { HPBar } from './HPBar'; +import { useT, type Translations } from '../i18n'; + +function buffLabel(tr: Translations, type: BuffType): string { + const map: Record = { + [BuffType.Rush]: tr.buffRush, + [BuffType.Rage]: tr.buffRage, + [BuffType.Shield]: tr.buffShield, + [BuffType.Luck]: tr.buffLuck, + [BuffType.Resurrection]: tr.buffResurrection, + [BuffType.Heal]: tr.buffHeal, + [BuffType.PowerPotion]: tr.buffPowerPotion, + [BuffType.WarCry]: tr.buffWarCry, + }; + return map[type] ?? type; +} -const BUFF_LABEL: Record = { - [BuffType.Rush]: 'Rush', - [BuffType.Rage]: 'Rage', - [BuffType.Shield]: 'Shield', - [BuffType.Luck]: 'Luck', - [BuffType.Resurrection]: 'Resurrect', - [BuffType.Heal]: 'Heal', - [BuffType.PowerPotion]: 'Power', - [BuffType.WarCry]: 'War Cry', -}; - -const DEBUFF_LABEL: Record = { - [DebuffType.Poison]: 'Poison', - [DebuffType.Freeze]: 'Freeze', - [DebuffType.Burn]: 'Burn', - [DebuffType.Stun]: 'Stun', - [DebuffType.Slow]: 'Slow', - [DebuffType.Weaken]: 'Weaken', -}; - -const wrapStyle: CSSProperties = { - marginTop: 6, - borderRadius: 8, - backgroundColor: 'rgba(0,0,0,0.45)', - border: '1px solid rgba(255,255,255,0.12)', - overflow: 'hidden', - flex: 1, - minWidth: 0, -}; - -const headerStyle: CSSProperties = { - width: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '6px 10px', - fontSize: 12, - fontWeight: 700, - color: '#e8e8e8', - cursor: 'pointer', - userSelect: 'none', - WebkitTapHighlightColor: 'transparent', - background: 'rgba(255,255,255,0.04)', - border: 'none', - fontFamily: 'inherit', -}; +function debuffLabel(tr: Translations, type: DebuffType): string { + const map: Record = { + [DebuffType.Poison]: tr.debuffPoison, + [DebuffType.Freeze]: tr.debuffFreeze, + [DebuffType.Burn]: tr.debuffBurn, + [DebuffType.Stun]: tr.debuffStun, + [DebuffType.Slow]: tr.debuffSlow, + [DebuffType.Weaken]: tr.debuffWeaken, + }; + return map[type] ?? type; +} const bodyStyle: CSSProperties = { padding: '8px 10px 10px', @@ -129,9 +109,9 @@ function StatValue({ value, label, buffed, nerfed }: { value: string; label: str } -export function HeroPanel({ hero, nowMs }: HeroPanelProps) { - const [open, setOpen] = useState(false); - +/** Full hero stats block (Stats sheet tab). */ +export function HeroStatsContent({ hero, nowMs }: HeroPanelProps) { + const tr = useT(); const buffsLive = hero.activeBuffs .map((b) => ({ ...b, @@ -154,36 +134,24 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) { const defBuffed = hasActiveBuff(hero.activeBuffs, BuffType.Shield, nowMs); return ( -
- - {open && ( -
+
{/* Combat Stats */} - - - + + + -
STR{hero.strength}
-
CON{hero.constitution}
-
AGI{hero.agility}
-
LUCK{hero.luck}
- +
{tr.str}{hero.strength}
+
{tr.con}{hero.constitution}
+
{tr.agi}{hero.agility}
+
{tr.luck}{hero.luck}
{/* XP */} -
Experience
+
{tr.experience}
{/* Buffs */} -
Active Buffs
+
{tr.activeBuffs}
{buffsLive.length === 0 ? ( -
None
+
{tr.none}
) : (
{buffsLive.map((b) => ( @@ -210,7 +178,7 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) { border: '1px solid rgba(68, 170, 255, 0.6)', }} > - {BUFF_LABEL[b.type]} + {buffLabel(tr, b.type)} {Math.ceil(b.remainingMs / 1000)}s ))} @@ -218,9 +186,9 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) { )} {/* Debuffs */} -
Active Debuffs
+
{tr.activeDebuffs}
{debuffsLive.length === 0 ? ( -
None
+
{tr.none}
) : (
{debuffsLive.map((d) => { @@ -234,15 +202,13 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) { border: `1px solid ${color}99`, }} > - {DEBUFF_LABEL[d.type]} + {debuffLabel(tr, d.type)} {Math.ceil(d.remainingMs / 1000)}s ); })}
)} -
- )}
); } diff --git a/frontend/src/ui/InventoryStrip.tsx b/frontend/src/ui/InventoryStrip.tsx index ac20477..034727a 100644 --- a/frontend/src/ui/InventoryStrip.tsx +++ b/frontend/src/ui/InventoryStrip.tsx @@ -2,6 +2,7 @@ import type { CSSProperties } from 'react'; import type { HeroState, LootDrop } from '../game/types'; interface InventoryStripProps { + /** Kept for call sites; gold is shown on HUD and in the inventory tab. */ hero: HeroState; /** Most recent victory loot (gold + optional item); persists after popup fades */ lastLoot?: LootDrop | null; @@ -33,38 +34,22 @@ const stripStyle: CSSProperties = { minHeight: 28, }; -const goldStyle: CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: 5, - color: '#ffd700', - fontWeight: 700, - flexShrink: 0, - whiteSpace: 'nowrap', -}; - const lastLootStyle: CSSProperties = { - marginLeft: 'auto', fontSize: 10, color: 'rgba(220, 230, 255, 0.7)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', - maxWidth: 160, + maxWidth: '100%', }; -export function InventoryStrip({ hero, lastLoot }: InventoryStripProps) { +export function InventoryStrip({ lastLoot }: InventoryStripProps) { + if (!lastLoot) return null; return (
-
- {'\uD83E\uDE99'} - {hero.gold.toLocaleString()} +
+ {formatLastLootLine(lastLoot)}
- {lastLoot && ( -
- {formatLastLootLine(lastLoot)} -
- )}
); } diff --git a/frontend/src/ui/Minimap.tsx b/frontend/src/ui/Minimap.tsx index 8b8f8a5..e13a114 100644 --- a/frontend/src/ui/Minimap.tsx +++ b/frontend/src/ui/Minimap.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { buildWorldTerrainContext, proceduralTerrain, townsApiToInfluences } from '../game/procedural'; import type { Town } from '../game/types'; +import { useT } from '../i18n'; // ---- Types ---- @@ -58,7 +59,7 @@ const DEFAULT_RGB = hexToRgb(DEFAULT_BG); const containerStyle: CSSProperties = { position: 'fixed', - top: 42, + top: 12, right: 8, zIndex: 40, userSelect: 'none', @@ -96,15 +97,16 @@ function canvasStyleForSize(px: number): CSSProperties { }; } -function modeLabel(mode: MapMode): string { - if (mode === 0) return `MAP \u25B6`; - if (mode === 1) return 'MAP S'; - return 'MAP L'; +function modeLabel(mode: MapMode, mapStr: string): string { + if (mode === 0) return `${mapStr} \u25B6`; + if (mode === 1) return `${mapStr} S`; + return `${mapStr} L`; } // ---- Component ---- export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { + const tr = useT(); const [mode, setMode] = useState(1); const canvasRef = useRef(null); const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN }); @@ -275,7 +277,7 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { title="Карта: свернуть / малый / большой" onClick={() => setMode((m) => ((((m + 1) % 3) as MapMode)))} > - {modeLabel(mode)} + {modeLabel(mode, tr.map)} {!collapsed && ( ): string { switch (type) { - case 'quest_giver': return 'Quest Giver'; - case 'merchant': return 'Merchant'; - case 'healer': return 'Healer'; - default: return 'NPC'; + case 'quest_giver': return tr.questGiver; + case 'merchant': return tr.merchant; + case 'healer': return tr.healer; + default: return tr.npc; } } @@ -243,6 +244,7 @@ export function NPCDialog({ onHeroUpdated, onToast, }: NPCDialogProps) { + const tr = useT(); const [availableQuests, setAvailableQuests] = useState([]); const [loading, setLoading] = useState(false); @@ -275,14 +277,14 @@ export function NPCDialog({ acceptQuest(questId, telegramId) .then(() => { hapticImpact('medium'); - onToast('Quest accepted!', '#44aaff'); + onToast(tr.questAccepted, '#44aaff'); onQuestsChanged(); // Remove from available list setAvailableQuests((prev) => prev.filter((q) => q.id !== questId)); }) .catch((err) => { console.warn('[NPCDialog] Failed to accept quest:', err); - onToast('Failed to accept quest', '#ff4444'); + onToast(tr.failedToAcceptQuest, '#ff4444'); }); }, [telegramId, onQuestsChanged, onToast], @@ -293,13 +295,13 @@ export function NPCDialog({ claimQuest(heroQuestId, telegramId) .then((hero) => { hapticImpact('heavy'); - onToast('Quest rewards claimed!', '#ffd700'); + onToast(tr.questRewardsClaimed, '#ffd700'); onHeroUpdated(hero); onQuestsChanged(); }) .catch((err) => { console.warn('[NPCDialog] Failed to claim quest:', err); - onToast('Failed to claim rewards', '#ff4444'); + onToast(tr.failedToClaimRewards, '#ff4444'); }); }, [telegramId, onQuestsChanged, onHeroUpdated, onToast], @@ -307,35 +309,35 @@ export function NPCDialog({ const handleBuyPotion = useCallback(() => { if (heroGold < POTION_COST) { - onToast('Not enough gold!', '#ff4444'); + onToast(tr.notEnoughGold, '#ff4444'); return; } buyPotion(telegramId) .then((hero) => { hapticImpact('medium'); - onToast(`Bought a potion for ${POTION_COST} gold`, '#88dd88'); + onToast(t(tr.boughtPotion, { cost: POTION_COST }), '#88dd88'); onHeroUpdated(hero); }) .catch((err) => { console.warn('[NPCDialog] Failed to buy potion:', err); - onToast('Failed to buy potion', '#ff4444'); + onToast(tr.failedToBuyPotion, '#ff4444'); }); }, [telegramId, heroGold, onHeroUpdated, onToast]); const handleHeal = useCallback(() => { if (heroGold < HEAL_COST) { - onToast('Not enough gold!', '#ff4444'); + onToast(tr.notEnoughGold, '#ff4444'); return; } healAtNPC(telegramId) .then((hero) => { hapticImpact('medium'); - onToast('Healed to full HP!', '#44cc44'); + onToast(tr.healedToFull, '#44cc44'); onHeroUpdated(hero); }) .catch((err) => { console.warn('[NPCDialog] Failed to heal:', err); - onToast('Failed to heal', '#ff4444'); + onToast(tr.failedToHeal, '#ff4444'); }); }, [telegramId, heroGold, onHeroUpdated, onToast]); @@ -371,7 +373,7 @@ export function NPCDialog({
{npcTypeIcon(npc.type)} {npc.name} - {npcTypeLabel(npc.type)} + {npcTypeLabel(npc.type, tr)}
@@ -384,7 +386,7 @@ export function NPCDialog({ {/* Completed quests — claim first */} {npcCompletedQuests.length > 0 && ( <> -
Completed
+
{tr.completed}
{npcCompletedQuests.map((hq) => (
handleClaimQuest(hq.id)} > - Claim Rewards + {tr.claimRewards}
))} @@ -498,7 +500,7 @@ export function NPCDialog({ style={acceptBtnStyle} onClick={() => handleAcceptQuest(q.id)} > - Accept Quest + {tr.acceptQuest}
))} @@ -527,7 +529,7 @@ export function NPCDialog({ onClick={handleBuyPotion} disabled={heroGold < POTION_COST} > - {'\uD83E\uDDEA'} Buy Potion — {POTION_COST} gold + {'\uD83E\uDDEA'} {tr.buyPotion} — {POTION_COST} {tr.gold}
Your gold: {heroGold} @@ -548,7 +550,7 @@ export function NPCDialog({ onClick={handleHeal} disabled={heroGold < HEAL_COST} > - {'\u2764\uFE0F'} Heal to Full — {HEAL_COST} gold + {'\u2764\uFE0F'} {tr.healToFull} — {HEAL_COST} {tr.gold}
Your gold: {heroGold} diff --git a/frontend/src/ui/NameEntryScreen.tsx b/frontend/src/ui/NameEntryScreen.tsx index 1bc59aa..b7ad766 100644 --- a/frontend/src/ui/NameEntryScreen.tsx +++ b/frontend/src/ui/NameEntryScreen.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, type CSSProperties, type FormEvent } from import { ApiError, setHeroName } from '../network/api'; import type { HeroResponse } from '../network/api'; import { getTelegramUserId } from '../shared/telegram'; +import { useT, t } from '../i18n'; const MIN_LEN = 2; const MAX_LEN = 16; @@ -90,6 +91,7 @@ const errorStyle: CSSProperties = { }; export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) { + const tr = useT(); const [name, setName] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -119,19 +121,19 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) { } catch (err) { if (err instanceof ApiError) { if (err.status === 409) { - setError('Name already taken, try another'); + setError(tr.nameTaken); } else if (err.status === 400) { try { const j = JSON.parse(err.body) as { error?: string }; - setError(j.error ?? 'Invalid name'); + setError(j.error ?? tr.invalidName); } catch { - setError('Invalid name'); + setError(tr.invalidName); } } else { - setError(`Server error (${err.status})`); + setError(t(tr.serverError, { status: err.status })); } } else { - setError('Connection failed, please retry'); + setError(tr.connectionFailed); } } finally { setLoading(false); @@ -141,7 +143,7 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) { return (
-
Choose Your Hero Name
+
{tr.chooseHeroName}
setFocused(true)} onBlur={() => setFocused(false)} - placeholder="Enter a name..." + placeholder={tr.enterName} maxLength={MAX_LEN} autoComplete="off" autoCorrect="off" @@ -182,7 +184,7 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) { cursor: isValid && !loading ? 'pointer' : 'not-allowed', }} > - {loading ? 'Saving...' : 'Continue'} + {loading ? tr.saving : tr.continue}
diff --git a/frontend/src/ui/OfflineReport.tsx b/frontend/src/ui/OfflineReport.tsx index 99867c7..6bb8b12 100644 --- a/frontend/src/ui/OfflineReport.tsx +++ b/frontend/src/ui/OfflineReport.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type CSSProperties } from 'react'; +import { useT, t } from '../i18n'; interface OfflineReportProps { monstersKilled: number; @@ -48,11 +49,6 @@ const lineStyle: CSSProperties = { lineHeight: 1.8, }; -const highlightStyle: CSSProperties = { - fontWeight: 700, - color: '#ffd700', -}; - const hintStyle: CSSProperties = { fontSize: 11, color: 'rgba(180, 190, 220, 0.5)', @@ -66,6 +62,7 @@ export function OfflineReport({ levelsGained, onDismiss, }: OfflineReportProps) { + const tr = useT(); const [fading, setFading] = useState(false); useEffect(() => { @@ -94,26 +91,26 @@ export function OfflineReport({ onClick={onDismiss} >
e.stopPropagation()}> -
While you were away...
+
{tr.whileYouWereAway}
- Killed {monstersKilled} monster{monstersKilled !== 1 ? 's' : ''} + {t(tr.killedMonsters, { count: monstersKilled })}
{xpGained > 0 && (
- +{xpGained} XP + {t(tr.gainedXP, { xp: xpGained })}
)} {goldGained > 0 && (
- +{goldGained} gold + {t(tr.gainedGold, { gold: goldGained })}
)} {levelsGained > 0 && (
- Gained {levelsGained} level{levelsGained !== 1 ? 's' : ''}! + {t(tr.gainedLevels, { levels: levelsGained })}
)} -
Tap anywhere to dismiss
+
{tr.tapToDismiss}
diff --git a/frontend/src/ui/QuestLog.tsx b/frontend/src/ui/QuestLog.tsx index 94b73af..e03cf60 100644 --- a/frontend/src/ui/QuestLog.tsx +++ b/frontend/src/ui/QuestLog.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, type CSSProperties } from 'react'; import type { HeroQuest } from '../game/types'; +import { useT } from '../i18n'; // ---- Types ---- @@ -184,9 +185,15 @@ const emptyStyle: CSSProperties = { padding: '24px 0', }; -// ---- Component ---- +interface QuestLogListProps { + quests: HeroQuest[]; + onClaim: (heroQuestId: number) => void; + onAbandon: (heroQuestId: number) => void; +} -export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps) { +/** Quest list body (embedded in Hero sheet or standalone panel). */ +export function QuestLogList({ quests, onClaim, onAbandon }: QuestLogListProps) { + const tr = useT(); const [expandedId, setExpandedId] = useState(null); const activeQuests = quests.filter((q) => q.status !== 'claimed'); @@ -195,37 +202,23 @@ export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps) setExpandedId((prev) => (prev === id ? null : id)); }, []); - // Close on Escape - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [onClose]); - return ( <> -
-
-
-
- {'\uD83D\uDCDC'} Quest Log - -
-
+
{activeQuests.length === 0 ? ( -
No active quests. Visit an NPC to accept quests!
+
{tr.noActiveQuests}
) : ( activeQuests.map((q) => { const isExpanded = expandedId === q.id; @@ -302,7 +295,7 @@ export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps) onClaim(q.id); }} > - Claim Rewards + {tr.claimRewards} ) : ( )}
@@ -322,7 +315,37 @@ export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps) ); }) )} +
+ + ); +} + +export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps) { + const tr = useT(); + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [onClose]); + + return ( + <> + +
+
+
+
+ {'\uD83D\uDCDC'} {tr.questLog} +
+
diff --git a/frontend/src/ui/WanderingNPCPopup.tsx b/frontend/src/ui/WanderingNPCPopup.tsx index 4236933..90e01f7 100644 --- a/frontend/src/ui/WanderingNPCPopup.tsx +++ b/frontend/src/ui/WanderingNPCPopup.tsx @@ -1,4 +1,5 @@ import { useState, type CSSProperties } from 'react'; +import { useT, t } from '../i18n'; // ---- Types ---- @@ -89,6 +90,7 @@ export function WanderingNPCPopup({ onAccept, onDecline, }: WanderingNPCPopupProps) { + const tr = useT(); const [pending, setPending] = useState(false); const canAfford = heroGold >= cost; @@ -112,11 +114,11 @@ export function WanderingNPCPopup({
{npcName}
{message}
- Give {cost} gold for a mysterious item? + {t(tr.giveGoldForItem, { cost })}
{!canAfford && (
- Not enough gold ({heroGold}/{cost}) + {tr.notEnoughGold} ({heroGold}/{cost})
)}
@@ -131,7 +133,7 @@ export function WanderingNPCPopup({ onClick={handleAccept} disabled={!canAfford || pending} > - {pending ? 'Giving...' : 'Accept'} + {pending ? tr.giving : tr.accept}
diff --git a/scripts/admin-tool.ps1 b/scripts/admin-tool.ps1 index 475d104..a292e86 100644 --- a/scripts/admin-tool.ps1 +++ b/scripts/admin-tool.ps1 @@ -14,11 +14,18 @@ param( "engine-status", "engine-combats", "ws-connections", - "add-potions" + "add-potions", + "towns", + "start-adventure", + "teleport-town", + "start-rest", + "time-pause", + "time-resume" )] [string]$Command, [long]$HeroId, + [int]$TownId, [int]$Level, [long]$Gold, [int]$HP, @@ -92,10 +99,6 @@ switch ($Command) { Require-Value -Name "HeroId" -Value $HeroId $result = Invoke-AdminRequest -Method "GET" -Path "/admin/heroes/$HeroId" } - "hero-potions" { - Require-Value -Name "HeroId" -Value $HeroId - $result = Invoke-AdminRequest -Method "GET" -Path "/admin/heroes/$HeroId/potions" - } "set-level" { Require-Value -Name "HeroId" -Value $HeroId Require-Value -Name "Level" -Value $Level @@ -142,6 +145,28 @@ switch ($Command) { "ws-connections" { $result = Invoke-AdminRequest -Method "GET" -Path "/admin/ws/connections" } + "towns" { + $result = Invoke-AdminRequest -Method "GET" -Path "/admin/towns" + } + "start-adventure" { + Require-Value -Name "HeroId" -Value $HeroId + $result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/start-adventure" -Body @{} + } + "teleport-town" { + Require-Value -Name "HeroId" -Value $HeroId + Require-Value -Name "TownId" -Value $TownId + $result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/teleport-town" -Body @{ townId = $TownId } + } + "start-rest" { + Require-Value -Name "HeroId" -Value $HeroId + $result = Invoke-AdminRequest -Method "POST" -Path "/admin/heroes/$HeroId/start-rest" -Body @{} + } + "time-pause" { + $result = Invoke-AdminRequest -Method "POST" -Path "/admin/time/pause" -Body @{} + } + "time-resume" { + $result = Invoke-AdminRequest -Method "POST" -Path "/admin/time/resume" -Body @{} + } default { throw "Unsupported command: $Command" }