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

@ -64,6 +64,7 @@ func main() {
// Stores (created before hub callbacks which reference them).
heroStore := storage.NewHeroStore(pgPool, logger)
logStore := storage.NewLogStore(pgPool)
digestStore := storage.NewOfflineDigestStore(pgPool)
questStore := storage.NewQuestStore(pgPool)
gearStore := storage.NewGearStore(pgPool)
achievementStore := storage.NewAchievementStore(pgPool)
@ -130,6 +131,13 @@ func main() {
}
hub.OnDisconnect = func(heroID int64, remainingSameHero int) {
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.
@ -182,7 +190,8 @@ func main() {
return engine.IsTimePaused()
}, engine.HeroHasActiveMovement).
WithCombatTickRate(engine.TickRate()).
WithRewardStores(gearStore, achievementStore, taskStore)
WithRewardStores(gearStore, achievementStore, taskStore).
WithDigestStore(digestStore)
go func() {
if err := offlineSim.Run(ctx); err != nil && err != context.Canceled {
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)
// so the same hero is not simulated twice.
skipIfLive func(heroID int64) bool
digestStore *storage.OfflineDigestStore
}
// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
@ -67,6 +68,22 @@ func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievement
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.
func (s *OfflineSimulator) Run(ctx context.Context) error {
ticker := time.NewTicker(s.interval)
@ -131,6 +148,9 @@ func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Her
hero.State = model.StateWalking
hero.Debuffs = nil
s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second)))
if s.digestStore != nil && offlineDigestCollecting(hero.WsDisconnectedAt, now) {
_ = s.digestStore.ApplyDelta(ctx, hero.ID, storage.OfflineDigestDelta{Revives: 1})
}
}
// 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) {
s.addLog(ctx, hm.Hero.ID, FormatEncounterLogLine(enemy.Name))
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 {
s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
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.
// 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 {
enemy = *encounterEnemy
} else {
@ -369,14 +404,14 @@ func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Ene
hero.State = model.StateDead
hero.TotalDeaths++
hero.KillsSinceDeath = 0
return false, enemy, 0, 0
return false, enemy, 0, 0, nil
}
xpGained = enemy.XPReward
drops := ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
drops = ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
goldGained = sumGoldFromDrops(drops)
hero.RefreshDerivedCombatStats(now)
return true, enemy, xpGained, goldGained
return true, enemy, xpGained, goldGained, drops
}
func sumGoldFromDrops(drops []model.LootDrop) int64 {

@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) {
}
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 {
t.Fatalf("overpowered hero should survive, enemy was %s", enemy.Name)
@ -42,7 +42,7 @@ func TestSimulateOneFight_HeroDies(t *testing.T) {
}
now := time.Now()
survived, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
survived, _, _, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if survived {
t.Fatal("1 HP hero should die to any enemy")
@ -66,7 +66,7 @@ func TestSimulateOneFight_LevelUp(t *testing.T) {
}
now := time.Now()
survived, _, xpGained, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
survived, _, xpGained, _, _ := SimulateOneFight(hero, now, nil, nil, 100*time.Millisecond, VictoryRewardDeps{})
if !survived {
t.Fatal("overpowered hero should survive")

@ -2992,10 +2992,6 @@ func applyNewPlayerHeroDefaults(hero *model.Hero) {
hero.RestKind = model.RestKindNone
hero.TownPause = nil
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,

@ -28,6 +28,7 @@ type GameHandler struct {
engine *game.Engine
store *storage.HeroStore
logStore *storage.LogStore
digestStore *storage.OfflineDigestStore
hub *Hub
questStore *storage.QuestStore
gearStore *storage.GearStore
@ -55,11 +56,12 @@ type encounterEnemyResponse struct {
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{
engine: engine,
store: store,
logStore: logStore,
digestStore: digestStore,
hub: hub,
questStore: questStore,
gearStore: gearStore,
@ -688,37 +690,24 @@ type offlineReport struct {
XPGained int64 `json:"xpGained"`
GoldGained int64 `json:"goldGained"`
LevelsGained int `json:"levelsGained"`
PotionsUsed int `json:"potionsUsed"`
PotionsFound int `json:"potionsFound"`
Deaths int `json:"deaths"`
Revives int `json:"revives"`
Loot []model.LootDrop `json:"loot"`
HPBefore int `json:"hpBefore"`
Message string `json:"message"`
Log []string `json:"log"`
}
// buildOfflineReport constructs an offline report from real adventure log entries
// written by the offline simulator (and catch-up). Parses log messages to count
// defeats, XP, gold, levels, and deaths.
func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero, offlineDuration time.Duration) *offlineReport {
if offlineDuration < 30*time.Second {
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.
// buildOfflineReportFromDigest builds the API payload from hero_offline_digest (cleared in InitHero).
func (h *GameHandler) buildOfflineReportFromDigest(hero *model.Hero, offlineDuration time.Duration, d storage.OfflineDigestRow) *offlineReport {
empty := d.MonstersKilled == 0 && d.XPGained == 0 && d.GoldGained == 0 && d.LevelsGained == 0 &&
d.Deaths == 0 && d.Revives == 0 && len(d.Loot) == 0
if empty {
if hero.State == model.StateDead {
return &offlineReport{
OfflineSeconds: int(offlineDuration.Seconds()),
HPBefore: 0,
HPBefore: hero.HP,
Message: "Your hero remains dead. Revive to continue progression.",
Log: []string{},
Loot: []model.LootDrop{},
}
}
return nil
@ -726,41 +715,28 @@ func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero,
report := &offlineReport{
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,
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 {
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!"
} else if d.Deaths > 0 || d.Revives > 0 {
report.Message = "Your hero had a rough time while you were away!"
} else {
report.Message = "Your hero rested while you were away."
}
return report
}
@ -786,7 +762,8 @@ func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) b
rg = h.engine.RoadGraph()
}
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 {
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)
}
// 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.
// Also simulates offline progress based on time since last update.
// 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)
}
// Build offline report from real adventure log entries (written by the
// offline simulator and/or the catch-up above).
report := h.buildOfflineReport(r.Context(), hero, offlineDuration)
// Take persisted offline digest (accumulated after WS disconnect + grace) and clear markers.
digestRow := storage.OfflineDigestRow{Loot: []model.LootDrop{}}
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 err := h.store.Save(r.Context(), hero); err != nil {

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

@ -26,8 +26,6 @@ type Hero struct {
Agility int `json:"agility"`
Luck int `json:"luck"`
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"`
// Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots.
Inventory []*GearItem `json:"inventory,omitempty"`
@ -73,6 +71,8 @@ type Hero struct {
TownPause *TownPausePersisted `json:"-"`
LastOnlineAt *time.Time `json:"lastOnlineAt,omitempty"`
// WsDisconnectedAt is when the last WebSocket session ended (DB only; used for offline digest grace).
WsDisconnectedAt *time.Time `json:"-"`
// ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"`

@ -52,6 +52,7 @@ func New(deps Deps) *chi.Mux {
wsH := handler.NewWSHandler(deps.Hub, heroStore, deps.Logger)
r.Get("/ws", wsH.HandleWS)
logStore := storage.NewLogStore(deps.PgPool)
digestStore := storage.NewOfflineDigestStore(deps.PgPool)
questStore := storage.NewQuestStore(deps.PgPool)
gearStore := storage.NewGearStore(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)
// 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)
questH := handler.NewQuestHandler(questStore, heroStore, logStore, deps.Logger)
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) {
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, `
SELECT name, type, damage, speed, crit_chance, special_effect
FROM weapons
SELECT DISTINCT ON (name)
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 {
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() {
var name, typ, special string
var damage int
var name, subtype, statType, setName, special, formID string
var basePrimary, agi int
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()
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{
Slot: model.SlotMainHand,
FormID: "gear.form.main_hand." + typ,
FormID: formID,
Name: name,
Subtype: typ,
BasePrimary: damage,
StatType: "attack",
Subtype: subtype,
BasePrimary: basePrimary,
StatType: statType,
SpeedModifier: speed,
BaseCrit: crit,
AgilityBonus: agi,
SetName: setName,
SpecialEffect: special,
})
}
weaponRows.Close()
armorRows, err := s.pool.Query(ctx, `
SELECT name, type, defense, speed_modifier, agility_bonus, set_name, special_effect
FROM armor
SELECT DISTINCT ON (name)
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 {
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() {
var name, typ, setName, special string
var defense, agi int
var speed float64
if err := armorRows.Scan(&name, &typ, &defense, &speed, &agi, &setName, &special); err != nil {
var name, subtype, statType, setName, special, formID string
var basePrimary, agi int
var speed, crit float64
if err := armorRows.Scan(&name, &subtype, &basePrimary, &statType, &speed, &crit, &agi, &setName, &special, &formID); err != nil {
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{
Slot: model.SlotChest,
FormID: "gear.form.chest." + typ,
FormID: formID,
Name: name,
Subtype: typ,
BasePrimary: defense,
StatType: "defense",
Subtype: subtype,
BasePrimary: basePrimary,
StatType: statType,
SpeedModifier: speed,
BaseCrit: crit,
AgilityBonus: agi,
SetName: setName,
SpecialEffect: special,

@ -24,7 +24,7 @@ const heroSelectQuery = `
h.id, h.telegram_id, h.name,
h.hp, h.max_hp, h.attack, h.defense, h.speed,
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.revive_count, h.subscription_active, h.subscription_expires_at,
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.current_town_id, h.destination_town_id, h.move_state, h.town_pause,
h.last_online_at, h.changelog_ack_version,
h.ws_disconnected_at,
h.created_at, h.updated_at
FROM heroes h
`
@ -71,7 +72,7 @@ func (s *HeroStore) GearStore() *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.
func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) {
query := heroSelectQuery + ` WHERE h.telegram_id = $1`
@ -236,7 +237,7 @@ func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error {
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.
func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) {
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.
// 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 {
now := time.Now()
var weaponID int64 = 1
var armorID int64 = 1
hero.WeaponID = &weaponID
hero.ArmorID = &armorID
hero.CreatedAt = now
hero.UpdatedAt = now
@ -281,7 +277,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
telegram_id, name,
hp, max_hp, attack, defense, speed,
strength, constitution, agility, luck,
state, weapon_id, armor_id,
state,
gold, xp, level,
revive_count, subscription_active, subscription_expires_at,
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,
$3, $4, $5, $6, $7,
$8, $9, $10, $11,
$12, $13, $14,
$15, $16, $17,
$18, $19, $20,
$21, $22, $23,
$24, $25, $26,
$27, $28, $29, $30, $31,
$32,
$33, $34,
$35, $36, $37
$12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23, $24,
$25, $26, $27, $28, $29,
$30,
$31, $32,
$33, $34, $35
) RETURNING id
`
@ -310,7 +306,7 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.TelegramID, hero.Name,
hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
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.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
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,
attack = $3, defense = $4, speed = $5,
strength = $6, constitution = $7, agility = $8, luck = $9,
state = $10, weapon_id = $11, armor_id = $12,
gold = $13, xp = $14, level = $15,
revive_count = $16, subscription_active = $17, subscription_expires_at = $18,
buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21,
position_x = $22, position_y = $23, potions = $24,
total_kills = $25, elite_kills = $26, total_deaths = $27,
kills_since_death = $28, legendary_drops = $29,
last_online_at = $30,
updated_at = $31,
destination_town_id = $32,
current_town_id = $33,
move_state = $34,
town_pause = $35
WHERE id = $36
state = $10,
gold = $11, xp = $12, level = $13,
revive_count = $14, subscription_active = $15, subscription_expires_at = $16,
buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19,
position_x = $20, position_y = $21, potions = $22,
total_kills = $23, elite_kills = $24, total_deaths = $25,
kills_since_death = $26, legendary_drops = $27,
last_online_at = $28,
updated_at = $29,
destination_town_id = $30,
current_town_id = $31,
move_state = $32,
town_pause = $33
WHERE id = $34
`
townPauseJSON := marshalTownPause(hero.TownPause)
@ -586,7 +582,7 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed,
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.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
@ -647,6 +643,24 @@ func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64
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,
// 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).
@ -711,7 +725,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID,
&state,
&h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&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.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt,
)
if err != nil {
@ -745,7 +760,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
&h.ID, &h.TelegramID, &h.Name,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &h.WeaponID, &h.ArmorID,
&state,
&h.Gold, &h.XP, &h.Level,
&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
&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.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
&h.LastOnlineAt, &h.ChangelogAckVersion,
&h.WsDisconnectedAt,
&h.CreatedAt, &h.UpdatedAt,
)
if err != nil {

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

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

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

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

@ -152,6 +152,9 @@ export const ru: Translations = {
gainedXP: '+{xp} \u041e\u041f',
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}!',
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',
// Toasts

@ -109,10 +109,11 @@ export interface HeroResponse {
state: string;
restKind?: string;
excursionPhase?: string;
weaponId: number;
armorId: number;
weapon: WeaponResponse | null;
armor: ArmorResponse | null;
/** Removed from server; gear.main_hand / legacy weapon only */
weaponId?: number;
armorId?: number;
weapon?: WeaponResponse | null;
armor?: ArmorResponse | null;
gold: number;
xp: number;
xpToNext?: number;
@ -140,6 +141,8 @@ export interface HeroResponse {
ilvl?: number;
primaryStat?: number;
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` */
gear?: HeroResponse['equipment'];
@ -173,13 +176,47 @@ export async function getHero(telegramId?: number): Promise<HeroResponse> {
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 {
offlineSeconds: number;
monstersKilled: number;
xpGained: number;
goldGained: 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). */

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

Loading…
Cancel
Save