Тесты

Тесты плагинов без сервера

Тест загружает настоящий код плагина — собранный тем же 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 слышит каждое пользовательское сообщение, которое отправляет плагин.