The brain's Lua
Steady Commander's manifest and script, the hooks, what a brain may know and do, numbers, random draws, timers and chat.
This chapter summarises docs/brains/oabrain-standard.md, docs/brains/authoring.md and docs/playing-with-brains.md. Those docs are the normative text.
Steady Commander
Steady Commander is the made-up brain in brains/examples/steady-commander. The two files below are copied as they are written. works-with is a quoted star, so the package fits any mod and the base game. The script returns a table of hooks. Name that table something other than brain: a file that starts local brain shadows the global the profile installs. fits returns true. save returns how many squads have been sent. The attack choice is soon, steady or late.
Manifest ·oabrain.yaml# SPDX-FileCopyrightText: The Open Annihilation Authors; see COPYRIGHT
# SPDX-License-Identifier: GPL-3.0-only
oabrain: 1
id: steady-commander
name: Steady Commander
version: "1.0"
author:
name: Ridge Workshop
description: "Builds a little economy, then a factory, and sends a squad when it is ready."
tags:
- economy
- teaching
works-with:
- "*"
levels:
- id: easy
name: Easy
difficulty: easy
- id: medium
name: Medium
difficulty: medium
- id: hard
name: Hard
difficulty: hard
options:
attack:
type: choice
label: "How soon to attack"
values: ["soon", "steady", "late"]
default: "steady"
labels: ["Soon", "Steady", "Late"]
entry: brain.lua
Entry script · brain.lua-- SPDX-FileCopyrightText: The Open Annihilation Authors; see COPYRIGHT
-- SPDX-License-Identifier: GPL-3.0-only
-- Economy, then one land factory. Combat units gather until the attack
-- option's count is met, then go to the nearest enemy in sight.
-- save() keeps how many squads have been sent.
local hooks = {}
local sent, armed = 0, false
local commander, metal_type, energy_type, factory_type, combat_type
local metal_done, energy_done, factory_ordered = 0, 0, false
local factories, squad, sent_ids = {}, {}, {}
local ready = false
local function real_of(record, key)
local value = record[key]
if type(value) == "userdata" and type(value.tonumber) == "function" then
return value:tonumber()
end
return type(value) == "number" and value or 0
end
local function positive(record, key)
return real_of(record, key) > 0
end
local function words(record)
local found, list = {}, record.category
if type(list) ~= "table" then return found end
for index = 1, #list do found[list[index]] = true end
return found
end
-- A category the commander lacks, or no categories, so a copy is skipped.
local function labelled(record, commander_words)
local any, fresh = false, false
for word in pairs(words(record)) do
any, fresh = true, fresh or commander_words[word] ~= true
end
return fresh or not any
end
local function land(record)
return real_of(record, "minwaterdepth") <= 0
end
local function land_combat(record)
return record.canmove == true and record.canattack == true and record.builder ~= true
and record.canfly ~= true and land(record)
end
local function energy_record(record)
return positive(record, "energymake") or positive(record, "makesmetal")
or positive(record, "windgenerator") or positive(record, "tidalgenerator")
end
local function pick(list)
if #list == 0 then return nil end
return list[brain.random(#list)]
end
local function classify(list, commander_words)
local metals, energies, plants = {}, {}, {}
for index = 1, #list do
local record = list[index]
if record.commander ~= true and labelled(record, commander_words) then
if positive(record, "extractsmetal") and land(record) then
metals[#metals + 1] = record
elseif energy_record(record) and land(record) then
energies[#energies + 1] = record
elseif record.builder == true and record.bmcode == 0 and land(record) then
local built, fights = game.build_list(record), {}
if type(built) == "table" then
for slot = 1, #built do
if land_combat(built[slot]) then fights[#fights + 1] = built[slot] end
end
end
local fight = pick(fights)
if fight ~= nil then plants[#plants + 1] = { plant = record, unit = fight } end
end
end
end
return metals, energies, plants
end
local function squad_size()
if me.options.attack == "soon" then return 4 end
if me.options.attack == "late" then return 12 end
return 8
end
local function plan_counts()
if me.difficulty == "easy" then return 1, 1 end
if me.difficulty == "hard" then return 2, 2 end
return 2, 1
end
local function find_commander()
local found = nil
me:each_unit(function(unit)
if found == nil and unit.type ~= nil and unit.type.commander == true then found = unit end
end)
return found
end
local function free(unit)
local kind, _, idle = unit:head_order()
return kind == nil or idle == true
end
local function place_build(builder, record)
if builder.x == nil or builder.z == nil then return false end
local facing = brain.random(4) - 1
local _, _, x, z = brain.place(record, { x = builder.x, z = builder.z }, 200, facing)
if x == nil then return false end
return builder:build(record, { x = x, z = z }, facing) == true
end
local function note_finished(unit)
local record = unit.type
if record == nil or record.commander == true or unit.alive ~= true then return end
if positive(record, "extractsmetal") then metal_done = metal_done + 1
elseif energy_record(record) then energy_done = energy_done + 1
elseif record.builder == true and record.bmcode == 0 then
factories[unit.id], factory_ordered = unit, true
elseif land_combat(record) and sent_ids[unit.id] ~= true then squad[unit.id] = unit
end
end
local function nearest(origin)
local best, best_distance
for index = 1, #me.seen do
local unit = me.seen[index]
if unit.alive == true and unit.owner ~= me.player and me:allied(unit.owner) ~= true then
local distance = brain.distance(origin, unit)
if distance ~= nil and (best_distance == nil or distance < best_distance) then
best, best_distance = unit, distance
end
end
end
return best
end
local function count_squad()
local count = 0
for _ in pairs(squad) do count = count + 1 end
return count
end
local function try_send()
if count_squad() < squad_size() then return end
local origin = (commander ~= nil and commander.alive == true) and commander or nil
if origin == nil then
for _, unit in pairs(squad) do origin = unit break end
end
local target = origin ~= nil and nearest(origin) or nil
if target == nil then return end
for id, unit in pairs(squad) do
if unit.alive == true then unit:attack(target) end
sent_ids[id] = true
end
squad, sent = {}, sent + 1
end
local function prepare()
if ready then return end
if commander == nil or commander.alive ~= true then commander = find_commander() end
if commander == nil or commander.type == nil then return end
local list = game.build_list(commander.type)
if type(list) ~= "table" or #list == 0 then return end
local metals, energies, plants = classify(list, words(commander.type))
metal_type, energy_type = pick(metals), pick(energies)
local plant = pick(plants)
if plant ~= nil then factory_type, combat_type = plant.plant, plant.unit end
ready = metal_type ~= nil or energy_type ~= nil or factory_type ~= nil
end
local function build_next()
prepare()
if commander == nil or commander.alive ~= true then commander = find_commander() end
if commander == nil or not free(commander) then return end
local metal_need, energy_need = plan_counts()
if metal_type ~= nil and metal_done < metal_need then
if place_build(commander, metal_type) then return end
end
if energy_type ~= nil and energy_done < energy_need then
if place_build(commander, energy_type) then return end
end
if factory_type ~= nil and not factory_ordered then
if place_build(commander, factory_type) then factory_ordered = true end
end
end
local function queue_plants()
if combat_type == nil then return end
for _, plant in pairs(factories) do
if plant.alive == true and free(plant) then plant:queue(combat_type) end
end
end
local function step()
build_next()
queue_plants()
try_send()
end
function hooks.fits() return true end
function hooks.start(_, _, saved)
sent = type(saved) == "table" and type(saved.sent) == "number" and saved.sent or 0
metal_done, energy_done, factory_ordered, factories, squad = 0, 0, false, {}, {}
metal_type, energy_type, factory_type, combat_type = nil, nil, nil, nil
ready, commander = false, find_commander()
prepare()
me:each_unit(function(unit)
if unit.finished == true then note_finished(unit) end
end)
if not armed then
brain.every(30, step)
armed = true
end
end
function hooks.onUnitFinished(unit)
note_finished(unit)
try_send()
end
function hooks.onUnitIdle(unit)
local record = unit.type
if record == nil then return end
if record.commander == true then
commander = unit
build_next()
elseif record.builder == true and record.bmcode == 0 then
factories[unit.id] = unit
queue_plants()
end
end
function hooks.save() return { sent = sent } end
return hooks
Hooks
The entry returns one table. A missing function is skipped. fits and start run before the first tick. One turn then delivers, in this order: queued unit events, radar and sonar lost then gained, alliance changes, defeats, chat, timers, and onTick. The first turn stores the alliances and who is participating and reports none of those changes. At most 65,536 events are kept for one turn. Past that, the event is dropped.
| Hook | When | Arguments |
|---|---|---|
fits | Before the first tick. Returns true, or a string reason. Anything else stops seating. | game |
start | Before the first tick. saved is what a loaded game kept, or nil. | game, me, saved |
onUnitCreated | A unit is created. The seat's own unit. builder is nil when there is none. | unit, builder |
onUnitFinished | A unit finishes. The seat's own unit. builder is nil when there is none or it is the unit. A finished event is dropped when a cancelled death of the same unit follows it. | unit, builder |
onUnitDamaged | The seat's own unit takes damage. kind is weapon, paralyze, heal or environment. attacker is passed only when the seat may know that unit, and is nil otherwise. | unit, attacker, amount, kind |
onUnitDestroyed | A unit dies: the seat's own, an ally's, an enemy on the seen list, or any death when the brain has full awareness. A death of kind captured is not this hook. wreck is a name and a position, or nil. | unit, killer, wreck |
onUnitGiven | A unit is given. The old owner and the new owner. | unit, old, from, to |
onUnitIdle | The seat's own unit becomes idle. | unit |
onCommandDone | An order on the seat's own unit finishes. result is done or failed. | unit, order, result |
onUnitLoaded | A unit is loaded. The owner of the unit and the owner of the carrier. | unit, carrier |
onUnitUnloaded | A unit is unloaded. The owner of the unit and the owner of the carrier. | unit, carrier |
onUnitSeen | The seat gains sight of a unit. | unit |
onUnitLost | The seat loses sight of a unit. | unit |
onPathFailed | A unit cannot find a path. The unit's owner. | unit |
onRadarLost | A contact leaves the picture. Lost before gained for the same slot. An allied unit is never a contact. | contact |
onRadarContact | A contact enters the picture. A contact has no type, no owner and no unit handle. | contact |
onAllianceChanged | An alliance bit changes. Other players, in index order. | player, allied |
onPlayerDefeated | A player stops participating. | player |
onChat | A chat line reached the seat. from is the sender's index. text has no name on the front. to is all, allies, enemies or me. At most 32 lines wait. | from, text, to |
onTick | The end of the turn. The argument is the match tick. | tick |
save | When a save asks. Returns plain data, or nothing. An error hands the seat back. A value that is not plain data is left out of the save. | none |
report | When the console asks. Returns text, at most 64 KiB. A brain with no report writes one line: the manifest name, a space, the version, and gives no report. | none |
What a brain may know
game is one read-only table for the life of the seat. fits and start receive that same table. The standard names every field. These are the groups.
| Where | What it does |
|---|---|
game | The content bundle, the expansions that are on, the engine version, the mode, the difficulty, whether a match is open, the mod (its id, name, version, sides, hacks and limits), the mutators in apply order, the map, and the AI profile. |
game.map | The map's name, its sizes, sea level, tide and wind, metal and heights, movement classes, passability, and whether two positions can meet through a class's open cells. It does not look at what the seat has seen, and it does not search for a path. |
| Definitions | units, weapons and features: the match's loaded records, after the mod and the mutators. They are read-only. |
me | The player index, the level id, the difficulty, the resolved options, resources, allies, the seat's own units and squads, sight, and the radar picture. A contact gives a position while it is still a contact. It has no type and no owner. |
| A unit handle | id and slot read on any handle. Position, health and the type read when the seat can see that live unit, or when the brain has full awareness. Orders, the squad and the cloak read only on the seat's own live unit. health stays nil when the type hides damage, unless the seat owns the unit or the brain has full awareness. |
| Full awareness | Without the flag, game.units, nearest enemy and the raw seen list are nil. With the flag, game.units lists every occupied slot. me:sees and me:sees_unit do not treat the flag as a yes. The flag does not grant the shared random stream or a direct stance. |
What a brain may do
A unit handle takes the orders a player can give. Each call reaches the match at once. The order runs in the next tick. A refusal returns false. The first failing check is dead, not_own, over_limit (the turn has already passed 4,096 orders to the match), not_seen, or not_buildable. A match refusal counts as by_engine.
| Call | What it does |
|---|---|
unit:move | A ground move. |
unit:patrol | A patrol. |
unit:attack | An attack on a unit. |
unit:attack_ground | An attack on open ground. |
unit:attack_point | An attack on a point, unqueued. |
unit:dgun | The special attack, on a position or a unit. |
unit:guard | Guard a unit. |
unit:repair | Repair a unit. |
unit:help | Help a build. |
unit:reclaim | Reclaim a unit, or a feature at a position. |
unit:capture | Capture a unit. |
unit:load | Load a unit. |
unit:unload | Unload at a position. |
unit:build | A mobile build. facing is 0 to 3. |
unit:queue | A factory build. count is 1 to 1000, and the default is 1. |
unit:requeue | Changes the queued count. delta is a non-zero integer from -1000 to 1000. |
unit:stop | Stop. |
unit:on | Sets the active bit. |
unit:cloak | Cloak. |
unit:self_destruct | Toggles self-destruct for that one unit. |
unit:stance | move is hold, manoeuvre or roam. fire is hold, return or at_will. direct set to true writes the standing bits and gives no order, and only Classic may do that. |
me:assign | Puts one of the seat's own units in a squad. A unit the seat does not own, or a dead handle, returns false. |
brain.say | Hands one chat line to the match. The chat panel says how. |
brain.site | Tests a building cell. facing is 0 to 3. check is site or yard. |
brain.place | Walks rings from a point and returns the first cell where the site check holds, and that cell's centre, or nil. |
map:reachable | Whether two positions can meet through a movement class. It does not search for a path and does not change the movement map. |
Numbers
fixed counts 65536ths. Axes are x east, z south and y up. An angle is an integer, 65536 to a full turn. f32 holds one binary32 value, the width of a player's resources. geometry.direction, geometry.planar_length, geometry.length, geometry.scale, geometry.sine_scaled and geometry.cosine_scaled are the engine's own heading, distances and scaled sine and cosine.
Random draws
brain.random(n) returns an integer from 1 to n, from the brain's own stream. That stream is seeded from the match, the player and the brain's id and version. Draws from it do not advance the match's shared stream. Replacing math.random does not change brain.random.
brain.shared_random draws the match's shared stream, and brain.setup_random draws a copy taken at the seat's built-in set-up. The standard installs them for Classic, and says where a shadow run has them. Any other brain has neither. brain.float_heap_order sorts integer distances, nearest first, and is installed for every seat.
Timers
brain.after(ticks, fn) runs fn once, that many turns later. brain.every(ticks, fn) runs then, and again every ticks turns after the due tick it just met. ticks is an integer from 1 to 2147483647. Each call returns an id. Ids start at 1 and only grow. brain.cancel(id) returns true when it removed a waiting timer. A brain may keep 1024 timers waiting. Due timers run after chat and before onTick. An error inside a timer stops the seat the way a hook error does.
Chat
onChat runs once per line since the last turn. A missing onChat is skipped, and the line is still consumed. brain.say(text, to) hands one line to the match at once. to is all (the default), allies, enemies, or a player index from 0 to 9. The call returns true when the line was queued. It does not pace itself. The match does, at 30 ticks between lines a computer seat says. A refusal is not_a_seat, empty, command, too_soon, no_target or no_chat.