Перейти к содержимому

Ресурсы

Ресурс - это одна папка в resources/ в рабочем каталоге сервера. Эта страница - взгляд разработчика на эту папку: что где лежит, чем управляет манифест, когда сервер что читает и три места, где ресурс хранит данные. Взгляд хоста - установка, интерпретатор для обфускации, пути в Docker - на странице Ресурсы и контент.

resources/
└── hello/
├── resource.toml # optional manifest
├── server/
│ ├── main.lua # the server entry point
│ └── util.lua # more server files, loaded with require
├── client/
│ ├── main.lua # game-engine script, streamed to players
│ └── lua/
│ ├── ge/extensions/ # entry points, delivered first
│ └── vehicle/ # vehicle-side scripts
└── data/
└── words.json # read with node.fs

Для сервера что-то значат только манифест, серверная точка входа и client/. Всё остальное - ваше: node.fs читает и пишет любой путь внутри папки. Постоянное состояние живёт не здесь - node.storage держит его в storage/<name>.json рядом с resources/.

Серверная половина - это один файл точки входа. require в Lua ищет по пути по умолчанию, который отсчитывается от рабочего каталога сервера, а не от вашей папки; чтобы разбить серверную половину на файлы, сначала добавьте папку в путь:

local here = debug.getinfo(1, "S").source:sub(2):match("^(.*)[/\\]") -- .../resources/hello/server
package.path = here .. "/?.lua;" .. package.path
local util = require("util") -- server/util.lua
node.log(util.greet())
-- expect: hello from util.lua

Клиентским файлам это не нужно: внутри клиентской половины require("lib/helpers") сначала ищет среди переданных файлов самого ресурса.

Каждый ключ необязателен. Папка без манифеста - это Lua-ресурс с именем папки, точкой входа server/main.lua и всеми .lua из client/, передаваемыми игрокам.

name = "hello"
version = "1.0"
type = "lua"
[server]
main = "server/main.lua"
[client]
files = ["main.lua", "lua/vehicle/horn.lua"]
obfuscation = "light"
[config]
greeting = "Welcome"
maxCars = 2
Ключ По умолчанию Что он делает для вас
name имя папки Тег на каждой строке лога, которую вы печатаете, имя хранилища за node.storage (storage/<name>.json) и имя, под которым клиентский мод регистрирует ваши скрипты. Используйте только буквы, цифры, _, - и ., начиная с буквы или цифры: хранилище отказывает другим символам, а клиент отвергает доставку, в имени которой они есть.
version пусто Показывается после имени в строке загрузки: hello v1.0 loaded — lua · server 1 file · 1 client file.
type "lua" Какой языковой хост выполняет серверную половину; приводится к нижнему регистру. "js" требует модуля js-host. Тип, который никто не зарегистрировал, пишет в лог hello · resource type 'js' has no language host loaded, skipping (a native module in modules/ must register one), и папка пропускается.
[server] main "server/main.lua" Точка входа относительно папки, выполняется один раз при загрузке. Должна оставаться внутри папки; путь, выходящий за неё, отклоняется. Если файла нет, ресурс только клиентский - это нормально.
[client] files все .lua в client/, рекурсивно Передаваемые файлы относительно client/ (ведущий client/ допускается). Без ключа сначала идут файлы непосредственно в client/lua/ge/extensions/, остальные - по алфавиту; с ключом порядок доставки - ваш порядок. Перечисленный путь, выходящий за папку, игнорируется с предупреждением.
[client] obfuscation "light" none, light, medium или strong. off/false означают none, high/full означают strong, всё остальное - light. Игнорируется, когда сервер работает с [Resources] Obfuscate = false.
[config] нет Сервером не читается. Прелюдия отдаёт её вам как node.config, как написано.

Манифест, который не разбирается, пишет hello · failed to parse resource.toml (...), using defaults, и папка загружается со значениями по умолчанию из таблицы выше.

При запуске сервер сначала загружает modules/, затем обходит resources/ папка за папкой, без гарантированного порядка - никогда не полагайтесь на то, что другой ресурс загрузился раньше вашего; общайтесь с ним через шину и переживайте молчание. Для каждой папки:

  1. Разбирается манифест и ищется языковой хост для type.
  2. Разрешается [server] main и проверяется граница папки.
  3. Клиентские файлы читаются, обфусцируются и упаковываются в полезные нагрузки Content::ResourceChunk - каким бы ни был тип ресурса, потому что клиентская половина - всегда Lua игры.
  4. Папка, в которой нет ни серверной точки входа, ни клиентских файлов, пропускается (строка уровня отладки).
  5. Хост один раз выполняет точку входа. Прелюдия уже собрала node и прочитала node.config. Код верхнего уровня выполняется до любого события; здесь вы подписываетесь и инициализируетесь.
  6. Печатается строка загрузки: hello v1.0 loaded — lua · server 1 file · 1 client file.

