Inicio › Info de modding › El Lua del cerebro
Capítulo 08

El Lua del cerebro

El manifiesto y el script de Steady Commander, los hooks, lo que un cerebro puede saber y hacer, los números, las tiradas aleatorias, los temporizadores y el chat.

Este capítulo resume docs/brains/oabrain-standard.md, docs/brains/authoring.md y docs/playing-with-brains.md. Esos documentos son el texto normativo.

Steady Commander

Steady Commander es el cerebro inventado en brains/examples/steady-commander. Los dos archivos de abajo se copian tal como están escritos. works-with es un asterisco entre comillas, así que el paquete encaja con cualquier mod y con el juego base. El script devuelve una tabla de hooks. Nombra esa tabla con algo distinto de brain: un archivo que empieza por local brain hace sombra al global que instala el perfil. fits devuelve true. save devuelve cuántas escuadras se han enviado. La elección de ataque es soon, steady o late.

Manifiesto · 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
Script de entrada · 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

La entrada devuelve una tabla. Una función que falta se salta. fits y start corren antes del primer tick. Un turno entrega después, en este orden: eventos de unidad encolados, radar y sonar perdidos y luego ganados, cambios de alianza, derrotas, chat, temporizadores y onTick. El primer turno guarda las alianzas y quién participa, y no informa de ninguno de esos cambios. Como mucho se guardan 65.536 eventos de un turno. Pasado eso, el evento se descarta.

HookCuándoArgumentos
fitsAntes del primer tick. Devuelve true, o un motivo en texto. Cualquier otra cosa impide sentarlo.game
startAntes del primer tick. saved es lo que conservó una partida cargada, o nil.game, me, saved
onUnitCreatedSe crea una unidad. La unidad propia del asiento. builder es nil cuando no lo hay.unit, builder
onUnitFinishedUna unidad termina. La unidad propia del asiento. builder es nil cuando no lo hay o cuando es la unidad. Un evento de unidad terminada se descarta cuando lo sigue una muerte cancelada de la misma unidad.unit, builder
onUnitDamagedLa unidad propia del asiento recibe daño. kind es weapon, paralyze, heal o environment. attacker se pasa solo cuando el asiento puede conocer esa unidad, y en caso contrario es nil.unit, attacker, amount, kind
onUnitDestroyedMuere una unidad: la propia del asiento, la de un aliado, un enemigo de la lista de vistos, o cualquier muerte cuando el cerebro tiene visión total. Una muerte de kind captured no es este hook. wreck es un nombre y una posición, o nil.unit, killer, wreck
onUnitGivenSe cede una unidad. El dueño anterior y el dueño nuevo.unit, old, from, to
onUnitIdleLa unidad propia del asiento queda inactiva.unit
onCommandDoneTermina una orden sobre la unidad propia del asiento. result es done o failed.unit, order, result
onUnitLoadedSe carga una unidad. El dueño de la unidad y el dueño del transporte.unit, carrier
onUnitUnloadedSe descarga una unidad. El dueño de la unidad y el dueño del transporte.unit, carrier
onUnitSeenEl asiento gana visión de una unidad.unit
onUnitLostEl asiento pierde de vista una unidad.unit
onPathFailedUna unidad no encuentra un camino. El dueño de la unidad.unit
onRadarLostUn contacto sale de la imagen. Perdido antes que ganado, para el mismo slot. Una unidad aliada nunca es un contacto.contact
onRadarContactUn contacto entra en la imagen. Un contacto no tiene tipo, ni dueño, ni manejador de unidad.contact
onAllianceChangedCambia un bit de alianza. Los demás jugadores, en orden de índice.player, allied
onPlayerDefeatedUn jugador deja de participar.player
onChatUna línea de chat llegó al asiento. from es el índice de quien la envía. text no lleva el nombre delante. to es all, allies, enemies o me. Como mucho esperan 32 líneas.from, text, to
onTickEl final del turno. El argumento es el tick de la partida.tick
saveCuando lo pide un guardado. Devuelve datos planos, o nada. Un error devuelve el asiento. Un valor que no es dato plano se deja fuera del guardado.ninguno
reportCuando lo pide la consola. Devuelve texto, como mucho 64 KiB. Un cerebro sin report escribe una línea: el nombre del manifiesto, un espacio y la versión, y no da informe.ninguno

Qué puede saber un cerebro

game es una tabla de solo lectura mientras vive el asiento. fits y start reciben esa misma tabla. El estándar nombra cada campo. Estos son los grupos.

DóndeQué hace
gameEl bundle de contenido, las expansiones que están activas, la versión del motor, el modo, la dificultad, si una partida está abierta, el mod (su identificador, nombre, versión, bandos, hacks y límites), los mutadores en el orden en que se aplican, el mapa y el perfil de IA.
game.mapEl nombre del mapa, sus tamaños, el nivel del mar, la marea y el viento, el metal y las alturas, las clases de movimiento, la transitabilidad, y si dos posiciones pueden alcanzarse por las celdas abiertas de una clase. No mira lo que ha visto el asiento, y no busca un camino.
Definicionesunits, weapons y features: los registros cargados de la partida, después del mod y de los mutadores. Son de solo lectura.
meEl índice del jugador, el identificador del nivel, la dificultad, las opciones ya resueltas, los recursos, los aliados, las unidades y escuadras propias del asiento, la visión y la imagen del radar. Un contacto da una posición mientras sigue siendo un contacto. No tiene tipo ni dueño.
Un manejador de unidadid y slot se leen en cualquier manejador. La posición, la salud y el tipo se leen cuando el asiento puede ver esa unidad viva, o cuando el cerebro tiene visión total. Las órdenes, la escuadra y el camuflaje se leen solo en la unidad viva propia del asiento. health sigue nil cuando el tipo oculta el daño, salvo que el asiento posea la unidad o el cerebro tenga visión total.
Visión totalSin la bandera, game.units, el enemigo más cercano y la lista de vistos en bruto son nil. Con la bandera, game.units lista cada slot ocupado. me:sees y me:sees_unit no tratan la bandera como un sí. La bandera no concede el flujo aleatorio compartido ni una postura directa.

