package tc.oc.pgm.destroyable;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.TranslatableComponent;
import org.bukkit.ChatColor;
import org.bukkit.FireworkEffect;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.entity.Firework;
import org.bukkit.material.MaterialData;
import org.bukkit.util.BlockVector;
import tc.oc.api.docs.virtual.MatchDoc;
import tc.oc.commons.bukkit.chat.NameStyle;
import tc.oc.commons.bukkit.util.BlockUtils;
import tc.oc.commons.bukkit.util.NMSHacks;
import tc.oc.commons.core.chat.Components;
import tc.oc.commons.core.util.DefaultMapAdapter;
import tc.oc.pgm.Config;
import tc.oc.pgm.PGM;
import tc.oc.pgm.blockdrops.BlockDrops;
import tc.oc.pgm.blockdrops.BlockDropsMatchModule;
import tc.oc.pgm.blockdrops.BlockDropsRuleSet;
import tc.oc.pgm.events.FeatureChangeEvent;
import tc.oc.pgm.fireworks.FireworkUtil;
import tc.oc.pgm.goals.IncrementalGoal;
import tc.oc.pgm.goals.ModeChangeGoal;
import tc.oc.pgm.goals.TouchableGoal;
import tc.oc.pgm.goals.events.GoalCompleteEvent;
import tc.oc.pgm.goals.events.GoalStatusChangeEvent;
import tc.oc.pgm.match.Competitor;
import tc.oc.pgm.match.Match;
import tc.oc.pgm.match.MatchPlayer;
import tc.oc.pgm.match.MatchPlayerState;
import tc.oc.pgm.match.ParticipantState;
import tc.oc.pgm.match.Parties;
import tc.oc.pgm.match.Party;
import tc.oc.pgm.regions.FiniteBlockRegion;
import tc.oc.pgm.teams.Team;
import tc.oc.pgm.utils.MaterialPattern;
import tc.oc.pgm.utils.Strings;
public class Destroyable extends TouchableGoal<DestroyableFactory> implements IncrementalGoal<DestroyableFactory>,
ModeChangeGoal<DestroyableFactory> {
// Block replacement rules in this ruleset will be used to calculate destroyable health
protected final BlockDropsRuleSet blockDropsRuleSet;
protected final FiniteBlockRegion blockRegion;
protected final Set<MaterialPattern> materialPatterns = new HashSet<>();
protected final Set<MaterialData> materials = new HashSet<>();
// The percentage of blocks that must be broken for the entire Destroyable to be destroyed.
protected double destructionRequired;
protected final Duration SPARK_COOLDOWN = Duration.ofMillis(75);
protected Instant lastSparkTime;
/**
* The maximum possible health that this Destroyable can have, which is the sum of the max health
* of each block. The max health of a block is the maximum number of breaks between any destroyable
* material and any non-destroyable material. Note that blocks are not necessarily at max health when
* the match starts. This value can change as the result of mode changes.
*/
protected int maxHealth;
// The current health of the Destroyable
protected int health;
/**
* Map of block -> material -> health i.e. the health level that each material represents
* for each block in the destroyable. For example, (1,2,3) -> Gold Block -> 3 means that
* when there is a gold block at (1,2,3), it will need to be broken three times to change
* into a block that is not a destroyable material.
*
* This map will have en entry for every destroyable material for every block. If there are
* no custom block replacement rules affecting this destroyable, this will be null;
*/
protected Map<BlockVector, Map<MaterialData, Integer>> blockMaterialHealth;
protected final List<DestroyableHealthChange> events = Lists.newArrayList();
protected ImmutableList<DestroyableContribution> contributions;
protected Iterable<Location> proximityLocations;
public Destroyable(DestroyableFactory definition, Match match) {
super(definition, match);
for(MaterialPattern pattern : definition.getMaterials()) {
addMaterials(pattern);
}
this.destructionRequired = definition.getDestructionRequired();
final FiniteBlockRegion.Factory regionFactory = new FiniteBlockRegion.Factory(match.getMapInfo().proto);
this.blockRegion = regionFactory.fromWorld(definition.getRegion(), match.getWorld(), this.materialPatterns);
if(this.blockRegion.blockVolume() == 0) {
match.getServer().getLogger().warning("No destroyable blocks found in destroyable " + this.getName());
}
this.blockDropsRuleSet = match.needMatchModule(BlockDropsMatchModule.class)
.getRuleSet()
.subsetAffecting(this.materials)
.subsetAffecting(this.blockRegion);
this.recalculateHealth();
}
// Remove @Nullable
@Override
public @Nonnull Team getOwner() {
return super.getOwner();
}
@Override
public boolean getDeferTouches() {
return true;
}
@Override
public BaseComponent getTouchMessage(@Nullable ParticipantState toucher, boolean self) {
if(toucher == null) {
return new TranslatableComponent("match.touch.destroyable.owner",
Components.blank(),
getComponentName(),
getOwner().getComponentName());
} else if(self) {
return new TranslatableComponent("match.touch.destroyable.owner.you",
Components.blank(),
getComponentName(),
getOwner().getComponentName());
} else {
return new TranslatableComponent("match.touch.destroyable.owner.toucher",
toucher.getStyledName(NameStyle.COLOR),
getComponentName(),
getOwner().getComponentName());
}
}
@Override
public Iterable<Location> getProximityLocations(ParticipantState player) {
if(proximityLocations == null) {
proximityLocations = Collections.singleton(getBlockRegion().getBounds().center().toLocation(getOwner().getMatch().getWorld()));
}
return proximityLocations;
}
void addMaterials(MaterialPattern pattern) {
materialPatterns.add(pattern);
if(pattern.dataMatters()) {
materials.add(pattern.getMaterialData());
} else {
// Hacky, but there is no other simple way to deal with block replacement
materials.addAll(NMSHacks.getBlockStates(pattern.getMaterial()));
}
}
/**
* Calculate maximum/current health
*/
protected void recalculateHealth() {
// We only need blockMaterialHealth if there are destroyable blocks that are
// replaced by other destroyable blocks when broken.
if(this.isAffectedByBlockReplacementRules()) {
this.blockMaterialHealth = new HashMap<>();
this.buildMaterialHealthMap();
} else {
this.blockMaterialHealth = null;
this.maxHealth = (int) this.blockRegion.blockVolume();
this.health = 0;
for(Block block : this.blockRegion.getBlocks(match.getWorld())) {
if(this.hasMaterial(block.getState().getData())) {
this.health++;
}
}
}
}
protected boolean isAffectedByBlockReplacementRules() {
if (this.blockDropsRuleSet.getRules().isEmpty()) {
return false;
}
for(Block block : this.blockRegion.getBlocks(match.getWorld())) {
for(MaterialData material : this.materials) {
BlockDrops drops = this.blockDropsRuleSet.getDrops(block.getState(), material);
if(drops != null && drops.replacement != null && this.hasMaterial(drops.replacement)) {
return true;
}
}
}
return false;
}
/**
* Used internally to break out of the below recursive algorithm when a cycle is detected
*/
protected final static class Indestructible extends Exception { }
protected void buildMaterialHealthMap() {
this.maxHealth = 0;
this.health = 0;
Set<MaterialData> visited = new HashSet<>();
try {
for(Block block : blockRegion.getBlocks(match.getWorld())) {
Map<MaterialData, Integer> materialHealthMap = new HashMap<>();
int blockMaxHealth = 0;
for(MaterialData material : this.materials) {
visited.clear();
int blockHealth = this.buildBlockMaterialHealthMap(block, material, materialHealthMap, visited);
if(blockHealth > blockMaxHealth) {
blockMaxHealth = blockHealth;
}
}
this.blockMaterialHealth.put(block.getLocation().toVector().toBlockVector(), materialHealthMap);
this.maxHealth += blockMaxHealth;
this.health += this.getBlockHealth(block.getState());
}
}
catch(Indestructible ex) {
this.health = this.maxHealth = Integer.MAX_VALUE;
PGM.get().getLogger().warning("Destroyable " + this.getName() + " is indestructible due to block replacement cycle");
}
}
protected int buildBlockMaterialHealthMap(Block block,
MaterialData material,
Map<MaterialData, Integer> materialHealthMap,
Set<MaterialData> visited) throws Indestructible {
if(!this.hasMaterial(material)) {
return 0;
}
Integer healthBoxed = materialHealthMap.get(material);
if(healthBoxed != null) {
return healthBoxed;
}
if(visited.contains(material)) {
throw new Indestructible();
}
visited.add(material);
int health = 1;
if(this.blockDropsRuleSet != null) {
BlockDrops drops = this.blockDropsRuleSet.getDrops(block.getState(), material);
if(drops != null && drops.replacement != null) {
health += this.buildBlockMaterialHealthMap(block, drops.replacement, materialHealthMap, visited);
}
}
materialHealthMap.put(material, health);
return health;
}
/**
* Return the number of breaks required to change the given block to a non-objective material
*/
public int getBlockHealth(BlockState blockState) {
if(!this.getBlockRegion().contains(blockState)) {
return 0;
}
if(this.blockMaterialHealth == null) {
return this.hasMaterial(blockState.getData()) ? 1 : 0;
} else {
Map<MaterialData, Integer> materialHealthMap = this.blockMaterialHealth.get(blockState.getLocation().toVector().toBlockVector());
if(materialHealthMap == null) {
return 0;
}
Integer health = materialHealthMap.get(blockState.getData());
return health == null ? 0 : health;
}
}
public int getBlockHealthChange(BlockState oldState, BlockState newState) {
return this.getBlockHealth(newState) - this.getBlockHealth(oldState);
}
/**
* Update the state of this Destroyable to reflect the given block being changed by the given player.
* @param oldState State of the block before the change
* @param newState State of the block after the change
* @param player Player responsible for the change
* @return An object containing information about the change, including the health delta,
* or null if this Destroyable was not affected by the block change
*/
public DestroyableHealthChange handleBlockChange(BlockState oldState, BlockState newState, @Nullable ParticipantState player) {
if(this.isDestroyed() || !this.getBlockRegion().contains(oldState)) return null;
int deltaHealth = this.getBlockHealthChange(oldState, newState);
if(deltaHealth == 0) return null;
this.addHealth(deltaHealth);
DestroyableHealthChange changeInfo = new DestroyableHealthChange(oldState, newState, player, deltaHealth);
this.events.add(changeInfo);
if(deltaHealth < 0) {
touch(player);
if(this.definition.hasSparks()) {
Location blockLocation = BlockUtils.center(oldState);
Instant now = Instant.now();
// Probability of a spark is time_since_last_spark / cooldown_time
float chance = this.lastSparkTime == null ? 1.0f : ((float) Duration.between(lastSparkTime, now).toMillis()) / (float) SPARK_COOLDOWN.toMillis();
if(this.match.getRandom().nextFloat() < chance) {
this.lastSparkTime = now;
// Spawn a firework where the block was
Firework firework = FireworkUtil.spawnFirework(blockLocation,
FireworkEffect.builder()
.with(FireworkEffect.Type.BURST)
.withFlicker()
.withColor(this.getOwner().getFullColor())
.build(),
0);
NMSHacks.skipFireworksLaunch(firework);
// Players more than 64m away will not see or hear the fireworks, so just play the sound for them
for(MatchPlayer listener : this.getOwner().getMatch().getPlayers()) {
if(listener.getBukkit().getLocation().distance(blockLocation) > 64) {
listener.getBukkit().playSound(listener.getBukkit().getLocation(), Sound.ENTITY_FIREWORK_BLAST, 0.75f, 1f);
listener.getBukkit().playSound(listener.getBukkit().getLocation(), Sound.ENTITY_FIREWORK_TWINKLE, 0.75f, 1f);
}
}
}
}
}
match.callEvent(new DestroyableHealthChangeEvent(this.getMatch(), this, changeInfo));
match.callEvent(new GoalStatusChangeEvent(this));
if(this.isDestroyed()) {
match.callEvent(new DestroyableDestroyedEvent(this.match, this));
match.callEvent(new GoalCompleteEvent(this,
true,
c -> false,
c -> !c.equals(getOwner()),
this.getContributions()));
}
return changeInfo;
}
@Override
protected void playTouchEffects(ParticipantState toucher) {
// We make our own touch sounds
}
/**
* Test if the given block change is allowed by this Destroyable
* @param oldState State of the block before the change
* @param newState State of the block after the change
* @param player Player responsible for the change
* @return A player-readable message explaining why the block change is not allowed, or null if it is allowed
*/
public String testBlockChange(BlockState oldState, BlockState newState, @Nullable ParticipantState player) {
if(this.isDestroyed() || !this.getBlockRegion().contains(oldState)) return null;
int deltaHealth = this.getBlockHealthChange(oldState, newState);
if(deltaHealth == 0) return null;
if(deltaHealth < 0) {
// Damage
if(player != null && player.getParty() == this.getOwner()) {
return "match.destroyable.damageOwn";
}
} else if(deltaHealth > 0) {
// Repair
if(player != null && player.getParty() != this.getOwner()) {
return "match.destroyable.repairOther";
} else if(!this.definition.isRepairable()) {
return "match.destroyable.repairDisabled";
}
}
return null;
}
public FiniteBlockRegion getBlockRegion() {
return this.blockRegion;
}
public boolean hasMaterial(MaterialData data) {
for(MaterialPattern material : materialPatterns) {
if(material.matches(data)) return true;
}
return false;
}
public void addHealth(int delta) {
this.health = Math.max(0, Math.min(this.maxHealth, this.health + delta));
}
public int getMaxHealth() {
return this.maxHealth;
}
public int getHealth() {
return this.health;
}
public float getHealthPercent() {
return (float) this.health / this.maxHealth;
}
public int getBreaks() {
return this.maxHealth - this.health;
}
@Override
public boolean isAffectedByModeChanges() {
return this.definition.hasModeChanges();
}
public double getDestructionRequired() {
return this.destructionRequired;
}
public String renderDestructionRequired() {
return Math.round(this.destructionRequired * 100) + "%";
}
private int getBreaksRequired(double destructionRequired) {
return (int) Math.round(this.maxHealth * destructionRequired);
}
public int getBreaksRequired() {
return this.getBreaksRequired(this.destructionRequired);
}
public void setDestructionRequired(double destructionRequired) {
if(this.destructionRequired != destructionRequired) {
if(this.getBreaks() >= this.getBreaksRequired(destructionRequired)) {
throw new IllegalArgumentException("Destroyable is already destroyed that much");
} else if(destructionRequired > 1) {
throw new IllegalArgumentException("Cannot require more than 100% destruction");
}
this.destructionRequired = destructionRequired;
this.getOwner().getMatch().getPluginManager().callEvent(new FeatureChangeEvent(this.getMatch(), this));
}
}
public void setBreaksRequired(int breaks) {
this.setDestructionRequired((double) breaks / this.getMaxHealth());
}
@Override
public double getCompletion() {
return (double) this.getBreaks() / this.getBreaksRequired();
}
@Override
public String renderCompletion() {
return Strings.progressPercentage(this.getCompletion());
}
@Override
public String renderPreciseCompletion() {
return this.getBreaks() + "/" + this.getBreaksRequired();
}
@Override
public String renderSidebarStatusText(@Nullable Competitor competitor, Party viewer) {
if(this.getShowProgress() || Parties.isObservingType(viewer)) {
String text = this.renderCompletion();
if(Config.Scoreboard.preciseProgress()) {
String precise = this.renderPreciseCompletion();
if(precise != null) {
text += " " + ChatColor.GRAY + precise;
}
}
return text;
} else {
return super.renderSidebarStatusText(competitor, viewer);
}
}
@Override
public boolean getShowProgress() {
return this.definition.getShowProgress();
}
public boolean isDestroyed() {
return this.getBreaks() >= this.getBreaksRequired();
}
@Override
public boolean canComplete(Competitor team) {
return team != this.getOwner();
}
@Override
public boolean isCompleted() {
return this.isDestroyed();
}
@Override
public boolean isCompleted(Competitor team) {
return this.isDestroyed() && this.canComplete(team);
}
public @Nonnull List<DestroyableHealthChange> getEvents() {
return ImmutableList.copyOf(this.events);
}
public @Nonnull ImmutableList<DestroyableContribution> getContributions() {
if(this.contributions != null) {
return this.contributions;
}
Map<MatchPlayerState, Integer> playerDamage = new DefaultMapAdapter<>(new HashMap<MatchPlayerState, Integer>(), 0);
int totalDamage = 0;
for(DestroyableHealthChange change : this.events) {
if(change.getHealthChange() < 0) {
MatchPlayerState player = change.getPlayerCause();
if(player != null) {
playerDamage.put(player, playerDamage.get(player) - change.getHealthChange());
}
totalDamage -= change.getHealthChange();
}
}
ImmutableList.Builder<DestroyableContribution> builder = ImmutableList.builder();
for(Map.Entry<MatchPlayerState, Integer> entry : playerDamage.entrySet()) {
builder.add(new DestroyableContribution(
entry.getKey(),
(double) entry.getValue() / totalDamage, entry.getValue()
));
}
ImmutableList<DestroyableContribution> contributions = builder.build();
if(this.isDestroyed()) {
// Only cache if completely destroyed
this.contributions = contributions;
}
return contributions;
}
@SuppressWarnings("deprecation")
@Override
public void replaceBlocks(MaterialData newMaterial) {
// Calling this method causes all non-destroyed blocks to be replaced, and the material
// list to be replaced with one containing only the new block. If called on a multi-stage
// destroyable, i.e. one which is affected by block replacement rules, it effectively ceases
// to be multi-stage. Even if there are block replacement rules for the new block, the
// replacements will not be in the material list, and so those blocks will be considered
// destroyed the first time they are mined. This can have some strange effects on the health
// of the destroyable: individual block health can only decrease, while the total health
// percentage can only increase.
double oldCompletion = getCompletion();
for (Block block : this.getBlockRegion().getBlocks(match.getWorld())) {
BlockState oldState = block.getState();
int oldHealth = this.getBlockHealth(oldState);
if (oldHealth > 0) {
block.setTypeIdAndData(newMaterial.getItemTypeId(), newMaterial.getData(), true);
}
}
// Update the materials list on switch
this.materialPatterns.clear();
this.materials.clear();
addMaterials(new MaterialPattern(newMaterial));
// If there is a block health map, get rid of it, since there is now only one material in the list
this.blockMaterialHealth = null;
this.recalculateHealth();
if(oldCompletion != getCompletion()) {
match.callEvent(new DestroyableHealthChangeEvent(match, this, null));
}
}
@Override
public boolean isObjectiveMaterial(Block block) {
return this.hasMaterial(block.getState().getData());
}
@Override
public String getModeChangeMessage() {
return "match.objectiveMode.name.destroyable";
}
@Override
public MatchDoc.Destroyable getDocument() {
return new Document();
}
class Document extends TouchableGoal.Document implements MatchDoc.Destroyable {
@Override
public int total_blocks() {
return getMaxHealth();
}
@Override
public int breaks_required() {
return getBreaksRequired();
}
@Override
public int breaks() {
return getBreaks();
}
@Override
public double completion() {
return getCompletion();
}
}
}