Свой модуль
Модуль — код на TypeScript, которым пользуются плагины проекта, —
greeter.greet(player), без строки импорта, по имени, которое модуль даёт
себе сам. То, что он экспортирует, — функции и типы — и есть его API. Модуль говорит о себе через defineModule,
проект перечисляет его в amxts.config.ts, а на сервере работает один его
экземпляр. menu-core и config-core — модули. Код без своего состояния на
сервере — клиент какого-то сервиса, помощники — может быть и
библиотекой: она компилируется в каждый плагин, который её
импортирует.
С чего начать
npx amxts init --module @you/greeter # модуль в ./greeter
npx amxts init --module @you/greeter --natives # с нативами для Pawn-плагинов
cd greeter
npm install
npm test
pnpm dlx amxts init --module @you/greeter # модуль в ./greeter
pnpm dlx amxts init --module @you/greeter --natives # с нативами для Pawn-плагинов
cd greeter
pnpm install
pnpm test
yarn dlx amxts init --module @you/greeter # модуль в ./greeter
yarn dlx amxts init --module @you/greeter --natives # с нативами для Pawn-плагинов
cd greeter
yarn install
yarn test
bunx amxts init --module @you/greeter # модуль в ./greeter
bunx amxts init --module @you/greeter --natives # с нативами для Pawn-плагинов
cd greeter
bun install
bun run test
Команда пишет работающий модуль — приветствие, которое вы замените своим, — с проектом-песочницей и проходящим тестом.
Устройство
Модуль — пакет npm:
greeter/
├── package.json
├── tsconfig.json настройки редактора: extends @amxts/core/as/tsconfig.json
├── .oxlintrc.json правила линтера: @antfu/eslint-config, для oxlint
├── .oxfmtrc.json oxfmt, для файлов JSON и YAML
├── README.md
├── README.ru.md
├── LICENSE
├── src/
│ ├── index.ts модуль: defineModule и API
│ ├── types.ts его публичные типы (необязательно)
│ └── natives.ts его нативы для Pawn-плагинов (необязательно)
├── include/
│ └── greeter.inc Pawn-include этих нативов
├── playground/ проект с модулем внутри — попробовать и протестировать
│ ├── package.json "@you/greeter": "file:.."
│ ├── amxts.config.ts
│ └── plugins/
│ └── welcome.ts
└── test/
└── greeter.test.ts
package.json указывает на них в поле amxts:
{
"name": "@you/greeter",
"version": "0.1.0",
"type": "module",
"exports": { ".": "./src/index.ts" },
"types": "./src/index.ts",
"amxts": {
"module": "src/index.ts",
"natives": "src/natives.ts",
"include": "include/greeter.inc"
},
"files": ["README.md", "README.ru.md", "include", "src"]
}
module— файл, которым пользуются плагины. Обязателен.natives— плагин модуля с нативами для Pawn-плагинов (нативы). Без него сборка делает плагин, который только запускает модуль.include— Pawn-include этих нативов; он идёт в пакете для Pawn-плагинов. Сборка пишет его по нативам, если это не контракт.contract—true, когда include уже есть и меняться не должен: сборка сверяет с ним нативы, а не пишет его (см. три вида модуля).testing— тестовый набор модуля, если модулю нужно от поддельного сервера больше, чем у того есть (тестовый набор модуля).
Так или иначе плагин модуля — его единственный экземпляр на сервере: любой другой плагин, который импортирует модуль, вызывает этот экземпляр, с теми же функциями и типами (общие модули).
Модуль
// src/index.ts
import { Player, print } from "@amxts/core";
export interface GreeterOptions {
/** Чем приветствуют игрока. */
greeting: string;
/** Здороваться раз за карту. */
once: boolean;
}
export default defineModule<GreeterOptions>({
meta: { name: "greeter", configKey: "greeter" },
defaults: { greeting: "Hello", once: true },
imports: [{ from: "@you/greeter", as: "greeter" }],
setup(options) {
greeting = options.greeting;
once = options.once;
},
});
declare module "@amxts/core" {
interface ModuleOptions {
greeter?: Partial<GreeterOptions>;
}
}
let greeting = "";
let once = true;
const greeted: string[] = [];
/** Здоровается с игроком — раз за карту, если проект не решил иначе. */
export function greet(player: Player) {
if (once && greeted.includes(player.name)) return;
greeted.push(player.name);
print(player, `${greeting}, ${player.name}!`);
}
Пишется как любой код плагина: тот же API, те же правила — с одним
отличием: модуль импортирует то, чем пользуется. Автоимпорты —
проекта, а модуль собирается в проектах, настроек которых не знает:
@amxts/core, @amxts/core/natives, @amxts/core/fs и остальной API ядра
импортируются по имени пакета. Публичные типы могут жить в
src/types.ts и реэкспортироваться через export * from "./types"; блок
declare module "@amxts/core" можно положить туда же. Состояние модуля — его собственное;
другие плагины меняют его экспортированными функциями, а не
экспортированными переменными.
defineModule
defineModule глобальная, как defineConfig в amxts.config.ts: импорт не
нужен.
Что она берёт:
| поле | что |
|---|---|
meta.name | имя пакета без scope: menu-core у @amxts/menu-core |
meta.configKey | ключ, под которым настройки модуля пишутся в amxts.config.ts |
requires | пакеты-модули, которыми модуль пользуется: они приходят вместе с ним, когда проект его перечисляет, и загружаются раньше |
defaults | значение каждой настройки, когда проект её не задал |
imports | имя, под которым плагины пользуются модулем без импорта: [{ from: "@you/greeter", as: "greeter" }] — модуль как пространство имён, или [{ from: "@you/greeter", name: "greeter" }] — один из его экспортов, объект, свойства которого плагин присваивает (greeter.greeting = "Hi"); from — собственный пакет модуля |
setup(options) | выполняется один раз, в плагине модуля, когда сервер его загружает, — раньше init любого плагина |
setup получает значения по умолчанию, поверх которых — настройки проекта:
настройка-объект сливается по ключам, остальное заменяется. Здесь модуль их
применяет — и здесь же слушает сервер (server.addEventListener), если ему
нужно.
meta, requires, defaults и imports сборка читает из исходника, не выполняя его.
Они пишутся литералами — строки, числа, булевы, массивы и объекты из них, — а
тип настроек называется: defineModule<GreeterOptions>(...).Блок declare module "@amxts/core" добавляет ключ модуля в ModuleOptions,
и редактор проверяет amxts.config.ts проекта: greeter: { greting: "Hi" }
там красное, с «Did you mean greeting?».
Набор автора модуля
@amxts/core/kit — то, что коду модуля нужно помимо @amxts/core. Плагину он не
нужен.
import { defineModule, PawnFunction, caller, request } from "@amxts/core/kit";
defineModule | тот же глобальный defineModule, импортом по имени |
caller() | плагин, который вызвал работающий сейчас натив |
callingPlugin(), onPluginStop(listener) | плагин, чей вызов модуль выполняет сейчас, и момент, когда он остановился (ниже) |
PawnFunction.find(plugin, name) | public Pawn-плагина, чтобы вызвать его в ответ: .call().int(id).text("KEY").run(); .buffer(size) и bufferText читают, что он записал |
publicFor(handler, key) | функция этого плагина как имя public, чтобы её вызвал Pawn-плагин |
createCellArray, cellArrayRows, pushCellArrayRow, destroyCellArray, cellsText, textCells | Array: Pawn-плагина: прочитать и заполнить строка за строкой |
showMenu(id, keys, text, title) | show_menu для меню любой длины |
menuColors(text) | цветовые метки текста (!y, !r) как коды, которые рисует меню |
colorTags(text) | текст из Pawn — строка словаря, аргумент Pawn-плагина — с цветовыми кодами (\y, ^4), ставшими метками |
request(url, options) | сетевой клиент сервера как он есть: HTTP, HTTPS, FTP, FTPS и SFTP, отправка и приём файлов игровой папки (ниже) |
Плагин, который остановился с v0.2
Модуль хранит то, что ему дают плагины: меню, которое сделал один из них,
функцию для обратного вызова. Когда такой плагин выгружают или
перезагружают — amxts dev перезагружает плагин, который вы сохранили, —
новая загрузка даёт всё это заново, а функция остановившейся загрузки ничего
не отвечает (false, 0, "" или null, а консоль один раз говорит об
этом). Поэтому модуль помечает то, что дал плагин, через callingPlugin() и
убирает это в onPluginStop:
import { Player } from "@amxts/core";
import { callingPlugin, onPluginStop } from "@amxts/core/kit";
interface Greeting {
text: (player: Player) => string;
from: number;
}
let greetings: Greeting[] = [];
export function addGreeting(text: (player: Player) => string) {
greetings.push({ text, from: callingPlugin() });
}
onPluginStop((plugin) => {
greetings = greetings.filter(greeting => greeting.from != plugin);
});
callingPlugin() — число для одной загрузки плагина, чей вызов выполняет
модуль; после перезагрузки число новое. Оно равно 0, когда вызова другого
плагина нет — собственные нативы модуля, его события и таймеры, — и за это
ничего не убирается.
Сетевые запросы с v0.2
request отправляет запрос по адресу http:, https:, ftp:, ftps: или
sftp: и даёт то, чем он закончился. Он никогда не отклоняется: неудача —
это errorKind, со словами в errorText и кодом ответа протокола — 530,
550 у FTP, статус SFTP — в replyCode.
import { request } from "@amxts/core/kit";
async function backUp(map: string): Promise<void> {
const result = await request(`sftp://backup.example.com/maps/${map}.bsp`, {
user: "admin",
keyFile: "addons/amxmodx/data/backup_key",
upload: true,
createDirs: true,
file: `maps/${map}.bsp`,
});
if (result.errorKind != "") console.error(`backup of ${map}: ${result.errorKind} - ${result.errorText}`);
}
file— путь игровой папки, как в@amxts/core/fs: скачанное пишется туда, загружаемое читается оттуда, и байты не проходят через плагин — карта или демо любого размера. Путь, выходящий из игровой папки, отвергается.- Путь, который кончается на
/, — папка: её список, или имена по одному в строке сlist: true. quoteвыполняет команды FTP или SFTP до передачи (["DELE old.txt"],["rename a.txt b.txt"]); сmethod: "HEAD"ничего не передаётся.errorKind—"login","denied","notFound","refused","timeout","tls","aborted"или"other"— либо"", если ответ есть, в том числе HTTP404.
Ключ для SFTP — ключ RSA в PEM:
ssh-keygen -t rsa -m PEM. Ключи в
собственном формате OpenSSH, ключи ECDSA и Ed25519 пока не читаются
(ограничения).Три вида модуля
Без нативов
В поле amxts — только module. Плагин модуля сборка делает сама;
TypeScript-плагины модулем пользуются, Pawn-плагины вызвать его не могут.
С нативами
// @filename: src/index.ts
import { Player } from "@amxts/core";
export function greet(player: Player) {
// модуль выше
}
// @filename: src/natives.ts
import { Player, plugin } from "@amxts/core";
import * as greeter from "./index";
plugin({ name: "Greeter", version: "1.0.0", author: "you", description: "Greets players" });
/** Greets a player - for Pawn plugins. */
export function greeter_greet(player: Player) {
greeter.greet(player);
}
Каждая export function — натив для Pawn-плагинов (нативы).
npx amxts build в папке модуля собирает его и пишет include/greeter.inc
по нативам — сигнатуры из их типов, комментарии из JSDoc:
/** Greets a player - for Pawn plugins. */
native greeter_greet(id);
Include коммитится вместе с изменением: по нему компилируются Pawn-плагины.
Готовый include: контракт
Модуль может отдавать нативы include, который уже есть, — того, с которым
компилируются Pawn-плагины. Модуль оставляет include как есть, чтобы
скомпилированные .amxx загружались с ним без изменений, и говорит об этом в
package.json:
"amxts": {
"module": "src/index.ts",
"natives": "src/natives.ts",
"include": "include/mystats.inc",
"contract": true
}
Тогда сборка include не пишет. Сигнатуру каждого натива она берёт из него —
Float:, теги, ссылки &, буфер out[], len — и останавливается, если
экспорт с ним не сходится:
mystats.ts: export function mystats_get - parameter "extra": the include declares no argument for it
mystats.ts: mystats.inc declares mystats_get, and the plugin does not export it
Библиотека с v0.2
Модуль, коду которого не нужен свой экземпляр на сервере, — клиент сервиса,
протокол, помощники — может быть библиотекой: сборка компилирует её в каждый
плагин, который её импортирует, как сборщик берёт библиотеку из npm. У неё
нет своего плагина, нет вызовов между плагинами, и ничему из её экспорта не
нужно их пересекать: async-функции, классы, дженерики, Map работают как в
любом TypeScript.
"amxts": {
"module": "src/index.ts",
"library": true
}
Такая библиотека — официальный @amxts/ftp:
import { ftp } from "@amxts/ftp";
const client = await ftp.connect("sftp://admin@example.com", { password });
У библиотеки нет defineModule — ни параметров, ни имени для автоимпорта, —
нет нативов, include и тестового набора: плагины импортируют то, что она
экспортирует, по имени пакета. Её перечисляют в modules в
amxts.config.ts, как любой модуль (это делает npx amxts module add), и
она сама может импортировать модули.
Выбирают по состоянию. То, что все плагины должны видеть одинаково, — меню, конфиг, одно правило на сервер — модуль с его единственным экземпляром. Код, который каждый плагин может выполнять сам, — библиотека: у каждого импортирующего её плагина своя копия со своими переменными верхнего уровня.
Перед публикацией
npx amxts check # npm run check
pnpm amxts check # npm run check
yarn amxts check # npm run check
bunx amxts check # npm run check
в папке модуля проверяет, что пакет готов, — для CI перед npm publish:
- поле
amxtsуказывает на файлы, которые есть; - в файле модуля есть
defineModuleс егоmeta.name; - есть
README.mdиLICENSE; - нативы компилируются, а include — ровно то, что по ним пишет
npx amxts build, не устаревший после правкиnatives.ts; при"contract": true— что нативы сходятся с include.
ok "amxts" points at src/index.ts, src/natives.ts, include/greeter.inc
ok defineModule: greeter, options under "greeter", plugins use it as greeter
ok include/greeter.inc is up to date with 1 natives
Как пользоваться в проекте
Проект ставит модуль и перечисляет его в amxts.config.ts, рядом с
package.json:
// amxts.config.ts
export default defineConfig({
modules: ["@you/greeter"],
greeter: { greeting: "Привет" },
});
а его плагины пользуются модулем по имени, которое он даёт, без строки импорта:
// plugins/welcome.ts
server.addCommand("/hello", ({ player }) => greeter.greet(player));
import * as greeter from "@you/greeter" тоже работает — так плагин
пользуется модулем, который имени не даёт.
npx amxts build собирает плагин модуля — greeter.aot — рядом с плагинами
проекта и пишет plugins.ini: сначала модули, каждый после тех, что он
requires, потом плагины проекта. Собирается только модуль, которым
пользуется хоть один плагин: модуль из конфига, которым не пользуется никто,
в сборку не попадает, и сборка об этом говорит. Модуль, чьи нативы вызывают
Pawn-плагины, оставляет pawn (собирается только то, чем
пользуются). Сборка останавливается и объясняет
почему, если модуль не установлен, если нужный ему модуль не установлен, если
у настройки нет значения по умолчанию в модуле и если плагин импортирует
модуль, которого нет в конфиге.
Как тестировать
Модуль тестируется на поддельном сервере (тестирование) из
своей папки, командой npm test. setup() загружает туда проект так, как
сборка разложила бы его на сервере: модули, которыми пользуются его плагины,
в порядке загрузки, затем его плагины.
// test/greeter.test.ts
import { expect, test } from "bun:test";
import { setup } from "@amxts/core/test-utils";
test("плагин песочницы приветствует того, кто попросил", async () => {
const server = await setup({ rootDir: "playground" });
const alice = server.join("Alice");
alice.say("/hello");
expect(alice.chat).toContain("Hi, Alice!");
});
test("Pawn-плагин приветствует через натив", async () => {
const server = await setup(); // своя папка модуля: модуль один
const bob = server.join("Bob");
server.native("greeter_greet", bob.id);
expect(bob.chat).toContain("Hello, Bob!");
});
setup({ rootDir: "playground" }) — проект-песочница с её amxts.config.ts;
setup() в собственной папке модуля, где конфига нет, — модуль один, со
своими значениями по умолчанию. Модуль, который вызывает нативы, на которые
поддельный сервер не отвечает, поставляет для них тестовый набор —
тестовый набор модуля.
Модуль поверх другого модуля AMX Mod X
Модуль может оборачивать сторонний модуль или плагин AMX Mod X — как официальный resemiclip оборачивает ReSemiclip. Пусть при первом вызове он проверяет, что нужное загружено, и пишет в лог одну понятную строку вместо ошибки о пропавшем нативе. Что ему нужно, пишется в README — чтобы владелец сервера знал, что поставить.
Модуль внутри проекта
Код, общий для плагинов проекта, — модуль без пакета: файл
modules/<name>.ts в папке плагинов, импорт — ~/modules/<name>. Он
собирается в каждый плагин, который его импортирует, — у каждого своя копия
со своим состоянием, — если только в папке плагинов нет плагина с тем же
именем, <name>.ts. Тогда модуль работает в этом плагине, а остальные
вызывают его там, как пакет (общие модули). Папка modules/<name>/ рядом с amxts.config.ts с
таким же package.json, как выше, — тоже пакет-модуль: он находится по
имени, как установленный.
Как поделиться
Опубликуйте пакет на npm, затем добавьте его в каталог модулей.
Каталог показывает модули из реестра
amxts/modules — по YAML-файлу на модуль, —
и тот же список предлагают amxts init и amxts module add. Сделайте форк
реестра, добавьте modules/<name>.yml и откройте pull request:
name: greeter
npm: "@you/greeter"
repo: you/greeter
description: Greets players by name as they join
category: gameplay
type: community
logo: assets/logo.svg
maintainers:
- github: you
name — имя модуля в каталоге: amxts module add greeter и его страница,
/modules/greeter. category — одна из ui, gameplay, admin, config,
data, network и tools; logo, путь в репозитории, необязателен.
description пишется по-английски. CI проверяет запись, затем её
просматривает мейнтейнер:
- репозиторий на GitHub публичный, в нём есть README — это страница модуля в каталоге — и LICENSE;
- в его
package.json— имя изnpm, полеamxts,@amxts/coreвpeerDependenciesдиапазоном версий, в который входит текущее ядро, и нет скриптовpreinstall,installиpostinstall; - пакет есть на npm, и его
repository— тот, что в записи; - имя не похоже на имя другого модуля.
Остальное — в
руководстве реестра.
Когда pull request принят, amxts предлагает модуль через несколько минут, а
сайт показывает его после ближайшей ежедневной сборки.
Пакет, которого нет в каталоге, всё равно ставится через amxts module add —
он же есть на npm, — но с предупреждением, что в каталоге его нет.
Официальные модули и модули сообщества
Официальный модуль делает команда amxts: @amxts/<name> на npm,
amxts/<name> на GitHub, type: official в записи. Этот scope и эту
организацию занимают только официальные модули. Любой другой модуль — модуль
сообщества, type: community, под именем своего автора. Оба проходят одни
и те же проверки; каталог показывает, какой из них какой.
Общие модули
Модуль — пакет на TypeScript, которым пользуются плагины: официальные menu-core и config-core или ваш собственный. У модуля один экземпляр на сервер, общий для всех плагинов, которые им пользуются:
Нативы
Натив — функция, которую один плагин или модуль AMX Mod X даёт остальным. Здесь нативы работают в обе стороны: плагин на TypeScript экспортирует свои для плагинов на Pawn и вызывает нативы AMX Mod X, ReAPI и других модулей там, где у @amxts/core нет своего способа.