Рецепты
Каждый рецепт - один ресурс: папка в resources/, названная по рецепту, с server/main.lua и,
где нужна клиентская половина, client/main.lua. Каждый вызов есть в
справочнике Lua API; нативный модуль здесь не нужен ни разу. Четыре из
шести используют команды чата или player:tell, которые говорят через пример ресурса chat, -
установите в resources/ ещё и chat (скопируйте examples/chat из архива релиза; см.
Ресурсы и контент), иначе команды
молча пропадают, а ответы уходят в никуда. Без игры команда проверяется через шину, как показано на
странице Первые шаги. Консоль сервера
помечает каждую строку именем ресурса, так что строки лога ниже - ровно то, что вы увидите.
Команда чата
Заголовок раздела «Команда чата»Цель: /online отвечает игроку, кто подключён.
-- resources/online/server/main.luanode.commands.add("online", function(player, args, raw) local names = {} for _, p in ipairs(node.players.all()) do names[#names + 1] = p.name end player:tell("%d online: %s", #names, table.concat(names, ", ")) node.log("%s asked who is online", tostring(player))end)
-- expect: Player#\d+ Alice asked who is online-- expect-client: Alice chat:msg .*2 online: (Alice, Bob|Bob, Alice)node.commands.add(name, fn, opts?) регистрирует обработчик строки чата /name ...: fn получает
отправителя как Player, слова после имени как массив и всю строку. Для игрока имена не зависят от
регистра - /Online и /ONLINE доходят до одного обработчика, - потому что node.commands.add
приводит регистрируемое имя к нижнему регистру, а chat приводит к нижнему регистру набранное
слово; сам поиск точный, так что chat:command, который публикует другой ресурс, должен нести имя
в нижнем регистре (События → node.bus описывает
нагрузку). Один обработчик на имя на ресурс; node.commands.remove(name) снимает его. Ресурс chat
публикует каждую строку с / на шине как chat:command, так что команда, которую никто не
зарегистрировал, просто отбрасывается - и никогда не показывается в чате.
Алиса набирает /online и видит 2 online: Alice, Bob системной строкой - node.players.all()
перечисляет игроков в произвольном порядке, так что отсортируйте имена, если порядок важен.
Консоль печатает:
online · Player#0 Alice asked who is onlineПрава по роли и группе
Заголовок раздела «Права по роли и группе»Цель: повысить известные аккаунты до admin, закрыть команду ролью и позволить админам
открывать группы видимости.
name = "perms"
[config]admins = [42, 108] # directory account ids-- resources/perms/server/main.lualocal admins = {}for _, id in ipairs(node.config.admins or {}) do admins[id] = true end
local function promote(player) if player.accountRoles == "ADM" or (player.accountId and admins[player.accountId]) then player:setRole("admin") node.log("%s is admin (account %s)", tostring(player), tostring(player.accountId)) endend
node.on("playerJoined", promote)for _, p in ipairs(node.players.all()) do promote(p) end -- players already here after a reload
-- opts.role: the prelude refuses the command for everyone elsenode.commands.add("say", function(player, args) node.chat.say("[%s] %s", player.name, table.concat(args, " "))end, { role = "admin" })
-- an explicit check, for a rule opts.role cannot expressnode.commands.add("lobby", function(player, args) local n = tonumber(args[1]) or 0 if n ~= 0 and player.role ~= "admin" then player:tell("Only admins open lobbies; /lobby 0 takes you back") return end player:setGroup(n) for _, v in ipairs(player:vehicles()) do v:setGroup(n) end player:tell("You are now in world %d", n)end)
-- expect: Player#\d+ Alice is admin \(account 42\)-- expect-client: Alice chat:msg .*\[Alice\] hello all-- expect-client: Alice chat:msg .*You are now in world 1В игре три идентичности. player.accountId - идентификатор аккаунта в директории: стабилен между
сессиями и адресами, nil у гостя Test Drive, и единственное, что стоит использовать в качестве
ключа списка админов. player.accountRoles - строка роли из директории, "ADM" для администратора директории.
player.role - собственная метка этого сервера на одну сессию, задаётся player:setRole и
сбрасывается при отключении, поэтому promote выполняется при каждом подключении.
{ role = "admin" } заставляет прелюдию ответить You are not allowed to use /say всем остальным
ещё до вашего обработчика.
player:setGroup(n) помещает игрока в группу видимости n: игроки и машины видят друг друга,
только когда их числа совпадают, а 0 - мир, общий для всех. У машины своё число, так что
переводите машины игрока вместе с ним. Консоль показывает каждое повышение:
perms · Player#0 Alice is admin (account 42)Телепорт и трансформация
Заголовок раздела «Телепорт и трансформация»Цель: /where читает позицию, которую сервер держит для вашей машины; /tp <player>
переносит вашу машину к машине другого игрока.
Сервер сам никогда не двигает машину - позиции приходят от клиента, который её симулирует, - поэтому телепорт - это сетевое событие в собственную клиентскую половину игрока, которая и задаёт позицию в игре.
-- resources/tp/server/main.luanode.commands.add("where", function(player) local veh = player.vehicle local t = veh and veh:transform() if not t then player:tell("No position yet - sit in a car and move") return end local speed = math.sqrt(t.vel.x ^ 2 + t.vel.y ^ 2 + t.vel.z ^ 2) player:tell("%s at %.1f %.1f %.1f, %.1f m/s", tostring(veh), t.pos.x, t.pos.y, t.pos.z, speed)end)
node.commands.add("tp", function(player, args) local target = node.players.find(args[1] or "") if not target then player:tell("Usage: /tp <player>") return end local pos = target:position() if not pos or not player.vehicle then player:tell("%s has no position yet, or you are on foot", target.name) return end player:send("tp:to", { x = pos.x + 3, y = pos.y, z = pos.z + 0.5 }) node.log("%s -> %s (%.1f, %.1f, %.1f)", tostring(player), tostring(target), pos.x, pos.y, pos.z)end)
-- expect-client: Alice chat:msg .*No position yet - sit in a car and move-- expect-client: Alice chat:msg .*Bob has no position yet, or you are on foot-- expect-client: Alice chat:msg .*Usage: /tp <player>-- resources/tp/client/main.luanode.on("tp:to", function(data) local to = jsonDecode(data) local veh = be:getPlayerVehicle(0) if not veh then return end local rot = quat(veh:getRotation()) veh:setPositionRotation(to.x, to.y, to.z, rot.x, rot.y, rot.z, rot.w) node.log(string.format("teleported to %.1f %.1f %.1f", to.x, to.y, to.z))end)vehicle:transform() - последний снимок позиции, который прислал авторитет синхронизации машины:
pos, vel и angVel как {x, y, z}, rot как {x, y, z, w}, на несколько тиков устаревший
по самой своей природе, nil до первого снимка. player:position() - та же точка для игрока, из
машины, в которой он сидит, или из его пешего аватара. Клиентская половина получает цель как
JSON-текст, сохраняет текущий поворот машины и вызывает игровой setPositionRotation; новая позиция
затем возвращается серверу и всем остальным через обычный поток позиций. На сервере:
tp · Player#1 Bob -> Player#0 Alice (12.3, -45.6, 7.8)а в beamng.log Боба, под node.events: teleported to 15.3 -45.6 8.3.
Сохранение состояния
Заголовок раздела «Сохранение состояния»Цель: считать визиты и время игры на аккаунт между перезапусками.
-- resources/playtime/server/main.lualocal sessions = {} -- [player.id] = { since, account }; ids are reused, so this table is per session
node.on("playerJoined", function(player) sessions[player.id] = { since = node.server.unixTime(), account = player.accountId } if not player.accountId then return end -- a guest has no durable identity local visits = node.storage.get("visits:" .. player.accountId, 0) + 1 node.storage.set("visits:" .. player.accountId, visits) local played = node.storage.get("playtime:" .. player.accountId, 0) player:tell("Welcome back, %s - visit %d, %d min played", player.name, visits, math.floor(played / 60))end)
node.on("playerLeft", function(player) local s = sessions[player.id] sessions[player.id] = nil if not s or not s.account then return end local seconds = node.server.unixTime() - s.since local total = node.storage.get("playtime:" .. s.account, 0) + seconds node.storage.set("playtime:" .. s.account, total) node.log("%s played %d s, %d s in total", tostring(player), seconds, total)end)
-- expect-client: Alice chat:msg .*Welcome back, Alice - visit 1, 0 min played-- expect: Player#\d+ Alice played \d+ s, \d+ s in totalnode.storage - хранилище ключ/значение на ресурс: get(key, default), set(key, value) с любым
значением, сериализуемым в JSON, delete(key). Каждый set дописывается в storage/playtime.log
(рядом с resources/) до возврата, так что сбой не теряет ничего подтверждённого; снимок
storage/playtime.json переписывается, когда лог перерастает его, и при чистой остановке, а
.log, оставшийся после сбоя, проигрывается при следующем запуске. После чистой остановки вслед за
вторым визитом Алисы файл содержит:
{"playtime:42":3720,"visits:42":2}Рецепт держится на двух деталях. Долговечные ключи используют player.accountId, а не
player.id - маленькое число, которое достанется следующему игроку. А аккаунт запоминается при
подключении, потому что внутри playerLeft запись сессии уже исчезла - известно только
player.name. Строка консоли при выходе:
playtime · Player#0 Alice played 1800 s, 3720 s in totalHTTP-вебхук
Заголовок раздела «HTTP-вебхук»Цель: отправлять подключения и выходы в вебхук чата.
name = "webhook"
[config]url = "…" # the webhook URL is a secret; keep it out of the code-- resources/webhook/server/main.lualocal url = node.config.url
local function post(text) if not url or url == "" then node.log.warn("no [config] url in resource.toml, webhook disabled") return end node.http.request("POST", url, { headers = { ["Content-Type"] = "application/json" }, body = { content = text }, }, function(status, body, headers) if status < 200 or status >= 300 then node.log.warn("webhook failed (%d): %s", status, body) end end)end
node.on("playerJoined", function(player) post(string.format("%s joined (%d online)", player.name, node.players.count()))end)
node.on("playerLeft", function(player) post(string.format("%s left", player.name))end)
-- expect: webhook failed \(-1\): connect failednode.http.request(method, url, { headers?, body? }, cb) выполняет запрос в фоновом потоке пула и
вызывает cb(status, body, headers) в рабочем потоке; тело-таблица кодируется в JSON за вас, а
метод - тот, которого хочет сервис: здесь "POST", в другом месте "PUT", "PATCH", "DELETE"
или "HEAD" (node.http.post(url, body, headers?, cb) и его собратья - тот же вызов с
фиксированным методом). Content-Type задавайте сами - без него тело уходит как
application/octet-stream. Полученные headers имеют ключами имена заголовков в нижнем регистре
(headers["content-type"], никогда headers["Content-Type"]). Клиент следует не более чем пяти перенаправлениям, сдаётся примерно
через 15 секунд и ограничивает ответ 8 МБ; TLS-сертификат собеседника он проверяет только тогда,
когда хост задал [Http] CaFile (Конфигурация). Запрос, так и
не получивший ответа, вызывает колбэк со статусом -1 и текстом ошибки в body; сам
node.http.request возвращает false только тогда, когда запрос не удалось поставить в очередь.
Большинство вебхуков отвечают 200 или 204 с пустым телом, так что
здоровый прогон ничего не логирует; недоступный хост логирует транспортную ошибку с префиксом
отказавшего шага (resolve failed, connect failed, TLS handshake failed) и сообщением самой
операционной системы:
webhook · webhook failed (-1): connect failed: Connection refusedВнутри node.async тот же запрос - node.http.fetch(url, { method = "POST", body = t, headers = h }),
корутина, возвращающая status, body, headers (Конкурентность).
Кик и бан с причиной
Заголовок раздела «Кик и бан с причиной»Цель: /kick, /ban, /unban и /bans для игроков с ролью admin из рецепта о правах.
-- resources/moderation/server/main.lualocal function target(player, args) local who = node.players.find(args[1] or "") if not who then player:tell("No player named %s", tostring(args[1])) return nil end if who == player then player:tell("Not on yourself") return nil end return who, table.concat(args, " ", 2)end
node.commands.add("kick", function(player, args) local who, reason = target(player, args) if not who then return end who:kick(reason ~= "" and reason or "Kicked by " .. player.name)end, { role = "admin" })
node.commands.add("ban", function(player, args) local who, reason = target(player, args) if not who then return end node.bans.add(who, reason ~= "" and reason or "Banned by " .. player.name) -- kicks as wellend, { role = "admin" })
node.commands.add("unban", function(player, args) if not args[1] then player:tell("Usage: /unban <account id or IP>") return end local who = tonumber(args[1]) or args[1] -- an account id, or an IP if node.bans.remove(who) then player:tell("Unbanned %s", tostring(who)) else player:tell("%s was not banned", tostring(who)) endend, { role = "admin" })
node.commands.add("bans", function(player) for _, b in ipairs(node.bans.all()) do player:tell("%s: %s - %s", b.name or "?", b.account and ("account " .. b.account) or b.ip, b.reason or "") endend, { role = "admin" })
-- expect-log: Bob banned by a server plugin \(\S+, account 108\) — Spamming-- expect-log: Bob kicked — Spamming-- expect-client: Alice chat:msg .*Bob: account 108 - Spamming-- expect-client: Alice chat:msg .*Unbanned 108-- expect-client: Alice chat:msg .*No player named Bobplayer:kick(reason) отключает игрока и показывает причину в лаунчере. node.bans.add(who, reason)
принимает три формы: Player банит его IP и, когда билет на подключение был погашен для аккаунта,
этот аккаунт тоже - а затем кикает; строка с IP или число с идентификатором аккаунта банят будущие
подключения и не трогают текущую сессию. node.bans.remove и node.bans.has принимают ту же
строку или число; node.bans.all() перечисляет каждый бан с ip, account, reason, at и
name. Баны хранятся в bans.json рядом с server.toml; его формат и правила ручной правки - на
странице Запуск сервера → Баны. Два объекта Player равны, когда
совпадают их идентификаторы, - на это и опирается who == player.
/kick Bob Spamming и /ban Bob Spamming печатают под тегом Kick:
Bob kicked — SpammingBob banned by a server plugin (203.0.113.5, account 108) — SpammingBob kicked — SpammingСледующее подключение с этого адреса отклоняется на пороге:
connection from 203.0.113.5 refused (banned: Spamming); забаненный аккаунт отклоняется с любого
адреса, сразу после погашения его билета. node.bans.add и player:ban подставляют причину
Banned; You are banned from this server видит вернувшийся забаненный игрок, когда сохранённая
причина пуста.
- События - каждый вид событий, на которые подписываются эти рецепты.
- Конкурентность - что где выполняется, когда рецепт ждёт HTTP или задание.
- Клиентские скрипты - клиентская половина, на которую опирается телепорт.
- Соглашения - именование, идентификаторы против объектов, формы возвращаемых значений.
