new offline digest

master
Denis Ranneft 1 month ago
parent ae6fa7bb9c
commit 51be614b9f

@ -65,9 +65,9 @@ func main() {
gearCheckLevelMax = flag.Int("gear-check-level-max", 0, "gear-check: max hero/enemy level inclusive (0 = use template max_level)") gearCheckLevelMax = flag.Int("gear-check-level-max", 0, "gear-check: max hero/enemy level inclusive (0 = use template max_level)")
gearCheckStrict = flag.Bool("gear-check-strict", false, "gear-check: baseline must get wins (SKIP → FAIL) so vacuous passes are impossible") gearCheckStrict = flag.Bool("gear-check-strict", false, "gear-check: baseline must get wins (SKIP → FAIL) so vacuous passes are impossible")
gearBase = flag.String("gear-base", "db", "gear catalog source before overlay: db (weapons/armor/equipment_items) or code (embedded defaults only)") gearBase = flag.String("gear-base", "db", "gear catalog source before overlay: db (gear/equipment_items) or code (embedded defaults only)")
gearOverlay = flag.String("gear-overlay", "", "JSON file: partial GearFamily patches keyed by item name or \"slot:name\"; merged over catalog for simulation only") gearOverlay = flag.String("gear-overlay", "", "JSON file: partial GearFamily patches keyed by item name or \"slot:name\"; merged over catalog for simulation only")
gearPrintSQL = flag.Bool("gear-print-sql", false, "print SQL UPDATEs for gear/weapons/armor/equipment_items from patched catalog; requires -gear-overlay; then exit") gearPrintSQL = flag.Bool("gear-print-sql", false, "print SQL UPDATEs for gear/equipment_items from patched catalog; requires -gear-overlay; then exit")
) )
flag.Parse() flag.Parse()

@ -64,6 +64,7 @@ func main() {
// Stores (created before hub callbacks which reference them). // Stores (created before hub callbacks which reference them).
heroStore := storage.NewHeroStore(pgPool, logger) heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool) logStore := storage.NewLogStore(pgPool)
digestStore := storage.NewOfflineDigestStore(pgPool)
questStore := storage.NewQuestStore(pgPool) questStore := storage.NewQuestStore(pgPool)
gearStore := storage.NewGearStore(pgPool) gearStore := storage.NewGearStore(pgPool)
achievementStore := storage.NewAchievementStore(pgPool) achievementStore := storage.NewAchievementStore(pgPool)
@ -130,6 +131,13 @@ func main() {
} }
hub.OnDisconnect = func(heroID int64, remainingSameHero int) { hub.OnDisconnect = func(heroID int64, remainingSameHero int) {
engine.HeroSocketDetached(heroID, remainingSameHero == 0) engine.HeroSocketDetached(heroID, remainingSameHero == 0)
if remainingSameHero == 0 {
dctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := heroStore.SetWsDisconnectedAt(dctx, heroID, time.Now()); err != nil {
logger.Warn("set ws_disconnected_at", "hero_id", heroID, "error", err)
}
}
} }
// Bridge hub incoming client messages to engine's command channel. // Bridge hub incoming client messages to engine's command channel.
@ -182,7 +190,8 @@ func main() {
return engine.IsTimePaused() return engine.IsTimePaused()
}, engine.HeroHasActiveMovement). }, engine.HeroHasActiveMovement).
WithCombatTickRate(engine.TickRate()). WithCombatTickRate(engine.TickRate()).
WithRewardStores(gearStore, achievementStore, taskStore) WithRewardStores(gearStore, achievementStore, taskStore).
WithDigestStore(digestStore)
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)

