Ресурсы
Ресурс - это одна папка в 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/serverpackage.path = here .. "/?.lua;" .. package.pathlocal util = require("util") -- server/util.luanode.log(util.greet())
-- expect: hello from util.luaКлиентским файлам это не нужно: внутри клиентской половины require("lib/helpers") сначала ищет
среди переданных файлов самого ресурса.
resource.toml
Заголовок раздела «resource.toml»Каждый ключ необязателен. Папка без манифеста - это 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/ папка за папкой, без
гарантированного порядка - никогда не полагайтесь на то, что другой ресурс загрузился раньше
вашего; общайтесь с ним через шину и переживайте
молчание. Для каждой папки:
- Разбирается манифест и ищется языковой хост для
type. - Разрешается
[server] mainи проверяется граница папки. - Клиентские файлы читаются, обфусцируются и упаковываются в полезные нагрузки
Content::ResourceChunk- каким бы ни был тип ресурса, потому что клиентская половина - всегда Lua игры. - Папка, в которой нет ни серверной точки входа, ни клиентских файлов, пропускается (строка уровня отладки).
- Хост один раз выполняет точку входа. Прелюдия уже собрала
nodeи прочиталаnode.config. Код верхнего уровня выполняется до любого события; здесь вы подписываетесь и инициализируетесь. - Печатается строка загрузки:
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
Заголовок раздела «Файлы: node.fs»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 "?")endnode.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Используйте папку для данных, которые вы поставляете, и для отчётов; для состояния используйте хранилище.
Чего в node.fs нет
Заголовок раздела «Чего в node.fs нет»Эти четыре вызова - весь 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 falseend
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 librarynode.fs.write("backup/config.json", node.fs.read("data/config.json"))local here = debug.getinfo(1, "S").source:sub(2):match("^(.*)[/\\]") -- .../resources/<name>/serverlocal 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
Заголовок раздела «Состояние: node.storage»node.storage - хранилище ключ/значение на ресурс, которое держится в памяти и пишется в
storage/<name>.json в рабочем каталоге сервера. Значения - любое Lua-значение, сериализуемое в
JSON: строки, числа, логические значения, таблицы (массив, когда ключи 1..n, иначе объект;
функции становятся null; вложенность останавливается на 32 уровнях). Ключи - строки до 256
символов.
local visits = node.storage.get("visits", 0) + 1node.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)) endend)
-- 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, который переиспользуется.
Настройки: node.config и node.resources.manifest
Заголовок раздела «Настройки: node.config и node.resources.manifest»Таблица [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 2node.resources.manifest() заново читает resource.toml с диска и возвращает весь файл таблицей -
name, version, type, server = { main }, client = { files, obfuscation } и config - или
nil, если он не разбирается. Вызывайте его, когда хотите изменить настройки без перезагрузки; сам
node.config не меняется до перезагрузки ресурса.
- События - на что может реагировать серверная половина.
- Конкурентность - поток, в котором выполняется ваш код, и как из него выйти.
- Клиентские скрипты - клиентская таблица
nodeполностью. - Справочник Lua API -
node.fs,node.storage,node.resourcesиnode.configзапись за записью.
