diff --git a/src/main/java/fr/quatrevieux/araknemu/game/fight/castable/effect/handler/heal/HealOnAttackHandler.java b/src/main/java/fr/quatrevieux/araknemu/game/fight/castable/effect/handler/heal/HealOnAttackHandler.java
new file mode 100644
index 000000000..e6b39231d
--- /dev/null
+++ b/src/main/java/fr/quatrevieux/araknemu/game/fight/castable/effect/handler/heal/HealOnAttackHandler.java
@@ -0,0 +1,59 @@
+/*
+ * This file is part of Araknemu.
+ *
+ * Araknemu is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Araknemu is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Araknemu. If not, see .
+ *
+ * Copyright (c) 2017-2022 Vincent Quatrevieux
+ */
+
+package fr.quatrevieux.araknemu.game.fight.castable.effect.handler.heal;
+
+import fr.quatrevieux.araknemu.game.fight.castable.CastScope;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.buff.Buff;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.buff.BuffHook;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.EffectHandler;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.damage.Damage;
+import fr.quatrevieux.araknemu.game.fight.fighter.ActiveFighter;
+import fr.quatrevieux.araknemu.game.fight.fighter.PassiveFighter;
+import fr.quatrevieux.araknemu.util.Asserter;
+
+/**
+ * Heal the attacker by returning suffered damage as heal
+ * The return factor is configured by the "special" effect parameter (i.e. third parameter) in percent
+ *
+ * Note: only direct damage are taken in account
+ */
+public final class HealOnAttackHandler implements EffectHandler, BuffHook {
+ @Override
+ public void handle(CastScope cast, CastScope.EffectScope effect) {
+ throw new UnsupportedOperationException("Heal on attack effect must be used as a buff");
+ }
+
+ @Override
+ public void buff(CastScope cast, CastScope.EffectScope effect) {
+ for (PassiveFighter target : effect.targets()) {
+ target.buffs().add(new Buff(effect.effect(), cast.action(), cast.caster(), target, this));
+ }
+ }
+
+ @Override
+ public void onDirectDamage(Buff buff, ActiveFighter caster, Damage value) {
+ final int percent = Asserter.assertPercent(buff.effect().special());
+ final int heal = value.value() * percent / 100;
+
+ if (heal > 0) {
+ caster.life().alter(buff.target(), heal);
+ }
+ }
+}
diff --git a/src/main/java/fr/quatrevieux/araknemu/game/fight/module/CommonEffectsModule.java b/src/main/java/fr/quatrevieux/araknemu/game/fight/module/CommonEffectsModule.java
index 670f17ef3..5e485c3b1 100644
--- a/src/main/java/fr/quatrevieux/araknemu/game/fight/module/CommonEffectsModule.java
+++ b/src/main/java/fr/quatrevieux/araknemu/game/fight/module/CommonEffectsModule.java
@@ -59,6 +59,7 @@
import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.heal.FixedHealHandler;
import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.heal.GivePercentLifeHandler;
import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.heal.HealHandler;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.heal.HealOnAttackHandler;
import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.heal.HealOnDamageHandler;
import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.misc.ChangeAppearanceHandler;
import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.misc.DispelHandler;
@@ -135,6 +136,7 @@ public void effects(EffectsHandler handler) {
handler.register(90, new GivePercentLifeHandler());
handler.register(108, new HealHandler());
handler.register(143, new FixedHealHandler());
+ handler.register(786, new HealOnAttackHandler());
handler.register(140, new SkipTurnHandler(fight));
handler.register(149, new ChangeAppearanceHandler(fight));
diff --git a/src/test/java/fr/quatrevieux/araknemu/game/GameDataSet.java b/src/test/java/fr/quatrevieux/araknemu/game/GameDataSet.java
index 59ee7c114..258a29874 100644
--- a/src/test/java/fr/quatrevieux/araknemu/game/GameDataSet.java
+++ b/src/test/java/fr/quatrevieux/araknemu/game/GameDataSet.java
@@ -518,6 +518,7 @@ public GameDataSet pushFunctionalSpells() throws SQLException, ContainerExceptio
"(155, 'Vitalité ', 801, '10,1,1', '125,81,90,,20,0,1d10+80|125,110,,,20,0,0d0+110|4|0|1|45|100|true|false|false|false|0|0|0|5|PaPa||18;19;3;1;41|36|false', '125,91,110,,20,0,1d20+90|125,130,,,20,0,0d0+130|4|0|1|45|100|true|false|false|false|0|0|0|5|PaPa||18;19;3;1;41|36|false', '125,101,130,,20,0,1d30+100|125,150,,,20,0,0d0+150|4|0|1|45|100|true|false|false|false|0|0|0|5|PaPa||18;19;3;1;41|36|false', '125,121,150,,20,0,1d30+120|125,175,,,20,0,0d0+175|4|0|1|45|100|true|false|false|false|0|0|0|5|PaPa||18;19;3;1;41|36|false', '125,151,180,,20,0,1d30+150|125,200,,,20,0,0d0+200|4|0|1|45|100|true|false|false|false|0|0|0|5|PaPa||18;19;3;1;41|36|false', '125,251,300,,20,0,1d50+250|125,350,,,20,0,0d0+350|3|0|1|45|100|true|false|false|false|0|0|0|5|PaPa||18;19;3;1;41|136|false', '')",
"(1723, 'Spajuste', -1, '0,0,0', '268,8,12,,4,0,1d5+7|268,14,,,4,0,0d0+14|2|1|3|40|100|false|true|false|true|0|0|1|0|PaPa||18;19;3;1;41|30|false', '268,10,14,,4,0,1d5+9|268,16,,,4,0,0d0+16|2|1|3|40|100|false|true|false|true|0|0|1|0|PaPa||18;19;3;1;41|30|false', '268,12,16,,4,0,1d5+11|268,18,,,4,0,0d0+18|2|1|4|40|100|false|true|false|true|0|0|1|0|PaPa||18;19;3;1;41|30|false', '268,14,18,,4,0,1d5+13|268,20,,,4,0,0d0+20|2|1|4|40|100|false|true|false|true|0|0|1|0|PaPa||18;19;3;1;41|30|false', '268,16,20,,4,0,1d5+15|268,22,,,4,0,0d0+22|2|1|5|40|100|false|true|false|true|0|0|1|0|PaPa||18;19;3;1;41|30|false', '268,21,25,,4,0,1d5+20|268,30,,,4,0,0d0+30|2|1|6|35|100|false|true|false|true|0|0|1|0|PaPa||18;19;3;1;41|30|false', '')",
"(171, 'Flèche Punitive', 912, '51,2,1', '97,15,17,,0,0,1d3+14;293,171,,16,3,0|97,20,22,,0,0,1d3+19;293,171,,21,3,0|4|6|8|30|100|false|true|false|true|0|0|0|2|PaPaPaPa||18;19;3;1;41|31|false', '97,17,19,,0,0,1d3+16;293,171,,18,2,0|97,22,24,,0,0,1d3+21;293,171,,23,2,0|4|6|8|30|100|false|true|false|true|0|0|0|2|PaPaPaPa||18;19;3;1;41|31|false', '97,19,21,,0,0,1d3+18;293,171,,20,2,0|97,23,25,,0,0,1d3+22;293,171,,24,2,0|4|6|8|30|100|false|true|false|true|0|0|0|2|PaPaPaPa||18;19;3;1;41|31|false', '97,23,25,,0,0,1d3+22;293,171,,24,2,0|97,29,31,,0,0,1d3+28;293,171,,30,2,0|4|6|8|30|100|false|true|false|true|0|0|0|2|PaPaPaPa||18;19;3;1;41|31|false', '97,25,27,,0,0,1d3+24;293,171,,26,2,0|97,30,32,,0,0,1d3+29;293,171,,31,2,0|4|6|8|30|100|false|true|false|true|0|0|0|2|PaPaPaPa||18;19;3;1;41|31|false', '97,31,33,,0,0,1d3+30;293,171,,32,2,0|97,37,39,,0,0,1d3+36;293,171,,38,2,0|4|6|8|30|100|false|true|false|true|0|0|0|2|PaPaPaPa||18;19;3;1;41|131|false', '0;32')",
+ "(1687, 'Soin Sylvestre', 0, '0,-1,0', '786,,,100,1,0||6|0|0|0|0|false|true|false|false|0|1|0|0|Pa||18;19;3;1;41|0|false', '786,,,100,1,0||6|0|0|0|0|false|true|false|false|0|1|0|0|Pa||18;19;3;1;41|0|false', '786,,,100,1,0||6|0|0|0|0|false|true|false|false|0|1|0|0|Pa||18;19;3;1;41|0|false', '786,,,100,1,0||6|0|0|0|0|false|true|false|false|0|1|0|0|Pa||18;19;3;1;41|0|false', '786,,,100,1,0||6|0|0|0|0|false|true|false|false|0|1|0|0|Pa||18;19;3;1;41|0|false', '786,,,100,1,0||6|0|0|0|0|false|true|false|false|0|1|0|0|Pa||18;19;3;1;41|0|false', '')",
}, ",") + ";"
);
diff --git a/src/test/java/fr/quatrevieux/araknemu/game/fight/castable/effect/FunctionalTest.java b/src/test/java/fr/quatrevieux/araknemu/game/fight/castable/effect/FunctionalTest.java
index 5943f9596..fa21da9d9 100644
--- a/src/test/java/fr/quatrevieux/araknemu/game/fight/castable/effect/FunctionalTest.java
+++ b/src/test/java/fr/quatrevieux/araknemu/game/fight/castable/effect/FunctionalTest.java
@@ -1031,6 +1031,22 @@ void boostSpellDamage() {
assertBetween(102, 106, damage);
}
+ @Test
+ void healOnAttack() {
+ castNormal(1687, fighter1.cell()); // Soin Sylvestre
+ fighter2.life().alter(fighter2, -20);
+ fighter1.turn().stop();
+
+ int lastLife = fighter2.life().current();
+
+ castNormal(183, fighter1.cell()); // Simple attack
+
+ int damage = fighter1.life().max() - fighter1.life().current();
+
+ assertEquals(damage, fighter2.life().current() - lastLife);
+ requestStack.assertOne(ActionEffect.alterLifePoints(fighter1, fighter2, damage));
+ }
+
private List configureFight(Consumer configurator) {
fight.cancel(true);
diff --git a/src/test/java/fr/quatrevieux/araknemu/game/fight/castable/effect/handler/heal/HealOnAttackHandlerTest.java b/src/test/java/fr/quatrevieux/araknemu/game/fight/castable/effect/handler/heal/HealOnAttackHandlerTest.java
new file mode 100644
index 000000000..0ffb1d3cf
--- /dev/null
+++ b/src/test/java/fr/quatrevieux/araknemu/game/fight/castable/effect/handler/heal/HealOnAttackHandlerTest.java
@@ -0,0 +1,232 @@
+/*
+ * This file is part of Araknemu.
+ *
+ * Araknemu is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Araknemu is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Araknemu. If not, see .
+ *
+ * Copyright (c) 2017-2022 Vincent Quatrevieux
+ */
+
+package fr.quatrevieux.araknemu.game.fight.castable.effect.handler.heal;
+
+import fr.quatrevieux.araknemu.data.constant.Characteristic;
+import fr.quatrevieux.araknemu.data.value.EffectArea;
+import fr.quatrevieux.araknemu.game.fight.Fight;
+import fr.quatrevieux.araknemu.game.fight.FightBaseCase;
+import fr.quatrevieux.araknemu.game.fight.castable.CastScope;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.Element;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.buff.Buff;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.buff.BuffHook;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.damage.Damage;
+import fr.quatrevieux.araknemu.game.fight.castable.effect.handler.damage.DamageApplier;
+import fr.quatrevieux.araknemu.game.fight.fighter.player.PlayerFighter;
+import fr.quatrevieux.araknemu.game.spell.Spell;
+import fr.quatrevieux.araknemu.game.spell.SpellConstraints;
+import fr.quatrevieux.araknemu.game.spell.effect.SpellEffect;
+import fr.quatrevieux.araknemu.game.spell.effect.area.CellArea;
+import fr.quatrevieux.araknemu.game.spell.effect.area.CircleArea;
+import fr.quatrevieux.araknemu.game.spell.effect.target.SpellEffectTarget;
+import fr.quatrevieux.araknemu.network.game.out.fight.action.ActionEffect;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class HealOnAttackHandlerTest extends FightBaseCase {
+ private Fight fight;
+ private PlayerFighter caster;
+ private PlayerFighter target;
+ private HealOnAttackHandler handler;
+ private int lastCasterLife;
+
+ @Override
+ @BeforeEach
+ public void setUp() throws Exception {
+ super.setUp();
+
+ fight = createFight();
+ fight.nextState();
+
+ caster = player.fighter();
+ target = other.fighter();
+
+ target.move(fight.map().get(123));
+ caster.life().alter(caster, -30);
+ lastCasterLife = caster.life().current();
+
+ handler = new HealOnAttackHandler();
+
+ player.properties().characteristics().base().set(Characteristic.INTELLIGENCE, 0);
+
+ requestStack.clear();
+ }
+
+ @Test
+ void handle() {
+ SpellEffect effect = Mockito.mock(SpellEffect.class);
+ Spell spell = Mockito.mock(Spell.class);
+ SpellConstraints constraints = Mockito.mock(SpellConstraints.class);
+
+ Mockito.when(effect.area()).thenReturn(new CellArea());
+ Mockito.when(effect.target()).thenReturn(SpellEffectTarget.DEFAULT);
+ Mockito.when(spell.constraints()).thenReturn(constraints);
+ Mockito.when(constraints.freeCell()).thenReturn(false);
+
+ CastScope scope = makeCastScope(caster, spell, effect, caster.cell());
+ assertThrows(UnsupportedOperationException.class, () -> handler.handle(scope, scope.effects().get(0)));
+ }
+
+ @Test
+ void buffWillAddBuffToList() {
+ SpellEffect effect = Mockito.mock(SpellEffect.class);
+ Spell spell = Mockito.mock(Spell.class);
+ SpellConstraints constraints = Mockito.mock(SpellConstraints.class);
+
+ Mockito.when(effect.min()).thenReturn(10);
+ Mockito.when(effect.area()).thenReturn(new CellArea());
+ Mockito.when(effect.target()).thenReturn(SpellEffectTarget.DEFAULT);
+ Mockito.when(effect.duration()).thenReturn(5);
+ Mockito.when(spell.constraints()).thenReturn(constraints);
+ Mockito.when(constraints.freeCell()).thenReturn(false);
+
+ CastScope scope = makeCastScope(caster, spell, effect, target.cell());
+ handler.buff(scope, scope.effects().get(0));
+
+ Optional found = target.buffs().stream().filter(buff -> buff.effect().equals(effect)).findFirst();
+
+ assertTrue(found.isPresent());
+ assertEquals(caster, found.get().caster());
+ assertEquals(target, found.get().target());
+ assertEquals(effect, found.get().effect());
+ assertEquals(spell, found.get().action());
+ assertEquals(handler, found.get().hook());
+ assertEquals(5, found.get().remainingTurns());
+ }
+
+ @Test
+ void buffWithAreaMultipleFighters() {
+ SpellEffect effect = Mockito.mock(SpellEffect.class);
+ Spell spell = Mockito.mock(Spell.class);
+ SpellConstraints constraints = Mockito.mock(SpellConstraints.class);
+
+ Mockito.when(effect.min()).thenReturn(10);
+ Mockito.when(effect.area()).thenReturn(new CircleArea(new EffectArea(EffectArea.Type.CIRCLE, 20)));
+ Mockito.when(effect.target()).thenReturn(SpellEffectTarget.DEFAULT);
+ Mockito.when(spell.constraints()).thenReturn(constraints);
+ Mockito.when(constraints.freeCell()).thenReturn(false);
+
+ CastScope scope = makeCastScope(caster, spell, effect, fight.map().get(122));
+ handler.buff(scope, scope.effects().get(0));
+
+ assertTrue(caster.buffs().stream().anyMatch(buff -> buff.effect().equals(effect)));
+ assertTrue(target.buffs().stream().anyMatch(buff -> buff.effect().equals(effect)));
+ }
+
+ @Test
+ void onDirectDamage() {
+ SpellEffect effect = Mockito.mock(SpellEffect.class);
+ Spell spell = Mockito.mock(Spell.class);
+ SpellConstraints constraints = Mockito.mock(SpellConstraints.class);
+
+ Mockito.when(effect.special()).thenReturn(100);
+ Mockito.when(effect.area()).thenReturn(new CellArea());
+ Mockito.when(effect.target()).thenReturn(SpellEffectTarget.DEFAULT);
+ Mockito.when(spell.constraints()).thenReturn(constraints);
+ Mockito.when(constraints.freeCell()).thenReturn(false);
+
+ CastScope scope = makeCastScope(caster, spell, effect, target.cell());
+ handler.buff(scope, scope.effects().get(0));
+
+ new DamageApplier(Element.WATER, fight).applyFixed(caster, 10, target);
+
+ assertEquals(10, computeHeal());
+
+ requestStack.assertOne(ActionEffect.alterLifePoints(target, caster, computeHeal()));
+ }
+
+ @Test
+ void onDirectDamageTransformedToHealShouldBeIgnored() {
+ SpellEffect effect = Mockito.mock(SpellEffect.class);
+ Spell spell = Mockito.mock(Spell.class);
+ SpellConstraints constraints = Mockito.mock(SpellConstraints.class);
+
+ Mockito.when(effect.special()).thenReturn(100);
+ Mockito.when(effect.area()).thenReturn(new CellArea());
+ Mockito.when(effect.target()).thenReturn(SpellEffectTarget.DEFAULT);
+ Mockito.when(spell.constraints()).thenReturn(constraints);
+ Mockito.when(constraints.freeCell()).thenReturn(false);
+
+ target.buffs().add(new Buff(effect, spell, target, target, new BuffHook() {
+ @Override
+ public void onDamage(Buff buff, Damage value) {
+ value.multiply(-1); // Transform damage to heal
+ }
+ }));
+
+ CastScope scope = makeCastScope(caster, spell, effect, target.cell());
+ handler.buff(scope, scope.effects().get(0));
+
+ new DamageApplier(Element.WATER, fight).applyFixed(caster, 10, target);
+
+ assertEquals(0, computeHeal());
+ }
+
+ @Test
+ void onDirectDamageNot100Percent() {
+ SpellEffect effect = Mockito.mock(SpellEffect.class);
+ Spell spell = Mockito.mock(Spell.class);
+ SpellConstraints constraints = Mockito.mock(SpellConstraints.class);
+
+ Mockito.when(effect.special()).thenReturn(50);
+ Mockito.when(effect.area()).thenReturn(new CellArea());
+ Mockito.when(effect.target()).thenReturn(SpellEffectTarget.DEFAULT);
+ Mockito.when(spell.constraints()).thenReturn(constraints);
+ Mockito.when(constraints.freeCell()).thenReturn(false);
+
+ CastScope scope = makeCastScope(caster, spell, effect, target.cell());
+ handler.buff(scope, scope.effects().get(0));
+
+ new DamageApplier(Element.WATER, fight).applyFixed(caster, 10, target);
+
+ assertEquals(5, computeHeal());
+
+ requestStack.assertOne(ActionEffect.alterLifePoints(target, caster, computeHeal()));
+ }
+
+ @Test
+ void onIndirectDamageShouldBeIgnored() {
+ SpellEffect effect = Mockito.mock(SpellEffect.class);
+ Spell spell = Mockito.mock(Spell.class);
+ SpellConstraints constraints = Mockito.mock(SpellConstraints.class);
+
+ Mockito.when(effect.special()).thenReturn(100);
+ Mockito.when(effect.area()).thenReturn(new CellArea());
+ Mockito.when(effect.target()).thenReturn(SpellEffectTarget.DEFAULT);
+ Mockito.when(spell.constraints()).thenReturn(constraints);
+ Mockito.when(constraints.freeCell()).thenReturn(false);
+
+ CastScope scope = makeCastScope(caster, spell, effect, target.cell());
+ handler.buff(scope, scope.effects().get(0));
+
+ new DamageApplier(Element.WATER, fight).applyIndirectFixed(caster, 10, target);
+
+ assertEquals(0, computeHeal());
+ }
+
+ private int computeHeal() {
+ return caster.life().current() - lastCasterLife;
+ }
+}