diff --git a/admin-web/index.html b/admin-web/index.html
index 20c39f3..cd75983 100644
--- a/admin-web/index.html
+++ b/admin-web/index.html
@@ -112,6 +112,10 @@
};
state._confirmAction = null;
+ /** Matches model.AllBuffTypes / AllDebuffTypes (admin manual apply). */
+ const ADMIN_BUFF_TYPES = ["rush", "rage", "shield", "luck", "resurrection", "heal", "power_potion", "war_cry"];
+ const ADMIN_DEBUFF_TYPES = ["poison", "freeze", "burn", "stun", "slow", "weaken", "ice_slow"];
+
function e(v) { return String(v ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); }
function authHeader() { return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`; }
function setMessage(text) { state.message = text; render(); }
@@ -1185,6 +1189,25 @@
async function claimQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}/claim`, { method: "POST", body: "{}" }); await loadHero(state.selectedHeroId); }
async function abandonQuest(questId) { await api(`heroes/${state.selectedHeroId}/quests/${questId}`, { method: "DELETE" }); await loadHero(state.selectedHeroId); }
+ async function applyHeroBuffAdmin() {
+ if (!state.selectedHeroId) { setMessage("Сначала выберите героя"); return; }
+ const buffType = document.getElementById("hero-admin-buff-type")?.value;
+ if (!buffType) { setMessage("Выберите тип баффа"); return; }
+ await withRowAction("hero-admin-buff", async () => {
+ await api(`heroes/${state.selectedHeroId}/apply-buff`, { method: "POST", body: JSON.stringify({ buffType }) });
+ await loadHero(state.selectedHeroId);
+ }, "Бафф применён");
+ }
+ async function applyHeroDebuffAdmin() {
+ if (!state.selectedHeroId) { setMessage("Сначала выберите героя"); return; }
+ const debuffType = document.getElementById("hero-admin-debuff-type")?.value;
+ if (!debuffType) { setMessage("Выберите тип дебаффа"); return; }
+ await withRowAction("hero-admin-debuff", async () => {
+ await api(`heroes/${state.selectedHeroId}/apply-debuff`, { method: "POST", body: JSON.stringify({ debuffType }) });
+ await loadHero(state.selectedHeroId);
+ }, "Дебафф применён");
+ }
+
function login() {
state.auth.username = document.getElementById("login-user").value.trim();
state.auth.password = document.getElementById("login-pass").value.trim();
@@ -1412,6 +1435,33 @@
Towns come from the loaded road graph (GET /admin/towns). Hero must be alive and not in combat.
+
+
Баффы / дебаффы (вручную)
+
Эффект из серверного каталога, без списания бесплатных зарядов. Только вне боя (как и прочие правки героя).
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${e(state.rowStatus["hero-admin-buff"]?.message || "")}
+
+ ${e(state.rowStatus["hero-admin-debuff"]?.message || "")}
+
+
` : `Select hero from list
`}
diff --git a/backend/internal/game/combat.go b/backend/internal/game/combat.go
index 72a7562..513d2ad 100644
--- a/backend/internal/game/combat.go
+++ b/backend/internal/game/combat.go
@@ -272,15 +272,15 @@ func tryApplyDebuff(hero *model.Hero, enemy *model.Enemy, now time.Time) string
continue
}
- applyDebuff(hero, rule.debuff, now)
+ ApplyDebuff(hero, rule.debuff, now)
return string(rule.debuff)
}
return ""
}
-// applyDebuff adds a debuff to the hero. If the same debuff type is already active, it refreshes.
-func applyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) {
+// ApplyDebuff adds a debuff to the hero. If the same debuff type is already active, it refreshes.
+func ApplyDebuff(hero *model.Hero, debuffType model.DebuffType, now time.Time) {
def, ok := model.DebuffDefinition(debuffType)
if !ok {
return
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 4ecf806..f77dd84 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -1312,6 +1312,149 @@ func (h *AdminHandler) ResetBuffCharges(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, hero)
}
+type applyBuffAdminRequest struct {
+ BuffType string `json:"buffType"`
+}
+
+// ApplyHeroBuff applies a buff from the catalog without consuming free-charge quota (admin/testing).
+// POST /admin/heroes/{heroId}/apply-buff
+func (h *AdminHandler) ApplyHeroBuff(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
+ }
+ if h.isHeroInCombat(w, heroID) {
+ return
+ }
+
+ var req applyBuffAdminRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "invalid request body: " + err.Error(),
+ })
+ return
+ }
+ bt, ok := model.ValidBuffType(req.BuffType)
+ if !ok {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "invalid buffType: " + req.BuffType,
+ })
+ return
+ }
+
+ hero, err := h.store.GetByID(r.Context(), heroID)
+ if err != nil {
+ h.logger.Error("admin: get hero for apply-buff", "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
+ }
+
+ now := time.Now()
+ if game.ApplyBuff(hero, bt, now) == nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "buff could not be applied (unknown catalog entry)",
+ })
+ return
+ }
+
+ if err := h.store.Save(r.Context(), hero); err != nil {
+ h.logger.Error("admin: save hero after apply-buff", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "failed to save hero",
+ })
+ return
+ }
+
+ h.logger.Info("admin: buff applied", "hero_id", heroID, "buff_type", bt)
+ hero.EnsureGearMap()
+ hero.RefreshDerivedCombatStats(now)
+ h.engine.ApplyAdminHeroSnapshot(hero)
+ writeJSON(w, http.StatusOK, hero)
+}
+
+type applyDebuffAdminRequest struct {
+ DebuffType string `json:"debuffType"`
+}
+
+// ApplyHeroDebuff applies a debuff from the catalog (admin/testing).
+// POST /admin/heroes/{heroId}/apply-debuff
+func (h *AdminHandler) ApplyHeroDebuff(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
+ }
+ if h.isHeroInCombat(w, heroID) {
+ return
+ }
+
+ var req applyDebuffAdminRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "invalid request body: " + err.Error(),
+ })
+ return
+ }
+ dt, ok := model.ValidDebuffType(req.DebuffType)
+ if !ok {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "invalid debuffType: " + req.DebuffType,
+ })
+ return
+ }
+
+ hero, err := h.store.GetByID(r.Context(), heroID)
+ if err != nil {
+ h.logger.Error("admin: get hero for apply-debuff", "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
+ }
+
+ now := time.Now()
+ if _, defOk := model.DebuffDefinition(dt); !defOk {
+ writeJSON(w, http.StatusBadRequest, map[string]string{
+ "error": "debuff not in catalog: " + req.DebuffType,
+ })
+ return
+ }
+ game.ApplyDebuff(hero, dt, now)
+
+ if err := h.store.Save(r.Context(), hero); err != nil {
+ h.logger.Error("admin: save hero after apply-debuff", "hero_id", heroID, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "failed to save hero",
+ })
+ return
+ }
+
+ h.logger.Info("admin: debuff applied", "hero_id", heroID, "debuff_type", dt)
+ hero.EnsureGearMap()
+ hero.RefreshDerivedCombatStats(now)
+ h.engine.ApplyAdminHeroSnapshot(hero)
+ writeJSON(w, http.StatusOK, hero)
+}
+
// DeleteHero permanently removes a hero from the database.
// DELETE /admin/heroes/{heroId}
func (h *AdminHandler) DeleteHero(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 103710b..1997dc0 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -84,6 +84,8 @@ func New(deps Deps) *chi.Mux {
r.Post("/heroes/{heroId}/revive", adminH.ReviveHero)
r.Post("/heroes/{heroId}/reset", adminH.ResetHero)
r.Post("/heroes/{heroId}/reset-buff-charges", adminH.ResetBuffCharges)
+ r.Post("/heroes/{heroId}/apply-buff", adminH.ApplyHeroBuff)
+ r.Post("/heroes/{heroId}/apply-debuff", adminH.ApplyHeroDebuff)
r.Post("/heroes/{heroId}/teleport-town", adminH.TeleportHeroTown)
r.Post("/heroes/{heroId}/start-rest", adminH.StartHeroRest)
r.Post("/heroes/{heroId}/start-roadside-rest", adminH.StartHeroRoadsideRest)