285 lines
12 KiB
Java
285 lines
12 KiB
Java
package tc.oc.pgm.tracker.trackers;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.Map;
|
|
import javax.annotation.Nullable;
|
|
import javax.inject.Inject;
|
|
|
|
import net.md_5.bungee.api.chat.TranslatableComponent;
|
|
import org.bukkit.GameMode;
|
|
import org.bukkit.Location;
|
|
import org.bukkit.Material;
|
|
import org.bukkit.block.Block;
|
|
import org.bukkit.entity.LivingEntity;
|
|
import org.bukkit.entity.Player;
|
|
import org.bukkit.event.EventHandler;
|
|
import org.bukkit.event.EventPriority;
|
|
import org.bukkit.event.Listener;
|
|
import org.bukkit.event.entity.EntityDamageByBlockEvent;
|
|
import org.bukkit.event.entity.EntityDamageEvent;
|
|
import org.bukkit.event.entity.PlayerDeathEvent;
|
|
import org.bukkit.event.player.PlayerQuitEvent;
|
|
import org.bukkit.inventory.ItemStack;
|
|
import org.bukkit.potion.PotionEffect;
|
|
import org.bukkit.potion.PotionEffectType;
|
|
import java.time.Duration;
|
|
import java.time.Instant;
|
|
import tc.oc.commons.bukkit.util.Materials;
|
|
import tc.oc.pgm.events.ListenerScope;
|
|
import tc.oc.pgm.events.PlayerParticipationStopEvent;
|
|
import tc.oc.pgm.match.Match;
|
|
import tc.oc.pgm.match.MatchPlayer;
|
|
import tc.oc.pgm.match.MatchScope;
|
|
import tc.oc.pgm.match.Matches;
|
|
|
|
/**
|
|
* Predicts the death of players who disconnect while participating, and simulates the
|
|
* damage and death events that would have been fired if they had stayed in the game.
|
|
*
|
|
* Also prevents team switching while in imminent danger..
|
|
*/
|
|
@ListenerScope(MatchScope.RUNNING)
|
|
public class CombatLogTracker implements Listener {
|
|
// Logout within this time since last damage is considered combat log
|
|
private static final Duration RECENT_DAMAGE_THRESHOLD = Duration.ofSeconds(3);
|
|
|
|
// Maximum height player can fall without taking damage
|
|
private static final double SAFE_FALL_DISTANCE = 2;
|
|
|
|
// Minimum water required to stop the player's fall
|
|
private static final int BREAK_FALL_WATER_DEPTH = 3;
|
|
|
|
private final Match match;
|
|
|
|
private static class Damage {
|
|
public final Instant time;
|
|
public final EntityDamageEvent event;
|
|
|
|
private Damage(Instant time, EntityDamageEvent event) {
|
|
this.time = time;
|
|
this.event = event;
|
|
}
|
|
}
|
|
|
|
private static class ImminentDeath {
|
|
public final EntityDamageEvent.DamageCause cause; // what will cause the death
|
|
public final Location deathLocation;
|
|
public final Block blockDamager;
|
|
public final boolean alreadyDamaged; // if the player has already been damaged by this cause
|
|
|
|
private ImminentDeath(EntityDamageEvent.DamageCause cause, Location deathLocation, @Nullable Block blockDamager, boolean damaged) {
|
|
this.cause = cause;
|
|
this.deathLocation = deathLocation;
|
|
this.blockDamager = blockDamager;
|
|
this.alreadyDamaged = damaged;
|
|
}
|
|
}
|
|
|
|
private Map<Player, Damage> recentDamage = new HashMap<>();
|
|
|
|
@Inject CombatLogTracker(Match match) {
|
|
this.match = match;
|
|
}
|
|
|
|
private static boolean hasFireResistance(LivingEntity entity) {
|
|
for(PotionEffect effect : entity.getActivePotionEffects()) {
|
|
if(PotionEffectType.FIRE_RESISTANCE.equals(effect.getType())) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static double getResistanceFactor(LivingEntity entity) {
|
|
int amplifier = 0;
|
|
for(PotionEffect effect : entity.getActivePotionEffects()) {
|
|
if(PotionEffectType.DAMAGE_RESISTANCE.equals(effect.getType()) && effect.getAmplifier() > amplifier) {
|
|
amplifier = effect.getAmplifier();
|
|
}
|
|
}
|
|
return 1d - (amplifier / 5d);
|
|
}
|
|
|
|
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
|
public void onPlayerDamage(EntityDamageEvent event) {
|
|
if(event.getDamage() <= 0) return;
|
|
|
|
if(!(event.getEntity() instanceof Player)) return;
|
|
Player player = (Player) event.getEntity();
|
|
|
|
if(player.getGameMode() == GameMode.CREATIVE) return;
|
|
|
|
if(player.isDead()) return;
|
|
|
|
if(player.getNoDamageTicks() > 0) return;
|
|
|
|
if(getResistanceFactor(player) <= 0) return;
|
|
|
|
switch(event.getCause()) {
|
|
case ENTITY_EXPLOSION:
|
|
case BLOCK_EXPLOSION:
|
|
case CUSTOM:
|
|
case FALL:
|
|
case FALLING_BLOCK:
|
|
case LIGHTNING:
|
|
case MELTING:
|
|
case SUICIDE:
|
|
case THORNS:
|
|
return; // Skip damage causes that are not particularly likely to be followed by more damage
|
|
|
|
case FIRE:
|
|
case FIRE_TICK:
|
|
case LAVA:
|
|
if(hasFireResistance(player)) return;
|
|
break;
|
|
}
|
|
|
|
// Record the player's damage with a timestamp
|
|
this.recentDamage.put(player, new Damage(Instant.now(), event));
|
|
}
|
|
|
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
|
public void onPlayerDeath(PlayerDeathEvent event) {
|
|
// Clear last damage when a player dies
|
|
this.recentDamage.remove(event.getEntity());
|
|
}
|
|
|
|
// This must be called BEFORE the listener that removes the player from the match
|
|
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
|
public void onQuit(PlayerQuitEvent event) {
|
|
Match match = Matches.get(event.getWorld());
|
|
if(match == null || !match.isRunning()) return;
|
|
|
|
MatchPlayer player = match.getPlayer(event.getPlayer());
|
|
if(player == null || !player.isParticipating()) return;
|
|
|
|
ImminentDeath imminentDeath = this.getImminentDeath(player.getBukkit());
|
|
if(imminentDeath == null) return;
|
|
|
|
if(!imminentDeath.alreadyDamaged) {
|
|
// Simulate the damage event that would have killed them,
|
|
// allowing the tracker to figure out the cause of death
|
|
EntityDamageEvent damageEvent;
|
|
if(imminentDeath.blockDamager != null) {
|
|
damageEvent = new EntityDamageByBlockEvent(imminentDeath.blockDamager, player.getBukkit(), imminentDeath.cause, player.getBukkit().getHealth());
|
|
} else {
|
|
damageEvent = new EntityDamageEvent(player.getBukkit(), imminentDeath.cause, player.getBukkit().getHealth());
|
|
}
|
|
match.callEvent(damageEvent);
|
|
|
|
// If the damage event was cancelled, don't simulate the kill
|
|
if(damageEvent.isCancelled()) return;
|
|
|
|
player.getBukkit().setLastDamageCause(damageEvent);
|
|
}
|
|
|
|
// Simulate the player's death. The tracker will assume the death was caused by the
|
|
// last damage event, which was either a real one or the fake one we generated above.
|
|
ArrayList<ItemStack> drops = new ArrayList<>();
|
|
for(ItemStack stack : player.getInventory().contents()) {
|
|
if(stack != null && stack.getType() != Material.AIR) drops.add(stack);
|
|
}
|
|
|
|
try {
|
|
currentDeathEvent = new PlayerDeathEvent(player.getBukkit(), drops, 0, player.getDisplayName() + " logged out to avoid death");
|
|
match.callEvent(currentDeathEvent);
|
|
} finally {
|
|
currentDeathEvent = null;
|
|
}
|
|
}
|
|
|
|
// A simple way to tag an event as a combat log, hacky but it works
|
|
private static @Nullable PlayerDeathEvent currentDeathEvent;
|
|
public static boolean isCombatLog(PlayerDeathEvent event) {
|
|
return event == currentDeathEvent;
|
|
}
|
|
|
|
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
|
public void onParticipationStop(PlayerParticipationStopEvent event) {
|
|
if(event.getMatch().isRunning() && this.getImminentDeath(event.getPlayer().getBukkit()) != null) {
|
|
event.setCancelled(true);
|
|
event.getPlayer().sendWarning(new TranslatableComponent("match.noCombatLog"), true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the cause of the player's imminent death, or null if they are not about to die
|
|
* NOTE: not idempotent, has the side effect of clearing the recentDamage cache
|
|
*/
|
|
public @Nullable ImminentDeath getImminentDeath(Player player) {
|
|
// If the player is already dead or in creative mode, we don't care
|
|
if(player.isDead() || player.getGameMode() == GameMode.CREATIVE) return null;
|
|
|
|
// If the player was on the ground, or is flying, or is able to fly, they are fine
|
|
if(!(player.isOnGround() || player.isFlying() || player.getAllowFlight())) {
|
|
// If the player is falling, detect an imminent falling death
|
|
double fallDistance = player.getFallDistance();
|
|
Block landingBlock = null;
|
|
int waterDepth = 0;
|
|
Location location = player.getLocation();
|
|
|
|
if(location.getY() > 256) {
|
|
// If player is above Y 256, assume they fell at least to there
|
|
fallDistance += location.getY() - 256;
|
|
location.setY(256);
|
|
}
|
|
|
|
// Search the blocks directly beneath the player until we find what they would have landed on
|
|
Block block = null;
|
|
for(; location.getY() >= 0; location.add(0, -1, 0)) {
|
|
block = location.getBlock();
|
|
if(block != null) {
|
|
landingBlock = block;
|
|
|
|
if(Materials.isWater(landingBlock.getType())) {
|
|
// If the player falls through water, reset fall distance and inc the water depth
|
|
fallDistance = -1;
|
|
waterDepth += 1;
|
|
|
|
// Break if they have fallen through enough water to stop falling
|
|
if(waterDepth >= BREAK_FALL_WATER_DEPTH) break;
|
|
} else {
|
|
// If the block is not water, reset the water depth
|
|
waterDepth = 0;
|
|
|
|
if(Materials.isColliding(landingBlock.getType()) || Materials.isLava(landingBlock.getType())) {
|
|
// Break if the player hits a solid block or lava
|
|
break;
|
|
} else if(landingBlock.getType() == Material.WEB) {
|
|
// If they hit web, reset their fall distance, but assume they keep falling
|
|
fallDistance = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
fallDistance += 1;
|
|
}
|
|
|
|
double resistanceFactor = getResistanceFactor(player);
|
|
boolean fireResistance = hasFireResistance(player);
|
|
|
|
// Now decide if the landing would have killed them
|
|
if(location.getBlockY() < 0) {
|
|
// The player would have fallen into the void
|
|
return new ImminentDeath(EntityDamageEvent.DamageCause.VOID, location, null, false);
|
|
} else if(landingBlock != null) {
|
|
if(Materials.isColliding(landingBlock.getType()) && player.getHealth() <= resistanceFactor * (fallDistance - SAFE_FALL_DISTANCE)) {
|
|
// The player would have landed on a solid block and taken enough fall damage to kill them
|
|
return new ImminentDeath(EntityDamageEvent.DamageCause.FALL, landingBlock.getLocation().add(0, 0.5, 0), null, false);
|
|
} else if (Materials.isLava(landingBlock.getType()) && resistanceFactor > 0 && !fireResistance) {
|
|
// The player would have landed in lava, and we give the lava the benefit of the doubt
|
|
return new ImminentDeath(EntityDamageEvent.DamageCause.LAVA, landingBlock.getLocation(), landingBlock, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we didn't predict a falling death, detect combat log due to recent damage
|
|
Damage damage = this.recentDamage.remove(player);
|
|
if(damage != null && damage.time.plus(RECENT_DAMAGE_THRESHOLD).isAfter(Instant.now())) {
|
|
// Player logged out too soon after taking damage
|
|
return new ImminentDeath(damage.event.getCause(), player.getLocation(), null, true);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|