Scheduling
Ten functions to do something later, or on a regular basis — blocking the script or not, as you like.
That is where it differs from the Sleep family: SleepSec(30) stops your script for thirty seconds, whereas ExecIn(30000, f) lets it carry on and will call f in thirty seconds, alongside.
The ten
| Function | Returns | |
|---|---|---|
ExecIn(ms, f) | 1: a cancel function | Calls f once, in ms milliseconds. |
ExecAt(when, f) | 1: a cancel function | Calls f once, at the stated time. |
IntervalExec(ms, f) | 1: a cancel function | Calls f every ms milliseconds, forever. |
CronExec(schedule, f) | 2: a number and an error | Calls f on a schedule. |
RangeCronExec(start, end, label, f) | 2: a number and an error | Calls f once a day, at a time drawn at random inside the window. |
RemoveCron(number) | 1: a boolean | Removes a task placed by the two above. |
ExecInCh(ms, f) | 1: a channel | Like ExecIn, but the channel closes after f. |
ExecAtCh(when, f) | 1: a channel | Like ExecAt, but the channel closes after f. |
ExecInSync(ms, f) | 1: a cancel function | Waits, then calls f, without handing back. |
ExecAtSync(when, f) | nothing | Waits for the stated time, then calls f. |
cancel = ExecIn(60000, func() { Print("one minute later") })
// … later on, if you change your mind:
cancel()
The first three return a cancel function. Keep it if you intend to stop the task before it finishes; otherwise ignore it, everything stops anyway when the script stops.
The CronExec schedule
Three forms are accepted.
| Form | Meaning |
|---|---|
"0 30 3 * * *" | Cron with six fields, seconds first: every day at 03:30. |
"@16h43" | Every day at 16:43. |
"16:43:12" | Every day at 16:43 and 12 seconds. |
The last two are shorthands for the daily schedule, which is the most common case.
id, err = CronExec("@03h30", func() {
Print("night report")
})
if err != nil { LogError("schedule refused:", err) }
Mind the seconds field: a standard cron has five, this one has six. "30 3 * * *" will therefore be refused, with an error that says so.
Waiting for your own appointment
The first three hand back at once. The next four let you choose.
Print("before")
<-ExecInCh(60000, func() { Print("one minute later") })
Print("after") // only written once the callback has finished
The channel closes after the function has finished. That is the form the reference tool uses to bring a fleet home at a precise time:
<-ExecAtCh("03:17:00", func() {
f = NewFleet()
f.SetOrigin("M:1:2:3")
f.SetDestination("M:1:2:4")
f.SetSpeed(TEN_PERCENT)
f.SetMission(PARK)
f.SetAllResources()
f.SendNow()
})
A channel rather than a direct call, because you then choose when to wait: place ten appointments, then wait for them in whatever order suits you.
The Sync versions do the same without a channel, when you have nothing to choose:
ExecAtSync("03:17:00", func() { BringTheFleetHome() })
Print("the fleet is home")
ExecInSync returns a cancel function, as the reference tool declares. It cancels nothing: by the time your script receives it, the call is already done. It exists so that a copied script does not stop on it.
Two safeguards
The channel also closes when the script stops, without running the callback. Otherwise, stopping at noon a script sitting on <-ExecAtCh("03:00:00", …) would have left it hanging for fifteen hours — and the bot's shutdown with it.
An unreadable time returns an already-closed channel, and the reason goes to the log. You get control back at once, with the callback not fired. Hanging forever on a typo would be the worse of the two answers.
A window rather than a time
CronExec("@03h30", f) calls f at half past three on the dot, every night. That is exactly what you do not want from an action meant to go unnoticed: a second that never changes gets noticed.
RangeCronExec takes two bounds and draws the time at random between them, every day.
id, err = RangeCronExec("03:00:00", "03:45:00", "night return", func() {
Print("the fleet comes home")
})
The third argument is a label of your own. It is written to the console when the task is placed, then again each time it fires: that is what lets you follow three windows without confusing them.
night return - 03:00:00 ... 03:45:00 ← on placing, outside the window
night return - 03:12:47 ← on placing, already inside it
night return ← when the call happens
If you place the window while it is already open, the appointment is made in the time that remains, not put off until tomorrow. A script restarted at 3:05 on a 3:00-to-3:45 window will therefore fire that same night.
A window that crosses midnight is accepted. RangeCronExec("23:00:00", "01:00:00", …) lasts two hours, as you would expect, and not minus twenty-two.
Bounds are written hh:mm or hh:mm:ss, as everywhere else. An unreadable bound returns an error there and then, before anything is placed.
Removing a task
id, err = CronExec("@03h30", f)
// … later
if RemoveCron(id) {
Print("unhooked")
} else {
Print("that number no longer points at anything")
}
RemoveCron returns a boolean: true if the number pointed at a task, false otherwise. The reference tool returns nothing; a script copied across as it stands — the line RemoveCron(id) on its own — works the same, and whoever wants to know can read the answer.
It is the only way to tell a real removal from a stale number, and that matters: a script that believes it has unhooked its night task and is wrong will watch it fire anyway.
Numbers are your script's own and distinct from one another. Removing a window also cancels its appointment for the day, if one was pending.
A task set up by a !global.ank function belongs to the script that calls the function. A helper such as func Every(ms, f) { return IntervalExec(ms, f) } therefore sets up the task of the script using it: the task stops with that script, its errors are written to that script's console, and its number is removed from that script. The same goes from a go: the go belongs to the script that started it, even from a task or from another go. What !global.ank sets up itself, at its top level or in its own tasks, stays its own, even when a script reloads it with StartScript("!global.ank").
RemoveCron written in a !global.ank function looks for the number first among the tasks of !global.ank, then among those of the script calling the function. A helper func StopGlobal() { return RemoveCron(GID) } therefore removes the global file's task, even when called by a script that set up its own task under the same number. Every script numbers its tasks from 1: to remove a task a helper set up for you, remove it from your own script.
What happens when the task fails
Your function runs alongside the script, in its own thread. If it fails, it brings down neither the script nor the bot: the error goes to the log under scheduled task failed, with the name of the script and the reason.
A panic in the bot itself is logged separately under panic in a scheduled task. The stack is attached only if Go recognises the panic as a runtime fault (index out of range, nil map, nil pointer); in the rarer case of a panic carrying another value, the line appears without a stack. If you see panic in a scheduled task, that is a fault on our side and not on yours: report it, stack or no stack.
IntervalExec and the zero interval
IntervalExec(0, f) // ✗ will repeat nothing
A zero or negative interval repeats nothing. The bot logs it rather than letting you believe the task is running. Write a proper interval.
Waiting forever
A script that only schedules tasks ends at once, and its tasks die with it. OnQuitCh is there to hold it back: that channel closes when the script stops.
IntervalExec(600000, func() { Print("every ten minutes") })
CronExec("@04h00", func() { Print("every night") })
<-OnQuitCh // the script waits here until it is stopped
That is the backbone of most monitoring scripts: schedule, then block.
Reacting to an event
A script can be woken by the bot instead of polling in a loop. It then pays no request to wait, and learns about an attack the moment Defender sees it, not on the next turn.
The functions wait, with a delay:
| Function | Returns | |
|---|---|---|
WaitAttack(seconds) | 2: the attack and a boolean | Waits for an attack. The boolean says whether one came. |
WaitAttackGone(seconds) | 2: the attack and a boolean | Waits for an attack to disappear. |
WaitChatMessage(seconds) | 2: the message and a boolean | Waits for a player to write. |
WaitFleet(seconds) | 2: the fleet and a boolean | Waits for a worker to send a fleet. |
WaitHunterTargetActive(seconds) | 2: the change and a boolean | Waits for a target to come back to their screen. |
WaitHunterTargetIdle(seconds) | 2: the change and a boolean | Waits for a target to leave it. |
The channels are not called: you receive on them. They are values, read with <-.
| Value | Carries | |
|---|---|---|
OnAttackCh | a channel of attacks | The attacks detected. |
OnAttackGoneCh | a channel of attacks | The ones that disappear. |
OnStateChangeCh | a channel of states | The account's state changes. |
OnChatMessageReceivedCh | a channel of messages | What the players write. |
OnFleetCh | a channel of fleets | The fleets the workers send. |
OnHunterTargetActiveCh | a channel of changes | The targets coming back. |
OnHunterTargetIdleCh | a channel of changes | The targets leaving. |
A delay of zero waits without limit, until the script stops. That is the form you want in a watch running through the night.
for {
a, seen = WaitAttack(0)
if !seen { break }
LogWarn("attack from", a.AttackerName, "on", a.DestinationName)
if a.ArriveIn < 120 {
LogError("impact in", a.ArriveIn, "seconds")
}
}
Answering a player
That is what no loop could do.
for {
m, seen = WaitChatMessage(0)
if !seen { break } // the script is stopping
if !m.Private() { continue } // the alliance room talks a lot
LogInfo(m.SenderName, "writes:", m.Text)
SendMessage(m.SenderID, "busy right now, write later")
}
The message carries SenderID, SenderName, AssociationID, Text and Date, as with the reference tool. Private() is extra: it says whether the message was addressed to you alone, and that is the question that decides everything — a script answering the whole alliance room would soon be unbearable. AssociationID is zero on a private message.
The bot already holds that connection from the moment the account opens: the channel costs no request, and the message arrives within the second.
Knowing a fleet has left
for {
f, seen = WaitFleet(0)
if !seen { break }
if f.Kind == "expedition" {
Print("expedition to", f.Destination, "fleet", f.FleetID)
}
}
Kind names the worker sending it: repatriate, expedition, evacuation, colonization, espionage, discovery, phalanx, scheduled flight, supply, distribution, ghost. That is what you filter on.
All eleven, not eight — supply, distribution and ghost were missing from this list. A filter written against an incomplete list lets through exactly what it meant to keep out, and nothing tells you.
Origin and FleetID are sometimes empty. Spying and discovery have no origin to give; what matters for them is the position aimed at. An empty field means "this worker does not say", not "there is none".
This channel says a fleet has LEFT, never that it has arrived. That is why it is not called OnRepatriateCompletedCh, which the reference tool declares: the bot cannot say when a transport lands, and a channel named "completed" that fired on departure would lie to every script listening to count what comes home.
Watching a target
The two moments of a watch, and they are listened to separately.
// The moment to act: the place frees up.
e, seen = WaitHunterTargetIdle(0)
if seen { Print(e.PlayerName, "has just left") }
// The moment to come home: they are back.
e, seen = WaitHunterTargetActive(0)
if seen { BringTheFleetHome() }
The change carries PlayerID, PlayerName, AllianceTag and PreviousActivity, as with the reference tool, plus Activity and Active which give the state after and the direction of the change.
What those numbers mean. The game shows 15 while the player is at their screen, then the number of minutes since they left, and nothing at all — 0 — past its display window. So a return is a move to 15; a departure, a move away from it.
The two channels never mix. Without that split, a script recalling its fleet when a target wakes up would also recall it when they leave — that is, at the worst possible moment.
They depend on no setting. The interface alert stays under the panel's checkbox; a script you have entrusted with a watch should not have to wait for someone to tick it.
OnHunterTargetIdleCh does not exist in the reference tool, which exposes only the return. Yet the departure is what the hunter watches for, since that is what opens the window: leaving it out would have been an oversight, not a decision.
To say who to watch, see Driving the workers.
Why WaitAttack rather than a select
The script engine has no select. Writing one returns a syntax error, in every form. Yet that is the pattern the reference tool's documentation shows everywhere: copied as is, it does not start.
A plain receive, on the other hand, works:
a = <-OnAttackCh
Print(a.AttackerName)
But it blocks until something arrives: no delay, no second condition. WaitAttack does what you would have written in a select, and returns a boolean second. Without it you could not tell an attack from the absence of one, both having the same zero value.
The channel closes when the script stops: the receive then returns rather than hanging.
What Kepler fills, and what it leaves empty
The attack you get carries AttackerName, Origin, Destination, DestinationName, ArrivalTime, MissionType, Missiles, and ArriveIn, the last one worked out at the moment you read it.
Left empty, deliberately: ID, AttackerID and UnionID, which Defender does not keep because it works on the target and the impact time; and Ships, because the bot only knows the composition as names translated by the game, never as ids.
One channel for the disappearance
The reference tool has two, OnAttackDoneCh and OnAttackCancelledCh. Kepler exposes only one, OnAttackGoneCh, because the bot sees that an attack is no longer there without being able to say whether it landed or whether the attacker recalled it. Offering both their names would land both on the same event, and a script listening to both would handle the same attack twice.
What arrives between two waits is not lost
That is the point that makes the loop above honest.
for {
a, seen = WaitAttack(0)
if !seen { break }
HandleTheAlert(a) // two seconds, say
}
During those two seconds your script is not waiting. A second attack landing then is kept, and the next round finds it: each wait function keeps its channel from one call to the next, with sixteen events in reserve.
It opened a fresh one on every call until 05/09/2026, and therefore threw away everything that arrived in the gaps — silently.
Sixteen, and no more. Beyond that, see just below.
Each function keeps its own. WaitAttack and WaitChatMessage do not wake each other.
If your script does not read fast enough
The events it does not take are dropped, and the log says so once. That is deliberate: the same bus feeds every account's interface, and a script asleep on its channel would freeze the whole bot. Better to miss an alert than to stop the house.
Removing, in short
For a deferred or repeated task, keep the cancel function returned by ExecIn, ExecAt and IntervalExec. For a scheduled one, RemoveCron and its number. And in any case, everything stops when the script stops.