Модули

Свой модуль

Модуль — код на 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

Команда пишет работающий модуль — приветствие, которое вы замените своим, — с проектом-песочницей и проходящим тестом.

Устройство

Модуль — пакет 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, textCellsArray: 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" — либо "", если ответ есть, в том числе HTTP 404.
Ключи SSH
Ключ для 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

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