package io.papermc.paper.threadedregions; import ca.spottedleaf.concurrentutil.util.Validate; import ca.spottedleaf.moonrise.common.util.TickThread; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import net.minecraft.world.entity.Entity; import org.bukkit.craftbukkit.entity.CraftEntity; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; /** * An entity can move between worlds with an arbitrary tick delay, be temporarily removed * for players (i.e end credits), be partially removed from world state (i.e inactive but not removed), * teleport between ticking regions, teleport between worlds (which will change the underlying Entity object * for non-players), and even be removed entirely from the server. The uncertainty of an entity's state can make * it difficult to schedule tasks without worrying about undefined behaviors resulting from any of the states listed * previously. * *

* This class is designed to eliminate those states by providing an interface to run tasks only when an entity * is contained in a world, on the owning thread for the region, and by providing the current Entity object. * The scheduler also allows a task to provide a callback, the "retired" callback, that will be invoked * if the entity is removed before a task that was scheduled could be executed. The scheduler is also * completely thread-safe, allowing tasks to be scheduled from any thread context. The scheduler also indicates * properly whether a task was scheduled successfully (i.e scheduler not retired), thus the code scheduling any task * knows whether the given callbacks will be invoked eventually or not - which may be critical for off-thread * contexts. *

*/ public final class EntityScheduler { /** * The Entity. Note that it is the CraftEntity, since only that class properly tracks world transfers. */ public final CraftEntity entity; private static final record ScheduledTask(Consumer run, Consumer retired) {} private long tickCount = 0L; private static final long RETIRED_TICK_COUNT = -1L; private final Object stateLock = new Object(); private final Long2ObjectOpenHashMap> oneTimeDelayed = new Long2ObjectOpenHashMap<>(); private final ArrayDeque currentlyExecuting = new ArrayDeque<>(); public EntityScheduler(final CraftEntity entity) { this.entity = Validate.notNull(entity); } /** * Retires the scheduler, preventing new tasks from being scheduled and invoking the retired callback * on all currently scheduled tasks. * *

* Note: This should only be invoked after synchronously removing the entity from the world. *

* * @throws IllegalStateException If the scheduler is already retired. */ public void retire() { synchronized (this.stateLock) { if (this.tickCount == RETIRED_TICK_COUNT) { throw new IllegalStateException("Already retired"); } this.tickCount = RETIRED_TICK_COUNT; } final Entity thisEntity = this.entity.getHandleRaw(); // correctly handle and order retiring while running executeTick for (int i = 0, len = this.currentlyExecuting.size(); i < len; ++i) { final ScheduledTask task = this.currentlyExecuting.pollFirst(); final Consumer retireTask = (Consumer)task.retired; if (retireTask == null) { continue; } retireTask.accept(thisEntity); } for (final List tasks : this.oneTimeDelayed.values()) { for (int i = 0, len = tasks.size(); i < len; ++i) { final ScheduledTask task = tasks.get(i); final Consumer retireTask = (Consumer)task.retired; if (retireTask == null) { continue; } retireTask.accept(thisEntity); } } } /** * Schedules a task with the given delay. If the task failed to schedule because the scheduler is retired (entity * removed), then returns {@code false}. Otherwise, either the run callback will be invoked after the specified delay, * or the retired callback will be invoked if the scheduler is retired. * Note that the retired callback is invoked in critical code, so it should not attempt to remove the entity, remove * other entities, load chunks, load worlds, modify ticket levels, etc. * *

* It is guaranteed that the run and retired callback are invoked on the region which owns the entity. *

*

* The run and retired callback take an Entity parameter representing the current object entity that the scheduler * is tied to. Since the scheduler is transferred when an entity changes dimensions, it is possible the entity parameter * is not the same when the task was first scheduled. Thus, only the parameter provided should be used. *

* @param run The callback to run after the specified delay, may not be null. * @param retired Retire callback to run if the entity is retired before the run callback can be invoked, may be null. * @param delay The delay in ticks before the run callback is invoked. Any value less-than 1 is treated as 1. * @return {@code true} if the task was scheduled, which means that either the run function or the retired function * will be invoked (but never both), or {@code false} indicating neither the run nor retired function will be invoked * since the scheduler has been retired. */ public boolean schedule(final Consumer run, final Consumer retired, final long delay) { Validate.notNull(run, "Run task may not be null"); final ScheduledTask task = new ScheduledTask(run, retired); synchronized (this.stateLock) { if (this.tickCount == RETIRED_TICK_COUNT) { return false; } this.oneTimeDelayed.computeIfAbsent(this.tickCount + Math.max(1L, delay), (final long keyInMap) -> { return new ArrayList<>(); }).add(task); } return true; } /** * Executes a tick for the scheduler. * * @throws IllegalStateException If the scheduler is retired. */ public void executeTick() { final Entity thisEntity = this.entity.getHandleRaw(); TickThread.ensureTickThread(thisEntity, "May not tick entity scheduler asynchronously"); final List toRun; synchronized (this.stateLock) { if (this.tickCount == RETIRED_TICK_COUNT) { throw new IllegalStateException("Ticking retired scheduler"); } ++this.tickCount; if (this.oneTimeDelayed.isEmpty()) { toRun = null; } else { toRun = this.oneTimeDelayed.remove(this.tickCount); } } if (toRun != null) { for (int i = 0, len = toRun.size(); i < len; ++i) { this.currentlyExecuting.addLast(toRun.get(i)); } } // Note: It is allowed for the tasks executed to retire the entity in a given task. for (int i = 0, len = this.currentlyExecuting.size(); i < len; ++i) { if (!TickThread.isTickThreadFor(thisEntity)) { // tp has been queued sync by one of the tasks // in this case, we need to delay the tasks for next tick break; } final ScheduledTask task = this.currentlyExecuting.pollFirst(); if (this.tickCount != RETIRED_TICK_COUNT) { ((Consumer)task.run).accept(thisEntity); } else { // retired synchronously // note: here task is null break; } } } }