Accueil › Infos modding › Le Lua du cerveau
Chapitre 08

Le Lua du cerveau

Le manifeste et le script de Steady Commander, les hooks, ce qu'un cerveau peut savoir et faire, les nombres, les tirages aléatoires, les minuteurs et le chat.

Ce chapitre résume docs/brains/oabrain-standard.md, docs/brains/authoring.md et docs/playing-with-brains.md. Ces documents sont le texte normatif.

Steady Commander

Steady Commander est le cerveau fictif de brains/examples/steady-commander. Les deux fichiers ci-dessous sont copiés tels qu'ils sont écrits. works-with est une étoile entre guillemets, donc le paquet convient à n'importe quel mod et au jeu de base. Le script renvoie une table de hooks. Nommez cette table autrement que brain : un fichier qui commence par local brain masque la globale que le profil installe. fits renvoie true. save renvoie combien d'escouades ont été envoyées. Le choix d'attaque est soon, steady ou late.

Manifeste · 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 d'entrée · 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

L'entrée renvoie une seule table. Une fonction absente est sautée. fits et start tournent avant le premier tick. Un tour livre ensuite, dans cet ordre : événements d'unités en file, radar et sonar perdus puis gagnés, changements d'alliance, défaites, chat, minuteurs, et onTick. Le premier tour mémorise les alliances et qui participe, et ne signale aucun de ces changements. Au plus 65 536 événements sont gardés pour un tour. Au-delà, l'événement est écarté.

HookQuandArguments
fitsAvant le premier tick. Renvoie true, ou une raison en chaîne. Tout le reste arrête la mise en siège.game
startAvant le premier tick. saved est ce qu'une partie chargée a gardé, ou nil.game, me, saved
onUnitCreatedUne unité est créée. L'unité propre du siège. builder vaut nil quand il n'y en a pas.unit, builder
onUnitFinishedUne unité est achevée. L'unité propre du siège. builder vaut nil quand il n'y en a pas ou que c'est l'unité. Un événement de fin est écarté quand une mort annulée de la même unité le suit.unit, builder
onUnitDamagedL'unité propre du siège subit des dégâts. kind vaut weapon, paralyze, heal ou environment. attacker n'est passé que quand le siège peut connaître cette unité, et vaut nil sinon.unit, attacker, amount, kind
onUnitDestroyedUne unité meurt : celle du siège, celle d'un allié, un ennemi de la liste des vus, ou toute mort quand le cerveau a la vue complète. Une mort de kind captured n'est pas ce hook. wreck est un nom et une position, ou nil.unit, killer, wreck
onUnitGivenUne unité est donnée. L'ancien propriétaire et le nouveau.unit, old, from, to
onUnitIdleL'unité propre du siège devient inactive.unit
onCommandDoneUn ordre sur l'unité propre du siège se termine. result vaut done ou failed.unit, order, result
onUnitLoadedUne unité est chargée. Le propriétaire de l'unité et le propriétaire du transporteur.unit, carrier
onUnitUnloadedUne unité est déchargée. Le propriétaire de l'unité et le propriétaire du transporteur.unit, carrier
onUnitSeenLe siège gagne la vue d'une unité.unit
onUnitLostLe siège perd la vue d'une unité.unit
onPathFailedUne unité ne trouve pas de chemin. Le propriétaire de l'unité.unit
onRadarLostUn contact quitte l'image. Perdu avant gagné pour le même emplacement. Une unité alliée n'est jamais un contact.contact
onRadarContactUn contact entre dans l'image. Un contact n'a ni type, ni propriétaire, ni handle d'unité.contact
onAllianceChangedUn bit d'alliance change. Les autres joueurs, dans l'ordre des index.player, allied
onPlayerDefeatedUn joueur cesse de participer.player
onChatUne ligne de chat est parvenue au siège. from est l'index de l'expéditeur. text n'a pas de nom devant. to vaut all, allies, enemies ou me. Au plus 32 lignes attendent.from, text, to
onTickLa fin du tour. L'argument est le tick de la partie.tick
saveQuand une sauvegarde le demande. Renvoie des données simples, ou rien. Une erreur remet le siège. Une valeur qui n'est pas des données simples est laissée hors de la sauvegarde.aucun
reportQuand la console le demande. Renvoie du texte, au plus 64 Kio. Un cerveau sans report écrit une ligne : le nom du manifeste, un espace, la version, et ne donne pas de rapport.aucun

Ce qu'un cerveau peut savoir

game est une seule table en lecture seule pour la vie du siège. fits et start reçoivent cette même table. Le standard nomme chaque champ. Voici les groupes.

