Testing plugins without a server
A test loads the plugin's real code — compiled by the same AssemblyScript,
against the same API and natives as the server build — into a fake server
written in TypeScript, and drives it the way players and the game would. The
library is @amxts/core/test-utils, part of the core:
import { expect, test } from "bun:test";
import { setup } from "@amxts/core/test-utils";
test("/hp heals a hurt player", async () => {
const server = await setup(); // the project: its modules, then its plugins
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); // the first test compiles the plugins
npx amxts test runs it with the rest of your tests; it is bun test
underneath, and takes the same options. A plugin is compiled once per run and
every setup() gets fresh instances, so tests do not share state.
What was compiled is kept in node_modules/.cache/amxts of the folder the
tests run from, so the next run does not compile it again: a plugin is
compiled anew only when it, a file it imports, the project's
amxts.config.ts or the amxts version changes. AMXTS_TEST_CACHE=0 turns
this off; deleting the folder empties it.
The fake server is a stand-in: it answers as AMX Mod X and the amxts module
do, but it is not a game server. It runs the plugin's top level, then init,
then pluginsLoaded, then configsExecuted, in the server's order.
A native the fake server does not answer stops the test with its name:
the plugin called the native "get_user_time", which the fake server does not simulate. The fake server answers
the natives @amxts/core and the official modules use; a module that needs more
brings them in its test kit, and a test can answer one
itself with server.defineNative(name, native).
A project: setup()
setup() puts a project on a new fake server as amxts build lays it out on
a real one: the modules its amxts.config.ts lists that its plugins use (or
pawn keeps), each after what it requires and with its test kit, then its
plugins, then the map starts.
const server = await setup(); // the folder the tests run from
const server = await setup({ rootDir: "playground" }); // another project
const server = await setup({ plugins: ["welcome"] }); // only some of its plugins, in this order
const server = await setup({ start: false }); // load, but do not start the map yet
const server = await setup({ maxPlayers: 10, cvars: { mp_freezetime: "0" } }); // the server's options, below
A module's own folder, which has no amxts.config.ts, is the module alone —
and the modules it requires — with its defaults.
One plugin: 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 loads the plugins it is given, and nothing else, into a new
server and starts the map. new FakeServer(options) and
server.load("plugins/a.ts") do the same a step at a time; a module package
loads by its name — server.load("@you/greeter").
The server
const server = await setup({
map: "de_inferno", // "de_dust2"
maxPlayers: 20, // 32
modules: ["cstrike", "fun"], // what hasModule() finds; all of them
cvars: { mp_freezetime: "5" }, // before the plugin loads
files: { "addons/amxmodx/configs/myplugin.ini": "speed = 250" }, // the game folder
platform: "win32", // what platform() says; "linux"
timeZone: "Asia/Yerevan", // the server's, for Date's local getters; this machine's
});
modules without "reapi" is a server without ReAPI, as plain HLDS is: a
player's game events come through Ham Sandwich (server.fireHam), and an
event of ReGameDLL's own through the hook that hears it there - a message
(server.sendMessage), a line of the game's log
(server.gameLog('World triggered "Round_End"')), a fakemeta forward
(server.fireForward("FM_EmitSound", [...])) - or, where nothing hears it,
has no listener.
Plugins on one server hear each other's forwards and natives, as on a real one.
server.join(name, options) | a player connects: client_connect, client_authorized, client_putinserver |
server.advance(ms) | moves the clock and runs every timer that comes due, in order |
await server.responses() | waits for the web requests plugins sent with fetch and useFetch and hands each response to its plugin, as the next frame would; see below |
server.fire(name, ...args) | raises a forward — "plugin_cfg", "client_command", "my_on_round_end" — for handlers and Forward.subscribe() alike |
server.fireHook(event, args, { result }) | runs a hookchain; see below |
server.sendMessage(name, args, { player, types }) | sends a message to a client as the game would, by the game's name and in its order of arguments ("DeathMsg" for "death"): the message listeners read and change it; returns { prevented, args } |
server.fireHam(event, entity, args, { result }) | runs an entity's function through the listeners of its class; see below |
server.hooked(event, { post, classname }) | whether a game event reaches the plugins: false once its last listener is taken off, undefined when nothing listened for it; classname asks about Ham Sandwich's hook of that class |
server.hamCalls | the entity functions plugins ran - weapon.deploy() and the rest - in order: { fn, entity, args, hooks } |
server.native(name, ...args) | calls a plugin's export function native as Pawn would — text, Float, arrays, an out-buffer — and gives back what Pawn reads: the text, the array, the number |
server.amxtsNative(name, ...args) | calls one of the module's own natives for Pawn — the amxts_*_player_data* family — as a Pawn plugin would; server.playerData is the store itself |
server.nativeWithRoom(name, room, args) | the same with an out-buffer of room cells (charsmax(out), or an array's size) |
server.writeFile(path, text) / server.file(path) | the game folder fs and the file natives see, in memory |
server.setCvar(name, value) / server.cvar(name) | a cvar as the console sets it; change listeners hear it |
server.translate(lines, language) | dictionary lines as a file writes them, "en" unless said: server.translate({ MYPLUGIN_HELLO: "Hello, %s" }); a file in addons/amxmodx/data/lang is read when a plugin loads it. A player's language is his lang setinfo: alice.info.set("lang", "ru") |
server.vault(name) | a Storage's nVault, as a Map — fill it before the plugin reads it |
server.log | what console.log and server_print wrote, one line a line |
server.serverCommand(line) | a line typed in the server console, split as the engine splits it: the server.addServerCommand handlers of it; true if one took it |
server.commands | what server.command() ran |
server.forwards | the forwards sent out, { name, args } |
server.players, server.player(id) | who is connected |
server.time | milliseconds since the map started |
server.roundEnds | the rounds a plugin ended (game.endRound, rg_round_end): { status, event, delay, message, sound, trigger } |
server.sounds | sounds played: entity.emitSound from an entity, player.playSound to one player — { entity, sample } |
server.userMessages | user messages sent (player.screen.*, message_begin ... message_end), in order: { name, player, args } — args are the write_* values, a string as text: { name: "ScreenFade", player: 1, args: [2048, 0, 0, 200, 0, 0, 100] }; an effect (effects.*) is "SVC_TEMPENTITY", with dest - 0 everyone, 4 near a point, 8 one player - and origin |
server.semiclipMasks, server.semiclipControlled | resemiclip's mask per player; whether its rules are taken over - a semiclip.rule is set |
server.bartimes, server.rules, server.precached | the progress bar each player was shown; the game rules' members a plugin set; what server.precache took, in the "precache" event the server raises before "init" |
server.touch(toucher, touched) | one entity moving into another: the "touch" listeners whose classes match; the answer is 1 when one blocked it |
server.answerCvar(player, cvar, value) | a client answering player.queryCvar (or query_client_cvar) |
A plugin that awaits — an async function, sleep, a promise — runs here as
on the server, so a sleep(1000) resumes when server.advance() passes it.
A web request a plugin sends with fetch or useFetch goes out for real,
from the machine the tests run on, and its response reaches the plugin on
await server.responses(). Point the plugin at a web server the test starts
itself (Bun.serve), not at the internet:
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();
A module's request
to an ftp:, ftps: or sftp: address goes out the same way, through the
basic-ftp and ssh2-sftp-client packages — install them beside the tests
(npm install -D basic-ftp ssh2-sftp-client). The test starts the server in
its own process — ftp-srv for FTP and FTPS, ssh2 for SFTP — and a
request's file is a file of server.files.
Players
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 for a bot
flags: "abcu", // users.ini letters; "z"
origin: [0, 0, 0],
weapons: ["weapon_knife", "weapon_usp"], // the first in his hands; a knife
});
alice.say("/hp"); // true if a plugin swallowed it
alice.sayTeam("rush b");
alice.command("amx_slap \"Bob\" 5"); // a console command, split as the engine splits it
alice.disconnect({ dropped: true, reason: "Kicked" });
What he was shown, one line a line, with the colour bytes taken out:
alice.chat, alice.center, alice.console, alice.hud; every line in
order is in alice.messages, and alice.clearMessages() starts again. A chat
line no plugin swallowed reaches everyone's chat as Alice: text.
His state: health, armor, frags, deaths, team, alive, origin,
muted, items and activeItem (weapons with a kind), ammo (by weapon
name), commands (what client_cmd ran in his console).
Entities
A player and a weapon are entities, and so is anything create_entity makes —
server.createEntity("info_target"). Any entvar or member reads and writes by
its name, with or without the prefix; a float is a number and a vector an
array of three:
alice.get("gravity"); // 0.5
alice.set("renderfx", 19);
alice.get("rendercolor"); // [0, 160, 255]
alice.get("m_iHideHUD"); // 32
Hookchains
fireHook runs a chain as the game would: the pre listeners, the game's own
function unless one of them stopped it, then the post listeners.
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; // a pre listener called preventDefault() or answered
hit.result; // the chain's answer after every listener
hit.args; // the arguments as listeners left them (event.damage = ...)
server.fireHook("fallDamage", [victim.id], { result: 40 }).result; // 20 with a halving post listener
The event is named as game.addEventListener names it, or by ReAPI's short
name ("take_damage"). The arguments are the chain's own, in order; a float
argument is written as a number. result is what the game's function answers
when it runs — what a post listener reads as event.result. The answer comes
back typed by the chain: a number, a boolean or text.
fireHam does the same for an event heard on one class of entity
({ classname }): the entity comes first, its class picks the listeners.
const knife = server.createEntity("weapon_knife");
server.fireHam("primaryAttack", knife).prevented; // true when a listener blocked it
server.fireHam("takeDamage", box, [0, attacker.id, 30.0, 2], { result: 1 }); // a breakable's damage
Timers
setTimeout and setInterval run on the fake clock and nothing else:
server.advance(45_000) runs a 45-second interval once, advance(90_000)
twice, and a clearTimeout before that means never.
A module's test kit
A module whose plugin calls natives the fake server does not answer — a menu
module's show_menu, a module that calls Pawn plugins back — ships a test
kit: a file its package.json names, whose default export adds what the
module needs to every fake server it runs on.
"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;
});
},
});
/** What a player's menu shows on this server. */
export function screenOf(server: FakeServer, player: FakePlayer) {
return shown.get(server)?.get(player.id) ?? null;
}
The kit is installed before the module's plugin loads: by setup(), and by
server.load("@you/menus"). A test imports what it gives from the package —
import { screenOf } from "@you/menus/testing". server.defineNative(name, native) answers a native on that server; the native gets the call —
call.memory reads the plugin's memory, call.plugin is who called — and the
cells Pawn pushed. server.messageListeners hears every user message a
plugin sends.