Тесты плагинов без сервера
Тест загружает настоящий код плагина — собранный тем же AssemblyScript, с тем
же API и теми же нативами, что и сборка для сервера, — в поддельный сервер
на TypeScript и управляет им так, как это делали бы игроки и игра. Библиотека —
@amxts/core/test-utils, она входит в ядро:
import { expect, test } from "bun:test";
import { setup } from "@amxts/core/test-utils";
test("/hp лечит раненого игрока", async () => {
const server = await setup(); // проект: его модули, затем его плагины
const player = server.join("Alice", { team: "CT", health: 40 });
player.say("/hp");
expect(player.chat).toContain("Alice, your HP: 40");
expect(player.health).toBe(100);
}, 60_000); // первый тест собирает плагины
npx amxts test запускает его вместе с остальными тестами; внутри это
bun test, и опции у него те же. Плагин собирается один раз за прогон, а
каждый setup() получает свои экземпляры, так что тесты не делят состояние.
Собранное хранится в node_modules/.cache/amxts папки, из которой запущены
тесты, и следующий прогон не собирает его заново: плагин собирается снова,
только когда меняется он сам, файл, который он импортирует, amxts.config.ts
проекта или версия amxts. AMXTS_TEST_CACHE=0 это отключает; удаление папки
очищает кеш.
Поддельный сервер — заменитель: он отвечает так, как AMX Mod X и модуль amxts,
но это не игровой сервер. Он выполняет верхний уровень плагина, затем
init, затем pluginsLoaded, затем configsExecuted — в серверном порядке.
Натив, на который фейковый сервер не отвечает, останавливает тест с его
именем: the plugin called the native "get_user_time", which the fake server does not simulate. Фейковый сервер
отвечает на нативы, которыми пользуются @amxts/core и официальные модули; модуль,
которому нужно больше, приносит их в своём тестовом наборе,
а тест может ответить на натив сам через server.defineNative(name, native).
Проект: setup()
setup() ставит проект на новый поддельный сервер так, как amxts build
раскладывает его на настоящем: модули из его amxts.config.ts, которыми
пользуются его плагины (или которые оставляет pawn), — каждый после тех,
что ему нужны, и со своим тестовым набором, — затем его плагины, затем
начинается карта.
const server = await setup(); // папка, из которой запущены тесты
const server = await setup({ rootDir: "playground" }); // другой проект
const server = await setup({ plugins: ["welcome"] }); // только некоторые плагины, в этом порядке
const server = await setup({ start: false }); // загрузить, но карту пока не начинать
const server = await setup({ maxPlayers: 10, cvars: { mp_freezetime: "0" } }); // настройки сервера, ниже
Собственная папка модуля, где нет amxts.config.ts, — это модуль один (и
модули, которые ему нужны) со своими значениями по умолчанию.
Один плагин: loadPlugin()
import { loadPlugin } from "@amxts/core/test-utils";
const server = await loadPlugin("plugins/hello.ts");
const both = await loadPlugin(["plugins/a.ts", "plugins/b.ts"]);
loadPlugin загружает в новый сервер только те плагины, что ему дали, и
начинает карту. new FakeServer(options) и server.load("plugins/a.ts")
делают то же по шагам; пакет модуля загружается по имени —
server.load("@you/greeter").
Сервер
const server = await setup({
map: "de_inferno", // "de_dust2"
maxPlayers: 20, // 32
modules: ["cstrike", "fun"], // что находит hasModule(); по умолчанию все
cvars: { mp_freezetime: "5" }, // до загрузки плагина
files: { "addons/amxmodx/configs/myplugin.ini": "speed = 250" }, // папка игры
platform: "win32", // что скажет platform(); "linux"
timeZone: "Asia/Yerevan", // пояс сервера для местных геттеров Date; этой машины
});
modules без "reapi" — сервер без ReAPI, как обычный HLDS: события игры
об игроке приходят через Ham Sandwich (server.fireHam), а событие, которое
есть только у ReGameDLL, — через хук, которым его там слышно: сообщение
(server.sendMessage), строку лога игры
(server.gameLog('World triggered "Round_End"')), форвард fakemeta
(server.fireForward("FM_EmitSound", [...])), — а где его никто не слышит,
обработчика нет.
Плагины на одном сервере слышат форварды и нативы друг друга, как на настоящем.
server.join(name, options) | игрок заходит: client_connect, client_authorized, client_putinserver |
server.advance(ms) | двигает часы и по порядку запускает все таймеры, чей срок подошёл |
await server.responses() | ждёт веб-запросы, которые плагины отправили через fetch и useFetch, и отдаёт каждый ответ его плагину, как это сделал бы следующий кадр; см. ниже |
server.fire(name, ...args) | поднимает форвард — "plugin_cfg", "client_command", "my_on_round_end" — для обработчиков и для Forward.subscribe() |
server.fireHook(event, args, { result }) | прогоняет хукчейн; см. ниже |
server.sendMessage(name, args, { player, types }) | шлёт клиенту сообщение, как игра, — по имени у игры и с аргументами в её порядке ("DeathMsg" у "death"): слушатели сообщений читают и меняют его; возвращает { prevented, args } |
server.fireHam(event, entity, args, { result }) | прогоняет функцию сущности через слушателей её класса; см. ниже |
server.hooked(event, { post, classname }) | доходит ли событие игры до плагинов: false, когда снят его последний обработчик, undefined, если его никто не слушал; classname спрашивает о хуке Ham Sandwich для этого класса |
server.hamCalls | функции сущностей, которые выполнили плагины, — weapon.deploy() и прочие — по порядку: { fn, entity, args, hooks } |
server.native(name, ...args) | вызывает натив плагина из export function так, как Pawn, — текст, Float, массивы, буфер для результата — и отдаёт то, что прочитал бы Pawn: текст, массив, число |
server.amxtsNative(name, ...args) | вызывает собственный натив модуля для Pawn — семейство amxts_*_player_data* — так, как Pawn-плагин; server.playerData — само хранилище |
server.nativeWithRoom(name, room, args) | то же с буфером результата на room ячеек (charsmax(out) или size массива) |
server.writeFile(path, text) / server.file(path) | папка игры, которую видят fs и файловые нативы, — в памяти |
server.setCvar(name, value) / server.cvar(name) | квар, как его ставит консоль; слушатели изменения его слышат |
server.translate(lines, language) | строки словаря, как их пишет файл, по умолчанию "en": server.translate({ MYPLUGIN_HELLO: "Hello, %s" }); файл в addons/amxmodx/data/lang читается, когда плагин его загружает. Язык игрока — его setinfo lang: alice.info.set("lang", "ru") |
server.vault(name) | nVault хранилища Storage как Map — заполните его до того, как плагин прочитает |
server.log | что написали console.log и server_print, строка за строкой |
server.serverCommand(line) | строка, набранная в консоли сервера и разбитая так, как её разбивает движок: её обработчики server.addServerCommand; true, если один из них её забрал |
server.commands | что выполнил server.command() |
server.forwards | отправленные форварды, { name, args } |
server.players, server.player(id) | кто подключён |
server.time | миллисекунды с начала карты |
server.roundEnds | раунды, которые плагин закончил (game.endRound, rg_round_end): { status, event, delay, message, sound, trigger } |
server.sounds | сыгранные звуки: entity.emitSound от сущности, player.playSound одному игроку — { entity, sample } |
server.userMessages | отправленные пользовательские сообщения (player.screen.*, message_begin ... message_end) по порядку: { name, player, args } — args это значения write_*, строка — текстом: { name: "ScreenFade", player: 1, args: [2048, 0, 0, 200, 0, 0, 100] }; эффект (effects.*) — это "SVC_TEMPENTITY", с dest — 0 все, 4 рядом с точкой, 8 один игрок — и origin |
server.semiclipMasks, server.semiclipControlled | маска resemiclip каждого игрока; забраны ли его правила — задано ли semiclip.rule |
server.bartimes, server.rules, server.precached | полоска прогресса у каждого игрока; члены game rules, которые записал плагин; что взял server.precache в событии "precache", которое сервер поднимает до "init" |
server.touch(toucher, touched) | одна сущность входит в другую: обработчики "touch", чьи классы подходят; ответ 1, если один из них касание заблокировал |
server.answerCvar(player, cvar, value) | ответ клиента на player.queryCvar (или query_client_cvar) |
Плагин, который ждёт — async-функция, sleep, промис, — работает здесь как
на сервере, так что sleep(1000) продолжается, когда server.advance() его
проходит.
Веб-запрос, который плагин отправляет через fetch или useFetch, уходит
по-настоящему, с машины, на которой идут тесты, а его ответ доходит до
плагина на await server.responses(). Направляйте плагин на веб-сервер,
который тест запускает сам (Bun.serve), а не в интернет:
const web = Bun.serve({ port: 0, fetch: () => Response.json({ temperature: 21 }) });
const server = await setup();
server.setCvar("myplugin_weather_url", `http://127.0.0.1:${web.port}`);
const alice = server.join("Alice");
alice.say("/weather");
await server.responses();
expect(alice.chat).toContain("21 °C");
web.stop();
request модуля по
адресу ftp:, ftps: или sftp: уходит так же, через пакеты basic-ftp и
ssh2-sftp-client — поставьте их рядом с тестами
(npm install -D basic-ftp ssh2-sftp-client). Сервер тест запускает в своём
процессе — ftp-srv для FTP и FTPS, ssh2 для SFTP, — а file запроса —
файл из server.files.
Игроки
import { setup } from "@amxts/core/test-utils";
const server = await setup();
const alice = server.join("Alice", {
team: "CT", // "CT"
health: 100, armor: 0, alive: true,
bot: false,
steamId: "STEAM_0:0:42", // STEAM_0:0:<id>, у бота BOT
flags: "abcu", // буквы users.ini; "z"
origin: [0, 0, 0],
weapons: ["weapon_knife", "weapon_usp"], // первое - в руках; нож
});
alice.say("/hp"); // true, если плагин его забрал
alice.sayTeam("rush b");
alice.command("amx_slap \"Bob\" 5"); // консольная команда, разбитая как это делает движок
alice.disconnect({ dropped: true, reason: "Kicked" });
Что ему показали, строка за строкой, без байтов цвета: alice.chat,
alice.center, alice.console, alice.hud; все строки по порядку — в
alice.messages, а alice.clearMessages() начинает заново. Строка чата,
которую не забрал ни один плагин, попадает в чат всем как Alice: text.
Его состояние: health, armor, frags, deaths, team, alive,
origin, muted, items и activeItem (оружие с полем kind), ammo (по
имени оружия), commands (что client_cmd выполнил в его консоли).
Сущности
Игрок и оружие — сущности, как и всё, что создаёт create_entity, —
server.createEntity("info_target"). Любое поле entvar или member читается и
пишется по имени, с префиксом или без; дробное — число, вектор — массив из трёх:
alice.get("gravity"); // 0.5
alice.set("renderfx", 19);
alice.get("rendercolor"); // [0, 160, 255]
alice.get("m_iHideHUD"); // 32
Хукчейны
fireHook прогоняет цепочку так, как её прогнала бы игра: pre-слушатели,
собственная функция игры, если её никто не остановил, затем post-слушатели.
import { constant, setup } from "@amxts/core/test-utils";
const server = await setup();
const attacker = server.join("Alice", { team: "CT" });
const victim = server.join("Bob", { team: "CT" });
const hit = server.fireHook("takeDamage", [victim.id, 0, attacker.id, 30.0, constant("DMG_BULLET")], { result: 1 });
hit.prevented; // pre-слушатель вызвал preventDefault() или ответил
hit.result; // ответ цепочки после всех слушателей
hit.args; // аргументы, какими их оставили слушатели (event.damage = ...)
server.fireHook("fallDamage", [victim.id], { result: 40 }).result; // 20 с post-слушателем, делящим пополам
Событие называется так же, как в game.addEventListener, или коротким именем
ReAPI ("take_damage"). Аргументы — собственные аргументы цепочки по порядку;
дробный пишется числом. result — что отвечает функция игры, когда она
выполняется, — то, что post-слушатель читает как event.result. Ответ
возвращается в типе цепочки: число, boolean или текст.
fireHam делает то же для события, которое слушают на одном классе
сущностей ({ classname }): сущность идёт первой, её класс выбирает
слушателей.
const knife = server.createEntity("weapon_knife");
server.fireHam("primaryAttack", knife).prevented; // true, если слушатель его заблокировал
server.fireHam("takeDamage", box, [0, attacker.id, 30.0, 2], { result: 1 }); // урон разбиваемому объекту
Таймеры
setTimeout и setInterval идут только по поддельным часам:
server.advance(45_000) запускает 45-секундный интервал один раз,
advance(90_000) — два, а clearTimeout до этого — ни разу.
Тестовый набор модуля
Модуль, чей плагин вызывает нативы, на которые поддельный сервер не отвечает, — show_menu у модуля меню, вызовы Pawn-плагинов в ответ, — поставляет
тестовый набор: файл, который называет его package.json, чей экспорт по
умолчанию добавляет нужное модулю в каждый поддельный сервер, где он работает.
"amxts": {
"module": "src/index.ts",
"testing": "testing/index.ts"
},
"exports": {
".": "./src/index.ts",
"./testing": "./testing/index.ts"
}
// testing/index.ts
import type { FakePlayer, FakeServer } from "@amxts/core/test-utils";
import { defineTestKit } from "@amxts/core/test-utils";
const shown = new WeakMap<FakeServer, Map<number, string>>();
export default defineTestKit({
install(server) {
const screens = new Map<number, string>();
shown.set(server, screens);
server.defineNative("show_menu", (call, [id, _keys, text]) => {
screens.set(id, call.memory.text(text));
return 1;
});
},
});
/** Что показывает меню игрока на этом сервере. */
export function screenOf(server: FakeServer, player: FakePlayer) {
return shown.get(server)?.get(player.id) ?? null;
}
Набор ставится до того, как загрузится плагин модуля: это делают setup() и
server.load("@you/menus"). Тест импортирует то, что набор даёт, из пакета —
import { screenOf } from "@you/menus/testing". server.defineNative(name, native) отвечает на натив на этом сервере; натив получает вызов —
call.memory читает память плагина, call.plugin — кто вызвал, — и ячейки,
которые положил Pawn. server.messageListeners слышит каждое пользовательское
сообщение, которое отправляет плагин.