@ -32,6 +32,7 @@ type OfflineSimulator struct {
// skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session) // skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session)
// so the same hero is not simulated twice. // so the same hero is not simulated twice.
skipIfLive func(heroID int64) bool skipIfLive func(heroID int64) bool
digestStore *storage.OfflineDigestStore
} }
// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds. // NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
@ -67,6 +68,22 @@ func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievement
return s return s
} }
// WithDigestStore wires persistent offline digest accumulation (after disconnect grace).
func (s *OfflineSimulator) WithDigestStore(d *storage.OfflineDigestStore) *OfflineSimulator {
s.digestStore = d
return s
}
// OfflineDigestGrace is the delay after the last WS disconnect before offline events count toward the digest.
const OfflineDigestGrace = 30 * time.Second
func offlineDigestCollecting(disconnect *time.Time, now time.Time) bool {
if disconnect == nil {
return false
}
return !now.Before(disconnect.Add(OfflineDigestGrace))
}
// Run starts the offline simulation loop. It blocks until the context is cancelled. // Run starts the offline simulation loop. It blocks until the context is cancelled.
func (s *OfflineSimulator) Run(ctx context.Context) error { func (s *OfflineSimulator) Run(ctx context.Context) error {
ticker := time.NewTicker(s.interval) ticker := time.NewTicker(s.interval)
@ -131,6 +148,9 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
hero.State = model.StateWalking hero.State = model.StateWalking
hero.Debuffs = nil hero.Debuffs = nil
s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second))) s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second)))
if s.digestStore != nil && offlineDigestCollecting(hero.WsDisconnectedAt, now) {
_ = s.digestStore.ApplyDelta(ctx, hero.ID, storage.OfflineDigestDelta{Revives: 1})
}
} }
// Dead heroes cannot move or fight. // Dead heroes cannot move or fight.
@ -157,7 +177,22 @@ 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, FormatEncounterLogLine(enemy.Name)) s.addLog(ctx, hm.Hero.ID, FormatEncounterLogLine(enemy.Name))
rewardDeps := s.rewardDeps(tickNow) rewardDeps := s.rewardDeps(tickNow)
survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps) levelBefore := hm.Hero.Level
survived, en, xpGained, goldGained, drops := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
if s.digestStore != nil && offlineDigestCollecting(hm.Hero.WsDisconnectedAt, tickNow) {
if survived {
levelGain := hm.Hero.Level - levelBefore
_ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{
MonstersKilled: 1,
XPGained: xpGained,
GoldGained: goldGained,
LevelsGained: levelGain,
LootAppend: drops,
})
} else {
_ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{Deaths: 1})
}
}
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)
@ -347,7 +382,7 @@ func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, message str
// SimulateOneFight runs one combat encounter using the shared combat loop and reward logic. // SimulateOneFight runs one combat encounter using the shared combat loop and reward logic.
// 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, g *RoadGraph, tickRate time.Duration, rewardDeps VictoryRewardDeps) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) { func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, tickRate time.Duration, rewardDeps VictoryRewardDeps) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64, drops []model.LootDrop) {
if encounterEnemy != nil { if encounterEnemy != nil {
enemy = *encounterEnemy enemy = *encounterEnemy
} else { } else {
@ -369,14 +404,14 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
hero.State = model.StateDead hero.State = model.StateDead
hero.TotalDeaths++ hero.TotalDeaths++
hero.KillsSinceDeath = 0 hero.KillsSinceDeath = 0
return false, enemy, 0, 0 return false, enemy, 0, 0, nil
} }
xpGained = enemy.XPReward xpGained = enemy.XPReward
drops := ApplyVictoryRewards(hero, &enemy, now, rewardDeps) drops = ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
goldGained = sumGoldFromDrops(drops) goldGained = sumGoldFromDrops(drops)
hero.RefreshDerivedCombatStats(now) hero.RefreshDerivedCombatStats(now)
return true, enemy, xpGained, goldGained return true, enemy, xpGained, goldGained, drops
} }
func sumGoldFromDrops(drops []model.LootDrop) int64 { func sumGoldFromDrops(drops []model.LootDrop) int64 {

@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) {
} }
now := time.Now() now := time.Now()
survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{}) survived, enemy, xpGained, goldGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
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, nil, 100*time.Millisecond, VictoryRewardDeps{}) survived, _, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
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, nil, 100*time.Millisecond, VictoryRewardDeps{}) survived, _, xpGained, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if !survived { if !survived {
t.Fatal("overpowered hero should survive") t.Fatal("overpowered hero should survive")

@ -2992,10 +2992,6 @@ func applyNewPlayerHeroDefaults(hero *model.Hero) {
hero.RestKind = model.RestKindNone hero.RestKind = model.RestKindNone
hero.TownPause = nil hero.TownPause = nil
hero.MoveState = string(model.StateWalking) hero.MoveState = string(model.StateWalking)
var w int64 = 1
var a int64 = 1
hero.WeaponID = &w
hero.ArmorID = &a
} }
// resetHeroToLevel1 restores a hero to fresh level 1 defaults, // resetHeroToLevel1 restores a hero to fresh level 1 defaults,

@ -28,6 +28,7 @@ type GameHandler struct {
engine *game.Engine engine *game.Engine
store *storage.HeroStore store *storage.HeroStore
logStore *storage.LogStore logStore *storage.LogStore
digestStore *storage.OfflineDigestStore
hub *Hub hub *Hub
questStore *storage.QuestStore questStore *storage.QuestStore
gearStore *storage.GearStore gearStore *storage.GearStore
@ -55,11 +56,12 @@ type encounterEnemyResponse struct {
EnemyType string `json:"enemyType"` // slug (enemies.type) EnemyType string `json:"enemyType"` // slug (enemies.type)
} }
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 { func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, digestStore *storage.OfflineDigestStore, 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,
digestStore: digestStore,
hub: hub, hub: hub,
questStore: questStore, questStore: questStore,
gearStore: gearStore, gearStore: gearStore,
@ -688,37 +690,24 @@ type offlineReport struct {
XPGained int64 `json:"xpGained"` XPGained int64 `json:"xpGained"`
GoldGained int64 `json:"goldGained"` GoldGained int64 `json:"goldGained"`
LevelsGained int `json:"levelsGained"` LevelsGained int `json:"levelsGained"`
PotionsUsed int `json:"potionsUsed"` Deaths int `json:"deaths"`
PotionsFound int `json:"potionsFound"` Revives int `json:"revives"`
Loot []model.LootDrop `json:"loot"`
HPBefore int `json:"hpBefore"` HPBefore int `json:"hpBefore"`
Message string `json:"message"` Message string `json:"message"`
Log []string `json:"log"`
} }
// buildOfflineReport constructs an offline report from real adventure log entries // buildOfflineReportFromDigest builds the API payload from hero_offline_digest (cleared in InitHero).
// written by the offline simulator (and catch-up). Parses log messages to count func (h *GameHandler) buildOfflineReportFromDigest(hero *model.Hero, offlineDuration time.Duration, d storage.OfflineDigestRow) *offlineReport {
// defeats, XP, gold, levels, and deaths. empty := d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 &&
func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero, offlineDuration time.Duration) *offlineReport { d.Deaths == 0 && d.Revives == 0 && len(d.Loot) == 0
if offlineDuration < 30*time.Second { if empty {
return nil
}
// Query log entries since hero was last updated (with a small buffer).
since := hero.UpdatedAt.Add(-5 * time.Minute)
entries, err := h.logStore.GetSince(ctx, hero.ID, since, 200)
if err != nil {
h.logger.Error("failed to get offline log entries", "hero_id", hero.ID, "error", err)
return nil
}
if len(entries) == 0 {
// No offline activity recorded.
if hero.State == model.StateDead { if hero.State == model.StateDead {
return &offlineReport{ return &offlineReport{
OfflineSeconds: int(offlineDuration.Seconds()), OfflineSeconds: int(offlineDuration.Seconds()),
HPBefore: 0, HPBefore: hero.HP,
Message: "Your hero remains dead. Revive to continue progression.", Message: "Your hero remains dead. Revive to continue progression.",
Log: []string{}, Loot: []model.LootDrop{},
} }
} }
return nil return nil
@ -726,41 +715,28 @@ func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero,
report := &offlineReport{ report := &offlineReport{
OfflineSeconds: int(offlineDuration.Seconds()), OfflineSeconds: int(offlineDuration.Seconds()),
MonstersKilled: d.MonstersKilled,
XPGained: d.XPGained,
GoldGained: d.GoldGained,
LevelsGained: d.LevelsGained,
Deaths: d.Deaths,
Revives: d.Revives,
Loot: d.Loot,
HPBefore: hero.HP, HPBefore: hero.HP,
Log: make([]string, 0, len(entries)),
}
for _, entry := range entries {
report.Log = append(report.Log, entry.Message)
// Parse structured log messages to populate summary counters.
// Messages written by the offline simulator follow known patterns.
if matched, _ := parseDefeatedLog(entry.Message); matched {
report.MonstersKilled++
}
if xp, gold, ok := parseGainsLog(entry.Message); ok {
report.XPGained += xp
report.GoldGained += gold
}
if isLevelUpLog(entry.Message) {
report.LevelsGained++
}
if isDeathLog(entry.Message) {
// Death was recorded
}
if isPotionLog(entry.Message) {
report.PotionsUsed++
} }
if report.Loot == nil {
report.Loot = []model.LootDrop{}
} }
if hero.State == model.StateDead { if hero.State == model.StateDead {
report.Message = "Your hero died while offline. Revive to continue progression." report.Message = "Your hero died while offline. Revive to continue progression."
} else if report.MonstersKilled > 0 { } else if d.MonstersKilled > 0 || d.XPGained > 0 || d.GoldGained > 0 {
report.Message = "Your hero fought while you were away!" report.Message = "Your hero fought while you were away!"
} else if d.Deaths > 0 || d.Revives > 0 {
report.Message = "Your hero had a rough time while you were away!"
} else { } else {
report.Message = "Your hero rested while you were away." report.Message = "Your hero rested while you were away."
} }
return report return report
} }
@ -786,7 +762,8 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
rg = h.engine.RoadGraph() rg = h.engine.RoadGraph()
} }
sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil). sim := game.NewOfflineSimulator(h.store, h.logStore, h.questStore, rg, h.logger, nil, nil).
WithRewardStores(h.gearStore, h.achievementStore, h.taskStore) WithRewardStores(h.gearStore, h.achievementStore, h.taskStore).
WithDigestStore(h.digestStore)
if h.engine != nil { if h.engine != nil {
sim.WithCombatTickRate(h.engine.TickRate()) sim.WithCombatTickRate(h.engine.TickRate())
} }
@ -798,52 +775,6 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
return hero.UpdatedAt.After(before) return hero.UpdatedAt.After(before)
} }
// parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern.
func parseDefeatedLog(msg string) (bool, string) {
if len(msg) > 9 && msg[:9] == "Defeated " {
return true, msg[9:]
}
return false, ""
}
// parseGainsLog parses "Defeated X, gained N XP and M gold" to extract XP and gold.
func parseGainsLog(msg string) (xp int64, gold int64, ok bool) {
// Pattern: "Defeated ..., gained %d XP and %d gold"
// Find ", gained " as the separator since enemy names may contain spaces.
const sep = ", gained "
idx := -1
for i := 0; i <= len(msg)-len(sep); i++ {
if msg[i:i+len(sep)] == sep {
idx = i
break
}
}
if idx < 0 {
return 0, 0, false
}
tail := msg[idx+len(sep):]
n, _ := fmt.Sscanf(tail, "%d XP and %d gold", &xp, &gold)
if n >= 2 {
return xp, gold, true
}
return 0, 0, false
}
// isLevelUpLog checks if a message is a level-up log.
func isLevelUpLog(msg string) bool {
return len(msg) > 12 && msg[:12] == "Leveled up t"
}
// isDeathLog checks if a message is a death log.
func isDeathLog(msg string) bool {
return len(msg) > 14 && msg[:14] == "Died fighting "
}
// isPotionLog checks if a message is a potion usage log.
func isPotionLog(msg string) bool {
return len(msg) > 20 && msg[:20] == "Used healing potion,"
}
// InitHero returns the hero for the given Telegram user, creating one with defaults if needed. // InitHero returns the hero for the given Telegram user, creating one with defaults if needed.
// Also simulates offline progress based on time since last update. // Also simulates offline progress based on time since last update.
// GET /api/v1/hero/init // GET /api/v1/hero/init
@ -905,9 +836,20 @@ func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero) catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero)
} }
// Build offline report from real adventure log entries (written by the // Take persisted offline digest (accumulated after WS disconnect + grace) and clear markers.
// offline simulator and/or the catch-up above). digestRow := storage.OfflineDigestRow{Loot: []model.LootDrop{}}
report := h.buildOfflineReport(r.Context(), hero, offlineDuration) if h.digestStore != nil {
row, err := h.digestStore.TakeDelete(r.Context(), hero.ID)
if err != nil {
h.logger.Error("failed to take offline digest", "hero_id", hero.ID, "error", err)
} else {
digestRow = row
}
}
if err := h.store.ClearWsDisconnectedAt(r.Context(), hero.ID); err != nil {
h.logger.Warn("failed to clear ws_disconnected_at", "hero_id", hero.ID, "error", err)
}
report := h.buildOfflineReportFromDigest(hero, offlineDuration, digestRow)
if catchUpPerformed { if catchUpPerformed {
if err := h.store.Save(r.Context(), hero); err != nil { if err := h.store.Save(r.Context(), hero); err != nil {

@ -2,72 +2,48 @@ package handler
import ( import (
"testing" "testing"
) "time"
func TestParseDefeatedLog(t *testing.T) {
tests := []struct {
msg string
matched bool
}{
{"Defeated Forest Wolf, gained 1 XP and 5 gold", true},
{"Encountered Forest Wolf", false},
{"Died fighting Forest Wolf", false},
{"Defeated a Forest Wolf", true},
}
for _, tt := range tests {
matched, _ := parseDefeatedLog(tt.msg)
if matched != tt.matched {
t.Errorf("parseDefeatedLog(%q) = %v, want %v", tt.msg, matched, tt.matched)
}
}
}
func TestParseGainsLog(t *testing.T) { "github.com/denisovdennis/autohero/internal/model"
tests := []struct { "github.com/denisovdennis/autohero/internal/storage"
msg string )
wantXP int64
wantGold int64
wantOK bool
}{
{"Defeated Forest Wolf, gained 1 XP and 5 gold", 1, 5, true},
{"Defeated Skeleton King, gained 3 XP and 10 gold", 3, 10, true},
{"Encountered Forest Wolf", 0, 0, false},
{"Died fighting Forest Wolf", 0, 0, false},
}
for _, tt := range tests { func TestBuildOfflineReportFromDigest_Fought(t *testing.T) {
xp, gold, ok := parseGainsLog(tt.msg) h := &GameHandler{}
if ok != tt.wantOK || xp != tt.wantXP || gold != tt.wantGold { hero := &model.Hero{State: model.StateWalking, HP: 100}
t.Errorf("parseGainsLog(%q) = (%d, %d, %v), want (%d, %d, %v)", d := storage.OfflineDigestRow{
tt.msg, xp, gold, ok, tt.wantXP, tt.wantGold, tt.wantOK) MonstersKilled: 2,
} XPGained: 10,
GoldGained: 5,
Loot: []model.LootDrop{},
} }
r := h.buildOfflineReportFromDigest(hero, time.Minute, d)
if r == nil {
t.Fatal("expected report")
} }
if r.MonstersKilled != 2 || r.XPGained != 10 || r.GoldGained != 5 {
func TestIsLevelUpLog(t *testing.T) { t.Fatalf("unexpected counters: %+v", r)
if !isLevelUpLog("Leveled up to 5!") {
t.Error("expected true for level-up log")
} }
if isLevelUpLog("Defeated a wolf") { if r.Message == "" {
t.Error("expected false for non-level-up log") t.Fatal("expected message")
} }
} }
func TestIsDeathLog(t *testing.T) { func TestBuildOfflineReportFromDigest_EmptyAlive(t *testing.T) {
if !isDeathLog("Died fighting Forest Wolf") { h := &GameHandler{}
t.Error("expected true for death log") hero := &model.Hero{State: model.StateWalking, HP: 100}
} d := storage.OfflineDigestRow{Loot: []model.LootDrop{}}
if isDeathLog("Defeated Forest Wolf") { if r := h.buildOfflineReportFromDigest(hero, time.Minute, d); r != nil {
t.Error("expected false for non-death log") t.Fatalf("expected nil, got %+v", r)
} }
} }
func TestIsPotionLog(t *testing.T) { func TestBuildOfflineReportFromDigest_DeadNoDigest(t *testing.T) {
if !isPotionLog("Used healing potion, restored 30 HP") { h := &GameHandler{}
t.Error("expected true for potion log") hero := &model.Hero{State: model.StateDead, HP: 0}
} d := storage.OfflineDigestRow{Loot: []model.LootDrop{}}
if isPotionLog("Defeated Forest Wolf") { r := h.buildOfflineReportFromDigest(hero, time.Minute, d)
t.Error("expected false for non-potion log") if r == nil {
t.Fatal("expected death message report")
} }
} }

@ -26,8 +26,6 @@ type Hero struct {
Agility int `json:"agility"` Agility int `json:"agility"`
Luck int `json:"luck"` Luck int `json:"luck"`
State GameState `json:"state"` State GameState `json:"state"`
WeaponID *int64 `json:"weaponId,omitempty"` // Deprecated: kept for DB backward compat
ArmorID *int64 `json:"armorId,omitempty"` // Deprecated: kept for DB backward compat
Gear map[EquipmentSlot]*GearItem `json:"gear"` Gear map[EquipmentSlot]*GearItem `json:"gear"`
// Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots. // Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots.
Inventory []*GearItem `json:"inventory,omitempty"` Inventory []*GearItem `json:"inventory,omitempty"`
@ -73,6 +71,8 @@ type Hero struct {
TownPause *TownPausePersisted `json:"-"` TownPause *TownPausePersisted `json:"-"`
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"` LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
// WsDisconnectedAt is when the last WebSocket session ended (DB only; used for offline digest grace).
WsDisconnectedAt *time.Time `json:"-"`
// ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only). // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
ChangelogAckVersion string `json:"-"` ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`

@ -52,6 +52,7 @@ func New(deps Deps) *chi.Mux {
wsH := handler.NewWSHandler(deps.Hub, heroStore, deps.Logger) wsH := handler.NewWSHandler(deps.Hub, heroStore, deps.Logger)
r.Get("/ws", wsH.HandleWS) r.Get("/ws", wsH.HandleWS)
logStore := storage.NewLogStore(deps.PgPool) logStore := storage.NewLogStore(deps.PgPool)
digestStore := storage.NewOfflineDigestStore(deps.PgPool)
questStore := storage.NewQuestStore(deps.PgPool) questStore := storage.NewQuestStore(deps.PgPool)
gearStore := storage.NewGearStore(deps.PgPool) gearStore := storage.NewGearStore(deps.PgPool)
achievementStore := storage.NewAchievementStore(deps.PgPool) achievementStore := storage.NewAchievementStore(deps.PgPool)
@ -143,7 +144,7 @@ func New(deps Deps) *chi.Mux {
r.Get("/admin-ws/hero/{heroId}", adminH.AdminHeroSnapshotWS) r.Get("/admin-ws/hero/{heroId}", adminH.AdminHeroSnapshotWS)
// API v1 (authenticated routes). // API v1 (authenticated routes).
gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, worldSvc, deps.Logger, deps.ServerStartedAt, questStore, gearStore, achievementStore, taskStore, deps.Hub) gameH := handler.NewGameHandler(deps.Engine, heroStore, logStore, digestStore, 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, deps.Engine, deps.Hub) npcH := handler.NewNPCHandler(questStore, heroStore, gearStore, logStore, deps.Logger, deps.Engine, deps.Hub)

@ -199,58 +199,74 @@ func normalizeEquipmentSlot(raw string) model.EquipmentSlot {
func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily, error) { func (s *ContentStore) LoadGearFamilies(ctx context.Context) ([]model.GearFamily, error) {
out := make([]model.GearFamily, 0, 128) out := make([]model.GearFamily, 0, 128)
// One catalog row per item name: pick lowest id (template rows predate player-owned gear ids).
weaponRows, err := s.pool.Query(ctx, ` weaponRows, err := s.pool.Query(ctx, `
SELECT name, type, damage, speed, crit_chance, special_effect SELECT DISTINCT ON (name)
FROM weapons name, subtype, base_primary, stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect, form_id
FROM gear
WHERE slot = 'main_hand'
ORDER BY name, id
`) `)
if err != nil { if err != nil {
return nil, fmt.Errorf("load weapons from db: %w", err) return nil, fmt.Errorf("load main_hand gear templates from db: %w", err)
} }
for weaponRows.Next() { for weaponRows.Next() {
var name, typ, special string var name, subtype, statType, setName, special, formID string
var damage int var basePrimary, agi int
var speed, crit float64 var speed, crit float64
if err := weaponRows.Scan(&name, &typ, &damage, &speed, &crit, &special); err != nil { if err := weaponRows.Scan(&name, &subtype, &basePrimary, &statType, &speed, &crit, &agi, &setName, &special, &formID); err != nil {
weaponRows.Close() weaponRows.Close()
return nil, fmt.Errorf("scan weapon row: %w", err) return nil, fmt.Errorf("scan main_hand gear row: %w", err)
}
if strings.TrimSpace(formID) == "" {
formID = "gear.form.main_hand." + subtype
} }
out = append(out, model.GearFamily{ out = append(out, model.GearFamily{
Slot: model.SlotMainHand, Slot: model.SlotMainHand,
FormID: "gear.form.main_hand." + typ, FormID: formID,
Name: name, Name: name,
Subtype: typ, Subtype: subtype,
BasePrimary: damage, BasePrimary: basePrimary,
StatType: "attack", StatType: statType,
SpeedModifier: speed, SpeedModifier: speed,
BaseCrit: crit, BaseCrit: crit,
AgilityBonus: agi,
SetName: setName,
SpecialEffect: special, SpecialEffect: special,
}) })
} }
weaponRows.Close() weaponRows.Close()
armorRows, err := s.pool.Query(ctx, ` armorRows, err := s.pool.Query(ctx, `
SELECT name, type, defense, speed_modifier, agility_bonus, set_name, special_effect SELECT DISTINCT ON (name)
FROM armor name, subtype, base_primary, stat_type, speed_modifier, crit_chance, agility_bonus, set_name, special_effect, form_id
FROM gear
WHERE slot = 'chest'
ORDER BY name, id
`) `)
if err != nil { if err != nil {
return nil, fmt.Errorf("load armor from db: %w", err) return nil, fmt.Errorf("load chest gear templates from db: %w", err)
} }
for armorRows.Next() { for armorRows.Next() {
var name, typ, setName, special string var name, subtype, statType, setName, special, formID string
var defense, agi int var basePrimary, agi int
var speed float64 var speed, crit float64
if err := armorRows.Scan(&name, &typ, &defense, &speed, &agi, &setName, &special); err != nil { if err := armorRows.Scan(&name, &subtype, &basePrimary, &statType, &speed, &crit, &agi, &setName, &special, &formID); err != nil {
armorRows.Close() armorRows.Close()
return nil, fmt.Errorf("scan armor row: %w", err) return nil, fmt.Errorf("scan chest gear row: %w", err)
}
if strings.TrimSpace(formID) == "" {
formID = "gear.form.chest." + subtype
} }
out = append(out, model.GearFamily{ out = append(out, model.GearFamily{
Slot: model.SlotChest, Slot: model.SlotChest,
FormID: "gear.form.chest." + typ, FormID: formID,
Name: name, Name: name,
Subtype: typ, Subtype: subtype,
BasePrimary: defense, BasePrimary: basePrimary,
StatType: "defense", StatType: statType,
SpeedModifier: speed, SpeedModifier: speed,
BaseCrit: crit,
AgilityBonus: agi, AgilityBonus: agi,
SetName: setName, SetName: setName,
SpecialEffect: special, SpecialEffect: special,

@ -24,7 +24,7 @@ const heroSelectQuery = `
h.id, h.telegram_id, h.name, h.id, h.telegram_id, h.name,
h.hp, h.max_hp, h.attack, h.defense, h.speed, h.hp, h.max_hp, h.attack, h.defense, h.speed,
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.gold, h.xp, h.level, h.gold, h.xp, h.level,
h.revive_count, h.subscription_active, h.subscription_expires_at, 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,
@ -32,6 +32,7 @@ const heroSelectQuery = `
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,
h.current_town_id, h.destination_town_id, h.move_state, h.town_pause, h.current_town_id, h.destination_town_id, h.move_state, h.town_pause,
h.last_online_at, h.changelog_ack_version, h.last_online_at, h.changelog_ack_version,
h.ws_disconnected_at,
h.created_at, h.updated_at h.created_at, h.updated_at
FROM heroes h FROM heroes h
` `
@ -71,7 +72,7 @@ func (s *HeroStore) GearStore() *GearStore {
return s.gearStore return s.gearStore
} }
// GetByTelegramID loads a hero by Telegram user ID, including weapon and armor via LEFT JOIN. // GetByTelegramID loads a hero by Telegram user ID.
// Returns (nil, nil) if no hero is found. // Returns (nil, nil) if no hero is found.
func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) { func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) {
query := heroSelectQuery + ` WHERE h.telegram_id = $1` query := heroSelectQuery + ` WHERE h.telegram_id = $1`
@ -236,7 +237,7 @@ func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error {
return nil return nil
} }
// GetByID loads a hero by its primary key, including weapon and armor. // GetByID loads a hero by its primary key.
// Returns (nil, nil) if not found. // Returns (nil, nil) if not found.
func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) { func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) {
query := heroSelectQuery + ` WHERE h.id = $1` query := heroSelectQuery + ` WHERE h.id = $1`
@ -259,14 +260,9 @@ func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error)
} }
// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear. // insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
// Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables.
func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error { func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error {
now := time.Now() now := time.Now()
var weaponID int64 = 1
var armorID int64 = 1
hero.WeaponID = &weaponID
hero.ArmorID = &armorID
hero.CreatedAt = now hero.CreatedAt = now
hero.UpdatedAt = now hero.UpdatedAt = now
@ -281,7 +277,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
telegram_id, name, telegram_id, name,
hp, max_hp, attack, defense, speed, hp, max_hp, attack, defense, speed,
strength, constitution, agility, luck, strength, constitution, agility, luck,
state, weapon_id, armor_id, state,
gold, xp, level, gold, xp, level,
revive_count, subscription_active, subscription_expires_at, 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,
@ -294,15 +290,15 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
$1, $2, $1, $2,
$3, $4, $5, $6, $7, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $8, $9, $10, $11,
$12, $13, $14, $12,
$15, $16, $17, $13, $14, $15,
$18, $19, $20, $16, $17, $18,
$21, $22, $23, $19, $20, $21,
$24, $25, $26, $22, $23, $24,
$27, $28, $29, $30, $31, $25, $26, $27, $28, $29,
$32, $30,
$33, $34, $31, $32,
$35, $36, $37 $33, $34, $35
) RETURNING id ) RETURNING id
` `
@ -310,7 +306,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.TelegramID, hero.Name, hero.TelegramID, hero.Name,
hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
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.Gold, hero.XP, hero.Level, hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
@ -565,20 +561,20 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hp = $1, max_hp = $2, hp = $1, max_hp = $2,
attack = $3, defense = $4, speed = $5, attack = $3, defense = $4, speed = $5,
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,
gold = $13, xp = $14, level = $15, gold = $11, xp = $12, level = $13,
revive_count = $16, subscription_active = $17, subscription_expires_at = $18, revive_count = $14, subscription_active = $15, subscription_expires_at = $16,
buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21, buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19,
position_x = $22, position_y = $23, potions = $24, position_x = $20, position_y = $21, potions = $22,
total_kills = $25, elite_kills = $26, total_deaths = $27, total_kills = $23, elite_kills = $24, total_deaths = $25,
kills_since_death = $28, legendary_drops = $29, kills_since_death = $26, legendary_drops = $27,
last_online_at = $30, last_online_at = $28,
updated_at = $31, updated_at = $29,
destination_town_id = $32, destination_town_id = $30,
current_town_id = $33, current_town_id = $31,
move_state = $34, move_state = $32,
town_pause = $35 town_pause = $33
WHERE id = $36 WHERE id = $34
` `
townPauseJSON := marshalTownPause(hero.TownPause) townPauseJSON := marshalTownPause(hero.TownPause)
@ -586,7 +582,7 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hero.HP, hero.MaxHP, hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed, hero.Attack, hero.Defense, hero.Speed,
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.Gold, hero.XP, hero.Level, hero.Gold, hero.XP, hero.Level,
hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt, hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON, hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
@ -647,6 +643,24 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
return nil return nil
} }
// SetWsDisconnectedAt records when the player's last WebSocket session ended.
func (s *HeroStore) SetWsDisconnectedAt(ctx context.Context, heroID int64, t time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE heroes SET ws_disconnected_at = $1, updated_at = now() WHERE id = $2`, t, heroID)
if err != nil {
return fmt.Errorf("set ws_disconnected_at: %w", err)
}
return nil
}
// ClearWsDisconnectedAt clears the offline marker after the client has synced (e.g. hero/init).
func (s *HeroStore) ClearWsDisconnectedAt(ctx context.Context, heroID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE heroes SET ws_disconnected_at = NULL, updated_at = now() WHERE id = $1`, heroID)
if err != nil {
return fmt.Errorf("clear ws_disconnected_at: %w", err)
}
return nil
}
// ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map, // ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map,
// or heroes resting / in town whose DB row has not been updated recently (offline town timers). // or heroes resting / in town whose DB row has not been updated recently (offline town timers).
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive). // Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
@ -711,7 +725,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID, &state,
&h.Gold, &h.XP, &h.Level, &h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
@ -719,6 +733,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { if err != nil {
@ -745,7 +760,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID, &state,
&h.Gold, &h.XP, &h.Level, &h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt, &h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw, &h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
@ -753,6 +768,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops, &h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw, &h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion, &h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt, &h.CreatedAt, &h.UpdatedAt,
) )
if err != nil { if err != nil {

@ -32,6 +32,7 @@ import {
requestRevive, requestRevive,
defaultNpcShopCosts, defaultNpcShopCosts,
npcShopCostsFromInit, npcShopCostsFromInit,
offlineReportHasActivity,
} from './network/api'; } from './network/api';
import type { HeroResponse, Achievement, ChangelogPayload } from './network/api'; import type { HeroResponse, Achievement, ChangelogPayload } from './network/api';
import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types'; import type { AdventureLogEntry, Town, HeroQuest, NPC, TownData, EquipmentItem, BuildingData } from './game/types';
@ -179,6 +180,7 @@ function mapEquipment(
ilvl: item.ilvl ?? 1, ilvl: item.ilvl ?? 1,
primaryStat: item.primaryStat ?? 0, primaryStat: item.primaryStat ?? 0,
statType: item.statType ?? 'mixed', statType: item.statType ?? 'mixed',
subtype: item.subtype,
}; };
} }
} }
@ -250,14 +252,14 @@ function heroResponseToState(res: HeroResponse): HeroState {
attackSpeed: res.attackSpeed ?? res.speed, attackSpeed: res.attackSpeed ?? res.speed,
damage: res.attackPower ?? res.attack, damage: res.attackPower ?? res.attack,
defense: res.defensePower ?? res.defense, defense: res.defensePower ?? res.defense,
weaponType: (res.weapon?.type ?? 'sword') as HeroState['weaponType'], weaponType: (res.gear?.main_hand?.subtype ?? res.weapon?.type ?? 'sword') as HeroState['weaponType'],
weaponName: res.weapon?.name ?? '', weaponName: res.gear?.main_hand?.name ?? res.weapon?.name ?? '',
weaponRarity: (res.weapon?.rarity ?? 'common') as Rarity, weaponRarity: (res.gear?.main_hand?.rarity ?? res.weapon?.rarity ?? 'common') as Rarity,
weaponIlvl: res.weapon?.ilvl, weaponIlvl: res.gear?.main_hand?.ilvl ?? res.weapon?.ilvl,
armorType: (res.armor?.type ?? 'medium') as HeroState['armorType'], armorType: (res.gear?.chest?.subtype ?? res.armor?.type ?? 'medium') as HeroState['armorType'],
armorName: res.armor?.name ?? '', armorName: res.gear?.chest?.name ?? res.armor?.name ?? '',
armorRarity: (res.armor?.rarity ?? 'common') as Rarity, armorRarity: (res.gear?.chest?.rarity ?? res.armor?.rarity ?? 'common') as Rarity,
armorIlvl: res.armor?.ilvl, armorIlvl: res.gear?.chest?.ilvl ?? res.armor?.ilvl,
activeBuffs: mapHeroBuffsFromServer(res.buffs, now), activeBuffs: mapHeroBuffsFromServer(res.buffs, now),
debuffs: mapHeroDebuffsFromServer(res.debuffs, now), debuffs: mapHeroDebuffsFromServer(res.debuffs, now),
level: res.level, level: res.level,
@ -590,9 +592,11 @@ export function App() {
engine.setHeroName(initRes.hero.name); engine.setHeroName(initRes.hero.name);
console.info('[App] Loaded hero from server, id=', initRes.hero.id); console.info('[App] Loaded hero from server, id=', initRes.hero.id);
if (initRes.offlineReport && initRes.offlineReport.monstersKilled > 0) { if (initRes.offlineReport && offlineReportHasActivity(initRes.offlineReport)) {
const r = initRes.offlineReport; const r = initRes.offlineReport;
console.info(`[Offline] ${r.message} Killed ${r.monstersKilled} monsters, +${r.xpGained} XP, +${r.goldGained} gold, +${r.levelsGained} levels`); console.info(
`[Offline] ${r.message ?? ''} Killed ${r.monstersKilled} monsters, +${r.xpGained} XP, +${r.goldGained} gold, +${r.levelsGained} levels, deaths ${r.deaths ?? 0}, revives ${r.revives ?? 0}`,
);
setOfflineReport(r); setOfflineReport(r);
} }
@ -1453,6 +1457,10 @@ export function App() {
xpGained={offlineReport.xpGained} xpGained={offlineReport.xpGained}
goldGained={offlineReport.goldGained} goldGained={offlineReport.goldGained}
levelsGained={offlineReport.levelsGained} levelsGained={offlineReport.levelsGained}
deaths={offlineReport.deaths}
revives={offlineReport.revives}
loot={offlineReport.loot}
message={offlineReport.message}
onDismiss={() => setOfflineReport(null)} onDismiss={() => setOfflineReport(null)}
/> />
)} )}

@ -444,6 +444,10 @@ export class GameEngine {
enemyHp: number, enemyHp: number,
outcome?: 'hit' | 'dodge' | 'block' | 'stun', outcome?: 'hit' | 'dodge' | 'block' | 'stun',
): void { ): void {
void source;
void damage;
void isCrit;
void outcome;
if (this._gameState.hero) { if (this._gameState.hero) {
this._gameState.hero.hp = heroHp; this._gameState.hero.hp = heroHp;
} }
@ -459,6 +463,7 @@ export class GameEngine {
* Updates enemy HP. * Updates enemy HP.
*/ */
applyEnemyRegen(amount: number, enemyHp: number): void { applyEnemyRegen(amount: number, enemyHp: number): void {
void amount;
if (!this._gameState.enemy) return; if (!this._gameState.enemy) return;
this._gameState.enemy.hp = enemyHp; this._gameState.enemy.hp = enemyHp;
this._notifyStateChange(); this._notifyStateChange();

@ -342,6 +342,8 @@ export interface EquipmentItem {
ilvl: number; ilvl: number;
primaryStat: number; primaryStat: number;
statType: string; // 'attack' | 'defense' | 'speed' | 'mixed' statType: string; // 'attack' | 'defense' | 'speed' | 'mixed'
/** Server GearItem subtype (weapon class, armor weight, …) */
subtype?: string;
} }
/** Canonical equipment slot keys from spec §6.3 */ /** Canonical equipment slot keys from spec §6.3 */

@ -149,6 +149,9 @@ export const en = {
gainedXP: '+{xp} XP', gainedXP: '+{xp} XP',
gainedGold: '+{gold} gold', gainedGold: '+{gold} gold',
gainedLevels: 'Gained {levels} level(s)!', gainedLevels: 'Gained {levels} level(s)!',
offlineDeaths: 'Deaths: {count}',
offlineRevives: 'Auto-revives: {count}',
offlineLootFound: 'Loot:',
tapToDismiss: 'Tap anywhere to dismiss', tapToDismiss: 'Tap anywhere to dismiss',
// Toasts // Toasts

@ -152,6 +152,9 @@ export const ru: Translations = {
gainedXP: '+{xp} \u041e\u041f', gainedXP: '+{xp} \u041e\u041f',
gainedGold: '+{gold} \u0437\u043e\u043b\u043e\u0442\u0430', gainedGold: '+{gold} \u0437\u043e\u043b\u043e\u0442\u0430',
gainedLevels: '\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0443\u0440\u043e\u0432\u043d\u0435\u0439: {levels}!', gainedLevels: '\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0443\u0440\u043e\u0432\u043d\u0435\u0439: {levels}!',
offlineDeaths: '\u0421\u043c\u0435\u0440\u0442\u0435\u0439: {count}',
offlineRevives: '\u0410\u0432\u0442\u043e-\u0432\u043e\u0441\u043a\u0440\u0435\u0448\u0435\u043d\u0438\u0439: {count}',
offlineLootFound: '\u0414\u043e\u0431\u044b\u0447\u0430:',
tapToDismiss: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f', tapToDismiss: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0434\u043b\u044f \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0435\u043d\u0438\u044f',
// Toasts // Toasts

@ -109,10 +109,11 @@ export interface HeroResponse {
state: string; state: string;
restKind?: string; restKind?: string;
excursionPhase?: string; excursionPhase?: string;
weaponId: number; /** Removed from server; gear.main_hand / legacy weapon only */
armorId: number; weaponId?: number;
weapon: WeaponResponse | null; armorId?: number;
armor: ArmorResponse | null; weapon?: WeaponResponse | null;
armor?: ArmorResponse | null;
gold: number; gold: number;
xp: number; xp: number;
xpToNext?: number; xpToNext?: number;
@ -140,6 +141,8 @@ export interface HeroResponse {
ilvl?: number; ilvl?: number;
primaryStat?: number; primaryStat?: number;
statType?: string; statType?: string;
/** Weapon/armor class from server GearItem (e.g. sword, light) */
subtype?: string;
}>; }>;
/** Same slot data as `equipment`; WebSocket `hero_state` from Go uses `gear` */ /** Same slot data as `equipment`; WebSocket `hero_state` from Go uses `gear` */
gear?: HeroResponse['equipment']; gear?: HeroResponse['equipment'];
@ -173,13 +176,47 @@ export async function getHero(telegramId?: number): Promise<HeroResponse> {
return apiGet<HeroResponse>(`/hero${query}`); return apiGet<HeroResponse>(`/hero${query}`);
} }
/** Loot lines from offline digest (matches server model.LootDrop JSON). */
export interface OfflineLootLine {
itemType: string;
itemId?: number;
itemName?: string;
rarity: string;
goldAmount?: number;
}
export interface OfflineReport { export interface OfflineReport {
offlineSeconds: number; offlineSeconds: number;
monstersKilled: number; monstersKilled: number;
xpGained: number; xpGained: number;
goldGained: number; goldGained: number;
levelsGained: number; levelsGained: number;
message: string; deaths?: number;
revives?: number;
loot?: OfflineLootLine[];
message?: string;
}
/** True when init should show the offline summary overlay (server sends non-null only when meaningful). */
export function offlineReportHasActivity(r: OfflineReport): boolean {
return (
r.monstersKilled > 0 ||
r.xpGained > 0 ||
r.goldGained > 0 ||
r.levelsGained > 0 ||
(r.deaths ?? 0) > 0 ||
(r.revives ?? 0) > 0 ||
(r.loot?.length ?? 0) > 0 ||
Boolean(r.message?.trim())
);
}
export function formatOfflineLootLine(line: OfflineLootLine): string {
if (line.itemType === 'gold' || (line.goldAmount ?? 0) > 0) {
return `+${line.goldAmount ?? 0} gold`;
}
const name = line.itemName?.trim() || line.itemType;
return `${name} (${line.rarity})`;
} }
/** Curated release notes for the current server version (see backend changelog.json). */ /** Curated release notes for the current server version (see backend changelog.json). */

@ -1,11 +1,16 @@
import { useEffect, useState, type CSSProperties } from 'react'; import { useEffect, useState, type CSSProperties } from 'react';
import { useT, t } from '../i18n'; import { useT, t } from '../i18n';
import { formatOfflineLootLine, type OfflineLootLine } from '../network/api';
interface OfflineReportProps { interface OfflineReportProps {
monstersKilled: number; monstersKilled: number;
xpGained: number; xpGained: number;
goldGained: number; goldGained: number;
levelsGained: number; levelsGained: number;
deaths?: number;
revives?: number;
loot?: OfflineLootLine[];
message?: string;
onDismiss: () => void; onDismiss: () => void;
} }
@ -29,7 +34,9 @@ const cardStyle: CSSProperties = {
border: '1px solid rgba(100, 160, 255, 0.3)', border: '1px solid rgba(100, 160, 255, 0.3)',
borderRadius: 12, borderRadius: 12,
padding: '20px 28px', padding: '20px 28px',
maxWidth: 320, maxWidth: 360,
maxHeight: '80vh',
overflowY: 'auto',
width: 'calc(100vw - 48px)', width: 'calc(100vw - 48px)',
textAlign: 'center', textAlign: 'center',
boxShadow: '0 0 30px rgba(50, 100, 200, 0.2)', boxShadow: '0 0 30px rgba(50, 100, 200, 0.2)',
@ -55,11 +62,24 @@ const hintStyle: CSSProperties = {
marginTop: 14, marginTop: 14,
}; };
const lootListStyle: CSSProperties = {
fontSize: 12,
color: '#c8d0e8',
textAlign: 'left',
marginTop: 8,
paddingLeft: 8,
lineHeight: 1.6,
};
export function OfflineReport({ export function OfflineReport({
monstersKilled, monstersKilled,
xpGained, xpGained,
goldGained, goldGained,
levelsGained, levelsGained,
deaths = 0,
revives = 0,
loot = [],
message,
onDismiss, onDismiss,
}: OfflineReportProps) { }: OfflineReportProps) {
const tr = useT(); const tr = useT();
@ -92,9 +112,10 @@ export function OfflineReport({
> >
<div style={cardStyle} onClick={(e) => e.stopPropagation()}> <div style={cardStyle} onClick={(e) => e.stopPropagation()}>
<div style={titleStyle}>{tr.whileYouWereAway}</div> <div style={titleStyle}>{tr.whileYouWereAway}</div>
<div style={lineStyle}> {message ? <div style={lineStyle}>{message}</div> : null}
{t(tr.killedMonsters, { count: monstersKilled })} {monstersKilled > 0 ? (
</div> <div style={lineStyle}>{t(tr.killedMonsters, { count: monstersKilled })}</div>
) : null}
{xpGained > 0 && ( {xpGained > 0 && (
<div style={lineStyle}> <div style={lineStyle}>
{t(tr.gainedXP, { xp: xpGained })} {t(tr.gainedXP, { xp: xpGained })}
@ -110,6 +131,22 @@ export function OfflineReport({
{t(tr.gainedLevels, { levels: levelsGained })} {t(tr.gainedLevels, { levels: levelsGained })}
</div> </div>
)} )}
{deaths > 0 && (
<div style={lineStyle}>{t(tr.offlineDeaths, { count: deaths })}</div>
)}
{revives > 0 && (
<div style={lineStyle}>{t(tr.offlineRevives, { count: revives })}</div>
)}
{loot.length > 0 && (
<>
<div style={{ ...lineStyle, marginTop: 10 }}>{tr.offlineLootFound}</div>
<div style={lootListStyle}>
{loot.map((line, i) => (
<div key={i}>{formatOfflineLootLine(line)}</div>
))}
</div>
</>
)}
<div style={hintStyle}>{tr.tapToDismiss}</div> <div style={hintStyle}>{tr.tapToDismiss}</div>
</div> </div>
</div> </div>

Loading…
Cancel
Save