ホーム › MOD制作情報 › ブレインの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にある例として作ったブレインです。下の2つのファイルは書かれたとおりに写しています。works-withは引用符で囲んだ星印なので、パッケージはどのMODとベースゲームにも合います。スクリプトはフックのテーブルを返します。そのテーブルには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

フック

エントリはテーブルを1つ返します。ない関数は飛ばされます。fitsとstartは最初のティックの前に実行されます。その後の各ターンでは、溜まったユニットイベント、レーダーとソナーの喪失、続いて獲得、同盟の変化、敗北、チャット、タイマー、onTickの順に届きます。最初のターンは同盟と参加中のプレイヤーを記録し、それらの変化は報告しません。1ターンに保持されるイベントは最大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ユニットが死んだとき。席自身のもの、同盟のもの、発見リストにある敵、またはブレインが全視界を持つならあらゆる死亡。kindが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のないブレインは、マニフェストの名前、スペース、バージョンからなる1行を書き、レポートは出しません。なし

ブレインが知りうること

gameは、席が存在するあいだずっと同じ、1つの読み取り専用テーブルです。fitsとstartも同じテーブルを受け取ります。各フィールドは規格に載っています。グループは次のとおりです。

場所内容
gameコンテンツバンドル、有効な拡張パック、エンジンのバージョン、モード、対戦でプレイヤーを配置した開始ルール(fixedかrandom)、難易度、対戦が開いているかどうか、MOD(ID、名前、バージョン、陣営、ハック、上限)、適用順のミューテータ、マップ、AIプロファイル。
game.mapマップの名前、サイズ、海面、潮汐と風、金属と高さ、金属と地熱のスポット、StartPos順の開始位置、移動クラス、通行可否、2つの位置が移動クラスの通れるセルでつながるかどうか、そして移動クラスのユニットが2つの位置の間を進む経路の見積もり(長さ、通るセル数、求めれば曲がり角)。席がすでに見たものは考慮しません。つながるかどうかの判定は経路を探索せず、経路の見積もりは探索します。
定義units、weapons、features:MODとミューテータを適用した後の、対戦で読み込まれたレコード。読み取り専用です。
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の0以外の整数。
unit:stop停止。
unit:on起動ビットを設定。
unit:cloakクローク。
unit:self_destructそのユニット1体の自爆を切り替え。
unit:stancemoveはhold、manoeuvre、roamのいずれか。fireはhold、return、at_willのいずれか。directをtrueにすると常設のビットを書き込み、命令は出しません。これができるのはClassicだけです。
me:assign席自身のユニットを1体、分隊に入れます。席が所有していないユニットや死んだハンドルではfalseを返します。
brain.sayチャットの行を1つ対戦に渡します。詳しくはチャットのパネルにあります。
brain.site建物のセルを確認します。facingは0〜3。checkはsiteかyard。
brain.place地点から輪を広げながら探し、建設地点の確認を満たす最初のセルとその中心を返します。見つからなければnil。
map:reachable2つの位置が移動クラスでつながるかどうか。経路は探索せず、移動マップも変えません。
map:route移動クラスのユニットが進む経路を見積もり、対戦には何も変更を加えません。展開したセル1つにつき1命令、加えて16命令を消費します。展開したセルが1,048,576個に達すると止まり、nilを返します。曲がり角を求めると、もう一度探索して再び消費します。2つの場所がつながるかだけを知りたいブレインは、まずmap:reachableを使ってください。

数値

fixedは65536分の1単位で数えます。軸はxが東、zが南、yが上です。角度は整数で、1回転が65536です。f32はbinary32の値を1つ保持し、プレイヤーの資源と同じ幅です。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を1回実行します。brain.every(ticks, fn)はそのときに実行し、その後も、直前に迎えた予定ティックからticksターンごとに実行します。ticksは1〜2147483647の整数です。呼び出しはそれぞれIDを返し、IDは1から始まって増えるだけです。brain.cancel(id)は、待機中のタイマーを削除したときにtrueを返します。ブレインが待機させておけるタイマーは1024個です。期限が来たタイマーはチャットの後、onTickの前に実行されます。タイマー内のエラーは、フックのエラーと同じように席を停止させます。

チャット

onChatは、前のターン以降の行ごとに1回実行されます。onChatがなければ飛ばされますが、行は消費されます。brain.say(text, to)は、行を1つすぐに対戦に渡します。toはall(既定)、allies、enemies、または0〜9のプレイヤーインデックスです。行が送信待ちに入るとtrueを返します。この呼び出し自体は間隔を空けません。間隔を空けるのは対戦側で、コンピューターの席が言う行のあいだに30ティックを置きます。拒否の理由はnot_a_seat、empty、command、too_soon、no_target、no_chatのいずれかです。