Головна › Моддинг › Lua мозку
Розділ 08

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 KiB. Мозок без report пише один рядок: назву з маніфесту, пробіл і версію — і не дає звіту.немає

Що мозок може знати

game — одна таблиця лише для читання на весь час існування місця. fits і start отримують ту саму таблицю. Стандарт називає кожне поле. Ось групи.

ДеЩо робить
gameКомплект вмісту, увімкнені доповнення, версія рушія, режим, правило старту, за яким матч розставив гравців (fixed або random), складність, чи відкритий матч, мод (його id, назва, версія, сторони, хаки й ліміти), мутатори в порядку застосування, мапа та профіль ШІ.
game.mapНазва мапи, її розміри, рівень моря, приплив і вітер, метал і висоти, родовища металу й геотермальні джерела, стартові позиції в порядку StartPos, класи руху, прохідність, чи можуть дві позиції зʼєднатися через відкриті клітинки класу, і оцінка маршруту, яким пройшов би юніт класу між двома позиціями: його довжина, скільки клітинок він перетинає і, на запит, його точки повороту. Це не залежить від того, що бачило місце. Перевірка, чи можуть дві позиції зʼєднатися, не шукає шлях, а оцінка маршруту — шукає.
Визначенняunits, weapons і features: завантажені записи матчу після моду й мутаторів. Вони доступні лише для читання.
meІндекс гравця, id рівня, складність, остаточні параметри, ресурси, союзники, кожен гравець у грі та їхня кількість, власні юніти й загони місця, поле зору й радарна картина. Для кожного гравця вказано його старт там, де правило старту робить його відомим (усі гравці з фіксованими стартами, лише власний старт місця з випадковими), і гравців, яких він вважає союзниками. Радарний контакт дає позицію, поки лишається контактом. Він не має типу й власника.
Дескриптор юнітаid і slot читаються на будь-якому дескрипторі. Позиція, здоровʼя і тип читаються, коли місце бачить цей живий юніт або коли мозок має повну видимість. Накази, загін і маскування читаються лише на власному живому юніті місця. health лишається nil, коли тип приховує пошкодження, якщо тільки юніт не належить місцю або мозок не має повної видимості.
Повна видимістьБез прапорця game.units, найближчий ворог і сирий список побачених — nil. З прапорцем game.units перелічує кожен зайнятий слот. me:sees і me:sees_unit не вважають прапорець відповіддю «так». Прапорець не дає доступу до спільного випадкового потоку чи прямого запису режиму.

Що мозок може робити

Дескриптор юніта приймає ті накази, які може віддати гравець. Кожен виклик одразу доходить до матчу. Наказ виконується в наступному тіку. Відмова повертає false. Перша невдала перевірка — dead, not_own, over_limit (за цей хід матчу вже передано 4096 наказів), 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:stancemove — 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 рахує в 1/65 536. Осі: x — схід, z — південь, y — угору. Кут — ціле число, 65 536 на повний оберт. 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.