홈 › 모딩 정보 › 브레인의 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는 보낸 분대 수를 돌려줍니다. 공격 선택은 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콘텐츠 번들, 켜진 확장팩, 엔진 버전, 게임 방식(mode), 경기가 플레이어를 배치한 시작 규칙(fixed 또는 random), 난이도, 경기가 열려 있는지 여부, 모드(ID, 이름, 버전, 진영, 수정 항목, 제한), 적용 순서대로의 뮤테이터, 맵, AI 프로필.
game.map맵 이름, 크기, 해수면, 조수와 바람, 금속과 높이, 금속 지점과 지열 지점, StartPos 순서의 시작 위치, 이동 클래스, 통과 가능 여부, 두 위치가 어떤 클래스의 열린 칸을 통해 이어지는지, 그리고 그 클래스의 유닛이 두 위치 사이에서 택할 경로의 추정치(길이, 지나는 칸 수, 요청하면 꺾이는 지점). 자리가 본 것은 참고하지 않습니다. 두 위치가 이어지는지 확인할 때는 경로를 탐색하지 않고, 경로 추정은 탐색합니다.
정의units, weapons, features: 모드와 뮤테이터가 적용된 뒤 경기에 불러온 레코드입니다. 읽기 전용입니다.
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그 유닛 하나의 자폭을 켜거나 끕니다.
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이동 클래스의 유닛이 택할 경로를 추정하며, 경기에서는 아무것도 바꾸지 않습니다. 확장한 칸마다 명령 1개를 차감하고, 16개를 더 차감합니다. 확장한 칸이 1,048,576개에 이르면 멈추고 nil을 돌려줍니다. 꺾이는 지점을 요청하면 다시 탐색하며 한 번 더 차감합니다. 두 곳이 이어지는지만 알면 되는 브레인은 먼저 map:reachable을 물어보세요.

숫자

fixed는 65536분의 1 단위로 셉니다. 축은 x가 동쪽, z가 남쪽, y가 위쪽입니다. 각도는 정수이며 한 바퀴가 65536입니다. 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 중 하나입니다.