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.MONITOR, 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; } }