ブレインの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:stance | moveは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:reachable | 2つの位置が移動クラスでつながるかどうか。経路は探索せず、移動マップも変えません。 |
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のいずれかです。