ProjectAres/PGM/src/main/java/tc/oc/pgm/tracker/trackers/CombatLogTracker.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;
}
}