WorldBorder/src/main/java/com/wimbli/WorldBorder/WorldFillTask.java

555 lines
17 KiB
Java

package com.wimbli.WorldBorder;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.entity.Player;
import org.bukkit.Server;
import org.bukkit.World;
import com.wimbli.WorldBorder.Events.WorldBorderFillFinishedEvent;
import com.wimbli.WorldBorder.Events.WorldBorderFillStartEvent;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
public class WorldFillTask implements Runnable
{
// general task-related reference data
private transient Server server = null;
private transient World world = null;
private transient BorderData border = null;
private transient WorldFileData worldData = null;
private transient boolean readyToGo = false;
private transient boolean paused = false;
private transient boolean pausedForMemory = false;
private transient int taskID = -1;
private transient Player notifyPlayer = null;
private transient int chunksPerRun = 1;
private transient boolean continueNotice = false;
private transient boolean forceLoad = false;
// these are only stored for saving task to config
private transient int fillDistance = 208;
private transient int tickFrequency = 1;
private transient int refX = 0, lastLegX = 0;
private transient int refZ = 0, lastLegZ = 0;
private transient int refLength = -1;
private transient int refTotal = 0, lastLegTotal = 0;
// values for the spiral pattern check which fills out the map to the border
private transient int x = 0;
private transient int z = 0;
private transient boolean isZLeg = false;
private transient boolean isNeg = false;
private transient int length = -1;
private transient int current = 0;
private transient boolean insideBorder = true;
private List<CoordXZ> storedChunks = new LinkedList<CoordXZ>();
private Set<CoordXZ> originalChunks = new HashSet<CoordXZ>();
private transient CoordXZ lastChunk = new CoordXZ(0, 0);
// for reporting progress back to user occasionally
private transient long lastReport = Config.Now();
private transient long lastAutosave = Config.Now();
private transient int reportTarget = 0;
private transient int reportTotal = 0;
private transient int reportNum = 0;
private transient boolean canUsePaperAPI = false;
private Set<CompletableFuture<Chunk>> pendingChunks;
public WorldFillTask(Server theServer, Player player, String worldName, int fillDistance, int chunksPerRun, int tickFrequency, boolean forceLoad)
{
this.server = theServer;
this.notifyPlayer = player;
this.fillDistance = fillDistance;
this.tickFrequency = tickFrequency;
this.chunksPerRun = chunksPerRun;
this.forceLoad = forceLoad;
this.world = server.getWorld(worldName);
if (this.world == null)
{
if (worldName.isEmpty())
sendMessage("You must specify a world!");
else
sendMessage("World \"" + worldName + "\" not found!");
this.stop();
return;
}
this.border = (Config.Border(worldName) == null) ? null : Config.Border(worldName).copy();
if (this.border == null)
{
sendMessage("No border found for world \"" + worldName + "\"!");
this.stop();
return;
}
// load up a new WorldFileData for the world in question, used to scan region files for which chunks are already fully generated and such
worldData = WorldFileData.create(world, notifyPlayer);
if (worldData == null)
{
this.stop();
return;
}
canUsePaperAPI = checkForPaperAPI();
if (canUsePaperAPI) {
pendingChunks = new HashSet<>();
}
this.border.setRadiusX(border.getRadiusX() + fillDistance);
this.border.setRadiusZ(border.getRadiusZ() + fillDistance);
this.x = CoordXZ.blockToChunk((int)border.getX());
this.z = CoordXZ.blockToChunk((int)border.getZ());
int chunkWidthX = (int) Math.ceil((double)((border.getRadiusX() + 16) * 2) / 16);
int chunkWidthZ = (int) Math.ceil((double)((border.getRadiusZ() + 16) * 2) / 16);
int biggerWidth = (chunkWidthX > chunkWidthZ) ? chunkWidthX : chunkWidthZ; //We need to calculate the reportTarget with the bigger width, since the spiral will only stop if it has a size of biggerWidth x biggerWidth
this.reportTarget = (biggerWidth * biggerWidth) + biggerWidth + 1;
//This would be another way to calculate reportTarget, it assumes that we don't need time to check if the chunk is outside and then skip it (it calculates the area of the rectangle/ellipse)
//this.reportTarget = (this.border.getShape()) ? ((int) Math.ceil(chunkWidthX * chunkWidthZ / 4 * Math.PI + 2 * chunkWidthX)) : (chunkWidthX * chunkWidthZ);
// Area of the ellipse just to be safe area of the rectangle
// keep track of the chunks which are already loaded when the task starts, to not unload them
Chunk[] originals = world.getLoadedChunks();
for (Chunk original : originals)
{
originalChunks.add(new CoordXZ(original.getX(), original.getZ()));
}
this.readyToGo = true;
Bukkit.getServer().getPluginManager().callEvent(new WorldBorderFillStartEvent(this));
}
// for backwards compatibility
public WorldFillTask(Server theServer, Player player, String worldName, int fillDistance, int chunksPerRun, int tickFrequency)
{
this(theServer, player, worldName, fillDistance, chunksPerRun, tickFrequency, false);
}
public void setTaskID(int ID)
{
if (ID == -1) this.stop();
this.taskID = ID;
}
private boolean checkForPaperAPI() {
try {
Class.forName("com.destroystokyo.paper.PaperConfig");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
@Override
public void run()
{
if (continueNotice)
{ // notify user that task has continued automatically
continueNotice = false;
sendMessage("World map generation task automatically continuing.");
sendMessage("Reminder: you can cancel at any time with \"wb fill cancel\", or pause/unpause with \"wb fill pause\".");
}
if (pausedForMemory)
{ // if available memory gets too low, we automatically pause, so handle that
if (Config.AvailableMemoryTooLow())
return;
pausedForMemory = false;
readyToGo = true;
sendMessage("Available memory is sufficient, automatically continuing.");
}
if (server == null || !readyToGo || paused)
return;
// this is set so it only does one iteration at a time, no matter how frequently the timer fires
readyToGo = false;
// and this is tracked to keep one iteration from dragging on too long and possibly choking the system if the user specified a really high frequency
if (canUsePaperAPI) {
Set<CompletableFuture<Chunk>> newPendingChunks = new HashSet<>();
for (CompletableFuture<Chunk> cf: pendingChunks) {
if (cf.isDone()) {
try {
Chunk chunk=cf.get();
// System.out.println(chunk);
if (chunk==null)
continue;
CoordXZ xz = new CoordXZ(chunk.getX(), chunk.getZ());
worldData.chunkExistsNow(xz.x, xz.z);
storedChunks.add(xz);
} catch (InterruptedException | ExecutionException ex) {
Config.log(ex.getMessage());
}
} else {
newPendingChunks.add(cf);
}
}
pendingChunks=newPendingChunks;
if (pendingChunks.size() > chunksPerRun*2) {
readyToGo = true;
return;
}
}
long loopStartTime = Config.Now();
for (int loop = 0; loop < chunksPerRun; loop++)
{
// in case the task has been paused while we're repeating...
if (paused || pausedForMemory)
return;
long now = Config.Now();
// every 5 seconds or so, give basic progress report to let user know how it's going
if (now > lastReport + 5000)
reportProgress();
// if this iteration has been running for 45ms (almost 1 tick) or more, stop to take a breather
if (now > loopStartTime + 45)
{
readyToGo = true;
return;
}
// if we've made it at least partly outside the border, skip past any such chunks
while (!border.insideBorder(CoordXZ.chunkToBlock(x) + 8, CoordXZ.chunkToBlock(z) + 8))
{
if (!moveToNext())
return;
}
insideBorder = true;
if (!forceLoad)
{
// skip past any chunks which are confirmed as fully generated using our super-special isChunkFullyGenerated routine
while (worldData.isChunkFullyGenerated(x, z))
{
insideBorder = true;
if (!moveToNext())
return;
}
}
// load the target chunk and generate it if necessary
if (canUsePaperAPI) {
pendingChunks.add(world.getChunkAtAsync(x, z, true));
} else {
world.loadChunk(x, z, true);
worldData.chunkExistsNow(x, z);
storedChunks.add(new CoordXZ(x, z));
}
// There need to be enough nearby chunks loaded to make the server populate a chunk with trees, snow, etc.
// So, we keep the last few chunks loaded, and need to also temporarily load an extra inside chunk (neighbor closest to center of map)
int popX = !isZLeg ? x : (x + (isNeg ? -1 : 1));
int popZ = isZLeg ? z : (z + (!isNeg ? -1 : 1));
if (canUsePaperAPI) {
pendingChunks.add(world.getChunkAtAsync(popX, popZ, false));
} else {
world.loadChunk(popX, popZ, false);
storedChunks.add(new CoordXZ(popX, popZ));
}
// make sure the previous chunk in our spiral is loaded as well (might have already existed and been skipped over)
if (!storedChunks.contains(lastChunk) && !originalChunks.contains(lastChunk))
{
if (canUsePaperAPI) {
pendingChunks.add(world.getChunkAtAsync(lastChunk.x, lastChunk.z, false));
} else {
world.loadChunk(lastChunk.x, lastChunk.z, false);
storedChunks.add(new CoordXZ(lastChunk.x, lastChunk.z));
}
}
// If enough stored chunks are buffered in, go ahead and unload the oldest to free up memory
while (storedChunks.size() > 8)
{
CoordXZ coord = storedChunks.remove(0);
if (!originalChunks.contains(coord))
world.unloadChunkRequest(coord.x, coord.z);
}
// move on to next chunk
if (!moveToNext())
return;
}
// ready for the next iteration to run
readyToGo = true;
}
// step through chunks in spiral pattern from center; returns false if we're done, otherwise returns true
public boolean moveToNext()
{
if (paused || pausedForMemory)
return false;
reportNum++;
// keep track of progress in case we need to save to config for restoring progress after server restart
if (!isNeg && current == 0 && length > 3)
{
if (!isZLeg)
{
lastLegX = x;
lastLegZ = z;
lastLegTotal = reportTotal + reportNum;
} else {
refX = lastLegX;
refZ = lastLegZ;
refTotal = lastLegTotal;
refLength = length - 1;
}
}
// make sure of the direction we're moving (X or Z? negative or positive?)
if (current < length)
current++;
else
{ // one leg/side of the spiral down...
current = 0;
isZLeg ^= true;
if (isZLeg)
{ // every second leg (between X and Z legs, negative or positive), length increases
isNeg ^= true;
length++;
}
}
// keep track of the last chunk we were at
lastChunk.x = x;
lastChunk.z = z;
// move one chunk further in the appropriate direction
if (isZLeg)
z += (isNeg) ? -1 : 1;
else
x += (isNeg) ? -1 : 1;
// if we've been around one full loop (4 legs)...
if (isZLeg && isNeg && current == 0)
{ // see if we've been outside the border for the whole loop
if (!insideBorder)
{ // and finish if so
finish();
return false;
} // otherwise, reset the "inside border" flag
else
insideBorder = false;
}
return true;
/* reference diagram used, should move in this pattern:
* 8 [>][>][>][>][>] etc.
* [^][6][>][>][>][>][>][6]
* [^][^][4][>][>][>][4][v]
* [^][^][^][2][>][2][v][v]
* [^][^][^][^][0][v][v][v]
* [^][^][^][1][1][v][v][v]
* [^][^][3][<][<][3][v][v]
* [^][5][<][<][<][<][5][v]
* [7][<][<][<][<][<][<][7]
*/
}
// for successful completion
public void finish()
{
this.paused = true;
reportProgress();
world.save();
Bukkit.getServer().getPluginManager().callEvent(new WorldBorderFillFinishedEvent(world, reportTotal));
sendMessage("task successfully completed for world \"" + refWorld() + "\"!");
this.stop();
}
// for cancelling prematurely
public void cancel()
{
this.stop();
}
// we're done, whether finished or cancelled
private void stop()
{
if (server == null)
return;
readyToGo = false;
if (taskID != -1)
server.getScheduler().cancelTask(taskID);
server = null;
// go ahead and unload any chunks we still have loaded
while(!storedChunks.isEmpty())
{
CoordXZ coord = storedChunks.remove(0);
if (!originalChunks.contains(coord))
world.unloadChunkRequest(coord.x, coord.z);
}
}
// is this task still valid/workable?
public boolean valid()
{
return this.server != null;
}
// handle pausing/unpausing the task
public void pause()
{
if(this.pausedForMemory)
pause(false);
else
pause(!this.paused);
}
public void pause(boolean pause)
{
if (this.pausedForMemory && !pause)
this.pausedForMemory = false;
else
this.paused = pause;
if (this.paused)
{
Config.StoreFillTask();
reportProgress();
}
else
Config.UnStoreFillTask();
}
public boolean isPaused()
{
return this.paused || this.pausedForMemory;
}
// let the user know how things are coming along
private void reportProgress()
{
lastReport = Config.Now();
double perc = getPercentageCompleted();
if (perc > 100) perc = 100;
sendMessage(reportNum + " more chunks processed (" + (reportTotal + reportNum) + " total, ~" + Config.coord.format(perc) + "%" + ")");
reportTotal += reportNum;
reportNum = 0;
// go ahead and save world to disk every 30 seconds or so by default, just in case; can take a couple of seconds or more, so we don't want to run it too often
if (Config.FillAutosaveFrequency() > 0 && lastAutosave + (Config.FillAutosaveFrequency() * 1000) < lastReport)
{
lastAutosave = lastReport;
sendMessage("Saving the world to disk, just to be on the safe side.");
world.save();
}
}
// send a message to the server console/log and possibly to an in-game player
private void sendMessage(String text)
{
// Due to chunk generation eating up memory and Java being too slow about GC, we need to track memory availability
int availMem = Config.AvailableMemory();
Config.log("[Fill] " + text + " (free mem: " + availMem + " MB)");
if (notifyPlayer != null)
notifyPlayer.sendMessage("[Fill] " + text);
if (availMem < 200)
{ // running low on memory, auto-pause
pausedForMemory = true;
Config.StoreFillTask();
text = "Available memory is very low, task is pausing. A cleanup will be attempted now, and the task will automatically continue if/when sufficient memory is freed up.\n Alternatively, if you restart the server, this task will automatically continue once the server is back up.";
Config.log("[Fill] " + text);
if (notifyPlayer != null)
notifyPlayer.sendMessage("[Fill] " + text);
// prod Java with a request to go ahead and do GC to clean unloaded chunks from memory; this seems to work wonders almost immediately
// yes, explicit calls to System.gc() are normally bad, but in this case it otherwise can take a long long long time for Java to recover memory
System.gc();
}
}
// stuff for saving / restoring progress
public void continueProgress(int x, int z, int length, int totalDone)
{
this.x = x;
this.z = z;
this.length = length;
this.reportTotal = totalDone;
this.continueNotice = true;
}
public int refX()
{
return refX;
}
public int refZ()
{
return refZ;
}
public int refLength()
{
return refLength;
}
public int refTotal()
{
return refTotal;
}
public int refFillDistance()
{
return fillDistance;
}
public int refTickFrequency()
{
return tickFrequency;
}
public int refChunksPerRun()
{
return chunksPerRun;
}
public String refWorld()
{
return world.getName();
}
public boolean refForceLoad()
{
return forceLoad;
}
/**
* Get the percentage completed for the fill task.
*
* @return Percentage
*/
public double getPercentageCompleted() {
return ((double) (reportTotal + reportNum) / (double) reportTarget) * 100;
}
/**
* Amount of chunks completed for the fill task.
*
* @return Number of chunks processed.
*/
public int getChunksCompleted() {
return reportTotal;
}
/**
* Total amount of chunks that need to be generated for the fill task.
*
* @return Number of chunks that need to be processed.
*/
public int getChunksTotal() {
return reportTarget;
}
}