From 62917c04527cc50c1756d3e91729f3485f641ed5 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:04:33 +0200 Subject: [PATCH] Keep war battles lethal for nonlethal attackers A nonlethal attacker's killing blow knocked a battle participant out instead of killing them, so SimpleFactions never saw the death: no battle life was spent and the respawn routing did not run. Skip the knockout for players in a started battle, the same roster check that already exempts battle deaths from permadeath and strikes. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- .../rpcharacters/pvp/PvpKnockoutManager.java | 9 +++- .../pvp/PvpKnockoutManagerTest.java | 45 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 91f6ab1e..4f6f2d91 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **Progression and rolls** — bring professions, attributes, and dice rolls into character gameplay. - **Injuries and recovery** — represent injuries and prosthetics, with related treatment and progression systems. Healing injuries heal in real time, including while the player is offline. - **Consequences and investigation** — support lethal or nonlethal PvP, graves, and discoverable clues left in the world. -- **Nonlethal knockouts** — GSit holds downed players in a crawl pose for the knockout duration, alongside freeze and blindness. The pose uses GSit's API, bypasses command restrictions, respects other plugins' crawl vetoes, and releases only knockout-created crawls on recovery. GSit is optional for the rest of RPCharacters; without it, knockouts retain freeze and blindness only. While downed, a player can only be finished by another player in lethal mode, a mob, `/kill`, the void or the world border: falls, drowning, fire and other damage with no attacker leave them at half a heart, and the fall distance built up by the freeze is cleared when they get up. +- **Nonlethal knockouts** — GSit holds downed players in a crawl pose for the knockout duration, alongside freeze and blindness. The pose uses GSit's API, bypasses command restrictions, respects other plugins' crawl vetoes, and releases only knockout-created crawls on recovery. GSit is optional for the rest of RPCharacters; without it, knockouts retain freeze and blindness only. While downed, a player can only be finished by another player in lethal mode, a mob, `/kill`, the void or the world border: falls, drowning, fire and other damage with no attacker leave them at half a heart, and the fall distance built up by the freeze is cleared when they get up. Knockouts never apply to players in a started SimpleFactions battle, so the battle records the death and routes the respawn. - **PvP strikes** — after `/pvp start`, the fight lasts 15 minutes unless the player who started it runs `/pvp end`. When it ends, players who have not died see a title that a new RP interaction is needed. Whoever kills or knocks someone out chooses to spare them or give a strike; the third strike kills the character, though a killer can wound or maim instead of killing. The same player must wait 24 hours by default before striking a character again (`strikes.same-target-cooldown-hours` in `pvp.yml`; 0 disables the wait), so at that default they cannot land all three strikes in a day. Lockpicking, robbing, pickpocketing and looting locked graves start a timed evil RP session, during which any strike kills and a death leaves an unlocked grave. - **Armour takes time** — putting armour on takes a while (by default 1 second for a helmet, 20 for a chestplate, 12 for leggings and 8 for boots). Pieces go on one at a time and the player is slowed until they're done. Taking or dealing damage, sprinting, dying or logging out stops it. `/pvp start` takes off any armour that someone in range put on in the last 3 minutes, and nobody in the fight can put armour on until it ends unless they die. The times are set under `armour` in `pvp.yml`. Masks, heads and elytras go on at once. diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java index 3fb50acc..88e72b74 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java @@ -29,6 +29,7 @@ import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.permadeath.PermadeathBattleExemption; import net.tfminecraft.rpcharacters.permadeath.PermadeathService; public final class PvpKnockoutManager implements Listener { @@ -82,10 +83,10 @@ public void onLethalDamage(EntityDamageEvent event) { if (!(event.getEntity() instanceof Player player)) { return; } - if (shouldSkipKnockout(player)) { + if (player.getHealth() - event.getFinalDamage() > 0) { return; } - if (player.getHealth() - event.getFinalDamage() > 0) { + if (shouldSkipKnockout(player)) { return; } if (isKnockedOut(player) && downedPlayerSurvives(event)) { @@ -233,6 +234,10 @@ private boolean shouldSkipKnockout(Player player) { if (CreationManager.activeCreators.containsKey(player)) { return true; } + // SimpleFactions counts battle lives and routes respawns from the death, so battles stay lethal. + if (PermadeathBattleExemption.isInStartedBattle(player)) { + return true; + } return false; } diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java index df32dfe6..3e72695c 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java @@ -29,6 +29,7 @@ import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.rpcharacters.permadeath.PermadeathBattleExemption; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -99,6 +100,50 @@ void downedPlayerSurvivesDamageWithoutAttacker(String cause) throws Exception { verify(event).setCancelled(true); } + @Test void nonlethalBlowStillKillsInStartedBattle() { + Player attacker = player(), victim = knockoutPlayer(); + when(victim.getHealth()).thenReturn(2.0); + var event = mock(EntityDamageByEntityEvent.class); + when(event.getEntity()).thenReturn(victim); + when(event.getDamager()).thenReturn(attacker); + when(event.getFinalDamage()).thenReturn(4.0); + var manager = new PvpKnockoutManager(); + PlayerData nonlethal = data(false); + try (var players = mockStatic(PlayerManager.class); var strikes = mockStatic(PvpStrikeService.class)) { + players.when(() -> PlayerManager.get(attacker)).thenReturn(nonlethal); + // SimpleFactions only counts a battle life when the victim really dies. + PermadeathBattleExemption.set(p -> p == victim); + manager.onLethalDamage(event); + verify(event, never()).setCancelled(true); + verify(victim, never()).setHealth(anyDouble()); + strikes.verifyNoInteractions(); + PermadeathBattleExemption.set(null); + manager.onLethalDamage(event); + verify(event).setCancelled(true); + strikes.verify(() -> PvpStrikeService.handleKnockout(victim, attacker)); + } finally { + PermadeathBattleExemption.set(null); + } + } + + @Test void playerDownedBeforeBattleStartsCanStillDie() throws Exception { + Player victim = knockoutPlayer(); + when(victim.getHealth()).thenReturn(1.0); + var manager = new PvpKnockoutManager(); + var event = mock(EntityDamageEvent.class); + when(event.getEntity()).thenReturn(victim); + when(event.getCause()).thenReturn(EntityDamageEvent.DamageCause.FALL); + when(event.getFinalDamage()).thenReturn(4.0); + invoke(manager, "applyKnockout", victim); + try { + PermadeathBattleExemption.set(p -> p == victim); + manager.onLethalDamage(event); + verify(event, never()).setCancelled(true); + } finally { + PermadeathBattleExemption.set(null); + } + } + @ParameterizedTest @ValueSource(strings = {"KILL", "VOID", "WORLD_BORDER"}) void killCommandVoidAndBorderStillKillDownedPlayer(String cause) {