diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/DistributorCorePlugin.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/DistributorCorePlugin.java
index 9f190bf0..b6029bd3 100644
--- a/distributor-core/src/main/java/com/xpdustry/distributor/core/DistributorCorePlugin.java
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/DistributorCorePlugin.java
@@ -30,9 +30,9 @@
public final class DistributorCorePlugin extends AbstractMindustryPlugin implements Distributor {
private final ServiceManager services = ServiceManager.simple();
+ private final MultiLocalizationSource source = MultiLocalizationSource.create();
private CommandFacade.@Nullable Factory factory = null;
private @Nullable PermissionManager permissions = null;
- private final MultiLocalizationSource source = MultiLocalizationSource.create();
@Override
public ServiceManager getServiceManager() {
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/annotation/TaskHandler.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/annotation/TaskHandler.java
new file mode 100644
index 00000000..506c1243
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/annotation/TaskHandler.java
@@ -0,0 +1,60 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.annotation;
+
+import com.xpdustry.distributor.core.scheduler.Cancellable;
+import com.xpdustry.distributor.core.scheduler.MindustryTimeUnit;
+import com.xpdustry.distributor.core.scheduler.PluginScheduler;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a method as a task handler, meaning it will be registered and called as a scheduled task in the
+ * {@link PluginScheduler}.
+ *
+ * The annotated method can have one {@link Cancellable} parameter to allow the task to cancel itself.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface TaskHandler {
+
+ /**
+ * The interval between each execution of the task.
+ * The task will be executed once if the interval is set to a value below -1.
+ */
+ long interval() default -1;
+
+ /**
+ * The initial delay before the first execution of the task.
+ * The task will be executed immediately if the delay is set to a value below -1.
+ */
+ long delay() default -1;
+
+ /**
+ * The time unit of the interval and initial delay.
+ */
+ MindustryTimeUnit unit() default MindustryTimeUnit.SECONDS;
+
+ /**
+ * Whether the task should be executed asynchronously.
+ */
+ boolean async() default false;
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/Cancellable.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/Cancellable.java
new file mode 100644
index 00000000..8504b606
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/Cancellable.java
@@ -0,0 +1,30 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+/**
+ * A {@code Cancellable} is used to cancel a task.
+ */
+public interface Cancellable {
+
+ /**
+ * Cancels the task bound to this {@code Cancellable}.
+ */
+ void cancel();
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/MindustryTimeUnit.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/MindustryTimeUnit.java
new file mode 100644
index 00000000..6a9897f9
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/MindustryTimeUnit.java
@@ -0,0 +1,109 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Time units used by the {@link PluginTaskBuilder} and {@link PluginTask} classes to represent time.
+ */
+public enum MindustryTimeUnit {
+
+ /**
+ * Time unit representing one thousandth of a second.
+ */
+ MILLISECONDS(TimeUnit.MILLISECONDS),
+
+ /**
+ * Time unit representing one game loop, which is 60 times per second.
+ */
+ TICKS(null),
+
+ /**
+ * Time unit representing one thousandth of a millisecond.
+ */
+ SECONDS(TimeUnit.SECONDS),
+
+ /**
+ * Time unit representing sixty seconds.
+ */
+ MINUTES(TimeUnit.MINUTES),
+
+ /**
+ * Time unit representing sixty minutes.
+ */
+ HOURS(TimeUnit.HOURS),
+
+ /**
+ * Time unit representing twenty-four hours.
+ */
+ DAYS(TimeUnit.DAYS);
+
+ private final @Nullable TimeUnit unit;
+
+ MindustryTimeUnit(final @Nullable TimeUnit unit) {
+ this.unit = unit;
+ }
+
+ /**
+ * Converts the given duration in the given time unit to this time unit.
+ *
+ * Since this method is equivalent to {@link TimeUnit#convert(long, TimeUnit)}:
+ *
+ * - If it overflows, the result will be {@link Long#MAX_VALUE} if the duration is positive,
+ * or {@link Long#MIN_VALUE} if it is negative.
+ * - Conversions are floored so converting 999 milliseconds to seconds results in 0.
+ *
+ *
+ * @param sourceDuration the duration to convert
+ * @param sourceUnit the time unit of the duration
+ * @return the converted duration
+ * @see TimeUnit#convert(long, TimeUnit)
+ */
+ public long convert(final long sourceDuration, final MindustryTimeUnit sourceUnit) {
+ if (this == sourceUnit) {
+ return sourceDuration;
+ }
+ final var sourceJavaUnit = sourceUnit.getJavaTimeUnit();
+ final var targetJavaUnit = this.getJavaTimeUnit();
+
+ if (sourceJavaUnit.isPresent() && targetJavaUnit.isPresent()) {
+ return targetJavaUnit.get().convert(sourceDuration, sourceJavaUnit.get());
+ } else if (sourceJavaUnit.isEmpty()) {
+ return targetJavaUnit
+ .orElseThrow()
+ .convert((long) Math.nextUp(sourceDuration * (1000F / 60F)), TimeUnit.MILLISECONDS);
+ } else {
+ final var millis = TimeUnit.MILLISECONDS.convert(sourceDuration, sourceJavaUnit.orElseThrow());
+ if (millis == Long.MAX_VALUE || millis == Long.MIN_VALUE) {
+ return millis;
+ }
+ return (long) (millis * (60F / 1000L));
+ }
+ }
+
+ /**
+ * Returns the Java time unit associated with this Mindustry time unit, if any.
+ */
+ public Optional getJavaTimeUnit() {
+ return Optional.ofNullable(this.unit);
+ }
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginScheduler.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginScheduler.java
new file mode 100644
index 00000000..d093b830
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginScheduler.java
@@ -0,0 +1,35 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+import com.xpdustry.distributor.core.plugin.MindustryPlugin;
+
+/**
+ * A {@code PluginScheduler} is used to schedule tasks for a plugin. A better alternative to {@link arc.util.Timer}.
+ */
+public interface PluginScheduler {
+
+ /**
+ * Returns a new {@link PluginTaskBuilder} instance scheduling a task.
+ *
+ * @param plugin the plugin to schedule the task for.
+ * @return a new {@link PluginTaskBuilder} instance.
+ */
+ PluginTaskBuilder schedule(final MindustryPlugin plugin);
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginSchedulerImpl.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginSchedulerImpl.java
new file mode 100644
index 00000000..69844baf
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginSchedulerImpl.java
@@ -0,0 +1,118 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+import com.xpdustry.distributor.core.plugin.MindustryPlugin;
+import com.xpdustry.distributor.core.plugin.PluginListener;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.Queue;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ForkJoinPool;
+import java.util.concurrent.ForkJoinWorkerThread;
+import java.util.concurrent.PriorityBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+final class PluginSchedulerImpl implements PluginScheduler, PluginListener {
+
+ static final String DISTRIBUTOR_WORKER_BASE_NAME = "distributor-worker-";
+ private static final Logger logger = LoggerFactory.getLogger("PluginScheduler");
+
+ private final Queue> tasks =
+ new PriorityBlockingQueue<>(16, Comparator.comparing(PluginTaskImpl::getNextExecutionTime));
+ private final ForkJoinPool pool;
+ private final Executor syncExecutor;
+ private final TimeSource source;
+
+ public PluginSchedulerImpl(final TimeSource source, final Executor syncExecutor, final int parallelism) {
+ this.pool = new ForkJoinPool(parallelism, new PluginSchedulerWorkerThreadFactory(), null, false);
+ this.syncExecutor = syncExecutor;
+ this.source = source;
+ }
+
+ @Override
+ public PluginTaskBuilder schedule(final MindustryPlugin plugin) {
+ return new PluginTaskImpl.Builder(this, plugin);
+ }
+
+ @Override
+ public void onPluginUpdate() {
+ while (!this.tasks.isEmpty()) {
+ final var task = this.tasks.peek();
+ if (task.isCancelled()) {
+ this.tasks.remove();
+ } else if (task.getNextExecutionTime() < this.source.getCurrentTicks()) {
+ this.tasks.remove();
+ final Executor executor = task.isAsync() ? this.pool : this.syncExecutor;
+ executor.execute(task);
+ } else {
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void onPluginExit() {
+ logger.info("Shutdown scheduler.");
+ this.pool.shutdown();
+ try {
+ if (!this.pool.awaitTermination(20, TimeUnit.SECONDS)) {
+ logger.error("Timed out waiting for the scheduler to terminate properly");
+ Thread.getAllStackTraces().forEach((thread, stack) -> {
+ if (thread.getName().startsWith(DISTRIBUTOR_WORKER_BASE_NAME)) {
+ logger.error(
+ "Worker thread {} may be blocked, possibly the reason for the slow shutdown:\n{}",
+ thread.getName(),
+ Arrays.stream(stack).map(e -> " " + e).collect(Collectors.joining("\n")));
+ }
+ });
+ }
+ } catch (final InterruptedException e) {
+ logger.error("The plugin scheduler shutdown have been interrupted.", e);
+ }
+ }
+
+ void schedule(final PluginTaskImpl> task) {
+ this.tasks.add(task);
+ }
+
+ TimeSource getTimeSource() {
+ return this.source;
+ }
+
+ boolean isShutdown() {
+ return this.pool.isShutdown();
+ }
+
+ private static final class PluginSchedulerWorkerThreadFactory implements ForkJoinPool.ForkJoinWorkerThreadFactory {
+
+ private static final AtomicInteger COUNT = new AtomicInteger(0);
+
+ @Override
+ public ForkJoinWorkerThread newThread(final ForkJoinPool pool) {
+ final var thread = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
+ thread.setName(DISTRIBUTOR_WORKER_BASE_NAME + COUNT.getAndIncrement());
+ return thread;
+ }
+ }
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTask.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTask.java
new file mode 100644
index 00000000..4d7b2b48
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTask.java
@@ -0,0 +1,35 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+import com.xpdustry.distributor.core.plugin.PluginAware;
+import java.util.concurrent.Future;
+
+/**
+ * A {@code PluginTask} is a future used by a {@link PluginScheduler}.
+ *
+ * @param the type of the value returned by this task.
+ */
+public interface PluginTask extends Future, PluginAware {
+
+ /**
+ * Returns whether this future is executed asynchronously.
+ */
+ boolean isAsync();
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTaskBuilder.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTaskBuilder.java
new file mode 100644
index 00000000..21d899a0
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTaskBuilder.java
@@ -0,0 +1,82 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * A helper object for building and scheduling a {@link PluginTask}.
+ *
+ * {@code
+ * final PluginScheduler scheduler = DistributorProvider.get().getPluginScheduler();
+ * final MindustryPlugin plugin = ...;
+ * // Warn the players the server is close in 5 minutes.
+ * Groups.player.each(p -> p.sendMessage("The server will restart in 5 minutes."));
+ * // Now schedule the closing task.
+ * scheduler.scheduleSync(plugin).delay(5L, MindustryTimeUnit.MINUTES).execute(() -> Core.app.exit());
+ * }
+ */
+public interface PluginTaskBuilder {
+
+ PluginTaskBuilder async(final boolean async);
+
+ /**
+ * Run the task after a delay.
+ *
+ * @param delay the delay.
+ * @param unit the time unit of the delay.
+ * @return this builder.
+ */
+ PluginTaskBuilder delay(final long delay, final MindustryTimeUnit unit);
+
+ /**
+ * Run the task periodically with a fixed interval.
+ * Stops the periodic execution if an exception is thrown.
+ *
+ * @param interval the interval between the end of the last execution and the start of the next.
+ * @param unit the time unit of the interval.
+ * @return this builder.
+ */
+ PluginTaskBuilder repeat(final long interval, final MindustryTimeUnit unit);
+
+ /**
+ * Build and schedule the task with the given task.
+ *
+ * @param runnable the task to run.
+ * @return a new plugin task.
+ */
+ PluginTask execute(final Runnable runnable);
+
+ /**
+ * Build and schedule the task with the given task.
+ *
+ * @param consumer the task to run, with a cancellable object to stop the task if it's periodic.
+ * @return a new plugin task.
+ */
+ PluginTask execute(final Consumer consumer);
+
+ /**
+ * Build and schedule the task with the given task.
+ *
+ * @param supplier the task to run, with an output value. Won't output any result value if the task is periodic.
+ * @return a new plugin task.
+ */
+ PluginTask execute(final Supplier supplier);
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTaskImpl.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTaskImpl.java
new file mode 100644
index 00000000..0cc03ff9
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/PluginTaskImpl.java
@@ -0,0 +1,163 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+import com.xpdustry.distributor.core.plugin.MindustryPlugin;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executors;
+import java.util.concurrent.FutureTask;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.jspecify.annotations.Nullable;
+
+final class PluginTaskImpl extends FutureTask implements PluginTask {
+
+ private final MindustryPlugin plugin;
+ private final boolean async;
+ private final long period;
+ private final PluginSchedulerImpl scheduler;
+ private long nextRun;
+
+ private PluginTaskImpl(
+ final MindustryPlugin plugin,
+ final Callable callable,
+ final boolean async,
+ final long period,
+ final PluginSchedulerImpl scheduler) {
+ super(callable);
+ this.plugin = plugin;
+ this.async = async;
+ this.period = period;
+ this.scheduler = scheduler;
+ }
+
+ @Override
+ public void run() {
+ if (this.scheduler.isShutdown()
+ && (this.period == 0
+ || this.nextRun - this.scheduler.getTimeSource().getCurrentTicks() > 0)) {
+ this.cancel(false);
+ } else if (this.period == 0) {
+ super.run();
+ } else if (super.runAndReset()) {
+ this.nextRun = this.scheduler.getTimeSource().getCurrentTicks() + this.period;
+ this.scheduler.schedule(this);
+ }
+ }
+
+ @Override
+ public boolean isAsync() {
+ return this.async;
+ }
+
+ @Override
+ public MindustryPlugin getPlugin() {
+ return this.plugin;
+ }
+
+ @Override
+ protected void setException(final Throwable throwable) {
+ super.setException(throwable);
+ this.plugin
+ .getLogger()
+ .error(
+ "An error occurred in thread {} of the plugin scheduler.",
+ Thread.currentThread().getName(),
+ throwable);
+ }
+
+ public long getNextExecutionTime() {
+ return this.nextRun;
+ }
+
+ static final class Builder implements PluginTaskBuilder {
+
+ private final PluginSchedulerImpl scheduler;
+ private final MindustryPlugin plugin;
+ private boolean async;
+ private long delay = 0;
+ private long repeat = 0;
+
+ public Builder(final PluginSchedulerImpl scheduler, final MindustryPlugin plugin) {
+ this.scheduler = scheduler;
+ this.plugin = plugin;
+ }
+
+ @Override
+ public PluginTaskBuilder async(final boolean async) {
+ this.async = async;
+ return this;
+ }
+
+ @Override
+ public PluginTaskBuilder delay(final long delay, final MindustryTimeUnit unit) {
+ this.delay = MindustryTimeUnit.TICKS.convert(delay, unit);
+ return this;
+ }
+
+ @Override
+ public PluginTaskBuilder repeat(final long interval, final MindustryTimeUnit unit) {
+ this.repeat = MindustryTimeUnit.TICKS.convert(interval, unit);
+ return this;
+ }
+
+ @Override
+ public PluginTask execute(final Runnable runnable) {
+ final var task = new PluginTaskImpl(
+ this.plugin, Executors.callable(runnable, null), this.async, this.repeat, this.scheduler);
+ return this.schedule(task);
+ }
+
+ @Override
+ public PluginTask execute(final Consumer consumer) {
+ final var cancellable = new PluginTaskCancellable();
+ final var task = new PluginTaskImpl(
+ this.plugin,
+ Executors.callable(() -> consumer.accept(cancellable), null),
+ this.async,
+ this.repeat,
+ this.scheduler);
+ cancellable.task = task;
+ return this.schedule(task);
+ }
+
+ @Override
+ public PluginTask execute(final Supplier supplier) {
+ final var task = new PluginTaskImpl<>(this.plugin, supplier::get, this.async, this.repeat, this.scheduler);
+ return this.schedule(task);
+ }
+
+ private PluginTaskImpl schedule(final PluginTaskImpl task) {
+ task.nextRun = this.scheduler.getTimeSource().getCurrentTicks() + this.delay;
+ this.scheduler.schedule(task);
+ return task;
+ }
+ }
+
+ private static final class PluginTaskCancellable implements Cancellable {
+
+ private @Nullable PluginTask> task = null;
+
+ @Override
+ public void cancel() {
+ Objects.requireNonNull(this.task).cancel(false);
+ }
+ }
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/TimeSource.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/TimeSource.java
new file mode 100644
index 00000000..6852fa6f
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/TimeSource.java
@@ -0,0 +1,44 @@
+/*
+ * Distributor, a feature-rich framework for Mindustry plugins.
+ *
+ * Copyright (C) 2024 Xpdustry
+ *
+ * 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 com.xpdustry.distributor.core.scheduler;
+
+import arc.util.Time;
+
+/**
+ * A {@code PluginTimeSource} provides the current time in milliseconds.
+ */
+@FunctionalInterface
+public interface TimeSource {
+
+ /**
+ * Returns a {@code PluginTimeSource} using {@link Time#globalTime} to provide the current time.
+ */
+ static TimeSource arc() {
+ return () -> (long) Time.globalTime;
+ }
+
+ /**
+ * Returns a {@code PluginTimeSource} using {@link System#currentTimeMillis()} to provide the current time.
+ */
+ static TimeSource standard() {
+ return () -> System.currentTimeMillis() / 16L;
+ }
+
+ long getCurrentTicks();
+}
diff --git a/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/package-info.java b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/package-info.java
new file mode 100644
index 00000000..0c035531
--- /dev/null
+++ b/distributor-core/src/main/java/com/xpdustry/distributor/core/scheduler/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package com.xpdustry.distributor.core.scheduler;
+
+import org.jspecify.annotations.NullMarked;