diff --git a/admin-web/index.html b/admin-web/index.html index cd75983..fce0452 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -1422,6 +1422,7 @@ + diff --git a/backend/internal/game/engine.go b/backend/internal/game/engine.go index 8056289..9715f95 100644 --- a/backend/internal/game/engine.go +++ b/backend/internal/game/engine.go @@ -1141,6 +1141,55 @@ func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) { } } +// ApplyAdminHeroDeath merges a persisted dead hero after POST /admin/.../force-death, clears combat, +// updates live movement (if any), and pushes hero_state; optionally hero_died for clients. +func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) { + if hero == nil { + return + } + e.mu.Lock() + defer e.mu.Unlock() + + delete(e.combats, hero.ID) + + hm, ok := e.movements[hero.ID] + if !ok { + if e.sender != nil { + now := time.Now() + hero.EnsureGearMap() + hero.RefreshDerivedCombatStats(now) + e.sender.SendToHero(hero.ID, "hero_state", hero) + if sendDiedEvent { + e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{ + KilledBy: "admin", + }) + } + } + return + } + + now := time.Now() + hm.TownNPCQueue = nil + hm.NextTownNPCRollAt = time.Time{} + *hm.Hero = *hero + hm.State = model.StateDead + hm.Hero.State = model.StateDead + hm.Hero.HP = 0 + hm.Die() + + if e.sender == nil { + return + } + hm.Hero.EnsureGearMap() + hm.Hero.RefreshDerivedCombatStats(now) + e.sender.SendToHero(hero.ID, "hero_state", hm.Hero) + if sendDiedEvent { + e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{ + KilledBy: "admin", + }) + } +} + // GetCombat returns the current combat state for a hero, if any. func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) { e.mu.RLock() diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go index 32ef4bd..e437368 100644 --- a/backend/internal/handler/admin.go +++ b/backend/internal/handler/admin.go @@ -1196,6 +1196,61 @@ func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) { writeHeroJSON(w, http.StatusOK, hero) } +// ForceHeroDeath sets the hero to dead (HP 0, state dead), ends active combat, clears buffs/debuffs, +// and increments death stats when transitioning from alive. +// POST /admin/heroes/{heroId}/force-death +func (h *AdminHandler) ForceHeroDeath(w http.ResponseWriter, r *http.Request) { + heroID, err := parseHeroID(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "invalid heroId: " + err.Error(), + }) + return + } + + h.engine.StopCombat(heroID) + + hero, err := h.store.GetByID(r.Context(), heroID) + if err != nil { + h.logger.Error("admin: get hero for force-death", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to load hero", + }) + return + } + if hero == nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": "hero not found", + }) + return + } + + wasAlive := hero.State != model.StateDead && hero.HP > 0 + hero.HP = 0 + hero.State = model.StateDead + hero.Buffs = nil + hero.Debuffs = nil + if wasAlive { + hero.TotalDeaths++ + hero.KillsSinceDeath = 0 + } + + if err := h.store.Save(r.Context(), hero); err != nil { + h.logger.Error("admin: save hero after force-death", "hero_id", heroID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "failed to save hero", + }) + return + } + + h.engine.ApplyAdminHeroDeath(hero, wasAlive) + + h.logger.Info("admin: hero force-death", "hero_id", heroID, "was_alive", wasAlive) + hero.EnsureGearMap() + hero.RefreshDerivedCombatStats(time.Now()) + writeHeroJSON(w, http.StatusOK, hero) +} + // ResetHero resets a hero to fresh level 1 defaults. // POST /admin/heroes/{heroId}/reset func (h *AdminHandler) ResetHero(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 1997dc0..a53d0ae 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -82,6 +82,7 @@ func New(deps Deps) *chi.Mux { r.Post("/heroes/{heroId}/set-hp", adminH.SetHeroHP) r.Post("/heroes/{heroId}/add-potions", adminH.AddPotions) r.Post("/heroes/{heroId}/revive", adminH.ReviveHero) + r.Post("/heroes/{heroId}/force-death", adminH.ForceHeroDeath) r.Post("/heroes/{heroId}/reset", adminH.ResetHero) r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges) r.Post("/heroes/{heroId}/apply-buff", adminH.ApplyHeroBuff) diff --git a/frontend/src/ui/DeathScreen.tsx b/frontend/src/ui/DeathScreen.tsx index b9a8b86..f6b2fef 100644 --- a/frontend/src/ui/DeathScreen.tsx +++ b/frontend/src/ui/DeathScreen.tsx @@ -9,6 +9,7 @@ interface DeathScreenProps { revivesRemaining?: number; } +/** Full-screen dimming; `pointerEvents: 'none'` so HUD controls (e.g. hero sheet) stay clickable. */ const overlayStyle: CSSProperties = { position: 'absolute', top: 0, @@ -20,9 +21,18 @@ const overlayStyle: CSSProperties = { flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - gap: 24, zIndex: 100, transition: 'opacity 0.5s ease', + pointerEvents: 'none', +}; + +/** Death UI captures taps; overlay around it passes clicks through to the game HUD. */ +const deathPanelStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 24, + pointerEvents: 'auto', }; const titleStyle: CSSProperties = { @@ -88,33 +98,35 @@ export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreen return (