Когда обход заканчивается, 2 resources · 0 modules loaded подводит итог, и запускается рабочий поток: с этого момента срабатывают таймеры, serverTick идёт каждые 100 мс, и игроки могут подключаться. Подключение игрока передаёт клиентские файлы каждого ресурса после синхронизации контента, затем срабатывает playerJoined.

Файлы в client/ - часть вашего ресурса, которая выполняется в игре. Сервер отправляет их каждому подключающемуся игроку одним или несколькими пакетами Content::ResourceChunk (каждый до 900 КБ, с разбиением при необходимости) и одним Content::ResourceDone после всех ресурсов. Один файл больше предела пропускается со строкой hello · client file 'main.lua' is 912 KB (over the 900 KB cap), skipped. Клиентский мод отвергает доставку больше 8 МБ или 512 файлов.

У каждого файла есть вид: vehicle, если путь начинается с lua/vehicle/, иначе ge.

  • Файлы ge компилируются в окружении, закрытом для ресурса, где node - клиентская таблица, а require находит остальные файлы ресурса по пути. Каждый файл выполняется один раз. Файл, возвращающий таблицу с функциями on… (onUpdate, onExtensionLoaded, onPreRender), регистрируется как расширение игры и получает эти колбэки; demo-numbers использует onUpdate(dt) как таймер. beamng.log подтверждает строкой Activated server resource "hello" (1 ge file(s), 0 vehicle file(s)).
  • Файлы vehicle внедряются в собственные машины игрока при активации и при каждом спавне. Чисто выгрузить их нельзя, поэтому клиент предупреждает; где можно, поставляйте Lua для машин как контент.

Клиентский мод выгружает обработчики и расширения ресурса, когда игрок выходит. Ресурс, доставленный повторно, пока его копия работает, заменяет эту копию. Клиентские файлы упаковываются один раз, при запуске сервера: правка client/ требует перезапуска, а игроки, уже находящиеся в сессии, остаются с тем, что получили.

Переданные файлы компилируются в памяти под собственным именем ресурса и никогда не пишутся на диск игрока, так что ресурс не может перезаписать или подменить файл игры или клиентского мода - он добавляет модули и хуки рядом с ними. Что можно изменить во время выполнения и как выключить встроенный клиентский модуль - на странице Клиентские скрипты.

Если сервер её не отключил, каждый файл ge и vehicle перед упаковкой прогоняется через Prometheus на уровне, который называет [client] obfuscation: light переименовывает локальные переменные и скрывает строковые константы и безопасен для кода, выполняющегося каждый кадр; medium добавляет косвенность для локальных переменных и переписывает числа; strong дополнительно оборачивает файл - для меню и разовой инициализации. Глобальные имена и ключи таблиц никогда не переименовываются, поэтому M.onUpdate и хуки игры продолжают работать. Пока разрабатываете, используйте none: тогда номера строк в beamng.log совпадают с вашим исходником. Файл, который Prometheus не может преобразовать, отдаётся открытым текстом с предупреждением; подключение никогда не срывается из-за обфускации. Результат кэшируется в .obfcache/.