OùRôle
gameLe bundle de contenu, les extensions qui sont actives, la version du moteur, le mode, la difficulté, si une partie est ouverte, le mod (id, name, version, sides, hacks et limits), les mutateurs dans l'ordre d'application, la carte, et le profil IA.
game.mapLe nom de la carte, ses tailles, le niveau de la mer, la marée et le vent, le métal et les hauteurs, les classes de mouvement, la praticabilité, et si deux positions peuvent se rejoindre par les cases ouvertes d'une classe. Cela ne regarde pas ce que le siège a vu, et cela ne cherche pas de chemin.
Définitionsunits, weapons et features : les fiches chargées de la partie, après le mod et les mutateurs. Elles sont en lecture seule.
meL'index du joueur, l'identifiant du niveau, la difficulté, les options résolues, les ressources, les alliés, les unités et les escouades propres du siège, la vue, et l'image radar. Un contact donne une position tant qu'il est encore un contact. Il n'a ni type ni propriétaire.
Un handle d'unitéid et slot se lisent sur tout handle. La position, health et le type se lisent quand le siège peut voir cette unité vivante, ou quand le cerveau a la vue complète. Les ordres, l'escouade et le cloak ne se lisent que sur l'unité vivante propre du siège. health reste nil quand le type cache les dégâts, sauf si le siège possède l'unité ou si le cerveau a la vue complète.
Vue complèteSans le drapeau, game.units, l'ennemi le plus proche et la liste vue brute valent nil. Avec le drapeau, game.units liste chaque emplacement occupé. me:sees et me:sees_unit ne traitent pas le drapeau comme un oui. Le drapeau n'accorde ni le flux aléatoire partagé ni une stance directe.

Ce qu'un cerveau peut faire

Un handle d'unité prend les ordres qu'un joueur peut donner. Chaque appel atteint la partie aussitôt. L'ordre s'exécute au tick suivant. Un refus renvoie false. Le premier contrôle qui échoue est dead, not_own, over_limit (le tour a déjà passé 4 096 ordres à la partie), not_seen, ou not_buildable. Un refus de la partie compte comme by_engine.

AppelRôle
unit:moveUn déplacement au sol.
unit:patrolUne patrouille.
unit:attackUne attaque sur une unité.
unit:attack_groundUne attaque sur le terrain libre.
unit:attack_pointUne attaque sur un point, hors file.
unit:dgunL'attaque spéciale, sur une position ou une unité.
unit:guardGarder une unité.
unit:repairRéparer une unité.
unit:helpAider une construction.
unit:reclaimRecycler une unité, ou un élément à une position.
unit:captureCapturer une unité.
unit:loadCharger une unité.
unit:unloadDécharger à une position.
unit:buildUne construction mobile. facing va de 0 à 3.
unit:queueUne construction d'usine. count va de 1 à 1 000, et la valeur par défaut est 1.
unit:requeueChange le nombre en file. delta est un entier non nul de -1 000 à 1 000.
unit:stopArrêt.
unit:onRègle le bit actif.
unit:cloakOccultation.
unit:self_destructBascule l'autodestruction pour cette seule unité.
unit:stancemove vaut hold, manoeuvre ou roam. fire vaut hold, return ou at_will. direct mis à true écrit les bits de posture et ne donne pas d'ordre, et seul Classic peut le faire.
me:assignMet une unité propre du siège dans une escouade. Une unité que le siège ne possède pas, ou un handle mort, renvoie false.
brain.sayRemet une ligne de chat à la partie. Le panneau de chat dit comment.
brain.siteTeste une case de construction. facing va de 0 à 3. check vaut site ou yard.
brain.placeParcourt des anneaux depuis un point et renvoie la première case où la vérification de site réussit, et le centre de cette case, ou nil.
map:reachableSi deux positions peuvent se rejoindre par une classe de mouvement. Cela ne cherche pas de chemin et ne change pas la carte de mouvement.

Nombres

fixed compte des 65 536es. Les axes sont x vers l'est, z vers le sud et y vers le haut. Un angle est un entier, 65 536 pour un tour complet. f32 tient une valeur binary32, la largeur des ressources d'un joueur. geometry.direction, geometry.planar_length, geometry.length, geometry.scale, geometry.sine_scaled et geometry.cosine_scaled sont le cap du moteur lui-même, ses distances, et son sinus et son cosinus mis à l'échelle.

Tirages aléatoires

brain.random(n) renvoie un entier de 1 à n, issu du flux propre du cerveau. Ce flux est ensemencé à partir de la partie, du joueur et de l'identifiant et de la version du cerveau. Les tirages qui en viennent n'avancent pas le flux partagé de la partie. Remplacer math.random ne change pas brain.random.

brain.shared_random tire du flux partagé de la partie, et brain.setup_random tire d'une copie prise à la mise en place intégrée du siège. Le standard les installe pour Classic, et dit où une exécution shadow les a. Tout autre cerveau n'a ni l'un ni l'autre. brain.float_heap_order trie des distances entières, la plus proche d'abord, et il est installé pour chaque siège.

Minuteurs

brain.after(ticks, fn) exécute fn une fois, ce nombre de tours plus tard. brain.every(ticks, fn) l'exécute alors, puis de nouveau tous les ticks tours après le tick d'échéance qu'il vient de rencontrer. ticks est un entier de 1 à 2 147 483 647. Chaque appel renvoie un identifiant. Les identifiants commencent à 1 et ne font que croître. brain.cancel(id) renvoie true quand il a retiré un minuteur en attente. Un cerveau peut garder 1 024 minuteurs en attente. Les minuteurs échus tournent après le chat et avant onTick. Une erreur dans un minuteur arrête le siège comme le fait une erreur de hook.

Chat

onChat tourne une fois par ligne depuis le dernier tour. Un onChat absent est sauté, et la ligne est tout de même consommée. brain.say(text, to) remet aussitôt une ligne à la partie. to vaut all (la valeur par défaut), allies, enemies, ou un index de joueur de 0 à 9. L'appel renvoie true quand la ligne a été mise en file. Il ne se cadence pas de lui-même. La partie le fait, à 30 ticks entre les lignes qu'un siège ordinateur dit. Un refus est not_a_seat, empty, command, too_soon, no_target ou no_chat.