Kotlin utilities for Quilt mod development
One of the features provided by Kettle is a selection of task scheduling tools allowing creation of configurable suspendable tasks. The tasks, similarly to the kotlinx.coroutines library, use coroutines to describe computations that can be stopped, delayed, and resume at will.
For example, the code below drops TNT on a random living player in the Overworld once a minute:
//create a scheduler with a server world context
val scheduler = scheduler<ServerWorld> {
//add a task
task {
//that runs infinitely and pauses
//for 1 minute (1200 ticks) after every run
run infinitely {
pause = 60 * 20
}
action {
//use the ServerWorld as `this`
withContext {
randomAlivePlayer?.let {
it.sendMessage(
Text.literal("Think fast"),
MessageType.SYSTEM
)
//suspend task for 1 second
sleepFor(20)
TntBlock.primeTnt(this, it.pos.up(2))
}
}
}
}
}
ServerTickEvents.START.register {
if (server.registryKey == World.OVERWORLD) {
//pass the ServerWorld as context and advance the tasks
scheduler.tick(server)
}
}
The basic units of the scheduling API are:
Additional elements of interest are:
yield()
interrupts it.sleepFor(ticks)
or waitUntil(condition)
,
which allow the task to delay execution.For this example, we will create a block entity that fills a square centered above itself with farmland. Let’s define the objects:
class ExampleBlockEntity(blockPos: BlockPos, blockState: BlockState) : BlockEntity(
exampleBEType, blockPos, blockState
)
//this example will not show parameters
//not relevant to Kettle
object ExampleBlock : BlockWithEntity() {
override fun <T : BlockEntity> getTicker(
world: World, state: BlockState, type: BlockEntityType<T>
): BlockEntityTicker<T> = exampleTicker.cast()
}
Note the .cast()
call in getTicker
: the ticker we will define requires a specific
block entity type, while the getTicker
method is generic. In cases where the block
or block entity type in question make it certain what type of block entity is present,
the cast extension can be used
to cast the ticker to the correct generic T
.
With that out of the way, let us implement a ticker:
//the compiler will often infer this
val exampleTicker = taskTicker<ExampleBlockEntity> {
task {
//this task will run immediately
name = "Farmland"
start = true
//and only once
run once {}
//the code executed by the task
action {
//adds the tick parameters
//as a context receiver
withContext {
//get a sequence of all block positions to be filled
val positions = Box(pos.up()).expand(5, 0, 5).getContainedBlockPos()
positions.forEach {
world.setBlockState(it, Blocks.FARMLAND.defaultState)
//place 2 blocks a second
sleepFor(10)
}
}
}
}
}
Provided the block and block entity are registered correctly, the block will immediately create an 11x11 square of farmland above itself once placed.
Let’s take a look at some of the code above:
withContext
is a method of the TaskContext
providing access
to the additional context argument. In this case, that argument is TickerContext.
sleepFor is a method of TaskContext allowing the task to suspend itself for a specified number of ticks. The task is then recorded to a continuation and begins counting down whenever the scheduler (in this case ticker) calls it.
run once {}
creates a default ExecutionConfiguration,
for a single execution meaning the task will not re-run once completed.
For the second step, let’s make changes to this task:
1) Place blocks as fast as possible, but don’t take more than 10ms per tick to prevent lag. 2) Run the task every time an iron block is placed under the block entity, consuming the block. 3) Redstone signal pauses the task.
val exampleTicker = taskTicker<ExampleBlockEntity> {
//hold a reference to this task
//so it can be paused
val farmTask = task {
name = "Farmland"
start = true
//this task now runs infinitely
run infinitely {
//and yields after 10 ms
yieldsAfterMs = 10
}
action {
withContext {
//only start once an iron block is detected
waitUntil {
world.getBlockState(pos.down()).block == Blocks.IRON_BLOCK
}
world.removeBlock(pos.down(), false)
val positions = Box(pos.up()).expand(5, 0, 5).getContainedBlockPos()
positions.forEach {
world.setBlockState(it, Blocks.FARMLAND.defaultState)
//yield after every loop to
//let the scheduling pause after 10ms
yield()
}
}
}
}
task {
name = "Redstone Control"
start = true
action {
waitUntil {
world.getReceivedRedstonePower(pos) > 0
}
//pause the task on high redstone
farmTask.pause()
//check every 3 ticks
waitUntil(checkEvery = 3) {
world.getReceivedRedstonePower(pos) == 0
}
//and resume on low
farmTask.resume()
}
}
}
With the second task having a reference to the first, it can pause and resume it at will, allowing separation of the main logic from the additional redstone pause.
The farmland task now calls waitUntil
allowing it to remain suspended until
it detects an iron block. Similarly, Redstone Control makes use of this method,
while also reducing the frequency of the checks for resuming the task with the
checkEvery
parameter.
In place of sleepFor
, the Farmland task now calls yield.
Unlike sleepFor
,yield
does not suspend the task unconditionally. However, if it detects that the
task has taken too long since it last resumed, as configured by yieldsAfterMs
, it suspends the task until next tick.
Note: like other coroutines, tasks rely on cooperative multitasking, meaning they have to allow control to be taken away from them and will not be stopped unexpectedly. While yield is a useful tool for mitigating lag, you must call it yourself at key points, such as within long-running loops, allowing the scheduler to take back control. Additionally, you should expect that the operation may or may not continue on the same tick and not make unsafe assumptions about whether your code is running continuously.