Сервер не выполняет ваши клиентские файлы - они упаковываются, а не запускаются, - но разбирает каждый при упаковке, и файл, который не разбирается, отмечается строкой Error с именем ресурса, файла и сообщением Lua, race · client file 'main.lua' has a syntax error: main.lua:1: unexpected symbol near '=' (the file ships anyway; the game's Lua will very likely refuse it too), независимо от настройки обфускации; файл всё равно отправляется. (До 1.2.1 синтаксическая ошибка была видна только как предупреждение Prometheus race · Prometheus failed on 'main.lua' (…), shipping it unobfuscated, а при none - не видна вовсе, пока beamng.log игрока не сообщал compile error.)

node.resources.reload(name) возвращает true, когда запрос принят, а не когда он завершён: перезагрузка выполняется в рабочем потоке после того, как текущий обработчик вернёт управление, поэтому ресурс может перезагрузить сам себя из команды чата. Она сбрасывает всё, что зарегистрировала серверная половина, - обработчики событий, подписки шины и модульных каналов, фильтры ретрансляции, таймеры, приёмники лога - вместе с работающими корутинами и всем состоянием Lua, затем снова выполняет точку входа и печатает hello reloaded — lua. false означает, что ресурса с таким именем не загружено.

Что переживает перезагрузку: файлы в вашей папке, node.storage и остальные ресурсы. Что нет: переменные Lua и всё, что ресурс зарегистрировал. node.config читается из манифеста заново, поэтому изменение настроек вступает в силу. Фоновое задание или HTTP-запрос, уже находящиеся в полёте, не отменяются, а клиентские файлы не упаковываются повторно.

Хук перед выгрузкой (добавлен в сервере 1.2.0): node.on("resourceUnload", function(reason) ... end) выполняется в старом экземпляре прямо перед тем, как его состояние будет сброшено, синхронно и только для выгружаемого ресурса - перезагрузка другого ресурса ваш хук не вызывает. reason здесь - "reload"; при остановке сервера - "shutdown", после того как serverShutdown сработал для всех. У него ограничения serverShutdown: запись node.storage, сделанная в нём, сохраняется (новый экземпляр читает её при загрузке; при остановке она сбрасывается на диск), обычный node.pg.exec или node.pg.query в форме с колбэком доставляется (обработчик - не корутина, так что приостанавливающая форма выбросит ошибку), но ни один колбэк, таймер или корутина, запущенные там, больше не выполнятся - так что запишите, что должны, и вернитесь, и ничего не ставьте после await. Не вызывайте из него node.resources.reload для самого себя: при перезагрузке это ставит в очередь ещё одну перезагрузку свежего экземпляра - бесконечный цикл; при остановке запрос отбрасывается (false), потому что во время остановки сервера ничего не загружается заново.

local session = { started = node.server.uptime(), joins = 0 }
node.on("playerJoined", function() session.joins = session.joins + 1 end)
node.on("resourceUnload", function(reason)
node.storage.set("lastSession", { reason = reason, joins = session.joins })
node.log("%s after %d join(s)", reason, session.joins)
end)
-- expect: shutdown after 1 join\(s\)

node.fs читает и пишет внутри вашей папки и больше нигде. Путь задаётся относительно папки, длиной до 512 символов, без шага .., без диска или корня и без байта NUL; всё остальное получает nil или false.

  • node.fs.read(path) -> string? - байты файла, nil, если его нет или он снаружи.
  • node.fs.write(path, data) -> boolean - синхронно; родительские папки создаются.
  • node.fs.writeAsync(path, data, cb?) -> boolean - запись выполняется в потоке записи файлов, а cb(ok) выполняется в рабочем потоке; true означает «принято». Несколько записей в один путь до того, как поток до них дойдёт, сворачиваются в последнюю.
  • node.fs.list(path?) -> array<record{name,dir,size}>? - одна папка, корень ресурса, если опущено.
local words = node.json.decode(node.fs.read("data/words.json") or "[]") or {}
node.log("%d word(s) shipped with the resource", #words)
local lines = {}
for _, p in ipairs(node.players.all()) do
lines[#lines + 1] = string.format("%s\t%s", p.name, p.ip or "?")
end
node.fs.writeAsync("reports/" .. os.date("%Y-%m-%d") .. ".txt", table.concat(lines, "\n"), function(ok)
node.log("report written: %s", tostring(ok))
end)
-- expect: 2 word\(s\) shipped with the resource
-- expect: report written: true

Используйте папку для данных, которые вы поставляете, и для отчётов; для состояния используйте хранилище.

Эти четыре вызова - весь API: нет ни exists, ни mkdir, ни remove, ни rename, ни copy, ни помощника для путей. Что делать сегодня:

  • Пути. Пишите их через / и на Windows, и на Linux - сервер принимает / на обеих, а \ является разделителем только на Windows (на Linux это обычный символ в имени файла). node.fs.list возвращает имена, а не пути, так что склеивайте сами: dir .. "/" .. entry.name.
  • Существует ли файл? node.fs.list(folder) и поиск по имени - запись заодно сообщает dir и size - либо node.fs.read(path) ~= nil, что читает весь файл. Оба отвечают nil одинаково и для отсутствующей папки, и для пути за пределами вашей.
  • Создать папку. node.fs.write создаёт родительские папки того пути, который пишет; пустую папку создать нельзя.
  • Скопировать. node.fs.write(to, node.fs.read(from)).
  • Удалить и переименовать. В node.fs этого нет. В Lua-стейте ресурса открыты стандартные библиотеки os и io (Lua 5.4: os.remove, os.rename, io.open), и они работают - но они ничего не знают о границе папки и разрешают относительный путь от рабочего каталога сервера, а не от вашей папки. Сначала соберите абсолютный путь: строка here из раздела Структура даёт .../resources/<name>/server, её родитель - ваша папка.
  • Следить за изменениями. При изменении файла ничего не срабатывает; опрашивайте по таймеру (События → События о файлах нет).
local function exists(path) -- a name inside a folder, from node.fs.list
local dir, name = path:match("^(.-)/?([^/]+)$")
for _, entry in ipairs(node.fs.list(dir ~= "" and dir or nil) or {}) do
if entry.name == name then return true, entry.dir end
end
return false
end
node.log("data/config.json exists: %s", tostring(exists("data/config.json")))
node.log("data/missing.json exists: %s", tostring(exists("data/missing.json")))
-- copy with node.fs (backup/ is created on the way); rename and delete with the standard library
node.fs.write("backup/config.json", node.fs.read("data/config.json"))
local here = debug.getinfo(1, "S").source:sub(2):match("^(.*)[/\\]") -- .../resources/<name>/server
local folder = here:match("^(.*)[/\\]") -- .../resources/<name>
assert(os.rename(folder .. "/backup/config.json", folder .. "/backup/config.old"))
assert(os.remove(folder .. "/backup/config.old"))
node.log("backup/ holds %d file(s)", #(node.fs.list("backup") or {}))
-- expect: data/config.json exists: true
-- expect: data/missing.json exists: false
-- expect: backup/ holds 0 file\(s\)

exists, mkdir, remove, rename, copy, stat со временем изменения и помощник для путей в API пока нет; до их появления способ - строки выше.

node.storage - хранилище ключ/значение на ресурс, которое держится в памяти и пишется в storage/<name>.json в рабочем каталоге сервера. Значения - любое Lua-значение, сериализуемое в JSON: строки, числа, логические значения, таблицы (массив, когда ключи 1..n, иначе объект; функции становятся null; вложенность останавливается на 32 уровнях). Ключи - строки до 256 символов.

local visits = node.storage.get("visits", 0) + 1
node.storage.set("visits", visits)
node.log("server start number %d", visits)
node.on("playerJoined", function(player)
if player.accountId then
node.storage.set("lastSeen:" .. player.accountId, node.server.unixTime())
node.log("last seen of account %d is %d", player.accountId, node.storage.get("lastSeen:" .. player.accountId))
end
end)
-- expect: server start number 1
-- expect: last seen of account 42 is \d+

Каждый set или delete оказывается на диске до возврата из вызова, поэтому падение процесса сервера не теряет ничего подтверждённого. Между сохранениями хранилище - это снимок плюс журнал изменений (storage/<name>.log); журнал сворачивается в снимок, когда перерастает его, и при каждой чистой остановке, так что после нормального завершения вы находите один читаемый JSON-файл. .log, оставшийся после падения, воспроизводится при следующем запуске: storage 'hello' replayed 3 changes from its log. Правьте JSON вручную только при остановленном сервере. Ключом для долговременных данных делайте player.accountId или player.name, никогда - player.id, который переиспользуется.

Таблица [config] вашего манифеста - ваша. node.config держит её как написано - строки, числа, логические значения, массивы и вложенные таблицы - или пустую таблицу, когда её нет. Она читается один раз, при загрузке ресурса. Подкладывайте под неё свои значения по умолчанию, как это делает vehicle-cleanup:

local defaults = { greeting = "Welcome", maxCars = 1, checkIntervalMs = 5000 }
local config = setmetatable(node.config, { __index = defaults })
node.log("greeting is %q, limit %d", config.greeting, config.maxCars)
-- expect: greeting is "Welcome", limit 2

node.resources.manifest() заново читает resource.toml с диска и возвращает весь файл таблицей - name, version, type, server = { main }, client = { files, obfuscation } и config - или nil, если он не разбирается. Вызывайте его, когда хотите изменить настройки без перезагрузки; сам node.config не меняется до перезагрузки ресурса.