Home › Modding Info › The brain's Lua
Chapter 08

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.

HookWhenArguments
fitsBefore the first tick. Returns true, or a string reason. Anything else stops seating.game
startBefore the first tick. saved is what a loaded game kept, or nil.game, me, saved
onUnitCreatedA unit is created. The seat's own unit. builder is nil when there is none.unit, builder
onUnitFinishedA 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
onUnitDamagedThe 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
onUnitDestroyedA 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
onUnitGivenA unit is given. The old owner and the new owner.unit, old, from, to
onUnitIdleThe seat's own unit becomes idle.unit
onCommandDoneAn order on the seat's own unit finishes. result is done or failed.unit, order, result
onUnitLoadedA unit is loaded. The owner of the unit and the owner of the carrier.unit, carrier
onUnitUnloadedA unit is unloaded. The owner of the unit and the owner of the carrier.unit, carrier
onUnitSeenThe seat gains sight of a unit.unit
onUnitLostThe seat loses sight of a unit.unit
onPathFailedA unit cannot find a path. The unit's owner.unit
onRadarLostA contact leaves the picture. Lost before gained for the same slot. An allied unit is never a contact.contact
onRadarContactA contact enters the picture. A contact has no type, no owner and no unit handle.contact
onAllianceChangedAn alliance bit changes. Other players, in index order.player, allied
onPlayerDefeatedA player stops participating.player
onChatA 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
onTickThe end of the turn. The argument is the match tick.tick
saveWhen 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
reportWhen 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.

WhereWhat it does
gameThe 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.mapThe 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.
Definitionsunits, weapons and features: the match's loaded records, after the mod and the mutators. They are read-only.
meThe 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 handleid 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 awarenessWithout 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.

CallWhat it does
unit:moveA ground move.
unit:patrolA patrol.
unit:attackAn attack on a unit.
unit:attack_groundAn attack on open ground.
unit:attack_pointAn attack on a point, unqueued.
unit:dgunThe special attack, on a position or a unit.
unit:guardGuard a unit.
unit:repairRepair a unit.
unit:helpHelp a build.
unit:reclaimReclaim a unit, or a feature at a position.
unit:captureCapture a unit.
unit:loadLoad a unit.
unit:unloadUnload at a position.
unit:buildA mobile build. facing is 0 to 3.
unit:queueA factory build. count is 1 to 1000, and the default is 1.
unit:requeueChanges the queued count. delta is a non-zero integer from -1000 to 1000.
unit:stopStop.
unit:onSets the active bit.
unit:cloakCloak.
unit:self_destructToggles self-destruct for that one unit.
unit:stancemove 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:assignPuts one of the seat's own units in a squad. A unit the seat does not own, or a dead handle, returns false.
brain.sayHands one chat line to the match. The chat panel says how.
brain.siteTests a building cell. facing is 0 to 3. check is site or yard.
brain.placeWalks rings from a point and returns the first cell where the site check holds, and that cell's centre, or nil.
map:reachableWhether 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.