master
Denis Ranneft 1 month ago
parent dbcb0c5c77
commit 945458d345

@ -1422,6 +1422,7 @@
<button class="btn" onclick="withAction(() => heroAction('set-gold',{gold:Number(document.getElementById('hero-gold').value)}))">Set Gold</button>
<button class="btn" onclick="withAction(() => heroAction('set-level',{level:Number(document.getElementById('hero-level').value)}))">Set Level</button>
<button class="btn" onclick="withAction(() => heroAction('revive',{}))">Revive</button>
<button type="button" class="btn warn" onclick="withAction(() => heroAction('force-death',{}))" title="HP 0, state dead, ends combat; counts as a death if the hero was alive">Режим смерти</button>
<button class="btn" onclick="withAction(() => heroAction('start-rest',{}, true))" title="Town rest (same duration as normal town rest)">Start rest (town)</button>
<button class="btn" onclick="withAction(() => heroAction('start-roadside-rest',{}, true))" title="Roadside rest at current road position (not in excursion)">Start rest (roadside)</button>
<button class="btn" onclick="withAction(() => heroAction('stop-rest',{}, true))" title="Exit roadside or adventure-inline rest back to walking">Stop rest</button>

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

@ -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) {

@ -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)

@ -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,6 +98,7 @@ export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreen
return (
<div style={overlayStyle}>
<div style={deathPanelStyle}>
<div style={titleStyle}>{tr.youDied}</div>
<div style={timerStyle}>{canRevive ? timer : '—'}</div>
{revivesRemaining !== undefined && (
@ -117,5 +128,6 @@ export function DeathScreen({ visible, onRevive, revivesRemaining }: DeathScreen
{canRevive ? t(tr.autoReviveIn, { timer }) : tr.noFreeRevives}
</div>
</div>
</div>
);
}

Loading…
Cancel
Save