Skip to main content

utils

Always available. Timing, logging, randomness, and two retry helpers.

Timing

utils.TickMS() --> number the host's tick interval in ms (250)
utils.Ticks(sec) --> number whole ticks in `sec` seconds, minimum 1
utils.Sleep(ms)

Ticks floors, then clamps to at least 1 for any positive input. Ticks(0.1) is 1, not 0 — a short wait never becomes no wait. Ticks(0) and any negative input are 0, so the clamp is about rounding, not about refusing zero.

local ticks = utils.Ticks

local SETTLE_TICKS = ticks(2) -- 8
local RETRY_TICKS = ticks(15) -- 60
This is the whole reason the helper exists

Hardcoding local TICK_MS = 250 works right up until the host changes its interval, at which point every duration in your plugin is silently wrong. utils.Ticks reads the real value from the runtime.

If utils.TickMS() ever returns something other than 250, plugins written against Ticks follow it and plugins with baked-in numbers do not.

Sleep blocks. It is fine for a couple hundred milliseconds inside a single logical action — between two key presses, say. It is not how you wait for the game, because a blocked on_tick delays every other plugin and can hit the VM's 30-second call timeout.

It does return early when the host shuts the plugin down, so a stop is not stuck behind your longest sleep. Zero or negative durations return immediately.

Logging

utils.Log(msg)
utils.LogOnce(key, msg)
utils.LogThrottled(key, msg, throttle_ms)

LogOnce prints the first time a key is seen and never again. LogThrottled prints at most once per interval per key. Both take the key first and the message second — swapping them throttles on the message text, which is usually not what you meant.

The key is remembered per plugin VM, so LogOnce fires again after a reload. Its key table is cleared wholesale if a plugin ever registers more than a few thousand distinct keys, which is a reason to keep keys to a fixed set of names rather than building them from live values.

At four ticks a second, unthrottled logging destroys your log output fast — the console's scrollback or the desktop app's own log view, whichever is watching. The convention is a pair of wrappers per plugin:

local LOG_THROTTLE_MS = 15000

local function log(msg)
utils.Log('[cp] ' .. msg)
end

local function log_throttled(key, msg)
utils.LogThrottled('cp_' .. key, '[cp] ' .. msg, LOG_THROTTLE_MS)
end

Prefixing the throttle key with the plugin name keeps two plugins from sharing a bucket.

Rule of thumb: log for state transitions that happen once per real event ("fight 3 started", "recovered to 88% hp"), log_throttled for anything that can repeat on consecutive ticks (failed teleports, wrong zone, no target found).

Waiting on a condition

utils.WaitUntil(predicate, opts) --> boolean
local arrived = utils.WaitUntil(function()
return c:GetZone() == 'WizardCity/WC_Hub'
end, { timeout_ms = 15000, interval_ms = 250 })

Options: timeout_ms (default 30000) and interval_ms (default 200). Only positive values are accepted; anything else falls back to the default.

Returns whether the predicate became true before the timeout. It also returns false if the predicate itself raised an error, or if the plugin was stopped mid-wait — a false is not proof that the condition stayed false.

Blocks. Belongs in execute or a lifecycle hook, not in on_tick. At the default 30s timeout it can consume the VM's entire 30-second call budget on its own.

Retry

utils.Retry(fn, opts) --> ok, err
local ok, err = utils.Retry(function()
return c:TeleportWithRecovery(target.position)
end, {
attempts = 3,
backoff_ms = 500,
on_error = function(attempt, e)
log_throttled('retry', 'attempt ' .. attempt .. ' failed: ' .. tostring(e))
end,
})

fn must return (boolean, any) — which is exactly the shape of every ok, err call in this API, so most client methods can be passed straight through.

Options: attempts (default 3), backoff_ms (default 500), on_error.

Returns true, nil as soon as an attempt returns truthy. After the last attempt it returns false plus the last failure value: whatever fn returned second, or the Lua error message if fn itself raised. on_error(attempt, err) is called after each failed attempt, including the final one, and its own errors are ignored.

The backoff sleeps between attempts but not after the last one, and it is cut short if the plugin is stopped.

Blocks. In a tick loop, express a retry as a phase that does not advance — see Ticks and timing.

Maths

utils.Random(min, max) --> number
utils.Distance(x1, y1, x2, y2) --> number

Random always returns an integer, inclusive of both bounds. The defaults are min = 0 and max = 100, so a bare utils.Random() gives an integer 0–100 — not a float in [0, 1). utils.Random() < 0.1 is therefore true only when the draw is exactly 0, roughly 1% of the time rather than the 10% a float would suggest. For a percentage roll, compare against an integer:

if utils.Random() < 10 then jitter() end -- ~10%

max <= min returns min rather than erroring. It is backed by a cryptographic source, so it is fine for jitter you do not want to be predictable:

state.wait = utils.Ticks(3) + utils.Random(0, 2)

Adding a little jitter to a fixed cadence is worth doing on anything long-running. A bot that acts on exactly a 3.000-second period is a more distinctive pattern than one that varies.

Distance is 2D. For positions you already have as tables, p:DistanceTo(other) and e:DistanceTo(target) are more direct.

Random(min, max) truncates both bounds to integers, so Random(1, 2.9) draws from 1–2.

What is not here

No os.time, no os.date, no os.clock, no io. The sandbox does not open those libraries, and load, loadstring, dofile, and loadfile are all set to nil — a plugin cannot build code at runtime. The libraries that are open are base, table, string, and math.

If you need elapsed time, count ticks — utils.Ticks is the intended unit and it survives a host that changes its cadence. If you need a timestamp in a log line, both hosts already stamp every entry with one.