From 51be614b9fb1937793a8b7520998ffb690c31073 Mon Sep 17 00:00:00 2001 From: Denis Ranneft Date: Wed, 1 Apr 2026 03:03:35 +0300 Subject: [PATCH] new offline digest --- backend/cmd/balanceall/main.go | 4 +- backend/cmd/server/main.go | 11 +- backend/internal/game/offline.go | 45 ++++++- backend/internal/game/offline_test.go | 6 +- backend/internal/handler/admin.go | 4 - backend/internal/handler/game.go | 154 +++++++--------------- backend/internal/handler/game_test.go | 88 +++++-------- backend/internal/model/hero.go | 4 +- backend/internal/router/router.go | 3 +- backend/internal/storage/content_store.go | 62 +++++---- backend/internal/storage/hero_store.go | 88 ++++++++----- frontend/src/App.tsx | 28 ++-- frontend/src/game/engine.ts | 5 + frontend/src/game/types.ts | 2 + frontend/src/i18n/en.ts | 3 + frontend/src/i18n/ru.ts | 3 + frontend/src/network/api.ts | 47 ++++++- frontend/src/ui/OfflineReport.tsx | 45 ++++++- 18 files changed, 344 insertions(+), 258 deletions(-) diff --git a/backend/cmd/balanceall/main.go b/backend/cmd/balanceall/main.go index 0046ebd..93361d3 100644 --- a/backend/cmd/balanceall/main.go +++ b/backend/cmd/balanceall/main.go @@ -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() diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ed292c6..9e03c1d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) diff --git a/backend/internal/game/offline.go b/backend/internal/game/offline.go index 72e836c..e1c71ea 100644 --- a/backend/internal/game/offline.go +++ b/backend/internal/game/offline.go @@ -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 { diff --git a/backend/internal/game/offline_test.go b/backend/internal/game/offline_test.go index ea3c525..5f5db3d 100644 --- a/backend/internal/game/offline_test.go +++ b/backend/internal/game/offline_test.go @@ -17,7 +17,7 @@ func TestSimulateOneFight_HeroSurvives(t *testing.T) { } now := time.Now() - survived, enemy, xpGained, goldGained := SimulateOneFight(hero, now, nil, 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") diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 2dac48f..d96e4da 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -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, diff --git a/backend/internal/handler/game.go b/backend/internal/handler/game.go index 4810a7c..47f6ec1 100644 --- a/backend/internal/handler/game.go +++ b/backend/internal/handler/game.go @@ -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, @@ -683,42 +685,29 @@ func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) { // offlineReport describes what happened while the hero was offline. type offlineReport struct { - OfflineSeconds int `json:"offlineSeconds"` - MonstersKilled int `json:"monstersKilled"` - XPGained int64 `json:"xpGained"` - GoldGained int64 `json:"goldGained"` - LevelsGained int `json:"levelsGained"` - PotionsUsed int `json:"potionsUsed"` - PotionsFound int `json:"potionsFound"` - HPBefore int `json:"hpBefore"` - Message string `json:"message"` - Log []string `json:"log"` + OfflineSeconds int `json:"offlineSeconds"` + MonstersKilled int `json:"monstersKilled"` + XPGained int64 `json:"xpGained"` + GoldGained int64 `json:"goldGained"` + LevelsGained int `json:"levelsGained"` + Deaths int `json:"deaths"` + Revives int `json:"revives"` + Loot []model.LootDrop `json:"loot"` + HPBefore int `json:"hpBefore"` + Message string `json:"message"` } -// 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 { diff --git a/backend/internal/handler/game_test.go b/backend/internal/handler/game_test.go index b643f4c..5584f92 100644 --- a/backend/internal/handler/game_test.go +++ b/backend/internal/handler/game_test.go @@ -2,72 +2,48 @@ package handler import ( "testing" + "time" + + "github.com/denisovdennis/autohero/internal/model" + "github.com/denisovdennis/autohero/internal/storage" ) -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}, +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{}, } - - for _, tt := range tests { - matched, _ := parseDefeatedLog(tt.msg) - if matched != tt.matched { - t.Errorf("parseDefeatedLog(%q) = %v, want %v", tt.msg, matched, tt.matched) - } + r := h.buildOfflineReportFromDigest(hero, time.Minute, d) + if r == nil { + t.Fatal("expected report") } -} - -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}, + if r.MonstersKilled != 2 || r.XPGained != 10 || r.GoldGained != 5 { + t.Fatalf("unexpected counters: %+v", r) } - - 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) - } + if r.Message == "" { + t.Fatal("expected message") } } -func TestIsLevelUpLog(t *testing.T) { - if !isLevelUpLog("Leveled up to 5!") { - t.Error("expected true for level-up log") - } - if isLevelUpLog("Defeated a wolf") { - t.Error("expected false for non-level-up 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 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 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") } } diff --git a/backend/internal/model/hero.go b/backend/internal/model/hero.go index cf4d430..3c52097 100644 --- a/backend/internal/model/hero.go +++ b/backend/internal/model/hero.go @@ -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"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b125256..c7619e9 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/internal/storage/content_store.go b/backend/internal/storage/content_store.go index 6eb8f51..624fcb1 100644 --- a/backend/internal/storage/content_store.go +++ b/backend/internal/storage/content_store.go @@ -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, diff --git a/backend/internal/storage/hero_store.go b/backend/internal/storage/hero_store.go index 3acc421..d4f52a9 100644 --- a/backend/internal/storage/hero_store.go +++ b/backend/internal/storage/hero_store.go @@ -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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 09086ae..b933c7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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)} /> )} diff --git a/frontend/src/game/engine.ts b/frontend/src/game/engine.ts index 985a2cf..8cfffd0 100644 --- a/frontend/src/game/engine.ts +++ b/frontend/src/game/engine.ts @@ -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(); diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index ece220f..0301b3c 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -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 */ diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 2362307..61b6b65 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -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 diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts index 8d10bbc..a5b70af 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -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 diff --git a/frontend/src/network/api.ts b/frontend/src/network/api.ts index 038bd50..11183cb 100644 --- a/frontend/src/network/api.ts +++ b/frontend/src/network/api.ts @@ -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 { return apiGet(`/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). */ diff --git a/frontend/src/ui/OfflineReport.tsx b/frontend/src/ui/OfflineReport.tsx index 6bb8b12..67bf27a 100644 --- a/frontend/src/ui/OfflineReport.tsx +++ b/frontend/src/ui/OfflineReport.tsx @@ -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({ >
e.stopPropagation()}>
{tr.whileYouWereAway}
-
- {t(tr.killedMonsters, { count: monstersKilled })} -
+ {message ?
{message}
: null} + {monstersKilled > 0 ? ( +
{t(tr.killedMonsters, { count: monstersKilled })}
+ ) : null} {xpGained > 0 && (
{t(tr.gainedXP, { xp: xpGained })} @@ -110,6 +131,22 @@ export function OfflineReport({ {t(tr.gainedLevels, { levels: levelsGained })}
)} + {deaths > 0 && ( +
{t(tr.offlineDeaths, { count: deaths })}
+ )} + {revives > 0 && ( +
{t(tr.offlineRevives, { count: revives })}
+ )} + {loot.length > 0 && ( + <> +
{tr.offlineLootFound}
+
+ {loot.map((line, i) => ( +
{formatOfflineLootLine(line)}
+ ))} +
+ + )}
{tr.tapToDismiss}