diff --git a/plugin.yml b/plugin.yml index dd71b3d..39c92dd 100644 --- a/plugin.yml +++ b/plugin.yml @@ -1,7 +1,7 @@ name: GriefPrevention main: me.ryanhamshire.GriefPrevention.GriefPrevention softdepend: [Vault, Multiverse-Core] -version: 3.8 +version: 4.2 commands: abandonclaim: description: Deletes a claim. @@ -60,6 +60,16 @@ commands: usage: /RestoreNature permission: griefprevention.restorenature aliases: rn + restorenatureaggressive: + description: Switches the shovel tool to aggressive restoration mode. + usage: /RestoreNatureAggressive + permission: griefprevention.restorenatureaggressive + aliases: rna + restorenaturefill: + description: Switches the shovel tool to fill mode. + usage: /RestoreNatureFill + permission: griefprevention.restorenatureaggressive + aliases: rnf basicclaims: description: Switches the shovel tool back to basic claims mode. usage: /BasicClaims @@ -102,6 +112,7 @@ permissions: description: Grants all administrative functionality. children: griefprevention.restorenature: true + griefprevention.restorenatureaggressive: true griefprevention.ignoreclaims: true griefprevention.adminclaims: true griefprevention.adjustclaimblocks: true @@ -132,4 +143,7 @@ permissions: default: op griefprevention.eavesdrop: description: Allows a player to see whispered chat messages (/tell). + default: op + griefprevention.restorenatureaggressive: + description: Grants access to /RestoreNatureAggressive and /RestoreNatureFill. default: op \ No newline at end of file diff --git a/src/me/ryanhamshire/GriefPrevention/BlockEventHandler.java b/src/me/ryanhamshire/GriefPrevention/BlockEventHandler.java index d69830f..1b8a65d 100644 --- a/src/me/ryanhamshire/GriefPrevention/BlockEventHandler.java +++ b/src/me/ryanhamshire/GriefPrevention/BlockEventHandler.java @@ -25,6 +25,7 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.OfflinePlayer; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; import org.bukkit.block.BlockState; import org.bukkit.block.Chest; import org.bukkit.entity.Player; @@ -41,6 +42,7 @@ import org.bukkit.event.block.BlockPistonExtendEvent; import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.world.StructureGrowEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; @@ -139,7 +141,7 @@ public class BlockEventHandler implements Listener } //when a player breaks a block... - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onBlockBreak(BlockBreakEvent breakEvent) { Player player = breakEvent.getPlayer(); @@ -177,8 +179,36 @@ public class BlockEventHandler implements Listener } } + //when a player places a sign... + @EventHandler(ignoreCancelled = true) + public void onSignChanged(SignChangeEvent event) + { + Player player = event.getPlayer(); + if(player == null) return; + + StringBuilder lines = new StringBuilder(); + boolean notEmpty = false; + for(int i = 0; i < event.getLines().length; i++) + { + if(event.getLine(i).length() != 0) notEmpty = true; + lines.append(event.getLine(i) + ";"); + } + + String signMessage = lines.toString(); + + //if not empty and wasn't the same as the last sign, log it and remember it for later + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + if(notEmpty && playerData.lastMessage != null && !playerData.lastMessage.equals(signMessage)) + { + GriefPrevention.AddLogEntry("[Sign Placement] <" + player.getName() + "> " + lines.toString()); + GriefPrevention.AddLogEntry("Location: " + GriefPrevention.getfriendlyLocationString(event.getBlock().getLocation())); + + playerData.lastMessage = signMessage; + } + } + //when a player places a block... - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onBlockPlace(BlockPlaceEvent placeEvent) { Player player = placeEvent.getPlayer(); @@ -203,6 +233,20 @@ public class BlockEventHandler implements Listener } } + //FEATURE: limit tree planting to grass, and dirt with more earth beneath it + if(block.getType() == Material.SAPLING) + { + Block earthBlock = placeEvent.getBlockAgainst(); + if(earthBlock.getType() != Material.GRASS) + { + if(earthBlock.getRelative(BlockFace.DOWN).getType() == Material.AIR || + earthBlock.getRelative(BlockFace.DOWN).getRelative(BlockFace.DOWN).getType() == Material.AIR) + { + placeEvent.setCancelled(true); + } + } + } + //make sure the player is allowed to build at the location String noBuildReason = GriefPrevention.instance.allowBuild(player, block.getLocation()); if(noBuildReason != null) @@ -292,7 +336,7 @@ public class BlockEventHandler implements Listener } //blocks "pushing" other players' blocks around (pistons) - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onBlockPistonExtend (BlockPistonExtendEvent event) { //who owns the piston, if anyone? @@ -316,7 +360,7 @@ public class BlockEventHandler implements Listener } //blocks theft by pulling blocks out of a claim (again pistons) - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onBlockPistonRetract (BlockPistonRetractEvent event) { //we only care about sticky pistons @@ -342,21 +386,21 @@ public class BlockEventHandler implements Listener } //blocks are ignited ONLY by flint and steel (not by being near lava, open flames, etc), unless configured otherwise - @EventHandler(priority = EventPriority.HIGHEST) + @EventHandler(priority = EventPriority.LOWEST) public void onBlockIgnite (BlockIgniteEvent igniteEvent) { if(igniteEvent.getCause() != IgniteCause.FLINT_AND_STEEL && !GriefPrevention.instance.config_fireSpreads) igniteEvent.setCancelled(true); } //fire doesn't spread unless configured to, but other blocks still do (mushrooms and vines, for example) - @EventHandler(priority = EventPriority.HIGHEST) + @EventHandler(priority = EventPriority.LOWEST) public void onBlockSpread (BlockSpreadEvent spreadEvent) { if(spreadEvent.getSource().getType() == Material.FIRE && !GriefPrevention.instance.config_fireSpreads) spreadEvent.setCancelled(true); } //blocks are not destroyed by fire, unless configured to do so - @EventHandler(priority = EventPriority.HIGHEST) + @EventHandler(priority = EventPriority.LOWEST) public void onBlockBurn (BlockBurnEvent burnEvent) { if(!GriefPrevention.instance.config_fireDestroys) @@ -371,28 +415,28 @@ public class BlockEventHandler implements Listener } } - //ensures fluids don't flow into claims, unless out of another claim where the owner is trusted to build in the receiving claim - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + //ensures fluids don't flow out of claims, unless into another claim where the owner is trusted to build + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onBlockFromTo (BlockFromToEvent spreadEvent) { - //where to? - Block toBlock = spreadEvent.getToBlock(); - Claim toClaim = this.dataStore.getClaimAt(toBlock.getLocation(), false, null); + //from where? + Block fromBlock = spreadEvent.getBlock(); + Claim fromClaim = this.dataStore.getClaimAt(fromBlock.getLocation(), false, null); - //if in a creative world, block any spread into the wilderness - if(GriefPrevention.instance.creativeRulesApply(toBlock.getLocation()) && toClaim == null) + //where to? + Block toBlock = spreadEvent.getToBlock(); + Claim toClaim = this.dataStore.getClaimAt(toBlock.getLocation(), false, fromClaim); + + //block any spread into the wilderness + if(fromClaim != null && toClaim == null) { spreadEvent.setCancelled(true); return; } //if spreading into a claim - if(toClaim != null) + else if(toClaim != null) { - //from where? - Block fromBlock = spreadEvent.getBlock(); - Claim fromClaim = this.dataStore.getClaimAt(fromBlock.getLocation(), false, null); - //who owns the spreading block, if anyone? OfflinePlayer fromOwner = null; if(fromClaim != null) diff --git a/src/me/ryanhamshire/GriefPrevention/Claim.java b/src/me/ryanhamshire/GriefPrevention/Claim.java index 4f9edd7..a2f6b71 100644 --- a/src/me/ryanhamshire/GriefPrevention/Claim.java +++ b/src/me/ryanhamshire/GriefPrevention/Claim.java @@ -26,6 +26,8 @@ import java.util.Iterator; import java.util.Map; import org.bukkit.*; +import org.bukkit.World.Environment; +import org.bukkit.block.Block; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -98,6 +100,44 @@ public class Claim return true; } + //removes any fluids above sea level in a claim + //exclusionClaim is another claim indicating an sub-area to be excluded from this operation + //it may be null + public void removeSurfaceFluids(Claim exclusionClaim) + { + //don't do this automatically for administrative claims + if(this.isAdminClaim()) return; + + Location lesser = this.getLesserBoundaryCorner(); + Location greater = this.getGreaterBoundaryCorner(); + + if(lesser.getWorld().getEnvironment() == Environment.NETHER) return; //don't clean up lava in the nether + + int seaLevel = 0; //clean up all fluids in the end + + //respect sea level in normal worlds + if(lesser.getWorld().getEnvironment() == Environment.NORMAL) seaLevel = lesser.getWorld().getSeaLevel(); + + for(int x = lesser.getBlockX(); x <= greater.getBlockX(); x++) + { + for(int z = lesser.getBlockZ(); z <= greater.getBlockZ(); z++) + { + for(int y = seaLevel - 1; y <= lesser.getWorld().getMaxHeight(); y++) + { + //dodge the exclusion claim + Block block = lesser.getWorld().getBlockAt(x, y, z); + if(exclusionClaim != null && exclusionClaim.contains(block.getLocation(), true, false)) continue; + + if(block.getType() == Material.STATIONARY_WATER || block.getType() == Material.STATIONARY_LAVA || block.getType() == Material.LAVA || block.getType() == Material.WATER) + { + block.setType(Material.AIR); + } + } + } + } + + } + //main constructor. note that only creating a claim instance does nothing - a claim must be added to the data store to be effective Claim(Location lesserBoundaryCorner, Location greaterBoundaryCorner, String ownerName, String [] builderNames, String [] containerNames, String [] accessorNames, String [] managerNames) { diff --git a/src/me/ryanhamshire/GriefPrevention/DataStore.java b/src/me/ryanhamshire/GriefPrevention/DataStore.java index 6111c80..24c17bb 100644 --- a/src/me/ryanhamshire/GriefPrevention/DataStore.java +++ b/src/me/ryanhamshire/GriefPrevention/DataStore.java @@ -217,11 +217,24 @@ public class DataStore //if that's a chest claim, delete it if(claim.getArea() <= areaOfDefaultClaim) { + claim.removeSurfaceFluids(null); this.deleteClaim(claim); GriefPrevention.AddLogEntry(" " + playerName + "'s new player claim expired."); } } + if(GriefPrevention.instance.config_claims_expirationDays > 0) + { + Calendar earliestPermissibleLastLogin = Calendar.getInstance(); + earliestPermissibleLastLogin.add(Calendar.DATE, -GriefPrevention.instance.config_claims_expirationDays); + + if(earliestPermissibleLastLogin.getTime().after(playerData.lastLogin)) + { + this.deleteClaimsForPlayer(playerName, true); + GriefPrevention.AddLogEntry(" All of " + playerName + "'s claims have expired."); + } + } + //toss that player data out of the cache, it's not needed in memory right now this.clearCachedPlayerData(playerName); } @@ -1074,6 +1087,7 @@ public class DataStore //delete them one by one for(int i = 0; i < claimsToDelete.size(); i++) { + claimsToDelete.get(i).removeSurfaceFluids(null); this.deleteClaim(claimsToDelete.get(i)); } } diff --git a/src/me/ryanhamshire/GriefPrevention/EntityEventHandler.java b/src/me/ryanhamshire/GriefPrevention/EntityEventHandler.java index 0239777..3ebd584 100644 --- a/src/me/ryanhamshire/GriefPrevention/EntityEventHandler.java +++ b/src/me/ryanhamshire/GriefPrevention/EntityEventHandler.java @@ -21,11 +21,12 @@ package me.ryanhamshire.GriefPrevention; import java.util.Calendar; import java.util.List; +import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.World.Environment; import org.bukkit.block.Block; import org.bukkit.entity.Animals; import org.bukkit.entity.Arrow; -import org.bukkit.entity.Creeper; import org.bukkit.entity.Enderman; import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; @@ -34,6 +35,7 @@ import org.bukkit.entity.ThrownPotion; import org.bukkit.entity.Vehicle; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason; @@ -59,17 +61,17 @@ class EntityEventHandler implements Listener } //when an entity explodes... - @EventHandler(ignoreCancelled = true) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onEntityExplode(EntityExplodeEvent explodeEvent) { List blocks = explodeEvent.blockList(); - Entity entity = explodeEvent.getEntity(); + Location location = explodeEvent.getLocation(); - //FEATURE: creepers don't destroy blocks when they explode near or above sea level + //FEATURE: explosions don't destroy blocks when they explode near or above sea level in standard worlds - if(GriefPrevention.instance.config_creepersDontDestroySurface && entity instanceof Creeper) + if(GriefPrevention.instance.config_blockSurfaceExplosions && location.getWorld().getEnvironment() == Environment.NORMAL) { - if(entity.getLocation().getBlockY() > entity.getLocation().getWorld().getSeaLevel() - 7) + if(location.getBlockY() >location.getWorld().getSeaLevel() - 7) { blocks.clear(); //explosion still happens, can damage creatures/players, but no blocks will be destroyed return; @@ -77,7 +79,7 @@ class EntityEventHandler implements Listener } //special rule for creative worlds: explosions don't destroy anything - if(GriefPrevention.instance.creativeRulesApply(entity.getLocation())) + if(GriefPrevention.instance.creativeRulesApply(explodeEvent.getLocation())) { blocks.clear(); } @@ -106,7 +108,7 @@ class EntityEventHandler implements Listener } //when an item spawns... - @EventHandler + @EventHandler(priority = EventPriority.LOWEST) public void onItemSpawn(ItemSpawnEvent event) { //if in a creative world, cancel the event (don't drop items on the ground) @@ -117,7 +119,7 @@ class EntityEventHandler implements Listener } //when a creature spawns... - @EventHandler + @EventHandler(priority = EventPriority.LOWEST) public void onEntitySpawn(CreatureSpawnEvent event) { LivingEntity entity = event.getEntity(); @@ -175,7 +177,7 @@ class EntityEventHandler implements Listener } //when an entity picks up an item - @EventHandler + @EventHandler(priority = EventPriority.LOWEST) public void onEntityPickup(EntityChangeBlockEvent event) { //FEATURE: endermen don't steal claimed blocks @@ -193,7 +195,7 @@ class EntityEventHandler implements Listener } //when a painting is broken - @EventHandler(ignoreCancelled = true) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPaintingBreak(PaintingBreakEvent event) { //FEATURE: claimed paintings are protected from breakage @@ -217,11 +219,7 @@ class EntityEventHandler implements Listener return; } - //make sure the player has build permission here - Claim claim = this.dataStore.getClaimAt(event.getPainting().getLocation(), false, null); - if(claim == null) return; - - //if the player doesn't have build permission, don't allow the breakage + //if the player doesn't have build permission, don't allow the breakage Player playerRemover = (Player)entityEvent.getRemover(); String noBuildReason = GriefPrevention.instance.allowBuild(playerRemover, event.getPainting().getLocation()); if(noBuildReason != null) @@ -232,7 +230,7 @@ class EntityEventHandler implements Listener } //when a painting is placed... - @EventHandler(ignoreCancelled = true) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPaintingPlace(PaintingPlaceEvent event) { //FEATURE: similar to above, placing a painting requires build permission in the claim @@ -247,7 +245,7 @@ class EntityEventHandler implements Listener } //when an entity is damaged - @EventHandler(ignoreCancelled = true) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onEntityDamage (EntityDamageEvent event) { //only actually interested in entities damaging entities (ignoring environmental damage) diff --git a/src/me/ryanhamshire/GriefPrevention/GriefPrevention.java b/src/me/ryanhamshire/GriefPrevention/GriefPrevention.java index 05e24cc..53e2c9a 100644 --- a/src/me/ryanhamshire/GriefPrevention/GriefPrevention.java +++ b/src/me/ryanhamshire/GriefPrevention/GriefPrevention.java @@ -54,7 +54,7 @@ public class GriefPrevention extends JavaPlugin //for logging to the console and log file private static Logger log = Logger.getLogger("Minecraft"); - + //this handles data storage, like player and region data public DataStore dataStore; @@ -69,6 +69,7 @@ public class GriefPrevention extends JavaPlugin public int config_claims_blocksAccruedPerHour; //how many additional blocks players get each hour of play (can be zero) public int config_claims_maxAccruedBlocks; //the limit on accrued blocks (over time). doesn't limit purchased or admin-gifted blocks public int config_claims_maxDepth; //limit on how deep claims can go + public int config_claims_expirationDays; //how many days of inactivity before a player loses his claims public int config_claims_automaticClaimsForNewPlayersRadius; //how big automatic new player claims (when they place a chest) should be. 0 to disable public boolean config_claims_creationRequiresPermission; //whether creating claims with the shovel requires a permission @@ -99,7 +100,7 @@ public class GriefPrevention extends JavaPlugin public double config_economy_claimBlocksPurchaseCost; //cost to purchase a claim block. set to zero to disable purchase. public double config_economy_claimBlocksSellValue; //return on a sold claim block. set to zero to disable sale. - public boolean config_creepersDontDestroySurface; //whether creeper explosions near or above the surface destroy blocks + public boolean config_blockSurfaceExplosions; //whether creeper/TNT explosions near or above the surface destroy blocks public boolean config_fireSpreads; //whether fire spreads outside of claims public boolean config_fireDestroys; //whether fire destroys blocks outside of claims @@ -107,12 +108,17 @@ public class GriefPrevention extends JavaPlugin public boolean config_addItemsToClaimedChests; //whether players may add items to claimed chests by left-clicking them public boolean config_eavesdrop; //whether whispered messages will be visible to administrators + public boolean config_smartBan; //whether to ban accounts which very likely owned by a banned player + //reference to the economy plugin, if economy integration is enabled public static Economy economy = null; //how far away to search from a tree trunk for its branch blocks public static final int TREE_RADIUS = 5; + //how long to wait before deciding a player is staying online or staying offline, for notication messages + public static final int NOTIFICATION_SECONDS = 20; + //adds a server log entry public static void AddLogEntry(String entry) { @@ -207,11 +213,12 @@ public class GriefPrevention extends JavaPlugin this.config_claims_creationRequiresPermission = config.getBoolean("GriefPrevention.Claims.CreationRequiresPermission", false); this.config_claims_minSize = config.getInt("GriefPrevention.Claims.MinimumSize", 10); this.config_claims_maxDepth = config.getInt("GriefPrevention.Claims.MaximumDepth", 0); + this.config_claims_expirationDays = config.getInt("GriefPrevention.Claims.IdleLimitDays", 0); this.config_claims_trappedCooldownHours = config.getInt("GriefPrevention.Claims.TrappedCommandCooldownHours", 8); this.config_spam_enabled = config.getBoolean("GriefPrevention.Spam.Enabled", true); - this.config_spam_loginCooldownMinutes = config.getInt("GriefPrevention.Spam.LoginCooldownMinutes", 5); - this.config_spam_warningMessage = config.getString("GriefPrevention.Spam.WarningMessage", "Please reduce your message speed. Spammers will be banned."); + this.config_spam_loginCooldownMinutes = config.getInt("GriefPrevention.Spam.LoginCooldownMinutes", 2); + this.config_spam_warningMessage = config.getString("GriefPrevention.Spam.WarningMessage", "Please reduce your noise level. Spammers will be banned."); this.config_spam_allowedIpAddresses = config.getString("GriefPrevention.Spam.AllowedIpAddresses", "1.2.3.4; 5.6.7.8"); this.config_spam_banOffenders = config.getBoolean("GriefPrevention.Spam.BanOffenders", true); this.config_spam_banMessage = config.getString("GriefPrevention.Spam.BanMessage", "Banned for spam."); @@ -228,7 +235,7 @@ public class GriefPrevention extends JavaPlugin this.config_economy_claimBlocksPurchaseCost = config.getDouble("GriefPrevention.Economy.ClaimBlocksPurchaseCost", 0); this.config_economy_claimBlocksSellValue = config.getDouble("GriefPrevention.Economy.ClaimBlocksSellValue", 0); - this.config_creepersDontDestroySurface = config.getBoolean("GriefPrevention.CreepersDontDestroySurface", true); + this.config_blockSurfaceExplosions = config.getBoolean("GriefPrevention.BlockSurfaceExplosions", true); this.config_fireSpreads = config.getBoolean("GriefPrevention.FireSpreads", false); this.config_fireDestroys = config.getBoolean("GriefPrevention.FireDestroys", false); @@ -236,6 +243,8 @@ public class GriefPrevention extends JavaPlugin this.config_addItemsToClaimedChests = config.getBoolean("GriefPrevention.AddItemsToClaimedChests", true); this.config_eavesdrop = config.getBoolean("GriefPrevention.EavesdropEnabled", false); + this.config_smartBan = config.getBoolean("GriefPrevention.SmartBan", true); + //default for siege worlds list ArrayList defaultSiegeWorldNames = new ArrayList(); @@ -320,6 +329,7 @@ public class GriefPrevention extends JavaPlugin config.set("GriefPrevention.Claims.CreationRequiresPermission", this.config_claims_creationRequiresPermission); config.set("GriefPrevention.Claims.MinimumSize", this.config_claims_minSize); config.set("GriefPrevention.Claims.MaximumDepth", this.config_claims_maxDepth); + config.set("GriefPrevention.Claims.IdleLimitDays", this.config_claims_expirationDays); config.set("GriefPrevention.Claims.TrappedCommandCooldownHours", this.config_claims_trappedCooldownHours); config.set("GriefPrevention.Spam.Enabled", this.config_spam_enabled); @@ -341,7 +351,7 @@ public class GriefPrevention extends JavaPlugin config.set("GriefPrevention.Economy.ClaimBlocksPurchaseCost", this.config_economy_claimBlocksPurchaseCost); config.set("GriefPrevention.Economy.ClaimBlocksSellValue", this.config_economy_claimBlocksSellValue); - config.set("GriefPrevention.CreepersDontDestroySurface", this.config_creepersDontDestroySurface); + config.set("GriefPrevention.BlockSurfaceExplosions", this.config_blockSurfaceExplosions); config.set("GriefPrevention.FireSpreads", this.config_fireSpreads); config.set("GriefPrevention.FireDestroys", this.config_fireDestroys); @@ -349,6 +359,8 @@ public class GriefPrevention extends JavaPlugin config.set("GriefPrevention.AddItemsToClaimedChests", this.config_addItemsToClaimedChests); config.set("GriefPrevention.EavesdropEnabled", this.config_eavesdrop); + config.set("GriefPrevention.SmartBan", this.config_smartBan); + config.set("GriefPrevention.Siege.Worlds", siegeEnabledWorldNames); config.set("GriefPrevention.Siege.BreakableBlocks", breakableBlocksList); @@ -518,6 +530,40 @@ public class GriefPrevention extends JavaPlugin return true; } + //restore nature aggressive mode + else if(cmd.getName().equalsIgnoreCase("restorenatureaggressive") && player != null) + { + //change shovel mode + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + playerData.shovelMode = ShovelMode.RestoreNatureAggressive; + GriefPrevention.sendMessage(player, TextMode.Warn, "Aggressive mode activated. Do NOT use this underneath anything you want to keep! Right click to aggressively restore nature, and use /BasicClaims to stop."); + return true; + } + + //restore nature fill mode + else if(cmd.getName().equalsIgnoreCase("restorenaturefill") && player != null) + { + //change shovel mode + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + playerData.shovelMode = ShovelMode.RestoreNatureFill; + + //set radius based on arguments + playerData.fillRadius = 2; + if(args.length > 0) + { + try + { + playerData.fillRadius = Integer.parseInt(args[0]); + } + catch(Exception exception){ } + } + + if(playerData.fillRadius < 0) playerData.fillRadius = 2; + + GriefPrevention.sendMessage(player, TextMode.Success, "Fill mode activated with radius " + playerData.fillRadius + ". Right-click an area to fill."); + return true; + } + //trust else if(cmd.getName().equalsIgnoreCase("trust") && player != null) { @@ -833,6 +879,17 @@ public class GriefPrevention extends JavaPlugin else { + //determine max purchasable blocks + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + int maxPurchasable = GriefPrevention.instance.config_claims_maxAccruedBlocks - playerData.accruedClaimBlocks; + + //if the player is at his max, tell him so + if(maxPurchasable <= 0) + { + GriefPrevention.sendMessage(player, TextMode.Err, "You've reached your claim block limit. You can't purchase more."); + return true; + } + //try to parse number of blocks int blockCount; try @@ -844,6 +901,12 @@ public class GriefPrevention extends JavaPlugin return false; //causes usage to be displayed } + //correct block count to max allowed + if(blockCount > maxPurchasable) + { + blockCount = maxPurchasable; + } + //if the player can't afford his purchase, send error message double balance = economy.getBalance(player.getName()); double totalCost = blockCount * GriefPrevention.instance.config_economy_claimBlocksPurchaseCost; @@ -859,8 +922,7 @@ public class GriefPrevention extends JavaPlugin economy.withdrawPlayer(player.getName(), totalCost); //add blocks - PlayerData playerData = this.dataStore.getPlayerData(player.getName()); - playerData.bonusClaimBlocks += blockCount; + playerData.accruedClaimBlocks += blockCount; this.dataStore.savePlayerData(player.getName(), playerData); //inform player @@ -920,7 +982,7 @@ public class GriefPrevention extends JavaPlugin economy.depositPlayer(player.getName(), totalValue); //subtract blocks - playerData.bonusClaimBlocks -= blockCount; + playerData.accruedClaimBlocks -= blockCount; this.dataStore.savePlayerData(player.getName(), playerData); //inform player @@ -978,12 +1040,24 @@ public class GriefPrevention extends JavaPlugin //deleting an admin claim additionally requires the adminclaims permission if(!claim.isAdminClaim() || player.hasPermission("griefprevention.adminclaims")) { - this.dataStore.deleteClaim(claim); - GriefPrevention.sendMessage(player, TextMode.Success, "Claim deleted."); - GriefPrevention.AddLogEntry(player.getName() + " deleted " + claim.getOwnerName() + "'s claim at " + GriefPrevention.getfriendlyLocationString(claim.getLesserBoundaryCorner())); - - //revert any current visualization - Visualization.Revert(player); + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + if(claim.children.size() > 0 && !playerData.warnedAboutMajorDeletion) + { + GriefPrevention.sendMessage(player, TextMode.Warn, "This claim includes subdivisions. If you're sure you want to delete it, use /DeleteClaim again."); + playerData.warnedAboutMajorDeletion = true; + } + else + { + claim.removeSurfaceFluids(null); + this.dataStore.deleteClaim(claim); + GriefPrevention.sendMessage(player, TextMode.Success, "Claim deleted."); + GriefPrevention.AddLogEntry(player.getName() + " deleted " + claim.getOwnerName() + "'s claim at " + GriefPrevention.getfriendlyLocationString(claim.getLesserBoundaryCorner())); + + //revert any current visualization + Visualization.Revert(player); + + playerData.warnedAboutMajorDeletion = false; + } } else { @@ -1266,6 +1340,7 @@ public class GriefPrevention extends JavaPlugin else { //delete it + claim.removeSurfaceFluids(null); this.dataStore.deleteClaim(claim); //tell the player how many claim blocks he has left @@ -1619,8 +1694,8 @@ public class GriefPrevention extends JavaPlugin //schedule a cleanup task for later, in case the player leaves part of this tree hanging in the air TreeCleanupTask cleanupTask = new TreeCleanupTask(block, rootBlock, treeBlocks); - //20L ~ 1 second, so 5 mins = 300 seconds ~ 6000L - GriefPrevention.instance.getServer().getScheduler().scheduleSyncDelayedTask(GriefPrevention.instance, cleanupTask, 6000L); + //20L ~ 1 second, so 2 mins = 120 seconds ~ 2400L + GriefPrevention.instance.getServer().getScheduler().scheduleSyncDelayedTask(GriefPrevention.instance, cleanupTask, 2400L); } } diff --git a/src/me/ryanhamshire/GriefPrevention/IpBanInfo.java b/src/me/ryanhamshire/GriefPrevention/IpBanInfo.java new file mode 100644 index 0000000..904b16a --- /dev/null +++ b/src/me/ryanhamshire/GriefPrevention/IpBanInfo.java @@ -0,0 +1,17 @@ +package me.ryanhamshire.GriefPrevention; + +import java.net.InetAddress; + +public class IpBanInfo +{ + InetAddress address; + long expirationTimestamp; + String bannedAccountName; + + IpBanInfo(InetAddress address, long expirationTimestamp, String bannedAccountName) + { + this.address = address; + this.expirationTimestamp = expirationTimestamp; + this.bannedAccountName = bannedAccountName; + } +} diff --git a/src/me/ryanhamshire/GriefPrevention/JoinLeaveAnnouncementTask.java b/src/me/ryanhamshire/GriefPrevention/JoinLeaveAnnouncementTask.java new file mode 100644 index 0000000..85007f3 --- /dev/null +++ b/src/me/ryanhamshire/GriefPrevention/JoinLeaveAnnouncementTask.java @@ -0,0 +1,66 @@ +/* + GriefPrevention Server Plugin for Minecraft + Copyright (C) 2011 Ryan Hamshire + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +package me.ryanhamshire.GriefPrevention; + +import org.bukkit.entity.Player; + +//this main thread task takes the output from the RestoreNatureProcessingTask\ +//and updates the world accordingly +class JoinLeaveAnnouncementTask implements Runnable +{ + //player joining or leaving the server + private Player player; + + //message to be displayed + private String message; + + //whether joining or leaving + private boolean joining; + + public JoinLeaveAnnouncementTask(Player player, String message, boolean joining) + { + this.player = player; + this.message = message; + this.joining = joining; + } + + @Override + public void run() + { + //verify the player still has the same online/offline status + if((this.joining && this.player.isOnline() || (!this.joining && !this.player.isOnline()))) + { + Player players [] = GriefPrevention.instance.getServer().getOnlinePlayers(); + for(int i = 0; i < players.length; i++) + { + if(!players[i].equals(this.player)) + { + players[i].sendMessage(this.message); + } + } + + //if left + if(!joining) + { + //drop player data from memory + GriefPrevention.instance.dataStore.clearCachedPlayerData(this.player.getName()); + } + } + } +} diff --git a/src/me/ryanhamshire/GriefPrevention/PlayerData.java b/src/me/ryanhamshire/GriefPrevention/PlayerData.java index 7b5c660..ac3c624 100644 --- a/src/me/ryanhamshire/GriefPrevention/PlayerData.java +++ b/src/me/ryanhamshire/GriefPrevention/PlayerData.java @@ -17,6 +17,7 @@ */ package me.ryanhamshire.GriefPrevention; +import java.net.InetAddress; import java.util.Calendar; import java.util.Date; import java.util.Vector; @@ -41,6 +42,9 @@ public class PlayerData //what "mode" the shovel is in determines what it will do when it's used public ShovelMode shovelMode = ShovelMode.Basic; + //radius for restore nature fill mode + int fillRadius = 0; + //last place the player used the shovel, useful in creating and resizing claims, //because the player must use the shovel twice in those instances public Location lastShovelLocation = null; @@ -64,7 +68,11 @@ public class PlayerData public Date lastLogin; //when the player last logged into the server public String lastMessage = ""; //the player's last chat message, or slash command complete with parameters public Date lastMessageTimestamp = new Date(); //last time the player sent a chat message or used a monitored slash command - public int spamCount = 0; //number of consecutive "spams" + public int spamCount = 0; //number of consecutive "spams" + public boolean spamWarned = false; //whether the player recently received a warning + + //last logout timestamp, default to long enough to trigger a join message, see player join event + public long lastLogout = Calendar.getInstance().getTimeInMillis() - GriefPrevention.NOTIFICATION_SECONDS * 2000; //visualization public Visualization currentVisualization = null; @@ -86,6 +94,11 @@ public class PlayerData public long lastPvpTimestamp = 0; public String lastPvpPlayer = ""; + //safety confirmation for deleting multi-subdivision claims + public boolean warnedAboutMajorDeletion = false; + + public InetAddress ipAddress; + PlayerData() { //default last login date value to a year ago to ensure a brand new player can log in diff --git a/src/me/ryanhamshire/GriefPrevention/PlayerEventHandler.java b/src/me/ryanhamshire/GriefPrevention/PlayerEventHandler.java index 64a04b8..78212e0 100644 --- a/src/me/ryanhamshire/GriefPrevention/PlayerEventHandler.java +++ b/src/me/ryanhamshire/GriefPrevention/PlayerEventHandler.java @@ -17,6 +17,7 @@ */ package me.ryanhamshire.GriefPrevention; +import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.List; @@ -27,7 +28,10 @@ import org.bukkit.ChatColor; import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.World.Environment; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; import org.bukkit.entity.Animals; import org.bukkit.entity.Boat; import org.bukkit.entity.Entity; @@ -49,6 +53,12 @@ class PlayerEventHandler implements Listener { private DataStore dataStore; + //list of temporarily banned ip's + private ArrayList tempBannedIps = new ArrayList(); + + //number of milliseconds in a day + private final long MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24; + //typical constructor, yawn PlayerEventHandler(DataStore dataStore, GriefPrevention plugin) { @@ -56,7 +66,7 @@ class PlayerEventHandler implements Listener } //when a player chats, monitor for spam - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) void onPlayerChat (PlayerChatEvent event) { Player player = event.getPlayer(); @@ -74,21 +84,29 @@ class PlayerEventHandler implements Listener if(!GriefPrevention.instance.config_spam_enabled) return; + //if the player has permission to spam, don't bother even examining the message + if(player.hasPermission("griefprevention.spam")) return; + //remedy any CAPS SPAM without bothering to fault the player for it - if(message.length() > 4 && !player.hasPermission("griefprevention.spam") && message.toUpperCase().equals(message)) + if(message.length() > 4 && message.toUpperCase().equals(message)) { event.setMessage(message.toLowerCase()); } + //where spam is concerned, casing isn't significant + message = message.toLowerCase(); + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); boolean spam = false; + boolean muted = false; //filter IP addresses if(!(event instanceof PlayerCommandPreprocessEvent)) { Pattern ipAddressPattern = Pattern.compile("\\d+\\.\\d+\\.\\d+\\.\\d+"); Matcher matcher = ipAddressPattern.matcher(event.getMessage()); + //if it looks like an IP address while(matcher.find()) { @@ -103,7 +121,7 @@ class PlayerEventHandler implements Listener spam = true; //block message - event.setCancelled(true); + muted = true; } } } @@ -112,35 +130,41 @@ class PlayerEventHandler implements Listener long millisecondsSinceLastMessage = (new Date()).getTime() - playerData.lastMessageTimestamp.getTime(); //if the message came too close to the last one - if(millisecondsSinceLastMessage < 2000) + if(millisecondsSinceLastMessage < 3000) { //increment the spam counter playerData.spamCount++; spam = true; } - //if it's the same as the last message - if(message.equals(playerData.lastMessage)) + //if it's very similar to the last message + if(this.stringsAreSimilar(message, playerData.lastMessage)) { playerData.spamCount++; - event.setCancelled(true); spam = true; + muted = true; } - //if the message was mostly non-alpha-numerics, consider it a spam (probably ansi art) + //if the message was mostly non-alpha-numerics or doesn't include much whitespace, consider it a spam (probably ansi art or random text gibberish) if(message.length() > 5) { int symbolsCount = 0; + int whitespaceCount = 0; for(int i = 0; i < message.length(); i++) { char character = message.charAt(i); - if(!(Character.isLetterOrDigit(character) || Character.isWhitespace(character))) + if(!(Character.isLetterOrDigit(character))) { symbolsCount++; - } + } + + if(Character.isWhitespace(character)) + { + whitespaceCount++; + } } - if(symbolsCount > message.length() / 2) + if(symbolsCount > message.length() / 2 || (message.length() > 15 && whitespaceCount < message.length() / 10)) { spam = true; playerData.spamCount++; @@ -150,12 +174,9 @@ class PlayerEventHandler implements Listener //if the message was determined to be a spam, consider taking action if(!player.hasPermission("griefprevention.spam") && spam) { - //at the fifth spam level, auto-ban (if enabled) - if(playerData.spamCount > 4) + //anything above level 4 for a player which has received a warning... kick or if enabled, ban + if(playerData.spamCount > 4 && playerData.spamWarned) { - event.setCancelled(true); - GriefPrevention.AddLogEntry("Muted spam from " + player.getName() + ": " + message); - if(GriefPrevention.instance.config_spam_banOffenders) { //log entry @@ -174,19 +195,36 @@ class PlayerEventHandler implements Listener } //cancel any messages while at or above the third spam level and issue warnings - else if(playerData.spamCount >= 3) + //anything above level 2, mute and warn + if(playerData.spamCount >= 3) { - GriefPrevention.sendMessage(player, TextMode.Warn, GriefPrevention.instance.config_spam_warningMessage); - event.setCancelled(true); - GriefPrevention.AddLogEntry("Warned " + player.getName() + " about spam penalties."); - GriefPrevention.AddLogEntry("Muted spam from " + player.getName() + ": " + message); + muted = true; + if(!playerData.spamWarned) + { + GriefPrevention.sendMessage(player, TextMode.Warn, GriefPrevention.instance.config_spam_warningMessage); + GriefPrevention.AddLogEntry("Warned " + player.getName() + " about spam penalties."); + playerData.spamWarned = true; + } } + + if(muted) + { + //cancel the event and make a log entry + //cancelling the event guarantees players don't receive the message + event.setCancelled(true); + GriefPrevention.AddLogEntry("Muted spam from " + player.getName() + ": " + message); + + //send a fake message so the player doesn't realize he's muted + //less information for spammers = less effective spam filter dodging + player.sendMessage("<" + player.getName() + "> " + event.getMessage()); + } } //otherwise if not a spam, reset the spam counter for this player else { playerData.spamCount = 0; + playerData.spamWarned = false; } //in any case, record the timestamp of this message and also its content for next time @@ -194,8 +232,50 @@ class PlayerEventHandler implements Listener playerData.lastMessage = message; } + //if two strings are 75% identical, they're too close to follow each other in the chat + private boolean stringsAreSimilar(String message, String lastMessage) + { + //determine which is shorter + String shorterString, longerString; + if(lastMessage.length() < message.length()) + { + shorterString = lastMessage; + longerString = message; + } + else + { + shorterString = message; + longerString = lastMessage; + } + + if(shorterString.length() <= 5) return shorterString.equals(longerString); + + //set similarity tolerance + int maxIdenticalCharacters = longerString.length() - longerString.length() / 4; + + //trivial check on length + if(shorterString.length() < maxIdenticalCharacters) return false; + + //compare forward + int identicalCount = 0; + for(int i = 0; i < shorterString.length(); i++) + { + if(shorterString.charAt(i) == longerString.charAt(i)) identicalCount++; + if(identicalCount > maxIdenticalCharacters) return true; + } + + //compare backward + for(int i = 0; i < shorterString.length(); i++) + { + if(shorterString.charAt(shorterString.length() - i - 1) == longerString.charAt(longerString.length() - i - 1)) identicalCount++; + if(identicalCount > maxIdenticalCharacters) return true; + } + + return false; + } + //when a player uses a slash command, monitor for spam - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) void onPlayerCommandPreprocess (PlayerCommandPreprocessEvent event) { if(!GriefPrevention.instance.config_spam_enabled) return; @@ -231,31 +311,97 @@ class PlayerEventHandler implements Listener } //when a player attempts to join the server... - @EventHandler(ignoreCancelled = true) + @EventHandler(priority = EventPriority.HIGHEST) void onPlayerLogin (PlayerLoginEvent event) { - if(!GriefPrevention.instance.config_spam_enabled) return; - Player player = event.getPlayer(); - //FEATURE: login cooldown to prevent login/logout spam with custom clients - - //if allowed to join and login cooldown enabled - if(GriefPrevention.instance.config_spam_loginCooldownMinutes > 0 && event.getResult() == Result.ALLOWED) + //all this is anti-spam code + if(GriefPrevention.instance.config_spam_enabled) { - //determine how long since last login and cooldown remaining - PlayerData playerData = this.dataStore.getPlayerData(player.getName()); - long millisecondsSinceLastLogin = (new Date()).getTime() - playerData.lastLogin.getTime(); - long minutesSinceLastLogin = millisecondsSinceLastLogin / 1000 / 60; - long cooldownRemaining = GriefPrevention.instance.config_spam_loginCooldownMinutes - minutesSinceLastLogin; + //FEATURE: login cooldown to prevent login/logout spam with custom clients - //if cooldown remaining and player doesn't have permission to spam - if(cooldownRemaining > 0 && !player.hasPermission("griefprevention.spam")) + //if allowed to join and login cooldown enabled + if(GriefPrevention.instance.config_spam_loginCooldownMinutes > 0 && event.getResult() == Result.ALLOWED) { - //DAS BOOT! - event.setResult(Result.KICK_OTHER); - event.setKickMessage("You must wait " + cooldownRemaining + " more minutes before logging-in again."); - event.disallow(event.getResult(), event.getKickMessage()); + //determine how long since last login and cooldown remaining + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + long millisecondsSinceLastLogin = (new Date()).getTime() - playerData.lastLogin.getTime(); + long minutesSinceLastLogin = millisecondsSinceLastLogin / 1000 / 60; + long cooldownRemaining = GriefPrevention.instance.config_spam_loginCooldownMinutes - minutesSinceLastLogin; + + //if cooldown remaining and player doesn't have permission to spam + if(cooldownRemaining > 0 && !player.hasPermission("griefprevention.spam")) + { + //DAS BOOT! + event.setResult(Result.KICK_OTHER); + event.setKickMessage("You must wait " + cooldownRemaining + " more minutes before logging-in again."); + event.disallow(event.getResult(), event.getKickMessage()); + return; + } + } + } + + //remember the player's ip address + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + playerData.ipAddress = event.getAddress(); + + //FEATURE: auto-ban accounts who use an IP address which was very recently used by another banned account + if(GriefPrevention.instance.config_smartBan) + { + //if logging-in account is banned, remember IP address for later + long now = Calendar.getInstance().getTimeInMillis(); + if(event.getResult() == Result.KICK_BANNED) + { + this.tempBannedIps.add(new IpBanInfo(event.getAddress(), now + this.MILLISECONDS_IN_DAY, player.getName())); + } + + //otherwise if not banned + else + { + //search temporarily banned IP addresses for this one + for(int i = 0; i < this.tempBannedIps.size(); i++) + { + IpBanInfo info = this.tempBannedIps.get(i); + String address = info.address.toString(); + + //eliminate any expired entries + if(now > info.expirationTimestamp) + { + this.tempBannedIps.remove(i--); + } + + //if we find a match + else if(address.equals(playerData.ipAddress.toString())) + { + //if the account associated with the IP ban has been pardoned, remove all ip bans for that ip and we're done + OfflinePlayer bannedPlayer = GriefPrevention.instance.getServer().getOfflinePlayer(info.bannedAccountName); + if(!bannedPlayer.isBanned()) + { + for(int j = 0; j < this.tempBannedIps.size(); j++) + { + IpBanInfo info2 = this.tempBannedIps.get(j); + if(info2.address.toString().equals(address)) + { + OfflinePlayer bannedAccount = GriefPrevention.instance.getServer().getOfflinePlayer(info2.bannedAccountName); + bannedAccount.setBanned(false); + this.tempBannedIps.remove(j--); + } + } + + break; + } + + //otherwise if that account is still banned, ban this account, too + else + { + player.setBanned(true); + event.setResult(Result.KICK_BANNED); + event.disallow(event.getResult(), ""); + GriefPrevention.AddLogEntry("Auto-banned " + player.getName() + " because that account is using an IP address very recently used by banned player " + info.bannedAccountName + " (" + info.address.toString() + ")."); + } + } + } } } } @@ -270,7 +416,7 @@ class PlayerEventHandler implements Listener } //when a player successfully joins the server... - @EventHandler(ignoreCancelled = true) + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) void onPlayerJoin(PlayerJoinEvent event) { String playerName = event.getPlayer().getName(); @@ -283,24 +429,47 @@ class PlayerEventHandler implements Listener //check inventory, may need pvp protection GriefPrevention.instance.checkPvpProtectionNeeded(event.getPlayer()); + + //how long since the last logout? + long elapsed = Calendar.getInstance().getTimeInMillis() - playerData.lastLogout; + + //remember message, then silence it. may broadcast it later + String message = event.getJoinMessage(); + event.setJoinMessage(null); + + if(message != null && elapsed >= GriefPrevention.NOTIFICATION_SECONDS * 1000) + { + //start a timer for a delayed join notification message (will only show if player is still online in 30 seconds) + JoinLeaveAnnouncementTask task = new JoinLeaveAnnouncementTask(event.getPlayer(), message, true); + GriefPrevention.instance.getServer().getScheduler().scheduleSyncDelayedTask(GriefPrevention.instance, task, 20L * GriefPrevention.NOTIFICATION_SECONDS); + } } //when a player quits... - @EventHandler - void onPlayerQuit(PlayerQuitEvent event) - { - this.onPlayerDisconnect(event.getPlayer()); - } - - //when a player gets kicked... - @EventHandler(ignoreCancelled = true) + @EventHandler(priority = EventPriority.HIGHEST) void onPlayerKicked(PlayerKickEvent event) { - this.onPlayerDisconnect(event.getPlayer()); + Player player = event.getPlayer(); + PlayerData playerData = this.dataStore.getPlayerData(player.getName()); + if(player.isBanned()) + { + long now = Calendar.getInstance().getTimeInMillis(); + this.tempBannedIps.add(new IpBanInfo(playerData.ipAddress, now + this.MILLISECONDS_IN_DAY, player.getName())); + } + } + + //when a player quits... + @EventHandler(priority = EventPriority.HIGHEST) + void onPlayerQuit(PlayerQuitEvent event) + { + this.onPlayerDisconnect(event.getPlayer(), event.getQuitMessage()); + + //silence the leave message (may be broadcast later, if the player stays offline) + event.setQuitMessage(null); } //helper for above - private void onPlayerDisconnect(Player player) + private void onPlayerDisconnect(Player player, String notificationMessage) { String playerName = player.getName(); PlayerData playerData = this.dataStore.getPlayerData(playerName); @@ -319,15 +488,24 @@ class PlayerEventHandler implements Listener if(player.getHealth() > 0) player.setHealth(0); //might already be zero from above, this avoids a double death message } - //disable ignore claims mode - playerData.ignoreClaims = false; + //how long was the player online? + long now = Calendar.getInstance().getTimeInMillis(); + long elapsed = now - playerData.lastLogin.getTime(); - //drop player data from memory - this.dataStore.clearCachedPlayerData(playerName); + //remember logout time + playerData.lastLogout = Calendar.getInstance().getTimeInMillis(); + + //if notification message isn't null and the player has been online for at least 30 seconds... + if(notificationMessage != null && elapsed >= 1000 * GriefPrevention.NOTIFICATION_SECONDS) + { + //start a timer for a delayed leave notification message (will only show if player is still offline in 30 seconds) + JoinLeaveAnnouncementTask task = new JoinLeaveAnnouncementTask(player, notificationMessage, false); + GriefPrevention.instance.getServer().getScheduler().scheduleSyncDelayedTask(GriefPrevention.instance, task, 20L * GriefPrevention.NOTIFICATION_SECONDS); + } } //when a player drops an item - @EventHandler(priority = EventPriority.HIGHEST) + @EventHandler(priority = EventPriority.LOWEST) public void onPlayerDropItem(PlayerDropItemEvent event) { Player player = event.getPlayer(); @@ -360,7 +538,7 @@ class PlayerEventHandler implements Listener } //when a player teleports - @EventHandler(priority = EventPriority.HIGHEST) + @EventHandler(priority = EventPriority.LOWEST) public void onPlayerTeleport(PlayerTeleportEvent event) { //FEATURE: prevent teleport abuse to win sieges @@ -390,7 +568,7 @@ class PlayerEventHandler implements Listener } //when a player interacts with an entity... - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerInteractEntity(PlayerInteractEntityEvent event) { Player player = event.getPlayer(); @@ -458,7 +636,7 @@ class PlayerEventHandler implements Listener } //when a player picks up an item... - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerPickupItem(PlayerPickupItemEvent event) { Player player = event.getPlayer(); @@ -504,7 +682,7 @@ class PlayerEventHandler implements Listener } //block players from entering beds they don't have permission for - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerBedEnter (PlayerBedEnterEvent bedEvent) { if(!GriefPrevention.instance.config_claims_preventButtonsSwitches) return; @@ -526,7 +704,7 @@ class PlayerEventHandler implements Listener } //block use of buckets within other players' claims - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerBucketEmpty (PlayerBucketEmptyEvent bucketEvent) { Player player = bucketEvent.getPlayer(); @@ -542,29 +720,19 @@ class PlayerEventHandler implements Listener return; } - //if the bucket is being used in a claim + //if the bucket is being used in a claim, allow for dumping lava closer to other players Claim claim = this.dataStore.getClaimAt(block.getLocation(), false, null); if(claim != null) { - //the claim must be at least an hour old - long now = Calendar.getInstance().getTimeInMillis(); - long lastModified = claim.modifiedDate.getTime(); - long elapsed = now - lastModified; - if(bucketEvent.getBucket() == Material.LAVA_BUCKET && !player.hasPermission("griefprevention.lava") && elapsed < 1000 * 60 * 60) - { - GriefPrevention.sendMessage(player, TextMode.Err, "You can't dump lava here because this claim was recently modified. Try again later."); - bucketEvent.setCancelled(true); - } - minLavaDistance = 3; } - //otherwise it must be underground + //otherwise no dumping anything unless underground else { - if(bucketEvent.getBucket() == Material.LAVA_BUCKET && block.getY() >= block.getWorld().getSeaLevel() - 5 && !player.hasPermission("griefprevention.lava")) + if(block.getY() >= block.getWorld().getSeaLevel() - 5 && !player.hasPermission("griefprevention.lava")) { - GriefPrevention.sendMessage(player, TextMode.Err, "You may only dump lava inside your claim(s) or underground."); + GriefPrevention.sendMessage(player, TextMode.Err, "You may only dump buckets inside your claim(s) or underground."); bucketEvent.setCancelled(true); return; } @@ -592,7 +760,7 @@ class PlayerEventHandler implements Listener } //see above - @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + @EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST) public void onPlayerBucketFill (PlayerBucketFillEvent bucketEvent) { Player player = bucketEvent.getPlayer(); @@ -609,7 +777,7 @@ class PlayerEventHandler implements Listener } //when a player interacts with the world - @EventHandler(priority = EventPriority.HIGHEST) + @EventHandler(priority = EventPriority.LOWEST) void onPlayerInteract(PlayerInteractEvent event) { Player player = event.getPlayer(); @@ -640,7 +808,7 @@ class PlayerEventHandler implements Listener Material clickedBlockType = clickedBlock.getType(); //apply rules for buttons and switches - if(GriefPrevention.instance.config_claims_preventButtonsSwitches && (clickedBlockType == Material.STONE_BUTTON || clickedBlockType == Material.LEVER)) + if(GriefPrevention.instance.config_claims_preventButtonsSwitches && (clickedBlockType == null || clickedBlockType == Material.STONE_BUTTON || clickedBlockType == Material.LEVER)) { Claim claim = this.dataStore.getClaimAt(clickedBlock.getLocation(), false, null); if(claim != null) @@ -814,7 +982,7 @@ class PlayerEventHandler implements Listener //if the player is in restore nature mode, do only that String playerName = player.getName(); playerData = this.dataStore.getPlayerData(player.getName()); - if(playerData.shovelMode == ShovelMode.RestoreNature) + if(playerData.shovelMode == ShovelMode.RestoreNature || playerData.shovelMode == ShovelMode.RestoreNatureAggressive) { //if the clicked block is in a claim, visualize that claim and deliver an error message Claim claim = this.dataStore.getClaimAt(clickedBlock.getLocation(), false, playerData.lastClaim); @@ -827,7 +995,7 @@ class PlayerEventHandler implements Listener return; } - //figure out which chunk to regen + //figure out which chunk to repair Chunk chunk = player.getWorld().getChunkAt(clickedBlock.getLocation()); //check it for players, and cancel if there are any @@ -863,9 +1031,14 @@ class PlayerEventHandler implements Listener //set boundaries for processing int miny = clickedBlock.getY(); - if(miny > chunk.getWorld().getSeaLevel() - 10) + + //if not in aggressive mode, extend the selection down to a little below sea level + if(!(playerData.shovelMode == ShovelMode.RestoreNatureAggressive)) { - miny = chunk.getWorld().getSeaLevel() - 10; + if(miny > chunk.getWorld().getSeaLevel() - 10) + { + miny = chunk.getWorld().getSeaLevel() - 10; + } } Location lesserBoundaryCorner = chunk.getBlock(0, 0, 0).getLocation(); @@ -873,12 +1046,112 @@ class PlayerEventHandler implements Listener //create task //when done processing, this task will create a main thread task to actually update the world with processing results - RestoreNatureProcessingTask task = new RestoreNatureProcessingTask(snapshots, miny, chunk.getWorld().getEnvironment(), chunk.getWorld().getBiome(lesserBoundaryCorner.getBlockX(), lesserBoundaryCorner.getBlockZ()), lesserBoundaryCorner, greaterBoundaryCorner, chunk.getWorld().getSeaLevel(), player); + RestoreNatureProcessingTask task = new RestoreNatureProcessingTask(snapshots, miny, chunk.getWorld().getEnvironment(), chunk.getWorld().getBiome(lesserBoundaryCorner.getBlockX(), lesserBoundaryCorner.getBlockZ()), lesserBoundaryCorner, greaterBoundaryCorner, chunk.getWorld().getSeaLevel(), playerData.shovelMode == ShovelMode.RestoreNatureAggressive, player); GriefPrevention.instance.getServer().getScheduler().scheduleAsyncDelayedTask(GriefPrevention.instance, task); return; } + //if in restore nature fill mode + if(playerData.shovelMode == ShovelMode.RestoreNatureFill) + { + ArrayList allowedFillBlocks = new ArrayList(); + Environment environment = clickedBlock.getWorld().getEnvironment(); + if(environment == Environment.NETHER) + { + allowedFillBlocks.add(Material.NETHERRACK); + } + else if(environment == Environment.THE_END) + { + allowedFillBlocks.add(Material.ENDER_STONE); + } + else + { + allowedFillBlocks.add(Material.STONE); + allowedFillBlocks.add(Material.SAND); + allowedFillBlocks.add(Material.SANDSTONE); + allowedFillBlocks.add(Material.DIRT); + allowedFillBlocks.add(Material.GRASS); + } + + Block centerBlock = clickedBlock; + int maxHeight = centerBlock.getY(); + int minx = centerBlock.getX() - playerData.fillRadius; + int maxx = centerBlock.getX() + playerData.fillRadius; + int minz = centerBlock.getZ() - playerData.fillRadius; + int maxz = centerBlock.getZ() + playerData.fillRadius; + int minHeight = maxHeight - 10; + if(minHeight < 0) minHeight = 0; + + Claim cachedClaim = null; + for(int x = minx; x <= maxx; x++) + { + for(int z = minz; z <= maxz; z++) + { + //circular brush + Location location = new Location(centerBlock.getWorld(), x, centerBlock.getY(), z); + if(location.distance(centerBlock.getLocation()) > playerData.fillRadius) continue; + + //fill bottom to top + for(int y = minHeight; y <= maxHeight; y++) + { + Block block = centerBlock.getWorld().getBlockAt(x, y, z); + + //respect claims + Claim claim = this.dataStore.getClaimAt(block.getLocation(), false, cachedClaim); + if(claim != null) + { + cachedClaim = claim; + break; + } + + //only replace air and spilling water + if(block.getType() == Material.AIR || block.getType() == Material.STATIONARY_WATER && block.getData() != 0) + { + //look to neighbors for an appropriate fill block + Block eastBlock = block.getRelative(BlockFace.EAST); + Block westBlock = block.getRelative(BlockFace.WEST); + Block northBlock = block.getRelative(BlockFace.NORTH); + Block southBlock = block.getRelative(BlockFace.SOUTH); + Block underBlock = block.getRelative(BlockFace.DOWN); + + //first, check lateral neighbors (ideally, want to keep natural layers) + if(allowedFillBlocks.contains(eastBlock.getType())) + { + block.setType(eastBlock.getType()); + } + else if(allowedFillBlocks.contains(westBlock.getType())) + { + block.setType(westBlock.getType()); + } + else if(allowedFillBlocks.contains(northBlock.getType())) + { + block.setType(northBlock.getType()); + } + else if(allowedFillBlocks.contains(southBlock.getType())) + { + block.setType(southBlock.getType()); + } + + //then check underneath + else if(allowedFillBlocks.contains(underBlock.getType())) + { + block.setType(underBlock.getType()); + } + + //if all else fails, use the first material listed in the acceptable fill blocks above + else + { + block.setType(allowedFillBlocks.get(0)); + } + } + } + } + } + + return; + } + //if the player doesn't have claims permission, don't do anything if(GriefPrevention.instance.config_claims_creationRequiresPermission && !player.hasPermission("griefprevention.createclaims")) { @@ -959,24 +1232,30 @@ class PlayerEventHandler implements Listener } } - //in creative mode, top-level claims can't be moved or resized smaller. - //to check this, verifying the old claim's corners are inside the new claim's boundaries. - if(!player.hasPermission("griefprevention.deleteclaims") && GriefPrevention.instance.creativeRulesApply(player.getLocation()) && playerData.claimResizing.parent == null) - { - Claim oldClaim = playerData.claimResizing; - + //special rules for making a top-level claim smaller. to check this, verifying the old claim's corners are inside the new claim's boundaries. + //rule1: in creative mode, top-level claims can't be moved or resized smaller. + //rule2: in any mode, shrinking a claim removes any surface fluids + Claim oldClaim = playerData.claimResizing; + if(oldClaim.parent == null) + { //temporary claim instance, just for checking contains() Claim newClaim = new Claim( new Location(oldClaim.getLesserBoundaryCorner().getWorld(), newx1, newy1, newz1), new Location(oldClaim.getLesserBoundaryCorner().getWorld(), newx2, newy2, newz2), "", new String[]{}, new String[]{}, new String[]{}, new String[]{}); - //both greater and lesser boundary corners of the old claim must be inside the new claim + //if the new claim is smaller if(!newClaim.contains(oldClaim.getLesserBoundaryCorner(), true, false) || !newClaim.contains(oldClaim.getGreaterBoundaryCorner(), true, false)) { - //otherwise, show an error message and stop here - GriefPrevention.sendMessage(player, TextMode.Err, "You can't un-claim creative mode land. You can only make this claim larger or create additional claims."); - return; + //enforce creative mode rule + if(!player.hasPermission("griefprevention.deleteclaims") && GriefPrevention.instance.creativeRulesApply(player.getLocation())) + { + GriefPrevention.sendMessage(player, TextMode.Err, "You can't un-claim creative mode land. You can only make this claim larger or create additional claims."); + return; + } + + //remove surface fluids about to be unclaimed + oldClaim.removeSurfaceFluids(newClaim); } } diff --git a/src/me/ryanhamshire/GriefPrevention/PlayerRescueTask.java b/src/me/ryanhamshire/GriefPrevention/PlayerRescueTask.java index f5373ec..e73bd88 100644 --- a/src/me/ryanhamshire/GriefPrevention/PlayerRescueTask.java +++ b/src/me/ryanhamshire/GriefPrevention/PlayerRescueTask.java @@ -61,7 +61,7 @@ class PlayerRescueTask implements Runnable Location destination = GriefPrevention.instance.ejectPlayer(this.player); //log entry, in case admins want to investigate the "trap" - GriefPrevention.AddLogEntry("Rescued trapped player " + player.getName() + " from " + this.location.toString() + " to " + destination.toString() + "."); + GriefPrevention.AddLogEntry("Rescued trapped player " + player.getName() + " from " + GriefPrevention.getfriendlyLocationString(this.location) + " to " + GriefPrevention.getfriendlyLocationString(destination) + "."); //timestamp this successful save so that he can't use /trapped again for a while playerData.lastTrappedUsage = Calendar.getInstance().getTime(); diff --git a/src/me/ryanhamshire/GriefPrevention/RestoreNatureProcessingTask.java b/src/me/ryanhamshire/GriefPrevention/RestoreNatureProcessingTask.java index 9427589..9deb386 100644 --- a/src/me/ryanhamshire/GriefPrevention/RestoreNatureProcessingTask.java +++ b/src/me/ryanhamshire/GriefPrevention/RestoreNatureProcessingTask.java @@ -43,12 +43,13 @@ class RestoreNatureProcessingTask implements Runnable private Player player; //absolutely must not be accessed. not thread safe. private Biome biome; private int seaLevel; + private boolean aggressiveMode; //two lists of materials private ArrayList notAllowedToHang; //natural blocks which don't naturally hang in their air private ArrayList playerBlocks; //a "complete" list of player-placed blocks. MUST BE MAINTAINED as patches introduce more - public RestoreNatureProcessingTask(BlockSnapshot[][][] snapshots, int miny, Environment environment, Biome biome, Location lesserBoundaryCorner, Location greaterBoundaryCorner, int seaLevel, Player player) + public RestoreNatureProcessingTask(BlockSnapshot[][][] snapshots, int miny, Environment environment, Biome biome, Location lesserBoundaryCorner, Location greaterBoundaryCorner, int seaLevel, boolean aggressiveMode, Player player) { this.snapshots = snapshots; this.miny = miny; @@ -57,18 +58,24 @@ class RestoreNatureProcessingTask implements Runnable this.greaterBoundaryCorner = greaterBoundaryCorner; this.biome = biome; this.seaLevel = seaLevel; + this.aggressiveMode = aggressiveMode; this.player = player; this.notAllowedToHang = new ArrayList(); this.notAllowedToHang.add(Material.DIRT.getId()); - this.notAllowedToHang.add(Material.GRASS.getId()); this.notAllowedToHang.add(Material.LONG_GRASS.getId()); this.notAllowedToHang.add(Material.SNOW.getId()); this.notAllowedToHang.add(Material.LOG.getId()); + if(this.aggressiveMode) + { + this.notAllowedToHang.add(Material.GRASS.getId()); + this.notAllowedToHang.add(Material.STONE.getId()); + } + //NOTE on this list. why not make a list of natural blocks? //answer: better to leave a few player blocks than to remove too many natural blocks. remember we're "restoring nature" - //a few extra player blocks can be manually removed, but it will be impossible to guess exactly which natural materials to use in replacements + //a few extra player blocks can be manually removed, but it will be impossible to guess exactly which natural materials to use in manual repair of an overzealous block removal this.playerBlocks = new ArrayList(); this.playerBlocks.add(Material.FIRE.getId()); this.playerBlocks.add(Material.BED_BLOCK.getId()); @@ -155,11 +162,23 @@ class RestoreNatureProcessingTask implements Runnable } //these are unnatural in sandy biomes, but not elsewhere - if(this.biome == Biome.DESERT || this.biome == Biome.DESERT_HILLS || this.biome == Biome.BEACH) + if(this.biome == Biome.DESERT || this.biome == Biome.DESERT_HILLS || this.biome == Biome.BEACH || this.aggressiveMode) { this.playerBlocks.add(Material.LEAVES.getId()); this.playerBlocks.add(Material.LOG.getId()); } + + //in aggressive mode, also treat these blocks as user placed, to be removed + //this is helpful in the few cases where griefers intentionally use natural blocks to grief, + //like a single-block tower of iron ore or a giant penis constructed with logs + if(this.aggressiveMode) + { + this.playerBlocks.add(Material.IRON_ORE.getId()); + this.playerBlocks.add(Material.PUMPKIN.getId()); + this.playerBlocks.add(Material.PUMPKIN_STEM.getId()); + this.playerBlocks.add(Material.MELON_BLOCK.getId()); + this.playerBlocks.add(Material.MELON_STEM.getId()); + } } @Override @@ -185,6 +204,9 @@ class RestoreNatureProcessingTask implements Runnable //fill water depressions and fix unnatural surface ripples this.fixWater(); + //remove water/lava above sea level + this.removeDumpedFluids(); + //schedule main thread task to apply the result to the world RestoreNatureExecutionTask task = new RestoreNatureExecutionTask(this.snapshots, this.miny, this.lesserBoundaryCorner, this.greaterBoundaryCorner, this.player); GriefPrevention.instance.getServer().getScheduler().scheduleSyncDelayedTask(GriefPrevention.instance, task); @@ -195,6 +217,7 @@ class RestoreNatureProcessingTask implements Runnable int miny = this.miny; if(miny < 1) miny = 1; + //remove all player blocks for(int x = 1; x < snapshots.length - 1; x++) { for(int z = 1; z < snapshots[0][0].length - 1; z++) @@ -208,7 +231,7 @@ class RestoreNatureProcessingTask implements Runnable } } } - } + } } private void removeHanging() @@ -225,7 +248,7 @@ class RestoreNatureProcessingTask implements Runnable BlockSnapshot block = snapshots[x][y][z]; BlockSnapshot underBlock = snapshots[x][y - 1][z]; - if(underBlock.typeId == Material.AIR.getId() || underBlock.typeId == Material.WATER.getId()) + if(underBlock.typeId == Material.AIR.getId() || underBlock.typeId == Material.STATIONARY_WATER.getId() || underBlock.typeId == Material.STATIONARY_LAVA.getId() || underBlock.typeId == Material.LEAVES.getId()) { if(this.notAllowedToHang.contains(block.typeId)) { @@ -267,19 +290,19 @@ class RestoreNatureProcessingTask implements Runnable { for(int z = 1; z < snapshots[0][0].length - 1; z++) { - int thisy = this.highestY(x, z); + int thisy = this.highestY(x, z, false); if(excludedBlocks.contains(this.snapshots[x][thisy][z].typeId)) continue; - int righty = this.highestY(x + 1, z); - int lefty = this.highestY(x - 1, z); + int righty = this.highestY(x + 1, z, false); + int lefty = this.highestY(x - 1, z, false); while(lefty < thisy && righty < thisy) { this.snapshots[x][thisy--][z].typeId = Material.AIR.getId(); changed = true; } - int upy = this.highestY(x, z + 1); - int downy = this.highestY(x, z - 1); + int upy = this.highestY(x, z + 1, false); + int downy = this.highestY(x, z - 1, false); while(upy < thisy && downy < thisy) { this.snapshots[x][thisy--][z].typeId = Material.AIR.getId(); @@ -296,7 +319,7 @@ class RestoreNatureProcessingTask implements Runnable { for(int z = 1; z < snapshots[0][0].length - 1; z++) { - int y = this.highestY(x, z); + int y = this.highestY(x, z, true); BlockSnapshot block = snapshots[x][y][z]; if(block.typeId == Material.STONE.getId() || block.typeId == Material.GRAVEL.getId() || block.typeId == Material.DIRT.getId()) @@ -454,13 +477,36 @@ class RestoreNatureProcessingTask implements Runnable }while(changed); } - private int highestY(int x, int z) + private void removeDumpedFluids() + { + //remove any surface water or lava above sea level, presumed to be placed by players + //sometimes, this is naturally generated. but replacing it is very easy with a bucket, so overall this is a good plan + if(this.environment == Environment.NETHER) return; + for(int x = 1; x < snapshots.length - 1; x++) + { + for(int z = 1; z < snapshots[0][0].length - 1; z++) + { + for(int y = this.seaLevel - 1; y < snapshots[0].length - 1; y++) + { + BlockSnapshot block = snapshots[x][y][z]; + if(block.typeId == Material.STATIONARY_WATER.getId() || block.typeId == Material.STATIONARY_LAVA.getId() || + block.typeId == Material.WATER.getId() || block.typeId == Material.LAVA.getId()) + { + block.typeId = Material.AIR.getId(); + } + } + } + } + } + + private int highestY(int x, int z, boolean ignoreLeaves) { int y; for(y = snapshots[0].length - 1; y > 0; y--) { BlockSnapshot block = this.snapshots[x][y][z]; - if(block.typeId != Material.AIR.getId() && + if(block.typeId != Material.AIR.getId() && + !(ignoreLeaves && block.typeId == Material.LEAVES.getId()) && !(block.typeId == Material.STATIONARY_WATER.getId() && block.data != 0) && !(block.typeId == Material.STATIONARY_LAVA.getId() && block.data != 0)) { diff --git a/src/me/ryanhamshire/GriefPrevention/ShovelMode.java b/src/me/ryanhamshire/GriefPrevention/ShovelMode.java index 44ef29d..1e5a018 100644 --- a/src/me/ryanhamshire/GriefPrevention/ShovelMode.java +++ b/src/me/ryanhamshire/GriefPrevention/ShovelMode.java @@ -24,5 +24,7 @@ enum ShovelMode Basic, Admin, Subdivide, - RestoreNature + RestoreNature, + RestoreNatureAggressive, + RestoreNatureFill }