Lua мозга
Манифест и скрипт Steady Commander, хуки, что мозг может знать и делать, числа, случайные значения, таймеры и чат.
Эта глава кратко пересказывает docs/brains/oabrain-standard.md, docs/brains/authoring.md и docs/playing-with-brains.md. Нормативный текст — в этих документах.
Steady Commander
Steady Commander — выдуманный мозг из brains/examples/steady-commander. Два файла ниже скопированы так, как написаны. works-with — звёздочка в кавычках, поэтому пакет подходит к любому моду и основной игре. Скрипт возвращает таблицу хуков. Назовите эту таблицу как угодно, только не brain: файл, который начинается с local brain, затеняет глобальную переменную, которую устанавливает профиль. fits возвращает true. save возвращает, сколько отрядов отправлено. Выбор attack — soon, steady или late.
Манифест ·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
Входной скрипт · 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
Хуки
Входной скрипт возвращает одну таблицу. Отсутствующая функция пропускается. fits и start выполняются до первого тика. Затем каждый ход доставляет в таком порядке: события юнитов из очереди, потерю и затем обнаружение на радаре и сонаре, изменения союзов, поражения, чат, таймеры и onTick. Первый ход запоминает союзы и участников и не сообщает ни об одном из этих изменений. За один ход хранится не больше 65 536 событий. Сверх этого событие отбрасывается.
| Хук | Когда | Аргументы |
|---|---|---|
fits | До первого тика. Возвращает true или строку с причиной. Всё остальное отменяет размещение. | game |
start | До первого тика. saved — то, что сохранила загруженная игра, или nil. | game, me, saved |
onUnitCreated | Юнит создан. Собственный юнит места. builder равен nil, если строителя нет. | unit, builder |
onUnitFinished | Юнит достроен. Собственный юнит места. builder равен nil, если строителя нет или это сам юнит. Событие завершения отбрасывается, если за ним следует отменённая гибель того же юнита. | unit, builder |
onUnitDamaged | Собственный юнит места получает урон. kind — weapon, paralyze, heal или environment. attacker передаётся, только если месту разрешено знать этот юнит, иначе nil. | unit, attacker, amount, kind |
onUnitDestroyed | Юнит гибнет: собственный юнит места, юнит союзника, противник из списка видимых или любая гибель, если у мозга полный обзор. Гибель вида captured в этот хук не попадает. wreck — название и позиция или nil. | unit, killer, wreck |
onUnitGiven | Юнит передан. Старый и новый владелец. | unit, old, from, to |
onUnitIdle | Собственный юнит места простаивает. | unit |
onCommandDone | Приказ собственному юниту места завершён. result — done или failed. | unit, order, result |
onUnitLoaded | Юнит погружен. Владелец юнита и владелец транспорта. | unit, carrier |
onUnitUnloaded | Юнит выгружен. Владелец юнита и владелец транспорта. | unit, carrier |
onUnitSeen | Юнит попадает в обзор места. | unit |
onUnitLost | Юнит выходит из обзора места. | unit |
onPathFailed | Юнит не может найти путь. Владелец юнита. | unit |
onRadarLost | Отметка исчезает с радарной картины. Для одного слота потеря идёт раньше обнаружения. Союзный юнит никогда не бывает отметкой. | contact |
onRadarContact | Отметка появляется на радарной картине. У отметки нет типа, владельца и дескриптора юнита. | contact |
onAllianceChanged | Меняется бит союза. Другие игроки, по порядку индексов. | player, allied |
onPlayerDefeated | Игрок перестаёт участвовать. | player |
onChat | Строка чата дошла до места. from — индекс отправителя. text — без имени в начале. to — all, allies, enemies или me. В ожидании не больше 32 строк. | from, text, to |
onTick | Конец хода. Аргумент — тик матча. | tick |
save | Когда запрашивается сохранение. Возвращает простые данные или ничего. Ошибка возвращает место встроенному игроку. Значение, которое не является простыми данными, в сохранение не попадает. | нет |
report | Когда запрашивает консоль. Возвращает текст, не больше 64 КиБ. Мозг без report пишет одну строку: название из манифеста, пробел и версию — и отчёта не даёт. | нет |
Что мозг может знать
game — это одна таблица только для чтения на всё время жизни места. fits и start получают ту же таблицу. Стандарт называет каждое поле. Здесь перечислены группы.
| Где | Что делает |
|---|---|
game | Комплект контента, включённые дополнения, версия движка, режим, правило старта, по которому матч расставил игроков (fixed или random), сложность, открыт ли матч, мод (его id, название, версия, стороны, доработки и лимиты), мутаторы в порядке применения, карта и профиль ИИ. |
game.map | Название карты, её размеры, уровень моря, прилив и ветер, металл и высоты, месторождения металла и геотермальные точки, стартовые позиции в порядке StartPos, классы движения, проходимость, могут ли две позиции соединиться через открытые клетки класса, и оценка маршрута, который прошёл бы юнит этого класса между двумя позициями: его длина, сколько клеток он пересекает и, по запросу, его точки поворота. Всё это не зависит от того, что видело место. Проверка того, могут ли соединиться две позиции, путь не ищет, а оценка маршрута — ищет. |
| Определения | units, weapons и features: загруженные записи матча после мода и мутаторов. Только для чтения. |
me | Индекс игрока, id уровня, сложность, итоговые значения параметров, ресурсы, союзники, все игроки в игре и их число, собственные юниты и отряды места, обзор и радарная картина. Для каждого игрока указан его старт, если правило старта его раскрывает (при fixed — у всех игроков, при random — только у самого места), и игроки, которых он считает союзниками. Радарная отметка даёт позицию, пока остаётся отметкой. У неё нет типа и владельца. |
| Дескриптор юнита | id и slot читаются с любого дескриптора. Позиция, здоровье и тип читаются, когда место видит этот живой юнит или когда у мозга полный обзор. Приказы, отряд и маскировка читаются только у живого собственного юнита места. health остаётся nil, если тип скрывает урон, кроме случаев, когда юнит принадлежит месту или у мозга полный обзор. |
| Полный обзор | Без флага game.units, ближайший противник и сырой список видимых равны nil. С флагом game.units перечисляет все занятые слоты. me:sees и me:sees_unit не считают флаг за «да». Флаг не даёт доступа ни к общему случайному потоку, ни к прямой стойке. |
Что мозг может делать
Дескриптор юнита принимает те же приказы, что может отдать игрок. Каждый вызов сразу доходит до матча. Приказ выполняется на следующем тике. Отказ возвращает false. Первая неудачная проверка — dead, not_own, over_limit (за этот ход в матч уже передано 4 096 приказов), not_seen или not_buildable. Отказ самого матча считается by_engine.
| Вызов | Что делает |
|---|---|
unit:move | Перемещение по земле. |
unit:patrol | Патрулирование. |
unit:attack | Атака юнита. |
unit:attack_ground | Атака по открытой местности. |
unit:attack_point | Атака точки, без очереди. |
unit:dgun | Особая атака по позиции или юниту. |
unit:guard | Охрана юнита. |
unit:repair | Ремонт юнита. |
unit:help | Помощь в строительстве. |
unit:reclaim | Утилизация юнита или объекта в позиции. |
unit:capture | Захват юнита. |
unit:load | Погрузка юнита. |
unit:unload | Выгрузка в позиции. |
unit:build | Строительство мобильным юнитом. facing — от 0 до 3. |
unit:queue | Строительство на заводе. count — от 1 до 1000, по умолчанию 1. |
unit:requeue | Меняет число в очереди. delta — ненулевое целое от -1000 до 1000. |
unit:stop | Стоп. |
unit:on | Задаёт бит активности. |
unit:cloak | Маскировка. |
unit:self_destruct | Включает или выключает самоуничтожение для этого одного юнита. |
unit:stance | move — hold, manoeuvre или roam. fire — hold, return или at_will. direct, равный true, записывает постоянные биты и не отдаёт приказа, и так может только Classic. |
me:assign | Помещает один из собственных юнитов места в отряд. Юнит, который месту не принадлежит, или мёртвый дескриптор возвращают false. |
brain.say | Передаёт в матч одну строку чата. Как именно — сказано в разделе о чате. |
brain.site | Проверяет клетку для строительства. facing — от 0 до 3. check — site или yard. |
brain.place | Обходит кольца от точки и возвращает первую клетку, где выполняется проверка площадки, и центр этой клетки, или nil. |
map:reachable | Могут ли две позиции соединиться через класс движения. Путь не ищет и карту движения не меняет. |
map:route | Оценивает маршрут, который прошёл бы юнит класса движения, и ничего в матче не меняет. Вызов списывает одну инструкцию за каждую раскрытую клетку плюс 16. Он останавливается после 1 048 576 раскрытых клеток и возвращает nil. Запрос точек поворота выполняет поиск и списывает инструкции ещё раз. Мозгу, которому нужно только знать, соединяются ли два места, лучше сначала спросить map:reachable. |
Числа
fixed считает в 65536-х долях. Оси: x — на восток, z — на юг, y — вверх. Угол — целое число, 65536 на полный оборот. f32 хранит одно значение binary32 — разрядность ресурсов игрока. geometry.direction, geometry.planar_length, geometry.length, geometry.scale, geometry.sine_scaled и geometry.cosine_scaled — собственные функции движка для направления, расстояний и масштабированных синуса и косинуса.
Случайные значения
brain.random(n) возвращает целое от 1 до n из собственного потока мозга. Этот поток инициализируется от матча, игрока, id и версии мозга. Значения из него не сдвигают общий поток матча. Замена math.random не меняет brain.random.
brain.shared_random берёт значения из общего потока матча, а brain.setup_random — из копии, снятой при встроенной подготовке места. Стандарт устанавливает их для Classic и указывает, где они есть в теневом прогоне. У любого другого мозга нет ни того, ни другого. brain.float_heap_order сортирует целые расстояния от ближайшего и устанавливается для каждого места.
Таймеры
brain.after(ticks, fn) выполняет fn один раз через указанное число ходов. brain.every(ticks, fn) выполняет её в тот же срок, а затем снова каждые ticks ходов после тика, на котором она только что сработала. ticks — целое от 1 до 2147483647. Каждый вызов возвращает id. Id начинаются с 1 и только растут. brain.cancel(id) возвращает true, если удалил ожидающий таймер. Мозг может держать в ожидании 1024 таймера. Наступившие таймеры выполняются после чата и до onTick. Ошибка внутри таймера останавливает место так же, как ошибка в хуке.
Чат
onChat выполняется один раз для каждой строки, пришедшей с прошлого хода. Отсутствующий onChat пропускается, а строка всё равно считается обработанной. brain.say(text, to) сразу передаёт в матч одну строку. to — all (по умолчанию), allies, enemies или индекс игрока от 0 до 9. Вызов возвращает true, если строка поставлена в очередь. Сам он темп не регулирует. Это делает матч: между строками, которые говорит место компьютера, проходит 30 тиков. Отказ — not_a_seat, empty, command, too_soon, no_target или no_chat.