Huge refactor, i18n, payments, stats, inventory, logic

master
Denis Ranneft 4 hours ago
parent 1571936d46
commit e8de1d62c1

@ -79,6 +79,15 @@ func main() {
engine.SetSender(hub) // Hub implements game.MessageSender engine.SetSender(hub) // Hub implements game.MessageSender
engine.SetRoadGraph(roadGraph) engine.SetRoadGraph(roadGraph)
engine.SetHeroStore(heroStore) 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 callbacks: on connect, load hero and register movement; on disconnect, persist.
hub.OnConnect = func(heroID int64) { hub.OnConnect = func(heroID int64) {
@ -100,6 +109,13 @@ func main() {
case <-ctx.Done(): case <-ctx.Done():
return return
case msg := <-hub.Incoming: 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{ engine.IncomingCh() <- game.IncomingMessage{
HeroID: msg.HeroID, HeroID: msg.HeroID,
Type: msg.Type, Type: msg.Type,
@ -132,7 +148,9 @@ func main() {
// Record server start time for catch-up gap calculation. // Record server start time for catch-up gap calculation.
serverStartedAt := time.Now() serverStartedAt := time.Now()
offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger) offlineSim := game.NewOfflineSimulator(heroStore, logStore, roadGraph, logger, func() bool {
return engine.IsTimePaused()
})
go func() { go func() {
if err := offlineSim.Run(ctx); err != nil && err != context.Canceled { if err := offlineSim.Run(ctx); err != nil && err != context.Canceled {
logger.Error("offline simulator error", "error", err) logger.Error("offline simulator error", "error", err)
@ -145,6 +163,7 @@ func main() {
Hub: hub, Hub: hub,
PgPool: pgPool, PgPool: pgPool,
BotToken: cfg.BotToken, BotToken: cfg.BotToken,
PaymentProviderToken: cfg.PaymentProviderToken,
AdminBasicAuthUsername: cfg.Admin.BasicAuthUsername, AdminBasicAuthUsername: cfg.Admin.BasicAuthUsername,
AdminBasicAuthPassword: cfg.Admin.BasicAuthPassword, AdminBasicAuthPassword: cfg.Admin.BasicAuthPassword,
AdminBasicAuthRealm: cfg.Admin.BasicAuthRealm, AdminBasicAuthRealm: cfg.Admin.BasicAuthRealm,

@ -6,12 +6,13 @@ import (
) )
type Config struct { type Config struct {
ServerPort string ServerPort string
BotToken string BotToken string
DB DBConfig PaymentProviderToken string
Redis RedisConfig DB DBConfig
Game GameConfig Redis RedisConfig
Admin AdminConfig Game GameConfig
Admin AdminConfig
} }
type DBConfig struct { type DBConfig struct {
@ -42,8 +43,9 @@ type AdminConfig struct {
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
ServerPort: envOrDefault("SERVER_PORT", "8080"), ServerPort: envOrDefault("SERVER_PORT", "8080"),
BotToken: os.Getenv("BOT_TOKEN"), BotToken: os.Getenv("BOT_TOKEN"),
PaymentProviderToken: os.Getenv("PAYMENT_PROVIDER_TOKEN"),
DB: DBConfig{ DB: DBConfig{
Host: envOrDefault("DB_HOST", "localhost"), Host: envOrDefault("DB_HOST", "localhost"),
Port: envOrDefault("DB_PORT", "5432"), Port: envOrDefault("DB_PORT", "5432"),

@ -31,6 +31,7 @@ type EngineStatus struct {
ActiveCombats int `json:"activeCombats"` ActiveCombats int `json:"activeCombats"`
ActiveMovements int `json:"activeMovements"` ActiveMovements int `json:"activeMovements"`
UptimeMs int64 `json:"uptimeMs"` UptimeMs int64 `json:"uptimeMs"`
TimePaused bool `json:"timePaused"`
} }
// CombatInfo is a read-only snapshot of a single active combat. // CombatInfo is a read-only snapshot of a single active combat.
@ -64,8 +65,16 @@ type Engine struct {
eventCh chan model.CombatEvent eventCh chan model.CombatEvent
logger *slog.Logger logger *slog.Logger
onEnemyDeath EnemyDeathCallback onEnemyDeath EnemyDeathCallback
adventureLog AdventureLogWriter
startedAt time.Time startedAt time.Time
running bool 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 const minAttackInterval = 250 * time.Millisecond
@ -93,6 +102,98 @@ func (e *Engine) GetMovements(heroId int64) *HeroMovement {
return e.movements[heroId] 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). // SetSender sets the WS message sender (typically handler.Hub).
func (e *Engine) SetSender(s MessageSender) { func (e *Engine) SetSender(s MessageSender) {
e.mu.Lock() e.mu.Lock()
@ -121,6 +222,20 @@ func (e *Engine) SetOnEnemyDeath(cb EnemyDeathCallback) {
e.onEnemyDeath = cb 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. // IncomingCh returns the channel for routing client WS commands into the engine.
func (e *Engine) IncomingCh() chan<- IncomingMessage { func (e *Engine) IncomingCh() chan<- IncomingMessage {
return e.incomingCh return e.incomingCh
@ -154,11 +269,17 @@ func (e *Engine) Run(ctx context.Context) error {
e.logger.Info("game engine shutting down") e.logger.Info("game engine shutting down")
return ctx.Err() return ctx.Err()
case now := <-combatTicker.C: case now := <-combatTicker.C:
e.processCombatTick(now) if !e.IsTimePaused() {
e.processCombatTick(now)
}
case now := <-moveTicker.C: case now := <-moveTicker.C:
e.processMovementTick(now) if !e.IsTimePaused() {
e.processMovementTick(now)
}
case now := <-syncTicker.C: case now := <-syncTicker.C:
e.processPositionSync(now) if !e.IsTimePaused() {
e.processPositionSync(now)
}
case msg := <-e.incomingCh: case msg := <-e.incomingCh:
e.handleClientMessage(msg) e.handleClientMessage(msg)
} }
@ -167,6 +288,10 @@ func (e *Engine) Run(ctx context.Context) error {
// handleClientMessage routes a single inbound client command. // handleClientMessage routes a single inbound client command.
func (e *Engine) handleClientMessage(msg IncomingMessage) { func (e *Engine) handleClientMessage(msg IncomingMessage) {
if e.IsTimePaused() {
e.sendError(msg.HeroID, "time_paused", "server time is paused")
return
}
switch msg.Type { switch msg.Type {
case "activate_buff": case "activate_buff":
e.handleActivateBuff(msg) e.handleActivateBuff(msg)
@ -174,6 +299,10 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) {
e.handleUsePotion(msg) e.handleUsePotion(msg)
case "revive": case "revive":
e.handleRevive(msg) e.handleRevive(msg)
case "npc_alms_accept":
e.handleNPCAlmsAccept(msg)
case "npc_alms_decline":
e.handleNPCAlmsDecline(msg)
default: default:
// Commands like accept_quest, claim_quest, npc_interact etc. // Commands like accept_quest, claim_quest, npc_interact etc.
// are handled by their respective REST handlers for now. // 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. // 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) { func (e *Engine) handleRevive(msg IncomingMessage) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
@ -305,8 +463,10 @@ func (e *Engine) handleRevive(msg IncomingMessage) {
} }
if e.sender != nil { 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_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 // 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. // do not replace (x,y) and route with a stale DB snapshot.
if existing, ok := e.movements[hero.ID]; ok { if existing, ok := e.movements[hero.ID]; ok {
existing.Hero.EnsureGearMap()
existing.Hero.RefreshDerivedCombatStats(now) existing.Hero.RefreshDerivedCombatStats(now)
e.logger.Info("hero movement reattached (existing session)", e.logger.Info("hero movement reattached (existing session)",
"hero_id", hero.ID, "hero_id", hero.ID,
@ -367,6 +528,7 @@ func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
// Send initial state via WS. // Send initial state via WS.
if e.sender != nil { if e.sender != nil {
hm.Hero.EnsureGearMap()
hm.Hero.RefreshDerivedCombatStats(now) hm.Hero.RefreshDerivedCombatStats(now)
e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
@ -428,7 +590,94 @@ func (e *Engine) Status() EngineStatus {
ActiveCombats: len(e.combats), ActiveCombats: len(e.combats),
ActiveMovements: len(e.movements), ActiveMovements: len(e.movements),
UptimeMs: uptimeMs, 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. // 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) { func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
now := time.Now() 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{ cs := &model.CombatState{
HeroID: hero.ID, HeroID: hero.ID,
Hero: hero, Hero: hero,
@ -529,10 +785,83 @@ func (e *Engine) SyncHeroState(hero *model.Hero) {
} }
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
if e.sender == nil {
return
}
hero.EnsureGearMap()
e.sender.SendToHero(hero.ID, "hero_state", hero) 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 // 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), // 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. // 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 { if e.sender == nil {
return return
} }
hm.Hero.EnsureGearMap()
hm.Hero.RefreshDerivedCombatStats(now) 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_state", hm.Hero)
e.sender.SendToHero(hero.ID, "hero_revived", model.HeroRevivedPayload{HP: hm.Hero.HP})
if routeAssigned { if routeAssigned {
if route := hm.RoutePayload(); route != nil { if route := hm.RoutePayload(); route != nil {
e.sender.SendToHero(hero.ID, "route_assigned", route) e.sender.SendToHero(hero.ID, "route_assigned", route)
@ -593,6 +924,22 @@ func (e *Engine) processCombatTick(now time.Time) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() 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. // Apply periodic effects (debuff DoT, enemy regen, summon damage) for all active combats.
for heroID, cs := range e.combats { for heroID, cs := range e.combats {
if cs.Hero == nil { 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{ e.sender.SendToHero(cs.HeroID, "level_up", model.LevelUpPayload{
NewLevel: hero.Level, 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) delete(e.combats, cs.HeroID)
@ -810,7 +1159,7 @@ func (e *Engine) processMovementTick(now time.Time) {
} }
for heroID, hm := range e.movements { 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)
} }
} }

@ -1,6 +1,7 @@
package game package game
import ( import (
"fmt"
"math" "math"
"math/rand" "math/rand"
"time" "time"
@ -21,19 +22,43 @@ const (
// EncounterCooldownBase is the minimum gap between road encounters (monster or merchant). // EncounterCooldownBase is the minimum gap between road encounters (monster or merchant).
EncounterCooldownBase = 12 * time.Second 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. // EncounterActivityBase scales per-tick chance to roll an encounter after cooldown.
// Effective activity is higher deep off-road (see rollRoadEncounter). // Effective activity is higher deep off-road (see rollRoadEncounter).
EncounterActivityBase = 0.035 EncounterActivityBase = 0.035
// StartAdventurePerTick is the chance per movement tick to leave the road for a timed excursion. // 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/Max bound how long an off-road excursion lasts.
AdventureDurationMin = 15 * time.Minute AdventureDurationMin = 15 * time.Minute
AdventureDurationMax = 20 * time.Minute AdventureDurationMax = 20 * time.Minute
// AdventureMaxLateral is max perpendicular offset from the road spine (world units) at peak wilderness. // 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 is the minimum rest duration when arriving at a town.
TownRestMin = 5 * 60 * time.Second TownRestMin = 5 * 60 * time.Second
@ -50,8 +75,20 @@ const (
townNPCRollMin = 800 * time.Millisecond townNPCRollMin = 800 * time.Millisecond
townNPCRollMax = 2600 * time.Millisecond townNPCRollMax = 2600 * time.Millisecond
townNPCRetryAfterMiss = 450 * 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. // HeroMovement holds the live movement state for a single online hero.
type HeroMovement struct { type HeroMovement struct {
HeroID int64 HeroID int64
@ -74,11 +111,28 @@ type HeroMovement struct {
TownNPCQueue []int64 TownNPCQueue []int64
NextTownNPCRollAt time.Time 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. // Off-road excursion ("looking for trouble"): not persisted; cleared on town enter and when it ends.
AdventureStartAt time.Time AdventureStartAt time.Time
AdventureEndAt time.Time AdventureEndAt time.Time
AdventureSide int // +1 or -1 perpendicular direction while adventuring; 0 = not adventuring 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 // 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. // instead of projecting (0,0) onto the polyline (unreliable) or sending hero_state at 0,0.
spawnAtRoadStart bool spawnAtRoadStart bool
@ -349,6 +403,32 @@ func (hm *HeroMovement) snapProgressToNearestPointOnRoad() {
hm.CurrentY = bestY 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. // refreshSpeed recalculates the effective movement speed using hero buffs/debuffs.
func (hm *HeroMovement) refreshSpeed(now time.Time) { func (hm *HeroMovement) refreshSpeed(now time.Time) {
// Per-hero speed variation: ±10% based on hero ID for natural spread. // 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 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. // tryStartAdventure begins a timed off-road excursion with small probability.
func (hm *HeroMovement) tryStartAdventure(now time.Time) { func (hm *HeroMovement) tryStartAdventure(now time.Time) {
if hm.adventureActive(now) { 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 { func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
if !hm.adventureActive(now) { if !hm.adventureActive(now) {
return 0 return 0
@ -490,10 +731,20 @@ func (hm *HeroMovement) wildernessFactor(now time.Time) float64 {
} else if p > 1 { } else if p > 1 {
p = 1 p = 1
} }
if p < 0.5 { r := AdventureWildernessRampFraction
return p * 2 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) { 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) { 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) w := hm.wildernessFactor(now)
if w <= 0 || hm.AdventureSide == 0 { if w <= 0 || hm.AdventureSide == 0 {
return 0, 0 return 0, 0
@ -568,9 +827,15 @@ func (hm *HeroMovement) EnterTown(now time.Time, graph *RoadGraph) {
hm.Road = nil hm.Road = nil
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.AdventureStartAt = time.Time{} hm.AdventureStartAt = time.Time{}
hm.AdventureEndAt = time.Time{} hm.AdventureEndAt = time.Time{}
hm.AdventureSide = 0 hm.AdventureSide = 0
hm.endRoadsideRest()
ids := graph.TownNPCIDs(destID) ids := graph.TownNPCIDs(destID)
if len(ids) == 0 { 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) { func (hm *HeroMovement) LeaveTown(graph *RoadGraph, now time.Time) {
hm.TownNPCQueue = nil hm.TownNPCQueue = nil
hm.NextTownNPCRollAt = time.Time{} hm.NextTownNPCRollAt = time.Time{}
hm.TownVisitNPCName = ""
hm.TownVisitNPCType = ""
hm.TownVisitStartedAt = time.Time{}
hm.TownVisitLogsEmitted = 0
hm.TownLeaveAt = time.Time{}
hm.State = model.StateWalking hm.State = model.StateWalking
hm.Hero.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.pickDestination(graph)
hm.assignRoad(graph) hm.assignRoad(graph)
hm.refreshSpeed(now) hm.refreshSpeed(now)
@ -608,6 +880,8 @@ func randomTownNPCDelay() time.Duration {
// StartFighting pauses movement for combat. // StartFighting pauses movement for combat.
func (hm *HeroMovement) StartFighting() { func (hm *HeroMovement) StartFighting() {
hm.State = model.StateFighting hm.State = model.StateFighting
hm.endRoadsideRest()
hm.WanderingMerchantDeadline = time.Time{}
} }
// ResumWalking resumes movement after combat. // ResumWalking resumes movement after combat.
@ -620,6 +894,7 @@ func (hm *HeroMovement) ResumeWalking(now time.Time) {
// Die sets the movement state to dead. // Die sets the movement state to dead.
func (hm *HeroMovement) Die() { func (hm *HeroMovement) Die() {
hm.State = model.StateDead hm.State = model.StateDead
hm.endRoadsideRest()
} }
// SyncToHero writes movement state back to the hero model for persistence. // 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). // 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) 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. // 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 // 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. // steps (plus a final partial step to real time) for catch-up simulation.
// //
// sender may be nil to suppress all WebSocket payloads (offline ticks). // sender may be nil to suppress all WebSocket payloads (offline ticks).
// onEncounter is required for walking encounter rolls; if nil, encounters are not triggered. // 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( func ProcessSingleHeroMovementTick(
heroID int64, heroID int64,
hm *HeroMovement, hm *HeroMovement,
@ -712,6 +1075,7 @@ func ProcessSingleHeroMovementTick(
sender MessageSender, sender MessageSender,
onEncounter EncounterStarter, onEncounter EncounterStarter,
onMerchantEncounter MerchantEncounterHook, onMerchantEncounter MerchantEncounterHook,
adventureLog AdventureLogWriter,
) { ) {
if graph == nil { if graph == nil {
return return
@ -722,6 +1086,8 @@ func ProcessSingleHeroMovementTick(
return return
case model.StateResting: 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) { if now.After(hm.RestUntil) {
hm.LeaveTown(graph, now) hm.LeaveTown(graph, now)
hm.SyncToHero() hm.SyncToHero()
@ -734,7 +1100,25 @@ func ProcessSingleHeroMovementTick(
} }
case model.StateInTown: 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 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.LeaveTown(graph, now)
hm.SyncToHero() hm.SyncToHero()
if sender != nil { if sender != nil {
@ -751,12 +1135,19 @@ func ProcessSingleHeroMovementTick(
if rand.Float64() < townNPCVisitChance { if rand.Float64() < townNPCVisitChance {
npcID := hm.TownNPCQueue[0] npcID := hm.TownNPCQueue[0]
hm.TownNPCQueue = hm.TownNPCQueue[1:] hm.TownNPCQueue = hm.TownNPCQueue[1:]
if npc, ok := graph.NPCByID[npcID]; ok && sender != nil { if npc, ok := graph.NPCByID[npcID]; ok {
sender.SendToHero(heroID, "town_npc_visit", model.TownNPCVisitPayload{ if sender != nil {
NPCID: npc.ID, Name: npc.Name, Type: npc.Type, TownID: hm.CurrentTownID, 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 { } else {
hm.NextTownNPCRollAt = now.Add(townNPCRetryAfterMiss) hm.NextTownNPCRollAt = now.Add(townNPCRetryAfterMiss)
} }
@ -768,6 +1159,62 @@ func ProcessSingleHeroMovementTick(
hm.pickDestination(graph) hm.pickDestination(graph)
hm.assignRoad(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) hm.tryStartAdventure(now)
reachedTown := hm.AdvanceTick(now, graph) reachedTown := hm.AdvanceTick(now, graph)
@ -815,6 +1262,7 @@ func ProcessSingleHeroMovementTick(
if sender != nil || onMerchantEncounter != nil { if sender != nil || onMerchantEncounter != nil {
hm.LastEncounterAt = now hm.LastEncounterAt = now
if sender != nil { if sender != nil {
hm.WanderingMerchantDeadline = now.Add(WanderingMerchantPromptTimeout)
sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{ sender.SendToHero(heroID, "npc_encounter", model.NPCEncounterPayload{
NPCID: 0, NPCID: 0,
NPCName: "Wandering Merchant", NPCName: "Wandering Merchant",

@ -21,16 +21,20 @@ type OfflineSimulator struct {
graph *RoadGraph graph *RoadGraph
interval time.Duration interval time.Duration
logger *slog.Logger 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. // 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{ return &OfflineSimulator{
store: store, store: store,
logStore: logStore, logStore: logStore,
graph: graph, graph: graph,
interval: 30 * time.Second, interval: 30 * time.Second,
logger: logger, 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. // processTick finds all offline heroes and simulates one fight for each.
func (s *OfflineSimulator) processTick(ctx context.Context) { func (s *OfflineSimulator) processTick(ctx context.Context) {
if s.isPaused != nil && s.isPaused() {
return
}
heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100) heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100)
if err != nil { if err != nil {
s.logger.Error("offline simulator: failed to list offline heroes", "error", err) 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 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 { if s.graph == nil {
s.logger.Warn("offline simulator: road graph nil, skipping movement tick", "hero_id", hero.ID) s.logger.Warn("offline simulator: road graph nil, skipping movement tick", "hero_id", hero.ID)
return nil 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) { encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) 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 { if survived {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained)) s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
hm.ResumeWalking(tickNow) hm.ResumeWalking(tickNow)
@ -139,7 +156,10 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
_ = cost _ = cost
s.addLog(ctx, hm.Hero.ID, "Encountered a Wandering Merchant on the road") 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 { if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
break 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). // 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); // 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. // 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. // 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 { if encounterEnemy != nil {
enemy = *encounterEnemy enemy = *encounterEnemy
} else { } else {
enemy = PickEnemyForLevel(hero.Level) enemy = PickEnemyForLevel(hero.Level)
} }
allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY)
heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense
if heroDmgPerHit < 1 { if heroDmgPerHit < 1 {
heroDmgPerHit = 1 heroDmgPerHit = 1
@ -245,8 +268,8 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
if family != nil { if family != nil {
ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite) ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
item := model.NewGearItem(family, ilvl, drop.Rarity) item := model.NewGearItem(family, ilvl, drop.Rarity)
AutoEquipGear(hero, item, now) TryEquipOrStashOffline(hero, item, now, onInventoryDiscard)
} else { } else if allowSell {
hero.Gold += model.AutoSellPrices[drop.Rarity] hero.Gold += model.AutoSellPrices[drop.Rarity]
goldGained += model.AutoSellPrices[drop.Rarity] goldGained += model.AutoSellPrices[drop.Rarity]
} }
@ -327,25 +350,3 @@ func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
return picked 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]
}

@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil) survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, nil)
if !survived { if !survived {
t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name) t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name)
@ -42,7 +42,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, _, _ := SimulateOneFight(hero, now, nil) survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, nil)
if survived { if survived {
t.Fatal("1 HP hero should die to any enemy") t.Fatal("1 HP hero should die to any enemy")
@ -66,7 +66,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, _, xpGained, _ := SimulateOneFight(hero, now, nil) survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, nil)
if !survived { if !survived {
t.Fatal("overpowered hero should survive") t.Fatal("overpowered hero should survive")
@ -98,7 +98,7 @@ func TestSimulateOneFight_PotionUsage(t *testing.T) {
break break
} }
hero.HP = 25 // force low HP to trigger potion usage hero.HP = 25 // force low HP to trigger potion usage
SimulateOneFight(hero, now, nil) SimulateOneFight(hero, now, nil, nil, nil)
} }
if hero.Potions >= startPotions { if hero.Potions >= startPotions {

@ -165,6 +165,24 @@ func (g *RoadGraph) NextTownInChain(currentTownID int64) int64 {
return g.TownOrder[(idx+1)%n] 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. // NearestTown returns the town ID closest to the given world position.
func (g *RoadGraph) NearestTown(x, y float64) int64 { func (g *RoadGraph) NearestTown(x, y float64) int64 {
bestDist := math.MaxFloat64 bestDist := math.MaxFloat64

@ -1,7 +1,9 @@
package handler package handler
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"runtime" "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) h.logger.Info("admin: hero HP set", "hero_id", heroID, "hp", hero.HP)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
h.engine.ApplyAdminHeroSnapshot(hero)
writeJSON(w, http.StatusOK, 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.engine.ApplyAdminHeroRevive(hero)
h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP) h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP)
hero.EnsureGearMap()
hero.RefreshDerivedCombatStats(time.Now()) hero.RefreshDerivedCombatStats(time.Now())
writeJSON(w, http.StatusOK, hero) 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) { func (h *AdminHandler) EngineStatus(w http.ResponseWriter, r *http.Request) {
status := h.engine.Status() status := h.engine.Status()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"running": status.Running, "running": status.Running,
"tickRateMs": status.TickRate.Milliseconds(), "tickRateMs": status.TickRate.Milliseconds(),
"activeCombats": status.ActiveCombats, "activeCombats": status.ActiveCombats,
"uptimeMs": status.UptimeMs, "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. // ActiveCombats returns all active combat sessions.
// GET /admin/engine/combats // GET /admin/engine/combats
func (h *AdminHandler) ActiveCombats(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ActiveCombats(w http.ResponseWriter, r *http.Request) {

@ -31,6 +31,7 @@ type GameHandler struct {
engine *game.Engine engine *game.Engine
store *storage.HeroStore store *storage.HeroStore
logStore *storage.LogStore logStore *storage.LogStore
hub *Hub
questStore *storage.QuestStore questStore *storage.QuestStore
gearStore *storage.GearStore gearStore *storage.GearStore
achievementStore *storage.AchievementStore achievementStore *storage.AchievementStore
@ -56,11 +57,12 @@ type encounterEnemyResponse struct {
EnemyType model.EnemyType `json:"enemyType"` 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{ h := &GameHandler{
engine: engine, engine: engine,
store: store, store: store,
logStore: logStore, logStore: logStore,
hub: hub,
questStore: questStore, questStore: questStore,
gearStore: gearStore, gearStore: gearStore,
achievementStore: achievementStore, achievementStore: achievementStore,
@ -75,7 +77,7 @@ func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *sto
return h 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) { func (h *GameHandler) addLog(heroID int64, message string) {
if h.logStore == nil { if h.logStore == nil {
return return
@ -84,6 +86,10 @@ func (h *GameHandler) addLog(heroID int64, message string) {
defer cancel() defer cancel()
if err := h.logStore.Add(ctx, heroID, message); err != nil { if err := h.logStore.Add(ctx, heroID, message); err != nil {
h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) 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. // processVictoryRewards is the single source of truth for post-kill rewards.
// It awards XP, generates loot (gold is guaranteed via GenerateLoot — no separate // It awards XP, generates loot (gold is guaranteed via GenerateLoot — no separate
// enemy.GoldReward add), processes equipment drops with nil-fallback auto-sell, // enemy.GoldReward add), processes equipment drops (auto-equip, else stash up to
// runs the level-up loop, sets hero state to walking, and records loot history. // 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. // Returns the drops for API response building.
func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop { func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop {
oldLevel := hero.Level oldLevel := hero.Level
@ -110,6 +117,10 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
luckMult := game.LuckMultiplier(hero, now) luckMult := game.LuckMultiplier(hero, now)
drops := model.GenerateLoot(enemy.Type, luckMult) 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() h.lootMu.Lock()
defer h.lootMu.Unlock() 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 { if err := h.gearStore.CreateItem(ctx, item); err != nil {
h.logger.Warn("failed to create gear item", "slot", slot, "error", err) h.logger.Warn("failed to create gear item", "slot", slot, "error", err)
cancel() cancel()
sellPrice := model.AutoSellPrices[drop.Rarity] if inTown {
hero.Gold += sellPrice sellPrice := model.AutoSellPrices[drop.Rarity]
drop.GoldAmount = sellPrice hero.Gold += sellPrice
drop.GoldAmount = sellPrice
} else {
drop.GoldAmount = 0
}
goto recordLoot goto recordLoot
} }
cancel() cancel()
@ -152,11 +167,43 @@ func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy
if equipped { if equipped {
h.addLog(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name)) h.addLog(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
} else { } else {
sellPrice := model.AutoSellPrices[drop.Rarity] hero.EnsureInventorySlice()
hero.Gold += sellPrice if len(hero.Inventory) >= model.MaxInventorySlots {
drop.GoldAmount = sellPrice 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] sellPrice := model.AutoSellPrices[drop.Rarity]
hero.Gold += sellPrice hero.Gold += sellPrice
drop.GoldAmount = sellPrice drop.GoldAmount = sellPrice
@ -229,7 +276,7 @@ func resolveTelegramID(r *http.Request) (int64, bool) {
} }
// Localhost fallback: default to telegram_id 1 for testing. // Localhost fallback: default to telegram_id 1 for testing.
host := r.Host 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 1, true
} }
return 0, false return 0, false
@ -262,9 +309,12 @@ func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) {
} }
now := time.Now() now := time.Now()
needsSave := hero.EnsureBuffChargesPopulated(now) needsSave := false
if hero.ApplyBuffQuotaRollover(now) { if h.engine == nil || !h.engine.IsTimePaused() {
needsSave = true needsSave = hero.EnsureBuffChargesPopulated(now)
if hero.ApplyBuffQuotaRollover(now) {
needsSave = true
}
} }
if needsSave { if needsSave {
if err := h.store.Save(r.Context(), hero); err != nil { 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 // 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. // (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 { func (h *GameHandler) tryAutoEquipGear(hero *model.Hero, item *model.GearItem, now time.Time) bool {
if hero.Gear == nil { if !game.TryAutoEquipInMemory(hero, item, now) {
hero.Gear = make(map[model.EquipmentSlot]*model.GearItem) return false
} }
current := hero.Gear[item.Slot] h.persistGearEquip(hero.ID, item)
if current == nil { return true
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
} }
// persistGearEquip saves the equip to the hero_gear table if gearStore is available. // 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. // This covers the period when the server was down and the offline simulator wasn't running.
// Returns true if any simulation was performed. // Returns true if any simulation was performed.
func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) bool { 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) gapDuration := h.serverStartedAt.Sub(hero.UpdatedAt)
if gapDuration < 30*time.Second { if gapDuration < 30*time.Second {
return false return false
@ -860,7 +901,13 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
break 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 performed = true
h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name)) 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() now := time.Now()
chargesInit := hero.EnsureBuffChargesPopulated(now) simFrozen := h.engine != nil && h.engine.IsTimePaused()
quotaRolled := hero.ApplyBuffQuotaRollover(now) if !simFrozen {
if chargesInit || quotaRolled { chargesInit := hero.EnsureBuffChargesPopulated(now)
if err := h.store.Save(r.Context(), hero); err != nil { quotaRolled := hero.ApplyBuffQuotaRollover(now)
h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err) 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). // (the period when the server was down and the offline simulator wasn't running).
offlineDuration := time.Since(hero.UpdatedAt) offlineDuration := time.Since(hero.UpdatedAt)
var catchUpPerformed bool 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) 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). // 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 hero.HP = hero.MaxHP / 2
if hero.HP < 1 { if hero.HP < 1 {
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) 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. // GetLoot returns the hero's recent loot history.
// GET /api/v1/hero/loot // GET /api/v1/hero/loot
func (h *GameHandler) GetLoot(w http.ResponseWriter, r *http.Request) { func (h *GameHandler) GetLoot(w http.ResponseWriter, r *http.Request) {

@ -13,6 +13,7 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/denisovdennis/autohero/internal/game"
"github.com/denisovdennis/autohero/internal/model" "github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/storage" "github.com/denisovdennis/autohero/internal/storage"
) )
@ -24,20 +25,37 @@ type NPCHandler struct {
gearStore *storage.GearStore gearStore *storage.GearStore
logStore *storage.LogStore logStore *storage.LogStore
logger *slog.Logger logger *slog.Logger
engine *game.Engine
hub *Hub
} }
// NewNPCHandler creates a new NPCHandler. // 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{ return &NPCHandler{
questStore: questStore, questStore: questStore,
heroStore: heroStore, heroStore: heroStore,
gearStore: gearStore, gearStore: gearStore,
logStore: logStore, logStore: logStore,
logger: logger, 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) { func (h *NPCHandler) addLog(heroID int64, message string) {
if h.logStore == nil { if h.logStore == nil {
return return
@ -46,6 +64,10 @@ func (h *NPCHandler) addLog(heroID int64, message string) {
defer cancel() defer cancel()
if err := h.logStore.Add(ctx, heroID, message); err != nil { if err := h.logStore.Add(ctx, heroID, message); err != nil {
h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err) 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) 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. // 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) { func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
telegramID, ok := resolveTelegramID(r) telegramID, ok := resolveTelegramID(r)
if !ok { if !ok {
@ -324,7 +458,6 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
return return
} }
// Compute cost: 20 + level * 5.
cost := int64(20 + hero.Level*5) cost := int64(20 + hero.Level*5)
if hero.Gold < cost { if hero.Gold < cost {
writeJSON(w, http.StatusBadRequest, map[string]string{ writeJSON(w, http.StatusBadRequest, map[string]string{
@ -334,39 +467,9 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
} }
hero.Gold -= cost hero.Gold -= cost
now := time.Now()
// Generate random equipment drop. drop, err := h.grantMerchantLoot(r.Context(), hero, now)
slots := model.AllEquipmentSlots if err != nil {
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.
hero.Gold += cost hero.Gold += cost
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to generate reward", "error": "failed to generate reward",
@ -374,6 +477,7 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
return return
} }
hero.RefreshDerivedCombatStats(now)
if err := h.heroStore.Save(r.Context(), hero); err != nil { 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) h.logger.Error("failed to save hero after alms", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{
@ -382,12 +486,19 @@ func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
return 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{ writeJSON(w, http.StatusOK, model.AlmsResponse{
Accepted: true, Accepted: true,
GoldSpent: cost, GoldSpent: cost,
ItemDrop: drop, ItemDrop: drop,
Hero: hero, Hero: hero,
Message: fmt.Sprintf("You gave %d gold to the wandering merchant and received %s.", cost, drop.ItemName), Message: msg,
}) })
} }

@ -8,6 +8,12 @@ import (
// FreeBuffActivationsPerPeriod is the legacy shared limit. Kept for backward compatibility. // FreeBuffActivationsPerPeriod is the legacy shared limit. Kept for backward compatibility.
const FreeBuffActivationsPerPeriod = 2 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. // BuffFreeChargesPerType defines the per-buff free charge limits per 24h window.
var BuffFreeChargesPerType = map[BuffType]int{ var BuffFreeChargesPerType = map[BuffType]int{
BuffRush: 3, BuffRush: 3,
@ -20,6 +26,67 @@ var BuffFreeChargesPerType = map[BuffType]int{
BuffWarCry: 2, 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. // ApplyBuffQuotaRollover refills free buff charges when the 24h window has passed.
// Returns true if the hero was mutated (caller may persist). // Returns true if the hero was mutated (caller may persist).
// Deprecated: kept for backward compat with the shared counter. New code should // 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) h.BuffCharges = make(map[string]BuffChargeState)
} }
maxCharges := BuffFreeChargesPerType[bt] maxCharges := h.MaxBuffCharges(bt)
if maxCharges == 0 {
maxCharges = FreeBuffActivationsPerPeriod // fallback
}
state, exists := h.BuffCharges[string(bt)] state, exists := h.BuffCharges[string(bt)]
if !exists { if !exists {
@ -151,9 +215,9 @@ func (h *Hero) ResetBuffCharges(bt *BuffType, now time.Time) {
} }
// Reset ALL buff types. // Reset ALL buff types.
for buffType, maxCharges := range BuffFreeChargesPerType { for buffType := range BuffFreeChargesPerType {
h.BuffCharges[string(buffType)] = BuffChargeState{ h.BuffCharges[string(buffType)] = BuffChargeState{
Remaining: maxCharges, Remaining: h.MaxBuffCharges(buffType),
PeriodEnd: &pe, PeriodEnd: &pe,
} }
} }
@ -174,9 +238,9 @@ func (h *Hero) EnsureBuffChargesPopulated(now time.Time) bool {
if h.BuffQuotaPeriodEnd != nil { if h.BuffQuotaPeriodEnd != nil {
pe = *h.BuffQuotaPeriodEnd pe = *h.BuffQuotaPeriodEnd
} }
for bt, maxCharges := range BuffFreeChargesPerType { for bt := range BuffFreeChargesPerType {
h.BuffCharges[string(bt)] = BuffChargeState{ h.BuffCharges[string(bt)] = BuffChargeState{
Remaining: maxCharges, Remaining: h.MaxBuffCharges(bt),
PeriodEnd: &pe, PeriodEnd: &pe,
} }
} }

@ -10,6 +10,8 @@ const (
AgilityCoef = 0.03 AgilityCoef = 0.03
// MaxAttackSpeed enforces the target cap of ~4 attacks/sec. // MaxAttackSpeed enforces the target cap of ~4 attacks/sec.
MaxAttackSpeed = 4.0 MaxAttackSpeed = 4.0
// MaxInventorySlots is the maximum unequipped gear items carried at once.
MaxInventorySlots = 40
) )
type Hero struct { type Hero struct {
@ -29,6 +31,8 @@ type Hero struct {
WeaponID *int64 `json:"weaponId,omitempty"` // Deprecated: kept for DB backward compat WeaponID *int64 `json:"weaponId,omitempty"` // Deprecated: kept for DB backward compat
ArmorID *int64 `json:"armorId,omitempty"` // Deprecated: kept for DB backward compat ArmorID *int64 `json:"armorId,omitempty"` // Deprecated: kept for DB backward compat
Gear map[EquipmentSlot]*GearItem `json:"gear"` 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"` Buffs []ActiveBuff `json:"buffs,omitempty"`
Debuffs []ActiveDebuff `json:"debuffs,omitempty"` Debuffs []ActiveDebuff `json:"debuffs,omitempty"`
Gold int64 `json:"gold"` Gold int64 `json:"gold"`
@ -43,7 +47,8 @@ type Hero struct {
PositionY float64 `json:"positionY"` PositionY float64 `json:"positionY"`
Potions int `json:"potions"` Potions int `json:"potions"`
ReviveCount int `json:"reviveCount"` 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. // Deprecated: BuffFreeChargesRemaining is the legacy shared counter. Use BuffCharges instead.
BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"` BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"`
// Deprecated: BuffQuotaPeriodEnd is the legacy shared period end. Use BuffCharges instead. // 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 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. // RefreshDerivedCombatStats updates exported derived combat fields for API/state usage.
func (h *Hero) RefreshDerivedCombatStats(now time.Time) { func (h *Hero) RefreshDerivedCombatStats(now time.Time) {
h.XPToNext = XPToNextLevel(h.Level) h.XPToNext = XPToNextLevel(h.Level)

@ -138,9 +138,23 @@ type TownNPCVisitPayload struct {
TownID int64 `json:"townId"` 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. // TownExitPayload is sent when the hero leaves a town.
type TownExitPayload struct{} 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). // NPCEncounterPayload is sent when the hero meets a wandering NPC on the road (e.g. merchant).
type NPCEncounterPayload struct { type NPCEncounterPayload struct {
NPCID int64 `json:"npcId"` NPCID int64 `json:"npcId"`
@ -150,6 +164,11 @@ type NPCEncounterPayload struct {
Cost int64 `json:"cost"` 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. // LevelUpPayload is sent on level-up.
type LevelUpPayload struct { type LevelUpPayload struct {
NewLevel int `json:"newLevel"` NewLevel int `json:"newLevel"`

@ -1,6 +1,7 @@
package router package router
import ( import (
"context"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@ -21,6 +22,7 @@ type Deps struct {
Hub *handler.Hub Hub *handler.Hub
PgPool *pgxpool.Pool PgPool *pgxpool.Pool
BotToken string BotToken string
PaymentProviderToken string
AdminBasicAuthUsername string AdminBasicAuthUsername string
AdminBasicAuthPassword string AdminBasicAuthPassword string
AdminBasicAuthRealm string AdminBasicAuthRealm string
@ -60,6 +62,11 @@ func New(deps Deps) *chi.Mux {
authH := handler.NewAuthHandler(deps.BotToken, heroStore, deps.Logger) authH := handler.NewAuthHandler(deps.BotToken, heroStore, deps.Logger)
r.Post("/api/v1/auth/telegram", authH.TelegramAuth) 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. // Admin routes protected with HTTP Basic authentication.
adminH := handler.NewAdminHandler(heroStore, deps.Engine, deps.Hub, deps.PgPool, deps.Logger) adminH := handler.NewAdminHandler(heroStore, deps.Engine, deps.Hub, deps.PgPool, deps.Logger)
r.Route("/admin", func(r chi.Router) { 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}/revive", adminH.ReviveHero)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) 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.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/status", adminH.EngineStatus)
r.Get("/engine/combats", adminH.ActiveCombats) r.Get("/engine/combats", adminH.ActiveCombats)
r.Get("/ws/connections", adminH.WSConnections) r.Get("/ws/connections", adminH.WSConnections)
r.Get("/info", adminH.ServerInfo) r.Get("/info", adminH.ServerInfo)
r.Post("/payments/set-webhook", paymentsH.SetWebhook)
}) })
// API v1 (authenticated routes). // 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) mapsH := handler.NewMapsHandler(worldSvc, deps.Logger)
questH := handler.NewQuestHandler(questStore, heroStore, logStore, 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) achieveH := handler.NewAchievementHandler(achievementStore, heroStore, deps.Logger)
taskH := handler.NewDailyTaskHandler(taskStore, heroStore, deps.Logger) taskH := handler.NewDailyTaskHandler(taskStore, heroStore, deps.Logger)
r.Route("/api/v1", func(r chi.Router) { 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. // Disabled for now to allow development without a bot token.
// r.Use(handler.TelegramAuthMiddleware(deps.BotToken)) // r.Use(handler.TelegramAuthMiddleware(deps.BotToken))
r.Use(handler.APITimePausedMiddleware(deps.Engine))
r.Get("/hero", gameH.GetHero) r.Get("/hero", gameH.GetHero)
r.Get("/hero/init", gameH.InitHero) r.Get("/hero/init", gameH.InitHero)
r.Post("/hero/name", gameH.SetHeroName) 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/victory", gameH.ReportVictory)
r.Post("/hero/revive", gameH.ReviveHero) r.Post("/hero/revive", gameH.ReviveHero)
r.Post("/hero/purchase-buff-refill", gameH.PurchaseBuffRefill) r.Post("/hero/purchase-buff-refill", gameH.PurchaseBuffRefill)
r.Post("/hero/purchase-subscription", gameH.PurchaseSubscription)
r.Get("/hero/loot", gameH.GetLoot) r.Get("/hero/loot", gameH.GetLoot)
r.Get("/hero/log", gameH.GetAdventureLog) r.Get("/hero/log", gameH.GetAdventureLog)
r.Post("/hero/use-potion", gameH.UsePotion) r.Post("/hero/use-potion", gameH.UsePotion)

@ -120,6 +120,18 @@ func (s *GearStore) EquipItem(ctx context.Context, heroID int64, slot model.Equi
return nil 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. // UnequipSlot removes the gear from the given slot for a hero.
func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.EquipmentSlot) error { func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.EquipmentSlot) error {
_, err := s.pool.Exec(ctx, ` _, err := s.pool.Exec(ctx, `
@ -130,3 +142,111 @@ func (s *GearStore) UnequipSlot(ctx context.Context, heroID int64, slot model.Eq
} }
return nil 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
}

@ -22,7 +22,7 @@ const heroSelectQuery = `
h.strength, h.constitution, h.agility, h.luck, h.strength, h.constitution, h.agility, h.luck,
h.state, h.weapon_id, h.armor_id, h.state, h.weapon_id, h.armor_id,
h.gold, h.xp, h.level, 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.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges,
h.position_x, h.position_y, h.potions, h.position_x, h.position_y, h.potions,
h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops, 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 { if err := s.loadHeroGear(ctx, hero); err != nil {
return nil, fmt.Errorf("get hero by telegram_id gear: %w", err) 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 { if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil {
return nil, fmt.Errorf("get hero by telegram_id buffs: %w", err) 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 { if err := s.loadHeroGear(ctx, h); err != nil {
return nil, fmt.Errorf("list heroes load gear: %w", err) 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 { if err := s.loadHeroBuffsAndDebuffs(ctx, h); err != nil {
return nil, fmt.Errorf("list heroes load buffs: %w", err) 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 { if err := s.loadHeroGear(ctx, hero); err != nil {
return nil, fmt.Errorf("get hero by id gear: %w", err) 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 { if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil {
return nil, fmt.Errorf("get hero by id buffs: %w", err) 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, strength, constitution, agility, luck,
state, weapon_id, armor_id, state, weapon_id, armor_id,
gold, xp, level, gold, xp, level,
revive_count, subscription_active, revive_count, subscription_active, subscription_expires_at,
buff_free_charges_remaining, buff_quota_period_end, buff_charges, buff_free_charges_remaining, buff_quota_period_end, buff_charges,
position_x, position_y, potions, position_x, position_y, potions,
total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops, 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, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), hero.WeaponID, hero.ArmorID, string(hero.State), hero.WeaponID, hero.ArmorID,
hero.Gold, hero.XP, hero.Level, hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
hero.PositionX, hero.PositionY, hero.Potions, hero.PositionX, hero.PositionY, hero.Potions,
hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops, 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, strength = $6, constitution = $7, agility = $8, luck = $9,
state = $10, weapon_id = $11, armor_id = $12, state = $10, weapon_id = $11, armor_id = $12,
gold = $13, xp = $14, level = $15, gold = $13, xp = $14, level = $15,
revive_count = $16, subscription_active = $17, revive_count = $16, subscription_active = $17, subscription_expires_at = $18,
buff_free_charges_remaining = $18, buff_quota_period_end = $19, buff_charges = $20, buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21,
position_x = $21, position_y = $22, potions = $23, position_x = $22, position_y = $23, potions = $24,
total_kills = $24, elite_kills = $25, total_deaths = $26, total_kills = $25, elite_kills = $26, total_deaths = $27,
kills_since_death = $27, legendary_drops = $28, kills_since_death = $28, legendary_drops = $29,
last_online_at = $29, last_online_at = $30,
updated_at = $30, updated_at = $31,
destination_town_id = $32, destination_town_id = $32,
current_town_id = $33, current_town_id = $33,
move_state = $34 move_state = $34
WHERE id = $31 WHERE id = $35
` `
tag, err := s.pool.Exec(ctx, query, 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, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), hero.WeaponID, hero.ArmorID, string(hero.State), hero.WeaponID, hero.ArmorID,
hero.Gold, hero.XP, hero.Level, hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
hero.PositionX, hero.PositionY, hero.Potions, hero.PositionX, hero.PositionY, hero.Potions,
hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.TotalKills, hero.EliteKills, hero.TotalDeaths,
hero.KillsSinceDeath, hero.LegendaryDrops, hero.KillsSinceDeath, hero.LegendaryDrops,
hero.LastOnlineAt, hero.LastOnlineAt,
hero.UpdatedAt, hero.UpdatedAt,
hero.ID,
hero.DestinationTownID, hero.DestinationTownID,
hero.CurrentTownID, hero.CurrentTownID,
hero.MoveState, hero.MoveState,
hero.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("update hero: %w", err) 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) 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) s.logger.Info("saved hero", "hero", hero)
return nil return nil
@ -400,6 +417,7 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time
query := heroSelectQuery + ` query := heroSelectQuery + `
WHERE h.state = 'walking' AND h.hp > 0 AND h.updated_at < $1 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 ORDER BY h.updated_at ASC
LIMIT $2 LIMIT $2
` `
@ -425,6 +443,9 @@ func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time
if err := s.loadHeroGear(ctx, h); err != nil { if err := s.loadHeroGear(ctx, h); err != nil {
return nil, fmt.Errorf("list offline heroes load gear: %w", err) 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 return heroes, nil
} }
@ -442,7 +463,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID, &state, &h.WeaponID, &h.ArmorID,
&h.Gold, &h.XP, &h.Level, &h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
&h.PositionX, &h.PositionY, &h.Potions, &h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &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, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID, &state, &h.WeaponID, &h.ArmorID,
&h.Gold, &h.XP, &h.Level, &h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
&h.PositionX, &h.PositionY, &h.Potions, &h.PositionX, &h.PositionY, &h.Potions,
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &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 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 // loadHeroBuffsAndDebuffs populates the hero's Buffs and Debuffs from the
// hero_active_buffs / hero_active_debuffs tables, filtering out expired entries. // hero_active_buffs / hero_active_debuffs tables, filtering out expired entries.
func (s *HeroStore) loadHeroBuffsAndDebuffs(ctx context.Context, hero *model.Hero) error { func (s *HeroStore) loadHeroBuffsAndDebuffs(ctx context.Context, hero *model.Hero) error {

@ -50,6 +50,7 @@ services:
DB_NAME: ${DB_NAME:-autohero} DB_NAME: ${DB_NAME:-autohero}
REDIS_ADDR: redis:6379 REDIS_ADDR: redis:6379
BOT_TOKEN: ${BOT_TOKEN:-} BOT_TOKEN: ${BOT_TOKEN:-}
PAYMENT_PROVIDER_TOKEN: ${PAYMENT_PROVIDER_TOKEN:-}
ADMIN_BASIC_AUTH_USERNAME: ${ADMIN_BASIC_AUTH_USERNAME:-} ADMIN_BASIC_AUTH_USERNAME: ${ADMIN_BASIC_AUTH_USERNAME:-}
ADMIN_BASIC_AUTH_PASSWORD: ${ADMIN_BASIC_AUTH_PASSWORD:-} ADMIN_BASIC_AUTH_PASSWORD: ${ADMIN_BASIC_AUTH_PASSWORD:-}
ADMIN_BASIC_AUTH_REALM: ${ADMIN_BASIC_AUTH_REALM:-AutoHero Admin} ADMIN_BASIC_AUTH_REALM: ${ADMIN_BASIC_AUTH_REALM:-AutoHero Admin}

@ -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 { GameEngine } from './game/engine';
import { GamePhase, BuffType, type GameState, type FloatingDamageData, type ActiveBuff, type NPCData } from './game/types'; import { GamePhase, BuffType, type GameState, type FloatingDamageData, type ActiveBuff, type NPCData } from './game/types';
import type { NPCEncounterEvent } from './game/types'; import type { NPCEncounterEvent } from './game/types';
@ -11,6 +11,7 @@ import {
sendNPCAlmsAccept, sendNPCAlmsAccept,
sendNPCAlmsDecline, sendNPCAlmsDecline,
buildLootFromCombatEnd, buildLootFromCombatEnd,
buildMerchantLootDrop,
} from './game/ws-handler'; } from './game/ws-handler';
import { import {
ApiError, ApiError,
@ -24,13 +25,11 @@ import {
abandonQuest, abandonQuest,
getAchievements, getAchievements,
getNearbyHeroes, getNearbyHeroes,
getDailyTasks,
claimDailyTask,
buyPotion, buyPotion,
healAtNPC, healAtNPC,
requestRevive, requestRevive,
} from './network/api'; } 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 { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem } from './game/types';
import type { OfflineReport as OfflineReportData } from './network/api'; import type { OfflineReport as OfflineReportData } from './network/api';
import { import {
@ -42,20 +41,21 @@ import {
import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } from './shared/telegram'; import { hapticImpact, hapticNotification, onThemeChanged, getTelegramUserId } from './shared/telegram';
import { Rarity } from './game/types'; import { Rarity } from './game/types';
import type { HeroState, BuffChargeState } 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 { HUD } from './ui/HUD';
import { DeathScreen } from './ui/DeathScreen'; import { DeathScreen } from './ui/DeathScreen';
import { FloatingDamage } from './ui/FloatingDamage'; import { FloatingDamage } from './ui/FloatingDamage';
import { GameToast } from './ui/GameToast'; import { GameToast } from './ui/GameToast';
import { AdventureLog } from './ui/AdventureLog';
import { OfflineReport } from './ui/OfflineReport'; import { OfflineReport } from './ui/OfflineReport';
import { QuestLog } from './ui/QuestLog'; import { HeroSheetModal, type HeroSheetTab } from './ui/HeroSheetModal';
import { NPCDialog } from './ui/NPCDialog'; import { NPCDialog } from './ui/NPCDialog';
import { NameEntryScreen } from './ui/NameEntryScreen'; import { NameEntryScreen } from './ui/NameEntryScreen';
import { DailyTasks } from './ui/DailyTasks';
import { AchievementsPanel } from './ui/AchievementsPanel'; import { AchievementsPanel } from './ui/AchievementsPanel';
import { Minimap } from './ui/Minimap'; import { Minimap } from './ui/Minimap';
import { NPCInteraction } from './ui/NPCInteraction'; import { NPCInteraction } from './ui/NPCInteraction';
import { WanderingNPCPopup } from './ui/WanderingNPCPopup'; import { WanderingNPCPopup } from './ui/WanderingNPCPopup';
import { I18nContext, t, detectLocale, getTranslations, type Locale } from './i18n';
const appStyle: CSSProperties = { const appStyle: CSSProperties = {
width: '100%', width: '100%',
@ -152,8 +152,13 @@ function mapEquipment(
): Record<string, EquipmentItem> { ): Record<string, EquipmentItem> {
const out: Record<string, EquipmentItem> = {}; const out: Record<string, EquipmentItem> = {};
if (raw) { // REST uses `equipment`, WS hero_state from Go uses `gear`. Treat JSON null like missing.
for (const [slot, item] of Object.entries(raw)) { 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] = { out[slot] = {
id: item.id, id: item.id,
slot: item.slot ?? slot, slot: item.slot ?? slot,
@ -255,11 +260,34 @@ function heroResponseToState(res: HeroResponse): HeroState {
potions: res.potions ?? 0, potions: res.potions ?? 0,
moveSpeed: res.moveSpeed, moveSpeed: res.moveSpeed,
equipment: mapEquipment(res.equipment, res), 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() { export function App() {
const [locale, setLocale] = useState<Locale>(() => 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<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const engineRef = useRef<GameEngine | null>(null); const engineRef = useRef<GameEngine | null>(null);
const wsRef = useRef<GameWebSocket | null>(null); const wsRef = useRef<GameWebSocket | null>(null);
@ -295,7 +323,8 @@ export function App() {
const [heroQuests, setHeroQuests] = useState<HeroQuest[]>([]); const [heroQuests, setHeroQuests] = useState<HeroQuest[]>([]);
const [currentTown, setCurrentTown] = useState<Town | null>(null); const [currentTown, setCurrentTown] = useState<Town | null>(null);
const [selectedNPC, setSelectedNPC] = useState<NPC | null>(null); const [selectedNPC, setSelectedNPC] = useState<NPC | null>(null);
const [questLogOpen, setQuestLogOpen] = useState(false); const [heroSheetOpen, setHeroSheetOpen] = useState(false);
const [heroSheetInitialTab, setHeroSheetInitialTab] = useState<HeroSheetTab>('stats');
// NPC interaction state (server-driven via town_enter) // NPC interaction state (server-driven via town_enter)
const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null); const [nearestNPC, setNearestNPC] = useState<NPCData | null>(null);
@ -303,12 +332,12 @@ export function App() {
// Wandering NPC encounter state // Wandering NPC encounter state
const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null); const [wanderingNPC, setWanderingNPC] = useState<NPCEncounterEvent | null>(null);
// Daily tasks (backend-driven)
const [dailyTasks, setDailyTasks] = useState<DailyTaskResponse[]>([]);
// Achievements // Achievements
const [achievements, setAchievements] = useState<Achievement[]>([]); const [achievements, setAchievements] = useState<Achievement[]>([]);
const prevAchievementsRef = useRef<Achievement[]>([]); const prevAchievementsRef = useRef<Achievement[]>([]);
const sheetNowMs = useUiClock(100);
const addLogEntry = useCallback((message: string) => { const addLogEntry = useCallback((message: string) => {
logIdCounter.current += 1; logIdCounter.current += 1;
const entry: AdventureLogEntry = { const entry: AdventureLogEntry = {
@ -444,10 +473,6 @@ export function App() {
.then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); })
.catch(() => console.warn('[App] Could not fetch achievements')); .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 // Poll nearby heroes every 5 seconds
const nearbyInterval = setInterval(() => { const nearbyInterval = setInterval(() => {
getNearbyHeroes(telegramId) getNearbyHeroes(telegramId)
@ -471,18 +496,12 @@ export function App() {
.catch(() => {}); .catch(() => {});
nearbyIntervalRef.current = nearbyInterval; nearbyIntervalRef.current = nearbyInterval;
// Fetch adventure log // Fetch adventure log (same source as server DB; response shape { log: [...] })
try { try {
const serverLog = await getAdventureLog(telegramId, 50); const serverLog = await getAdventureLog(telegramId, 50);
if (serverLog.length > 0) { const { entries, maxId } = adventureEntriesFromServerLog(serverLog);
const mapped: AdventureLogEntry[] = serverLog.map((entry, i) => ({ logIdCounter.current = Math.max(logIdCounter.current, maxId);
id: i + 1, setLogEntries(entries);
message: entry.message,
timestamp: new Date(entry.createdAt).getTime(),
}));
logIdCounter.current = mapped.length;
setLogEntries(mapped);
}
} catch { } catch {
console.warn('[App] Could not fetch adventure log'); console.warn('[App] Could not fetch adventure log');
} }
@ -529,18 +548,17 @@ export function App() {
if (p.goldGained > 0) parts.push(`+${p.goldGained} gold`); if (p.goldGained > 0) parts.push(`+${p.goldGained} gold`);
const equipDrop = p.loot.find((l) => l.itemType === 'weapon' || l.itemType === 'armor'); const equipDrop = p.loot.find((l) => l.itemType === 'weapon' || l.itemType === 'armor');
if (equipDrop?.name) parts.push(`found ${equipDrop.name}`); 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) { 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'); hapticNotification('success');
} }
// Refresh quests, equipment, daily tasks, achievements after combat // Refresh quests, equipment, achievements after combat
const tid = getTelegramUserId() ?? 1; const tid = getTelegramUserId() ?? 1;
getHeroQuests(tid).then((q) => setHeroQuests(q)).catch(() => {}); getHeroQuests(tid).then((q) => setHeroQuests(q)).catch(() => {});
refreshEquipment(); refreshEquipment();
getDailyTasks(tid).then((t) => setDailyTasks(t)).catch(() => {});
getAchievements(tid).then((a) => { getAchievements(tid).then((a) => {
const prevIds = new Set(prevAchievementsRef.current.filter((x) => x.unlocked).map((x) => x.id)); 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)); const newlyUnlocked = a.filter((x) => x.unlocked && !prevIds.has(x.id));
@ -553,24 +571,24 @@ export function App() {
}).catch(() => {}); }).catch(() => {});
}, },
onHeroDied: (p) => { onHeroDied: (_p) => {
hapticNotification('error'); hapticNotification('error');
addLogEntry(`Hero was slain by ${p.killedBy}`); // Death line comes from server log + WS
}, },
onHeroRevived: () => { onHeroRevived: () => {
addLogEntry('Hero revived!'); setToast({ message: tr.heroRevived, color: '#44cc44' });
setToast({ message: 'Hero revived!', color: '#44cc44' }); // "Hero revived" comes from server log + WS
}, },
onBuffApplied: (p) => { onBuffApplied: (_p) => {
addLogEntry(`Buff applied: ${p.buffType}`); // Buff activation comes from server log + WS
}, },
onTownEnter: (p) => { onTownEnter: (p) => {
const town = townsRef.current.find((t) => t.id === p.townId) ?? null; const town = townsRef.current.find((t) => t.id === p.townId) ?? null;
setCurrentTown(town); setCurrentTown(town);
setToast({ message: `Entering ${p.townName}`, color: '#daa520' }); setToast({ message: t(tr.entering, { townName: p.townName }), color: '#daa520' });
addLogEntry(`Entered ${p.townName}`); addLogEntry(`Entered ${p.townName}`);
const npcs = p.npcs ?? []; const npcs = p.npcs ?? [];
if (npcs.length > 0) { if (npcs.length > 0) {
@ -586,10 +604,13 @@ export function App() {
} }
}, },
onAdventureLogLine: (p) => {
addLogEntry(p.message);
},
onTownNPCVisit: (p) => { onTownNPCVisit: (p) => {
const role = const role =
p.type === 'merchant' ? 'Shop' : p.type === 'healer' ? 'Healer' : 'Quest'; p.type === 'merchant' ? tr.shopLabel : p.type === 'healer' ? tr.healerLabel : tr.questLabel;
addLogEntry(`${role}: ${p.name}`);
setToast({ message: `${role}: ${p.name}`, color: '#c9a227' }); setToast({ message: `${role}: ${p.name}`, color: '#c9a227' });
setNearestNPC({ setNearestNPC({
id: p.npcId, id: p.npcId,
@ -616,20 +637,27 @@ export function App() {
setWanderingNPC(npcEvent); setWanderingNPC(npcEvent);
}, },
onNPCEncounterEnd: (p) => {
if (p.reason === 'timeout') {
addLogEntry('Wandering merchant moved on');
}
setWanderingNPC(null);
},
onLevelUp: (p) => { onLevelUp: (p) => {
setToast({ message: `Level up! Now level ${p.newLevel}`, color: '#ffd700' }); setToast({ message: t(tr.levelUp, { level: p.newLevel }), color: '#ffd700' });
hapticNotification('success'); hapticNotification('success');
addLogEntry(`Reached level ${p.newLevel}`); // Level-up lines come from server log + WS
}, },
onEquipmentChange: (p) => { onEquipmentChange: (p) => {
setToast({ message: `New ${p.slot}: ${p.item.name}`, color: '#cc88ff' }); setToast({ message: t(tr.newEquipment, { slot: p.slot, itemName: p.item.name }), color: '#cc88ff' });
addLogEntry(`Equipped ${p.item.name} (${p.slot})`); // Equipment line comes from server log + WS
refreshEquipment(); refreshEquipment();
}, },
onPotionCollected: (p) => { 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) => { onQuestProgress: (p) => {
@ -642,7 +670,7 @@ export function App() {
); );
if (p.title) { if (p.title) {
setToast({ setToast({
message: `${p.title} (${p.current}/${p.target})`, message: t(tr.questProgress, { title: p.title, current: p.current, target: p.target }),
color: '#44aaff', color: '#44aaff',
}); });
} }
@ -656,13 +684,23 @@ export function App() {
: hq, : hq,
), ),
); );
setToast({ message: `Quest completed: ${p.title}!`, color: '#ffd700' }); setToast({ message: t(tr.questCompleted, { title: p.title }), color: '#ffd700' });
hapticNotification('success'); hapticNotification('success');
}, },
onError: (p) => { onError: (p) => {
setToast({ message: p.message, color: '#ff4444' }); 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(); ws.connect();
@ -706,7 +744,7 @@ export function App() {
const charge = hero.buffCharges?.[type]; const charge = hero.buffCharges?.[type];
if (charge != null && charge.remaining <= 0) { if (charge != null && charge.remaining <= 0) {
const label = type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' '); 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; return;
} }
} }
@ -781,14 +819,14 @@ export function App() {
} }
hapticImpact('medium'); hapticImpact('medium');
addLogEntry(`Activated ${type} buff`); // Server logs buff activation + sends adventure_log_line
} }
// Send command to server via WebSocket // Send command to server via WebSocket
if (ws) { if (ws) {
sendActivateBuff(ws, type); sendActivateBuff(ws, type);
} }
}, [addLogEntry]); }, []);
const handleRevive = useCallback(() => { const handleRevive = useCallback(() => {
const ws = wsRef.current; const ws = wsRef.current;
@ -824,13 +862,13 @@ export function App() {
if (pos) merged.position = pos; if (pos) merged.position = pos;
engine.applyHeroState(merged); engine.applyHeroState(merged);
} }
setToast({ message: 'Quest rewards claimed!', color: '#ffd700' }); setToast({ message: tr.questRewardsClaimed, color: '#ffd700' });
hapticNotification('success'); hapticNotification('success');
refreshHeroQuests(); refreshHeroQuests();
}) })
.catch((err) => { .catch((err) => {
console.warn('[App] Failed to claim quest:', err); console.warn('[App] Failed to claim quest:', err);
setToast({ message: 'Failed to claim rewards', color: '#ff4444' }); setToast({ message: tr.failedToClaimRewards, color: '#ff4444' });
}); });
}, },
[refreshHeroQuests], [refreshHeroQuests],
@ -841,12 +879,12 @@ export function App() {
const telegramId = getTelegramUserId() ?? 1; const telegramId = getTelegramUserId() ?? 1;
abandonQuest(heroQuestId, telegramId) abandonQuest(heroQuestId, telegramId)
.then(() => { .then(() => {
setToast({ message: 'Quest abandoned', color: '#ff8844' }); setToast({ message: tr.questAbandoned, color: '#ff8844' });
refreshHeroQuests(); refreshHeroQuests();
}) })
.catch((err) => { .catch((err) => {
console.warn('[App] Failed to abandon quest:', err); console.warn('[App] Failed to abandon quest:', err);
setToast({ message: 'Failed to abandon quest', color: '#ff4444' }); setToast({ message: tr.failedToAbandonQuest, color: '#ff4444' });
}); });
}, },
[refreshHeroQuests], [refreshHeroQuests],
@ -902,47 +940,17 @@ export function App() {
.catch(() => console.warn('[App] Could not fetch hero quests')); .catch(() => console.warn('[App] Could not fetch hero quests'));
getAdventureLog(telegramId, 50) getAdventureLog(telegramId, 50)
.then((serverLog) => { .then((serverLog) => {
if (serverLog.length > 0) { const { entries, maxId } = adventureEntriesFromServerLog(serverLog);
const mapped: AdventureLogEntry[] = serverLog.map((entry, i) => ({ logIdCounter.current = Math.max(logIdCounter.current, maxId);
id: i + 1, setLogEntries(entries);
message: entry.message,
timestamp: new Date(entry.createdAt).getTime(),
}));
logIdCounter.current = mapped.length;
setLogEntries(mapped);
}
}) })
.catch(() => console.warn('[App] Could not fetch adventure log')); .catch(() => console.warn('[App] Could not fetch adventure log'));
getAchievements(telegramId) getAchievements(telegramId)
.then((a) => { prevAchievementsRef.current = a; setAchievements(a); }) .then((a) => { prevAchievementsRef.current = a; setAchievements(a); })
.catch(() => {}); .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 handleUsePotion = useCallback(() => {
const ws = wsRef.current; const ws = wsRef.current;
const hero = engineRef.current?.gameState.hero; const hero = engineRef.current?.gameState.hero;
@ -954,9 +962,9 @@ export function App() {
} }
hero.potions-- hero.potions--
addLogEntry('Used healing potion');
hapticImpact('medium'); hapticImpact('medium');
}, [addLogEntry]); // Server logs potion use + sends adventure_log_line
}, []);
// ---- NPC Interaction Handlers ---- // ---- NPC Interaction Handlers ----
@ -973,49 +981,49 @@ export function App() {
setNpcInteractionDismissed(npc.id); setNpcInteractionDismissed(npc.id);
}, []); }, []);
const handleNPCBuyPotion = useCallback((npc: NPCData) => { const handleNPCBuyPotion = useCallback((_npc: NPCData) => {
const telegramId = getTelegramUserId() ?? 1; const telegramId = getTelegramUserId() ?? 1;
buyPotion(telegramId) buyPotion(telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
setToast({ message: 'Bought a potion for 50 gold', color: '#88dd88' }); setToast({ message: t(tr.boughtPotion, { cost: 50 }), color: '#88dd88' });
handleNPCHeroUpdated(hero); handleNPCHeroUpdated(hero);
addLogEntry(`Bought potion from ${npc.name}`); // Server logs purchase + WS
}) })
.catch((err) => { .catch((err) => {
console.warn('[App] Failed to buy potion:', err); console.warn('[App] Failed to buy potion:', err);
if (err instanceof ApiError) { if (err instanceof ApiError) {
try { try {
const j = JSON.parse(err.body) as { error?: string }; 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 { } 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; const telegramId = getTelegramUserId() ?? 1;
healAtNPC(telegramId) healAtNPC(telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
setToast({ message: 'Healed to full HP!', color: '#44cc44' }); setToast({ message: tr.healedToFull, color: '#44cc44' });
handleNPCHeroUpdated(hero); handleNPCHeroUpdated(hero);
addLogEntry(`Healed at ${npc.name}`); // Server logs heal + WS
}) })
.catch((err) => { .catch((err) => {
console.warn('[App] Failed to heal:', err); console.warn('[App] Failed to heal:', err);
if (err instanceof ApiError) { if (err instanceof ApiError) {
try { try {
const j = JSON.parse(err.body) as { error?: string }; 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 { } catch {
setToast({ message: 'Failed to heal', color: '#ff4444' }); setToast({ message: tr.failedToHeal, color: '#ff4444' });
} }
} }
}); });
}, [handleNPCHeroUpdated, addLogEntry]); }, [handleNPCHeroUpdated]);
const handleNPCInteractionDismiss = useCallback(() => { const handleNPCInteractionDismiss = useCallback(() => {
if (nearestNPC) { if (nearestNPC) {
@ -1031,8 +1039,8 @@ export function App() {
sendNPCAlmsAccept(ws); sendNPCAlmsAccept(ws);
} }
setWanderingNPC(null); setWanderingNPC(null);
addLogEntry('Accepted wandering merchant offer'); // Alms outcome is logged on server + WS when trade completes
}, [addLogEntry]); }, []);
const handleWanderingDecline = useCallback(() => { const handleWanderingDecline = useCallback(() => {
const ws = wsRef.current; const ws = wsRef.current;
@ -1051,6 +1059,7 @@ export function App() {
!selectedNPC; !selectedNPC;
return ( return (
<I18nContext.Provider value={{ tr: translations, locale, setLocale: handleSetLocale }}>
<div style={appStyle}> <div style={appStyle}>
{/* PixiJS Canvas */} {/* PixiJS Canvas */}
<div ref={canvasRef} style={canvasContainerStyle} /> <div ref={canvasRef} style={canvasContainerStyle} />
@ -1062,8 +1071,27 @@ export function App() {
buffCooldownEndsAt={buffCooldownEndsAt} buffCooldownEndsAt={buffCooldownEndsAt}
onUsePotion={handleUsePotion} onUsePotion={handleUsePotion}
onHeroUpdated={handleNPCHeroUpdated} onHeroUpdated={handleNPCHeroUpdated}
onOpenHeroSheet={() => {
setHeroSheetInitialTab('stats');
setHeroSheetOpen(true);
}}
/> />
{gameState.hero && (
<HeroSheetModal
open={heroSheetOpen}
onClose={() => setHeroSheetOpen(false)}
initialTab={heroSheetInitialTab}
hero={gameState.hero}
nowMs={sheetNowMs}
equipment={gameState.hero.equipment ?? {}}
logEntries={logEntries}
quests={heroQuests}
onQuestClaim={handleQuestClaim}
onQuestAbandon={handleQuestAbandon}
/>
)}
{/* Floating Damage Numbers */} {/* Floating Damage Numbers */}
<FloatingDamage damages={damages} /> <FloatingDamage damages={damages} />
@ -1114,70 +1142,6 @@ export function App() {
</div> </div>
)} )}
{/* Quest Log Toggle Button */}
{gameState.hero && (
<button
onClick={() => setQuestLogOpen((v) => !v)}
style={{
position: 'absolute',
bottom: 80,
right: 16,
width: 44,
height: 44,
borderRadius: 22,
border: '2px solid rgba(255, 215, 0, 0.35)',
backgroundColor: 'rgba(15, 15, 25, 0.85)',
color: '#ffd700',
fontSize: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: 50,
boxShadow: '0 2px 10px rgba(0,0,0,0.4)',
pointerEvents: 'auto',
}}
aria-label="Quest Log"
>
{'\uD83D\uDCDC'}
{heroQuests.filter((q) => q.status !== 'claimed').length > 0 && (
<span
style={{
position: 'absolute',
top: -4,
right: -4,
minWidth: 18,
height: 18,
borderRadius: 9,
backgroundColor: heroQuests.some((q) => q.status === 'completed')
? '#ffd700'
: '#4a90d9',
color: '#fff',
fontSize: 10,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
border: '1px solid rgba(0,0,0,0.3)',
}}
>
{heroQuests.filter((q) => q.status !== 'claimed').length}
</span>
)}
</button>
)}
{/* Quest Log Panel */}
{questLogOpen && (
<QuestLog
quests={heroQuests}
onClaim={handleQuestClaim}
onAbandon={handleQuestAbandon}
onClose={() => setQuestLogOpen(false)}
/>
)}
{/* NPC Proximity Interaction */} {/* NPC Proximity Interaction */}
{showNPCInteraction && nearestNPC && ( {showNPCInteraction && nearestNPC && (
<NPCInteraction <NPCInteraction
@ -1215,10 +1179,7 @@ export function App() {
/> />
)} )}
{/* Daily Tasks (top-right) */} {/* Minimap (top-right) */}
{gameState.hero && <DailyTasks tasks={dailyTasks} onClaim={handleClaimDailyTask} />}
{/* Minimap (below daily tasks, top-right) */}
{gameState.hero && ( {gameState.hero && (
<Minimap <Minimap
heroX={gameState.hero.position.x} heroX={gameState.hero.position.x}
@ -1231,9 +1192,6 @@ export function App() {
{/* Achievements Panel */} {/* Achievements Panel */}
{gameState.hero && <AchievementsPanel achievements={achievements} />} {gameState.hero && <AchievementsPanel achievements={achievements} />}
{/* Adventure Log */}
<AdventureLog entries={logEntries} />
{/* Offline Report Overlay */} {/* Offline Report Overlay */}
{offlineReport && ( {offlineReport && (
<OfflineReport <OfflineReport
@ -1276,5 +1234,6 @@ export function App() {
</div> </div>
)} )}
</div> </div>
</I18nContext.Provider>
); );
} }

@ -477,15 +477,16 @@ export class GameEngine {
this._notifyStateChange(); this._notifyStateChange();
} }
/** Server simulated approach to a town NPC (quest / shop / healer). */ /** Server simulated approach to a town NPC (quest / shop / healer). Narration uses adventure_log_line. */
applyTownNPCVisit(npcName: string, npcType: string): void { applyTownNPCVisit(_npcName: string, _npcType: string): void {
const label = void _npcName;
npcType === 'merchant' void _npcType;
? 'Shop' }
: npcType === 'healer'
? 'Healer' /** Same text as adventure log line — shown in the thought bubble above the hero (town NPC visits). */
: 'Quest'; applyAdventureLogLine(message: string): void {
this._thoughtText = `${label}: ${npcName}`; if (!message) return;
this._thoughtText = message;
this._thoughtStartMs = performance.now(); this._thoughtStartMs = performance.now();
this._notifyStateChange(); this._notifyStateChange();
} }

@ -304,7 +304,7 @@ export class GameRenderer {
fontFamily: 'system-ui, sans-serif', fontFamily: 'system-ui, sans-serif',
fill: 0x333333, fill: 0x333333,
wordWrap: true, wordWrap: true,
wordWrapWidth: 130, wordWrapWidth: 210,
align: 'center', align: 'center',
}), }),
}); });
@ -562,8 +562,14 @@ export class GameRenderer {
const bx = iso.x; const bx = iso.x;
const by = iso.y - 52; // above hero head const by = iso.y - 52; // above hero head
const w = 140; txt.text = text;
const h = 28; 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 left = bx - w / 2;
const top = by - h / 2; const top = by - h / 2;
@ -584,8 +590,7 @@ export class GameRenderer {
gfx.zIndex = by + 200; // above hero gfx.zIndex = by + 200; // above hero
// Text // Text position (centered in bubble)
txt.text = text;
txt.x = bx; txt.x = bx;
txt.y = by; txt.y = by;
txt.alpha = alpha; txt.alpha = alpha;

@ -153,6 +153,8 @@ export interface HeroState {
moveSpeed?: number; moveSpeed?: number;
/** Extended equipment slots (§6.3): slot key -> equipped item */ /** Extended equipment slots (§6.3): slot key -> equipped item */
equipment?: Record<string, EquipmentItem>; equipment?: Record<string, EquipmentItem>;
/** Backpack items (server max 40) */
inventory?: EquipmentItem[];
} }
export interface ActiveBuff { export interface ActiveBuff {
@ -380,12 +382,14 @@ export type ServerMessageType =
| 'town_exit' | 'town_exit'
| 'town_npc_visit' | 'town_npc_visit'
| 'npc_encounter' | 'npc_encounter'
| 'npc_encounter_end'
| 'level_up' | 'level_up'
| 'equipment_change' | 'equipment_change'
| 'potion_collected' | 'potion_collected'
| 'quest_available' | 'quest_available'
| 'quest_progress' | 'quest_progress'
| 'quest_complete' | 'quest_complete'
| 'merchant_loot'
| 'error' | 'error'
| 'pong'; | 'pong';
@ -443,6 +447,15 @@ export interface CombatEndPayload {
newLevel?: number; 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 { export interface HeroDiedPayload {
killedBy: string; killedBy: string;
} }
@ -472,6 +485,11 @@ export interface TownNPCVisitPayload {
townId: number; townId: number;
} }
/** Server-persisted adventure log line (e.g. town NPC visit narration). */
export interface AdventureLogLinePayload {
message: string;
}
export interface TownExitPayload {} export interface TownExitPayload {}
export interface NPCEncounterPayload { export interface NPCEncounterPayload {
@ -482,6 +500,10 @@ export interface NPCEncounterPayload {
cost: number; cost: number;
} }
export interface NPCEncounterEndPayload {
reason: 'timeout' | 'declined' | string;
}
export interface LevelUpPayload { export interface LevelUpPayload {
newLevel: number; newLevel: number;
statChanges: { statChanges: {

@ -12,7 +12,9 @@ import type {
BuffAppliedPayload, BuffAppliedPayload,
TownEnterPayload, TownEnterPayload,
TownNPCVisitPayload, TownNPCVisitPayload,
AdventureLogLinePayload,
NPCEncounterPayload, NPCEncounterPayload,
NPCEncounterEndPayload,
LevelUpPayload, LevelUpPayload,
EquipmentChangePayload, EquipmentChangePayload,
PotionCollectedPayload, PotionCollectedPayload,
@ -22,6 +24,7 @@ import type {
ServerErrorPayload, ServerErrorPayload,
EnemyState, EnemyState,
LootDrop, LootDrop,
MerchantLootPayload,
} from './types'; } from './types';
import { EnemyType, Rarity } from './types'; import { EnemyType, Rarity } from './types';
@ -34,8 +37,10 @@ export interface WSHandlerCallbacks {
onBuffApplied?: (payload: BuffAppliedPayload) => void; onBuffApplied?: (payload: BuffAppliedPayload) => void;
onTownEnter?: (payload: TownEnterPayload) => void; onTownEnter?: (payload: TownEnterPayload) => void;
onTownNPCVisit?: (payload: TownNPCVisitPayload) => void; onTownNPCVisit?: (payload: TownNPCVisitPayload) => void;
onAdventureLogLine?: (payload: AdventureLogLinePayload) => void;
onTownExit?: () => void; onTownExit?: () => void;
onNPCEncounter?: (payload: NPCEncounterPayload) => void; onNPCEncounter?: (payload: NPCEncounterPayload) => void;
onNPCEncounterEnd?: (payload: NPCEncounterEndPayload) => void;
onLevelUp?: (payload: LevelUpPayload) => void; onLevelUp?: (payload: LevelUpPayload) => void;
onEquipmentChange?: (payload: EquipmentChangePayload) => void; onEquipmentChange?: (payload: EquipmentChangePayload) => void;
onPotionCollected?: (payload: PotionCollectedPayload) => void; onPotionCollected?: (payload: PotionCollectedPayload) => void;
@ -44,6 +49,7 @@ export interface WSHandlerCallbacks {
onQuestAvailable?: (payload: QuestAvailablePayload) => void; onQuestAvailable?: (payload: QuestAvailablePayload) => void;
onError?: (payload: ServerErrorPayload) => void; onError?: (payload: ServerErrorPayload) => void;
onHeroStateReceived?: (hero: Record<string, unknown>) => void; onHeroStateReceived?: (hero: Record<string, unknown>) => void;
onMerchantLoot?: (payload: MerchantLootPayload) => void;
} }
/** /**
@ -111,6 +117,11 @@ export function wireWSHandler(
callbacks.onCombatEnd?.(p); callbacks.onCombatEnd?.(p);
}); });
ws.on('merchant_loot', (msg: ServerMessage) => {
const p = msg.payload as MerchantLootPayload;
callbacks.onMerchantLoot?.(p);
});
// ---- Server -> Client: Death / Revive ---- // ---- Server -> Client: Death / Revive ----
ws.on('hero_died', (msg: ServerMessage) => { ws.on('hero_died', (msg: ServerMessage) => {
@ -151,6 +162,12 @@ export function wireWSHandler(
callbacks.onTownNPCVisit?.(p); 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 ---- // ---- Server -> Client: NPC Encounter ----
ws.on('npc_encounter', (msg: ServerMessage) => { ws.on('npc_encounter', (msg: ServerMessage) => {
@ -158,6 +175,11 @@ export function wireWSHandler(
callbacks.onNPCEncounter?.(p); callbacks.onNPCEncounter?.(p);
}); });
ws.on('npc_encounter_end', (msg: ServerMessage) => {
const p = msg.payload as NPCEncounterEndPayload;
callbacks.onNPCEncounterEnd?.(p);
});
// ---- Server -> Client: Progression ---- // ---- Server -> Client: Progression ----
ws.on('level_up', (msg: ServerMessage) => { ws.on('level_up', (msg: ServerMessage) => {
@ -261,3 +283,27 @@ export function buildLootFromCombatEnd(p: CombatEndPayload): LootDrop | null {
: undefined, : 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;
}

@ -137,6 +137,19 @@ export interface HeroResponse {
primaryStat?: number; primaryStat?: number;
statType?: string; 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 { 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 (telegramId != null) params.set('telegramId', String(telegramId));
if (limit != null) params.set('limit', String(limit)); if (limit != null) params.set('limit', String(limit));
const query = params.toString() ? `?${params.toString()}` : ''; const query = params.toString() ? `?${params.toString()}` : '';
return apiGet<LogEntry[]>(`/hero/log${query}`); const raw = await apiGet<{ log?: LogEntry[] } | LogEntry[]>(`/hero/log${query}`);
if (Array.isArray(raw)) {
return raw;
}
return raw.log ?? [];
} }
// ---- Potions ---- // ---- Potions ----

@ -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'; import type { AdventureLogEntry } from '../game/types';
import { useT } from '../i18n';
interface AdventureLogProps {
entries: AdventureLogEntry[];
}
function formatTime(timestamp: number): string { function formatTime(timestamp: number): string {
const d = new Date(timestamp); const d = new Date(timestamp);
@ -12,62 +9,14 @@ function formatTime(timestamp: number): string {
return `${hh}:${mm}`; 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 = { const scrollAreaStyle: CSSProperties = {
flex: 1, flex: 1,
overflowY: 'auto', overflowY: 'auto',
padding: '6px 10px', padding: '4px 2px',
fontSize: 12, fontSize: 12,
lineHeight: 1.6, lineHeight: 1.6,
color: '#bbb', color: '#bbb',
maxHeight: 'min(52vh, 420px)',
}; };
const entryStyle: CSSProperties = { const entryStyle: CSSProperties = {
@ -83,56 +32,37 @@ const timestampStyle: CSSProperties = {
fontSize: 11, fontSize: 11,
}; };
const closeButtonStyle: CSSProperties = { /** Scrollable adventure log list (Hero sheet Journal tab). */
background: 'none', export function AdventureLogEntries({
border: 'none', entries,
color: '#999', scrollRef,
fontSize: 16, }: {
cursor: 'pointer', entries: AdventureLogEntry[];
padding: '0 4px', scrollRef?: RefObject<HTMLDivElement | null>;
lineHeight: 1, }) {
}; const tr = useT();
const innerRef = useRef<HTMLDivElement>(null);
export function AdventureLog({ entries }: AdventureLogProps) { const ref = scrollRef ?? innerRef;
const [expanded, setExpanded] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new entries arrive while expanded
useEffect(() => { useEffect(() => {
if (expanded && scrollRef.current) { if (ref.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; ref.current.scrollTop = ref.current.scrollHeight;
} }
}, [entries.length, expanded]); }, [entries.length, ref]);
if (!expanded) {
return (
<button style={buttonStyle} onClick={() => setExpanded(true)}>
<span style={{ fontSize: 14 }}>&#x1F4DC;</span> Log
</button>
);
}
return ( return (
<div style={panelStyle}> <div ref={ref} style={scrollAreaStyle}>
<div style={panelHeaderStyle}> {entries.length === 0 && (
<span>&#x1F4DC; Adventure Log</span> <div style={{ textAlign: 'center', opacity: 0.5, padding: 12 }}>
<button style={closeButtonStyle} onClick={() => setExpanded(false)}> {tr.noEventsYet}
&#x2715; </div>
</button> )}
</div> {entries.map((entry) => (
<div ref={scrollRef} style={scrollAreaStyle}> <div key={entry.id} style={entryStyle}>
{entries.length === 0 && ( <span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span>
<div style={{ textAlign: 'center', opacity: 0.5, padding: 12 }}> {entry.message}
No events yet... </div>
</div> ))}
)}
{entries.map((entry) => (
<div key={entry.id} style={entryStyle}>
<span style={timestampStyle}>[{formatTime(entry.timestamp)}]</span>
{entry.message}
</div>
))}
</div>
</div> </div>
); );
} }

@ -5,6 +5,7 @@ import { BUFF_META } from './buffMeta';
import { purchaseBuffRefill } from '../network/api'; import { purchaseBuffRefill } from '../network/api';
import type { HeroResponse } from '../network/api'; import type { HeroResponse } from '../network/api';
import { getTelegramUserId } from '../shared/telegram'; import { getTelegramUserId } from '../shared/telegram';
import { useT, t } from '../i18n';
// ---- Types ---- // ---- Types ----
@ -161,6 +162,7 @@ const buttonBase: CSSProperties = {
}; };
function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowMs }: BuffButtonProps) { function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowMs }: BuffButtonProps) {
const tr = useT();
const [pressed, setPressed] = useState(false); const [pressed, setPressed] = useState(false);
const [showTooltip, setShowTooltip] = useState(false); const [showTooltip, setShowTooltip] = useState(false);
const [showRefillConfirm, setShowRefillConfirm] = useState(false); const [showRefillConfirm, setShowRefillConfirm] = useState(false);
@ -332,12 +334,12 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
color: isOutOfCharges ? '#ff8844' : '#aaccff', color: isOutOfCharges ? '#ff8844' : '#aaccff',
}} }}
> >
Charges: {remaining}/{maxCharges} {tr.charges}: {remaining}/{maxCharges}
</div> </div>
)} )}
{isOutOfCharges && charge?.periodEnd && ( {isOutOfCharges && charge?.periodEnd && (
<div style={{ fontSize: 10, marginTop: 1, color: '#ff6644' }}> <div style={{ fontSize: 10, marginTop: 1, color: '#ff6644' }}>
Refills at {formatTimeHHMM(charge.periodEnd)} {tr.refillsAt} {formatTimeHHMM(charge.periodEnd)}
</div> </div>
)} )}
{isOutOfCharges && onRefill && ( {isOutOfCharges && onRefill && (
@ -361,7 +363,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
pointerEvents: 'auto', pointerEvents: 'auto',
}} }}
> >
Refill {tr.refill}
</button> </button>
)} )}
<div style={tooltipArrow} /> <div style={tooltipArrow} />
@ -390,7 +392,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
}} }}
> >
<div style={{ marginBottom: 6 }}> <div style={{ marginBottom: 6 }}>
Refill {meta.label}? {t(tr.refillQuestion, { label: meta.label })}
</div> </div>
<div style={{ fontSize: 10, color: '#aaa', marginBottom: 8 }}> <div style={{ fontSize: 10, color: '#aaa', marginBottom: 8 }}>
{buff.type === BuffType.Resurrection ? '150\u20BD' : '50\u20BD'} {buff.type === BuffType.Resurrection ? '150\u20BD' : '50\u20BD'}
@ -414,7 +416,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
Refill {tr.refill}
</button> </button>
<button <button
type="button" type="button"
@ -433,7 +435,7 @@ function BuffButton({ buff, meta, charge, maxCharges, onActivate, onRefill, nowM
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
Cancel {tr.cancel}
</button> </button>
</div> </div>
</div> </div>

@ -1,5 +1,6 @@
import { useEffect, useState, type CSSProperties } from 'react'; import { useEffect, useState, type CSSProperties } from 'react';
import { REVIVE_TIMER_SECONDS } from '../shared/constants'; import { REVIVE_TIMER_SECONDS } from '../shared/constants';
import { useT, t } from '../i18n';
interface DeathScreenProps { interface DeathScreenProps {
visible: boolean; visible: boolean;
@ -52,6 +53,7 @@ const buttonStyle: CSSProperties = {
}; };
export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreenProps) { export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreenProps) {
const tr = useT();
const [timer, setTimer] = useState(REVIVE_TIMER_SECONDS); const [timer, setTimer] = useState(REVIVE_TIMER_SECONDS);
const canRevive = revivesRemaining === undefined || revivesRemaining > 0; const canRevive = revivesRemaining === undefined || revivesRemaining > 0;
@ -86,11 +88,11 @@ export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreen
return ( return (
<div style={overlayStyle}> <div style={overlayStyle}>
<div style={titleStyle}>YOU DIED</div> <div style={titleStyle}>{tr.youDied}</div>
<div style={timerStyle}>{canRevive ? timer : '—'}</div> <div style={timerStyle}>{canRevive ? timer : '—'}</div>
{revivesRemaining !== undefined && ( {revivesRemaining !== undefined && (
<div style={{ color: '#aaa', fontSize: 14 }}> <div style={{ color: '#aaa', fontSize: 14 }}>
Free revives left: {Math.max(0, revivesRemaining)} {t(tr.freeRevivesLeft, { count: Math.max(0, revivesRemaining) })}
</div> </div>
)} )}
<button <button
@ -109,10 +111,10 @@ export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreen
e.currentTarget.style.backgroundColor = '#cc3333'; e.currentTarget.style.backgroundColor = '#cc3333';
}} }}
> >
REVIVE NOW {tr.reviveNow}
</button> </button>
<div style={{ color: '#888', fontSize: 13 }}> <div style={{ color: '#888', fontSize: 13 }}>
{canRevive ? `Auto-revive in ${timer}s` : 'No free revives left — subscription required'} {canRevive ? t(tr.autoReviveIn, { timer }) : tr.noFreeRevives}
</div> </div>
</div> </div>
); );

@ -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<string, EquipmentItem>;
}
export function EquipmentPanel({ equipment }: EquipmentPanelProps) {
const [open, setOpen] = useState(false);
const equippedCount = equipment ? Object.keys(equipment).length : 0;
return (
<div style={wrapStyle}>
<button
type="button"
style={headerStyle}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span>Equipment ({equippedCount})</span>
<span style={{ opacity: 0.7, fontSize: 11 }}>{open ? '\u25BC' : '\u25B6'}</span>
</button>
{open && (
<div style={bodyStyle}>
{SLOT_DEFS.map((def) => {
const item = equipment?.[def.key];
if (!item) {
return (
<div key={def.key} style={slotRow}>
<div style={slotLeftStyle}>
<span style={slotIconStyle}>{def.icon}</span>
<span style={emptyLabelStyle}>{def.label}</span>
</div>
<span style={{ ...statValStyle, color: '#444' }}>Empty</span>
</div>
);
}
const color = rarityColor(item.rarity);
const glow = rarityGlow(item.rarity);
return (
<div
key={def.key}
style={{
...slotRow,
borderBottomColor: `${color}22`,
boxShadow: glow !== 'none' ? `inset 0 -1px 4px ${color}15` : undefined,
}}
>
<div style={slotLeftStyle}>
<span style={slotIconStyle}>{def.icon}</span>
<span style={{ ...itemNameStyle, color }}>{item.name}</span>
</div>
<span style={statValStyle}>
{statLabel(item.statType)} {item.primaryStat}
{' \u00B7 '}ilvl {item.ilvl}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

@ -4,13 +4,12 @@ import { BuffBar } from './BuffBar';
import { BuffStatusStrip } from './BuffStatusStrip'; import { BuffStatusStrip } from './BuffStatusStrip';
import { DebuffBar } from './DebuffBar'; import { DebuffBar } from './DebuffBar';
import { LootPopup } from './LootPopup'; import { LootPopup } from './LootPopup';
import { HeroPanel } from './HeroPanel';
import { EquipmentPanel } from './EquipmentPanel';
import { InventoryStrip } from './InventoryStrip'; import { InventoryStrip } from './InventoryStrip';
import type { GameState } from '../game/types'; import type { GameState } from '../game/types';
import { GamePhase, BuffType } from '../game/types'; import { GamePhase, BuffType } from '../game/types';
import { useUiClock } from '../hooks/useUiClock'; import { useUiClock } from '../hooks/useUiClock';
import type { HeroResponse } from '../network/api'; 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 // FREE_BUFF_ACTIVATIONS_PER_PERIOD removed — per-buff charges are now shown on each button
interface HUDProps { interface HUDProps {
@ -19,6 +18,7 @@ interface HUDProps {
buffCooldownEndsAt: Partial<Record<BuffType, number>>; buffCooldownEndsAt: Partial<Record<BuffType, number>>;
onUsePotion?: () => void; onUsePotion?: () => void;
onHeroUpdated?: (hero: HeroResponse) => void; onHeroUpdated?: (hero: HeroResponse) => void;
onOpenHeroSheet: () => void;
} }
const containerStyle: CSSProperties = { const containerStyle: CSSProperties = {
@ -126,9 +126,69 @@ const potionButtonDisabledStyle: CSSProperties = {
cursor: 'default', 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 { hero, enemy, phase, lastVictoryLoot } = gameState;
const nowMs = useUiClock(100); const nowMs = useUiClock(100);
const tr = useT();
const handleBuffActivate = useCallback( const handleBuffActivate = useCallback(
(type: BuffType) => { (type: BuffType) => {
@ -170,6 +230,20 @@ export function HUD({ gameState, onBuffActivate, buffCooldownEndsAt, onUsePotion
</div> </div>
<div> <div>
<div style={hpBuffRowStyle}> <div style={hpBuffRowStyle}>
<button
type="button"
style={sheetBtnStyle}
onClick={onOpenHeroSheet}
aria-label="Hero sheet — stats, character, inventory"
title="Hero — stats, character, inventory, journal, quests"
>
<span style={sheetBtnIconStyle} aria-hidden>&#x2694;&#xFE0F;</span>
<span style={sheetBtnLabelStyle}>Hero</span>
</button>
<div style={hudGoldChipStyle} aria-label={`Gold: ${hero.gold}`}>
<span style={{ fontSize: 14 }} aria-hidden>{'\uD83E\uDE99'}</span>
<span>{hero.gold.toLocaleString()}</span>
</div>
<div style={hpBarFlex}> <div style={hpBarFlex}>
<HPBar <HPBar
current={hero.hp} current={hero.hp}
@ -177,7 +251,7 @@ export function HUD({ gameState, onBuffActivate, buffCooldownEndsAt, onUsePotion
color="#44cc44" color="#44cc44"
height={28} height={28}
showText showText
label="HP" label={tr.hp}
/> />
</div> </div>
<BuffStatusStrip buffs={hero.activeBuffs} nowMs={nowMs} /> <BuffStatusStrip buffs={hero.activeBuffs} nowMs={nowMs} />
@ -189,10 +263,6 @@ export function HUD({ gameState, onBuffActivate, buffCooldownEndsAt, onUsePotion
{/* Debuff indicators */} {/* Debuff indicators */}
{debuffsLive.length > 0 && <DebuffBar debuffs={debuffsLive} />} {debuffsLive.length > 0 && <DebuffBar debuffs={debuffsLive} />}
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<HeroPanel hero={hero} nowMs={nowMs} />
<EquipmentPanel equipment={hero.equipment ?? {}} />
</div>
</> </>
)} )}

@ -1,55 +1,35 @@
import { useState, type CSSProperties } from 'react'; import { type CSSProperties } from 'react';
import type { HeroState } from '../game/types'; import type { HeroState } from '../game/types';
import { BuffType, DebuffType, type ActiveBuff, type ActiveDebuff } from '../game/types'; import { BuffType, DebuffType, type ActiveBuff, type ActiveDebuff } from '../game/types';
import { DEBUFF_COLORS } from '../shared/constants'; import { DEBUFF_COLORS } from '../shared/constants';
import { HPBar } from './HPBar'; import { HPBar } from './HPBar';
import { useT, type Translations } from '../i18n';
function buffLabel(tr: Translations, type: BuffType): string {
const map: Record<BuffType, string> = {
[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, string> = { function debuffLabel(tr: Translations, type: DebuffType): string {
[BuffType.Rush]: 'Rush', const map: Record<DebuffType, string> = {
[BuffType.Rage]: 'Rage', [DebuffType.Poison]: tr.debuffPoison,
[BuffType.Shield]: 'Shield', [DebuffType.Freeze]: tr.debuffFreeze,
[BuffType.Luck]: 'Luck', [DebuffType.Burn]: tr.debuffBurn,
[BuffType.Resurrection]: 'Resurrect', [DebuffType.Stun]: tr.debuffStun,
[BuffType.Heal]: 'Heal', [DebuffType.Slow]: tr.debuffSlow,
[BuffType.PowerPotion]: 'Power', [DebuffType.Weaken]: tr.debuffWeaken,
[BuffType.WarCry]: 'War Cry', };
}; return map[type] ?? type;
}
const DEBUFF_LABEL: Record<DebuffType, string> = {
[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',
};
const bodyStyle: CSSProperties = { const bodyStyle: CSSProperties = {
padding: '8px 10px 10px', padding: '8px 10px 10px',
@ -129,9 +109,9 @@ function StatValue({ value, label, buffed, nerfed }: { value: string; label: str
} }
export function HeroPanel({ hero, nowMs }: HeroPanelProps) { /** Full hero stats block (Stats sheet tab). */
const [open, setOpen] = useState(false); export function HeroStatsContent({ hero, nowMs }: HeroPanelProps) {
const tr = useT();
const buffsLive = hero.activeBuffs const buffsLive = hero.activeBuffs
.map((b) => ({ .map((b) => ({
...b, ...b,
@ -154,36 +134,24 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) {
const defBuffed = hasActiveBuff(hero.activeBuffs, BuffType.Shield, nowMs); const defBuffed = hasActiveBuff(hero.activeBuffs, BuffType.Shield, nowMs);
return ( return (
<div style={wrapStyle}> <div style={bodyStyle}>
<button
type="button"
style={headerStyle}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span>Hero Stats</span>
<span style={{ opacity: 0.7, fontSize: 11 }}>{open ? '\u25BC' : '\u25B6'}</span>
</button>
{open && (
<div style={bodyStyle}>
{/* Combat Stats */} {/* Combat Stats */}
<StatValue label="ATK" value={String(hero.damage)} buffed={atkBuffed} nerfed={atkNerfed} /> <StatValue label={tr.atk} value={String(hero.damage)} buffed={atkBuffed} nerfed={atkNerfed} />
<StatValue label="DEF" value={String(hero.defense)} buffed={defBuffed} nerfed={false} /> <StatValue label={tr.def} value={String(hero.defense)} buffed={defBuffed} nerfed={false} />
<StatValue label="Speed" value={`${hero.attackSpeed.toFixed(2)}/s`} buffed={spdBuffed} nerfed={spdNerfed} /> <StatValue label={tr.spd} value={`${hero.attackSpeed.toFixed(2)}/s`} buffed={spdBuffed} nerfed={spdNerfed} />
<StatValue <StatValue
label="Move speed" label={tr.moveSpd}
value={`${(hero.moveSpeed ?? 1).toFixed(2)}x`} value={`${(hero.moveSpeed ?? 1).toFixed(2)}x`}
buffed={hasActiveBuff(hero.activeBuffs, BuffType.Rush, nowMs)} buffed={hasActiveBuff(hero.activeBuffs, BuffType.Rush, nowMs)}
nerfed={hasActiveDebuff(hero.debuffs, DebuffType.Slow, nowMs)} nerfed={hasActiveDebuff(hero.debuffs, DebuffType.Slow, nowMs)}
/> />
<div style={statRow}><span>STR</span><span>{hero.strength}</span></div> <div style={statRow}><span>{tr.str}</span><span>{hero.strength}</span></div>
<div style={statRow}><span>CON</span><span>{hero.constitution}</span></div> <div style={statRow}><span>{tr.con}</span><span>{hero.constitution}</span></div>
<div style={statRow}><span>AGI</span><span>{hero.agility}</span></div> <div style={statRow}><span>{tr.agi}</span><span>{hero.agility}</span></div>
<div style={statRow}><span>LUCK</span><span>{hero.luck}</span></div> <div style={statRow}><span>{tr.luck}</span><span>{hero.luck}</span></div>
{/* XP */} {/* XP */}
<div style={{ ...sectionTitle, marginTop: 12 }}>Experience</div> <div style={{ ...sectionTitle, marginTop: 12 }}>{tr.experience}</div>
<div style={{ marginBottom: 2 }}> <div style={{ marginBottom: 2 }}>
<HPBar <HPBar
current={hero.xp} current={hero.xp}
@ -191,14 +159,14 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) {
color="#44aaff" color="#44aaff"
height={12} height={12}
showText showText
label="XP" label={tr.xp}
/> />
</div> </div>
{/* Buffs */} {/* Buffs */}
<div style={sectionTitle}>Active Buffs</div> <div style={sectionTitle}>{tr.activeBuffs}</div>
{buffsLive.length === 0 ? ( {buffsLive.length === 0 ? (
<div style={{ opacity: 0.55, fontStyle: 'italic' }}>None</div> <div style={{ opacity: 0.55, fontStyle: 'italic' }}>{tr.none}</div>
) : ( ) : (
<div style={{ display: 'flex', flexWrap: 'wrap' }}> <div style={{ display: 'flex', flexWrap: 'wrap' }}>
{buffsLive.map((b) => ( {buffsLive.map((b) => (
@ -210,7 +178,7 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) {
border: '1px solid rgba(68, 170, 255, 0.6)', border: '1px solid rgba(68, 170, 255, 0.6)',
}} }}
> >
{BUFF_LABEL[b.type]} {buffLabel(tr, b.type)}
<span style={{ opacity: 0.9 }}>{Math.ceil(b.remainingMs / 1000)}s</span> <span style={{ opacity: 0.9 }}>{Math.ceil(b.remainingMs / 1000)}s</span>
</span> </span>
))} ))}
@ -218,9 +186,9 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) {
)} )}
{/* Debuffs */} {/* Debuffs */}
<div style={sectionTitle}>Active Debuffs</div> <div style={sectionTitle}>{tr.activeDebuffs}</div>
{debuffsLive.length === 0 ? ( {debuffsLive.length === 0 ? (
<div style={{ opacity: 0.55, fontStyle: 'italic' }}>None</div> <div style={{ opacity: 0.55, fontStyle: 'italic' }}>{tr.none}</div>
) : ( ) : (
<div style={{ display: 'flex', flexWrap: 'wrap' }}> <div style={{ display: 'flex', flexWrap: 'wrap' }}>
{debuffsLive.map((d) => { {debuffsLive.map((d) => {
@ -234,15 +202,13 @@ export function HeroPanel({ hero, nowMs }: HeroPanelProps) {
border: `1px solid ${color}99`, border: `1px solid ${color}99`,
}} }}
> >
{DEBUFF_LABEL[d.type]} {debuffLabel(tr, d.type)}
<span style={{ opacity: 0.9 }}>{Math.ceil(d.remainingMs / 1000)}s</span> <span style={{ opacity: 0.9 }}>{Math.ceil(d.remainingMs / 1000)}s</span>
</span> </span>
); );
})} })}
</div> </div>
)} )}
</div>
)}
</div> </div>
); );
} }

@ -2,6 +2,7 @@ import type { CSSProperties } from 'react';
import type { HeroState, LootDrop } from '../game/types'; import type { HeroState, LootDrop } from '../game/types';
interface InventoryStripProps { interface InventoryStripProps {
/** Kept for call sites; gold is shown on HUD and in the inventory tab. */
hero: HeroState; hero: HeroState;
/** Most recent victory loot (gold + optional item); persists after popup fades */ /** Most recent victory loot (gold + optional item); persists after popup fades */
lastLoot?: LootDrop | null; lastLoot?: LootDrop | null;
@ -33,38 +34,22 @@ const stripStyle: CSSProperties = {
minHeight: 28, minHeight: 28,
}; };
const goldStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 5,
color: '#ffd700',
fontWeight: 700,
flexShrink: 0,
whiteSpace: 'nowrap',
};
const lastLootStyle: CSSProperties = { const lastLootStyle: CSSProperties = {
marginLeft: 'auto',
fontSize: 10, fontSize: 10,
color: 'rgba(220, 230, 255, 0.7)', color: 'rgba(220, 230, 255, 0.7)',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
overflow: 'hidden', overflow: 'hidden',
maxWidth: 160, maxWidth: '100%',
}; };
export function InventoryStrip({ hero, lastLoot }: InventoryStripProps) { export function InventoryStrip({ lastLoot }: InventoryStripProps) {
if (!lastLoot) return null;
return ( return (
<div style={stripStyle}> <div style={stripStyle}>
<div style={goldStyle}> <div style={lastLootStyle} title={formatLastLootLine(lastLoot)}>
<span style={{ fontSize: 14 }}>{'\uD83E\uDE99'}</span> {formatLastLootLine(lastLoot)}
<span>{hero.gold.toLocaleString()}</span>
</div> </div>
{lastLoot && (
<div style={lastLootStyle} title={formatLastLootLine(lastLoot)}>
{formatLastLootLine(lastLoot)}
</div>
)}
</div> </div>
); );
} }

@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { buildWorldTerrainContext, proceduralTerrain, townsApiToInfluences } from '../game/procedural'; import { buildWorldTerrainContext, proceduralTerrain, townsApiToInfluences } from '../game/procedural';
import type { Town } from '../game/types'; import type { Town } from '../game/types';
import { useT } from '../i18n';
// ---- Types ---- // ---- Types ----
@ -58,7 +59,7 @@ const DEFAULT_RGB = hexToRgb(DEFAULT_BG);
const containerStyle: CSSProperties = { const containerStyle: CSSProperties = {
position: 'fixed', position: 'fixed',
top: 42, top: 12,
right: 8, right: 8,
zIndex: 40, zIndex: 40,
userSelect: 'none', userSelect: 'none',
@ -96,15 +97,16 @@ function canvasStyleForSize(px: number): CSSProperties {
}; };
} }
function modeLabel(mode: MapMode): string { function modeLabel(mode: MapMode, mapStr: string): string {
if (mode === 0) return `MAP \u25B6`; if (mode === 0) return `${mapStr} \u25B6`;
if (mode === 1) return 'MAP S'; if (mode === 1) return `${mapStr} S`;
return 'MAP L'; return `${mapStr} L`;
} }
// ---- Component ---- // ---- Component ----
export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
const tr = useT();
const [mode, setMode] = useState<MapMode>(1); const [mode, setMode] = useState<MapMode>(1);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN }); const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN });
@ -275,7 +277,7 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
title="Карта: свернуть / малый / большой" title="Карта: свернуть / малый / большой"
onClick={() => setMode((m) => ((((m + 1) % 3) as MapMode)))} onClick={() => setMode((m) => ((((m + 1) % 3) as MapMode)))}
> >
{modeLabel(mode)} {modeLabel(mode, tr.map)}
</button> </button>
{!collapsed && ( {!collapsed && (
<canvas <canvas

@ -4,6 +4,7 @@ import { getNPCQuests, acceptQuest, claimQuest, buyPotion, healAtNPC } from '../
import type { HeroResponse } from '../network/api'; import type { HeroResponse } from '../network/api';
import { getTelegramUserId } from '../shared/telegram'; import { getTelegramUserId } from '../shared/telegram';
import { hapticImpact } from '../shared/telegram'; import { hapticImpact } from '../shared/telegram';
import { useT, t } from '../i18n';
// ---- Types ---- // ---- Types ----
@ -209,12 +210,12 @@ function npcTypeIcon(type: string): string {
} }
} }
function npcTypeLabel(type: string): string { function npcTypeLabel(type: string, tr: ReturnType<typeof useT>): string {
switch (type) { switch (type) {
case 'quest_giver': return 'Quest Giver'; case 'quest_giver': return tr.questGiver;
case 'merchant': return 'Merchant'; case 'merchant': return tr.merchant;
case 'healer': return 'Healer'; case 'healer': return tr.healer;
default: return 'NPC'; default: return tr.npc;
} }
} }
@ -243,6 +244,7 @@ export function NPCDialog({
onHeroUpdated, onHeroUpdated,
onToast, onToast,
}: NPCDialogProps) { }: NPCDialogProps) {
const tr = useT();
const [availableQuests, setAvailableQuests] = useState<Quest[]>([]); const [availableQuests, setAvailableQuests] = useState<Quest[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -275,14 +277,14 @@ export function NPCDialog({
acceptQuest(questId, telegramId) acceptQuest(questId, telegramId)
.then(() => { .then(() => {
hapticImpact('medium'); hapticImpact('medium');
onToast('Quest accepted!', '#44aaff'); onToast(tr.questAccepted, '#44aaff');
onQuestsChanged(); onQuestsChanged();
// Remove from available list // Remove from available list
setAvailableQuests((prev) => prev.filter((q) => q.id !== questId)); setAvailableQuests((prev) => prev.filter((q) => q.id !== questId));
}) })
.catch((err) => { .catch((err) => {
console.warn('[NPCDialog] Failed to accept quest:', err); console.warn('[NPCDialog] Failed to accept quest:', err);
onToast('Failed to accept quest', '#ff4444'); onToast(tr.failedToAcceptQuest, '#ff4444');
}); });
}, },
[telegramId, onQuestsChanged, onToast], [telegramId, onQuestsChanged, onToast],
@ -293,13 +295,13 @@ export function NPCDialog({
claimQuest(heroQuestId, telegramId) claimQuest(heroQuestId, telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('heavy'); hapticImpact('heavy');
onToast('Quest rewards claimed!', '#ffd700'); onToast(tr.questRewardsClaimed, '#ffd700');
onHeroUpdated(hero); onHeroUpdated(hero);
onQuestsChanged(); onQuestsChanged();
}) })
.catch((err) => { .catch((err) => {
console.warn('[NPCDialog] Failed to claim quest:', err); console.warn('[NPCDialog] Failed to claim quest:', err);
onToast('Failed to claim rewards', '#ff4444'); onToast(tr.failedToClaimRewards, '#ff4444');
}); });
}, },
[telegramId, onQuestsChanged, onHeroUpdated, onToast], [telegramId, onQuestsChanged, onHeroUpdated, onToast],
@ -307,35 +309,35 @@ export function NPCDialog({
const handleBuyPotion = useCallback(() => { const handleBuyPotion = useCallback(() => {
if (heroGold < POTION_COST) { if (heroGold < POTION_COST) {
onToast('Not enough gold!', '#ff4444'); onToast(tr.notEnoughGold, '#ff4444');
return; return;
} }
buyPotion(telegramId) buyPotion(telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
onToast(`Bought a potion for ${POTION_COST} gold`, '#88dd88'); onToast(t(tr.boughtPotion, { cost: POTION_COST }), '#88dd88');
onHeroUpdated(hero); onHeroUpdated(hero);
}) })
.catch((err) => { .catch((err) => {
console.warn('[NPCDialog] Failed to buy potion:', err); console.warn('[NPCDialog] Failed to buy potion:', err);
onToast('Failed to buy potion', '#ff4444'); onToast(tr.failedToBuyPotion, '#ff4444');
}); });
}, [telegramId, heroGold, onHeroUpdated, onToast]); }, [telegramId, heroGold, onHeroUpdated, onToast]);
const handleHeal = useCallback(() => { const handleHeal = useCallback(() => {
if (heroGold < HEAL_COST) { if (heroGold < HEAL_COST) {
onToast('Not enough gold!', '#ff4444'); onToast(tr.notEnoughGold, '#ff4444');
return; return;
} }
healAtNPC(telegramId) healAtNPC(telegramId)
.then((hero) => { .then((hero) => {
hapticImpact('medium'); hapticImpact('medium');
onToast('Healed to full HP!', '#44cc44'); onToast(tr.healedToFull, '#44cc44');
onHeroUpdated(hero); onHeroUpdated(hero);
}) })
.catch((err) => { .catch((err) => {
console.warn('[NPCDialog] Failed to heal:', err); console.warn('[NPCDialog] Failed to heal:', err);
onToast('Failed to heal', '#ff4444'); onToast(tr.failedToHeal, '#ff4444');
}); });
}, [telegramId, heroGold, onHeroUpdated, onToast]); }, [telegramId, heroGold, onHeroUpdated, onToast]);
@ -371,7 +373,7 @@ export function NPCDialog({
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: 18, marginRight: 8 }}>{npcTypeIcon(npc.type)}</span> <span style={{ fontSize: 18, marginRight: 8 }}>{npcTypeIcon(npc.type)}</span>
<span style={npcNameStyle}>{npc.name}</span> <span style={npcNameStyle}>{npc.name}</span>
<span style={npcTypeTag}>{npcTypeLabel(npc.type)}</span> <span style={npcTypeTag}>{npcTypeLabel(npc.type, tr)}</span>
</div> </div>
<button style={closeBtnStyle} onClick={onClose}>{'\u2715'}</button> <button style={closeBtnStyle} onClick={onClose}>{'\u2715'}</button>
</div> </div>
@ -384,7 +386,7 @@ export function NPCDialog({
{/* Completed quests — claim first */} {/* Completed quests — claim first */}
{npcCompletedQuests.length > 0 && ( {npcCompletedQuests.length > 0 && (
<> <>
<div style={sectionTitleStyle}>Completed</div> <div style={sectionTitleStyle}>{tr.completed}</div>
{npcCompletedQuests.map((hq) => ( {npcCompletedQuests.map((hq) => (
<div <div
key={hq.id} key={hq.id}
@ -421,7 +423,7 @@ export function NPCDialog({
style={claimBtnStyle} style={claimBtnStyle}
onClick={() => handleClaimQuest(hq.id)} onClick={() => handleClaimQuest(hq.id)}
> >
Claim Rewards {tr.claimRewards}
</button> </button>
</div> </div>
))} ))}
@ -498,7 +500,7 @@ export function NPCDialog({
style={acceptBtnStyle} style={acceptBtnStyle}
onClick={() => handleAcceptQuest(q.id)} onClick={() => handleAcceptQuest(q.id)}
> >
Accept Quest {tr.acceptQuest}
</button> </button>
</div> </div>
))} ))}
@ -527,7 +529,7 @@ export function NPCDialog({
onClick={handleBuyPotion} onClick={handleBuyPotion}
disabled={heroGold < POTION_COST} disabled={heroGold < POTION_COST}
> >
{'\uD83E\uDDEA'} Buy Potion &mdash; {POTION_COST} gold {'\uD83E\uDDEA'} {tr.buyPotion} &mdash; {POTION_COST} {tr.gold}
</button> </button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}> <div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold} Your gold: {heroGold}
@ -548,7 +550,7 @@ export function NPCDialog({
onClick={handleHeal} onClick={handleHeal}
disabled={heroGold < HEAL_COST} disabled={heroGold < HEAL_COST}
> >
{'\u2764\uFE0F'} Heal to Full &mdash; {HEAL_COST} gold {'\u2764\uFE0F'} {tr.healToFull} &mdash; {HEAL_COST} {tr.gold}
</button> </button>
<div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}> <div style={{ fontSize: 11, color: '#666', textAlign: 'center' }}>
Your gold: {heroGold} Your gold: {heroGold}

@ -2,6 +2,7 @@ import { useState, useRef, useEffect, type CSSProperties, type FormEvent } from
import { ApiError, setHeroName } from '../network/api'; import { ApiError, setHeroName } from '../network/api';
import type { HeroResponse } from '../network/api'; import type { HeroResponse } from '../network/api';
import { getTelegramUserId } from '../shared/telegram'; import { getTelegramUserId } from '../shared/telegram';
import { useT, t } from '../i18n';
const MIN_LEN = 2; const MIN_LEN = 2;
const MAX_LEN = 16; const MAX_LEN = 16;
@ -90,6 +91,7 @@ const errorStyle: CSSProperties = {
}; };
export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) { export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) {
const tr = useT();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -119,19 +121,19 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) {
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.status === 409) { if (err.status === 409) {
setError('Name already taken, try another'); setError(tr.nameTaken);
} else if (err.status === 400) { } else if (err.status === 400) {
try { try {
const j = JSON.parse(err.body) as { error?: string }; const j = JSON.parse(err.body) as { error?: string };
setError(j.error ?? 'Invalid name'); setError(j.error ?? tr.invalidName);
} catch { } catch {
setError('Invalid name'); setError(tr.invalidName);
} }
} else { } else {
setError(`Server error (${err.status})`); setError(t(tr.serverError, { status: err.status }));
} }
} else { } else {
setError('Connection failed, please retry'); setError(tr.connectionFailed);
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -141,7 +143,7 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) {
return ( return (
<div style={overlayStyle}> <div style={overlayStyle}>
<form style={cardStyle} onSubmit={handleSubmit}> <form style={cardStyle} onSubmit={handleSubmit}>
<div style={titleStyle}>Choose Your Hero Name</div> <div style={titleStyle}>{tr.chooseHeroName}</div>
<input <input
ref={inputRef} ref={inputRef}
@ -156,7 +158,7 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) {
}} }}
onFocus={() => setFocused(true)} onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
placeholder="Enter a name..." placeholder={tr.enterName}
maxLength={MAX_LEN} maxLength={MAX_LEN}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
@ -182,7 +184,7 @@ export function NameEntryScreen({ onNameSet }: NameEntryScreenProps) {
cursor: isValid && !loading ? 'pointer' : 'not-allowed', cursor: isValid && !loading ? 'pointer' : 'not-allowed',
}} }}
> >
{loading ? 'Saving...' : 'Continue'} {loading ? tr.saving : tr.continue}
</button> </button>
</form> </form>
</div> </div>

@ -1,4 +1,5 @@
import { useEffect, useState, type CSSProperties } from 'react'; import { useEffect, useState, type CSSProperties } from 'react';
import { useT, t } from '../i18n';
interface OfflineReportProps { interface OfflineReportProps {
monstersKilled: number; monstersKilled: number;
@ -48,11 +49,6 @@ const lineStyle: CSSProperties = {
lineHeight: 1.8, lineHeight: 1.8,
}; };
const highlightStyle: CSSProperties = {
fontWeight: 700,
color: '#ffd700',
};
const hintStyle: CSSProperties = { const hintStyle: CSSProperties = {
fontSize: 11, fontSize: 11,
color: 'rgba(180, 190, 220, 0.5)', color: 'rgba(180, 190, 220, 0.5)',
@ -66,6 +62,7 @@ export function OfflineReport({
levelsGained, levelsGained,
onDismiss, onDismiss,
}: OfflineReportProps) { }: OfflineReportProps) {
const tr = useT();
const [fading, setFading] = useState(false); const [fading, setFading] = useState(false);
useEffect(() => { useEffect(() => {
@ -94,26 +91,26 @@ export function OfflineReport({
onClick={onDismiss} onClick={onDismiss}
> >
<div style={cardStyle} onClick={(e) => e.stopPropagation()}> <div style={cardStyle} onClick={(e) => e.stopPropagation()}>
<div style={titleStyle}>While you were away...</div> <div style={titleStyle}>{tr.whileYouWereAway}</div>
<div style={lineStyle}> <div style={lineStyle}>
Killed <span style={highlightStyle}>{monstersKilled}</span> monster{monstersKilled !== 1 ? 's' : ''} {t(tr.killedMonsters, { count: monstersKilled })}
</div> </div>
{xpGained > 0 && ( {xpGained > 0 && (
<div style={lineStyle}> <div style={lineStyle}>
+<span style={highlightStyle}>{xpGained}</span> XP {t(tr.gainedXP, { xp: xpGained })}
</div> </div>
)} )}
{goldGained > 0 && ( {goldGained > 0 && (
<div style={lineStyle}> <div style={lineStyle}>
+<span style={highlightStyle}>{goldGained}</span> gold {t(tr.gainedGold, { gold: goldGained })}
</div> </div>
)} )}
{levelsGained > 0 && ( {levelsGained > 0 && (
<div style={lineStyle}> <div style={lineStyle}>
Gained <span style={{ ...highlightStyle, color: '#44aaff' }}>{levelsGained}</span> level{levelsGained !== 1 ? 's' : ''}! {t(tr.gainedLevels, { levels: levelsGained })}
</div> </div>
)} )}
<div style={hintStyle}>Tap anywhere to dismiss</div> <div style={hintStyle}>{tr.tapToDismiss}</div>
</div> </div>
</div> </div>
</> </>

@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, type CSSProperties } from 'react'; import { useState, useCallback, useEffect, type CSSProperties } from 'react';
import type { HeroQuest } from '../game/types'; import type { HeroQuest } from '../game/types';
import { useT } from '../i18n';
// ---- Types ---- // ---- Types ----
@ -184,9 +185,15 @@ const emptyStyle: CSSProperties = {
padding: '24px 0', 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<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const activeQuests = quests.filter((q) => q.status !== 'claimed'); 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)); 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 ( return (
<> <>
<style>{` <style>{`
@keyframes quest-log-slide-up {
0% { transform: translateY(100%); }
100% { transform: translateY(0); }
}
@keyframes quest-claim-glow { @keyframes quest-claim-glow {
0%, 100% { box-shadow: 0 0 8px rgba(255, 215, 0, 0.15); } 0%, 100% { box-shadow: 0 0 8px rgba(255, 215, 0, 0.15); }
50% { box-shadow: 0 0 18px rgba(255, 215, 0, 0.4); } 50% { box-shadow: 0 0 18px rgba(255, 215, 0, 0.4); }
} }
`}</style> `}</style>
<div style={overlayStyle}> <div
<div style={backdropStyle} onClick={onClose} /> style={{
<div style={panelStyle}> ...listStyle,
<div style={headerStyle}> padding: '0 0 8px',
<span style={titleStyle}>{'\uD83D\uDCDC'} Quest Log</span> maxHeight: 'min(52vh, 400px)',
<button style={closeBtnStyle} onClick={onClose}>{'\u2715'}</button> }}
</div> >
<div style={listStyle}>
{activeQuests.length === 0 ? ( {activeQuests.length === 0 ? (
<div style={emptyStyle}>No active quests. Visit an NPC to accept quests!</div> <div style={emptyStyle}>{tr.noActiveQuests}</div>
) : ( ) : (
activeQuests.map((q) => { activeQuests.map((q) => {
const isExpanded = expandedId === q.id; const isExpanded = expandedId === q.id;
@ -302,7 +295,7 @@ export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps)
onClaim(q.id); onClaim(q.id);
}} }}
> >
Claim Rewards {tr.claimRewards}
</button> </button>
) : ( ) : (
<button <button
@ -312,7 +305,7 @@ export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps)
onAbandon(q.id); onAbandon(q.id);
}} }}
> >
Abandon {tr.abandon}
</button> </button>
)} )}
</div> </div>
@ -322,7 +315,37 @@ export function QuestLog({ quests, onClaim, onAbandon, onClose }: QuestLogProps)
); );
}) })
)} )}
</div>
</>
);
}
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 (
<>
<style>{`
@keyframes quest-log-slide-up {
0% { transform: translateY(100%); }
100% { transform: translateY(0); }
}
`}</style>
<div style={overlayStyle}>
<div style={backdropStyle} onClick={onClose} />
<div style={panelStyle}>
<div style={headerStyle}>
<span style={titleStyle}>{'\uD83D\uDCDC'} {tr.questLog}</span>
<button style={closeBtnStyle} onClick={onClose}>{'\u2715'}</button>
</div> </div>
<QuestLogList quests={quests} onClaim={onClaim} onAbandon={onAbandon} />
</div> </div>
</div> </div>
</> </>

@ -1,4 +1,5 @@
import { useState, type CSSProperties } from 'react'; import { useState, type CSSProperties } from 'react';
import { useT, t } from '../i18n';
// ---- Types ---- // ---- Types ----
@ -89,6 +90,7 @@ export function WanderingNPCPopup({
onAccept, onAccept,
onDecline, onDecline,
}: WanderingNPCPopupProps) { }: WanderingNPCPopupProps) {
const tr = useT();
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const canAfford = heroGold >= cost; const canAfford = heroGold >= cost;
@ -112,11 +114,11 @@ export function WanderingNPCPopup({
<div style={titleStyle}>{npcName}</div> <div style={titleStyle}>{npcName}</div>
<div style={messageStyle}>{message}</div> <div style={messageStyle}>{message}</div>
<div style={costStyle}> <div style={costStyle}>
Give {cost} gold for a mysterious item? {t(tr.giveGoldForItem, { cost })}
</div> </div>
{!canAfford && ( {!canAfford && (
<div style={{ fontSize: 11, color: '#ff6666', marginBottom: 10 }}> <div style={{ fontSize: 11, color: '#ff6666', marginBottom: 10 }}>
Not enough gold ({heroGold}/{cost}) {tr.notEnoughGold} ({heroGold}/{cost})
</div> </div>
)} )}
<div style={btnRow}> <div style={btnRow}>
@ -131,7 +133,7 @@ export function WanderingNPCPopup({
onClick={handleAccept} onClick={handleAccept}
disabled={!canAfford || pending} disabled={!canAfford || pending}
> >
{pending ? 'Giving...' : 'Accept'} {pending ? tr.giving : tr.accept}
</button> </button>
<button <button
style={{ style={{
@ -142,7 +144,7 @@ export function WanderingNPCPopup({
onClick={onDecline} onClick={onDecline}
disabled={pending} disabled={pending}
> >
Decline {tr.decline}
</button> </button>
</div> </div>
</div> </div>

@ -14,11 +14,18 @@ param(
"engine-status", "engine-status",
"engine-combats", "engine-combats",
"ws-connections", "ws-connections",
"add-potions" "add-potions",
"towns",
"start-adventure",
"teleport-town",
"start-rest",
"time-pause",
"time-resume"
)] )]
[string]$Command, [string]$Command,
[long]$HeroId, [long]$HeroId,
[int]$TownId,
[int]$Level, [int]$Level,
[long]$Gold, [long]$Gold,
[int]$HP, [int]$HP,
@ -92,10 +99,6 @@ switch ($Command) {
Require-Value -Name "HeroId" -Value $HeroId Require-Value -Name "HeroId" -Value $HeroId
$result = Invoke-AdminRequest -Method "GET" -Path "/admin/heroes/$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" { "set-level" {
Require-Value -Name "HeroId" -Value $HeroId Require-Value -Name "HeroId" -Value $HeroId
Require-Value -Name "Level" -Value $Level Require-Value -Name "Level" -Value $Level
@ -142,6 +145,28 @@ switch ($Command) {
"ws-connections" { "ws-connections" {
$result = Invoke-AdminRequest -Method "GET" -Path "/admin/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 { default {
throw "Unsupported command: $Command" throw "Unsupported command: $Command"
} }

Loading…
Cancel
Save