Qué puede hacer un cerebro

Un manejador de unidad acepta las órdenes que puede dar un jugador. Cada llamada llega a la partida en el acto. La orden corre en el siguiente tick. Un rechazo devuelve false. La primera comprobación que falla es dead, not_own, over_limit (el turno ya ha pasado 4.096 órdenes a la partida), not_seen o not_buildable. Un rechazo de la partida cuenta como by_engine.

LlamadaQué hace
unit:moveUn movimiento por tierra.
unit:patrolUna patrulla.
unit:attackUn ataque a una unidad.
unit:attack_groundUn ataque a terreno abierto.
unit:attack_pointUn ataque a un punto, sin encolar.
unit:dgunEl ataque especial, sobre una posición o una unidad.
unit:guardVigilar una unidad.
unit:repairReparar una unidad.
unit:helpAyudar en una construcción.
unit:reclaimReciclar una unidad, o un elemento en una posición.
unit:captureCapturar una unidad.
unit:loadCargar una unidad.
unit:unloadDescargar en una posición.
unit:buildUna construcción móvil. facing va de 0 a 3.
unit:queueUna construcción de fábrica. count va de 1 a 1000, y el valor por defecto es 1.
unit:requeueCambia la cantidad encolada. delta es un entero distinto de cero, de -1000 a 1000.
unit:stopDetener.
unit:onPone el bit de activo.
unit:cloakCamuflar.
unit:self_destructConmuta la autodestrucción de esa sola unidad.
unit:stancemove es hold, manoeuvre o roam. fire es hold, return o at_will. direct en true escribe los bits permanentes y no da orden, y solo Classic puede hacerlo.
me:assignPone una de las unidades propias del asiento en una escuadra. Una unidad que el asiento no posee, o un manejador muerto, devuelve false.
brain.sayEntrega una línea de chat a la partida. El panel de chat dice cómo.
brain.sitePrueba una celda de construcción. facing va de 0 a 3. check es site o yard.
brain.placeRecorre anillos desde un punto y devuelve la primera celda en la que se cumple la comprobación de sitio, y el centro de esa celda, o nil.
map:reachableSi dos posiciones pueden alcanzarse por una clase de movimiento. No busca un camino y no cambia el mapa de movimiento.

Números

fixed cuenta 65.536avos. Los ejes son x al este, z al sur e y hacia arriba. Un ángulo es un entero, 65.536 para un giro completo. f32 guarda un valor binary32, el ancho de los recursos de un jugador. geometry.direction, geometry.planar_length, geometry.length, geometry.scale, geometry.sine_scaled y geometry.cosine_scaled son el rumbo, las distancias y el seno y el coseno escalados del propio motor.

Tiradas aleatorias

brain.random(n) devuelve un entero de 1 a n, del flujo propio del cerebro. Ese flujo se siembra a partir de la partida, el jugador y el identificador y la versión del cerebro. Las tiradas de ese flujo no avanzan el flujo compartido de la partida. Sustituir math.random no cambia brain.random.

brain.shared_random extrae del flujo compartido de la partida, y brain.setup_random extrae de una copia tomada en la preparación integrada del asiento. El estándar los instala para Classic, y dice dónde los tiene una ejecución en sombra. Ningún otro cerebro tiene ni uno ni otro. brain.float_heap_order ordena distancias enteras, la más cercana primero, y se instala en cada asiento.

Temporizadores

brain.after(ticks, fn) ejecuta fn una vez, ese número de turnos después. brain.every(ticks, fn) se ejecuta entonces, y otra vez cada ticks turnos después del tick de vencimiento que acaba de cumplir. ticks es un entero de 1 a 2147483647. Cada llamada devuelve un identificador. Los identificadores empiezan en 1 y solo crecen. brain.cancel(id) devuelve true cuando quitó un temporizador que esperaba. Un cerebro puede tener 1024 temporizadores en espera. Los temporizadores vencidos corren después del chat y antes de onTick. Un error dentro de un temporizador detiene el asiento igual que un error de hook.

Chat

onChat corre una vez por cada línea desde el último turno. Si falta onChat se salta, y la línea se consume igual. brain.say(text, to) entrega una línea a la partida en el acto. to es all (el valor por defecto), allies, enemies, o un índice de jugador de 0 a 9. La llamada devuelve true cuando la línea quedó encolada. No se espacia sola. Lo hace la partida, a 30 ticks entre líneas que dice un asiento de ordenador. Un rechazo es not_a_seat, empty, command, too_soon, no_target o no_chat.