Основы

Async и промисы

Плагин ждёт так же, как JavaScript: async-функции, await, Promise. Пока одна функция ждёт — ответа на веб-запрос, пару секунд, — сервер и остальной плагин продолжают работать.

server
.
addCommand
("/weather", async ({
player
}) => {
print
(
player
, "спрашиваю...");
const
response
= await
fetch
("https://wttr.in/?format=3");
if (!
response
.
ok
) return;
print
(
player
, await
response
.
text
());
}); async function countdown(
player
: Player) {
for (let
i
= 3;
i
> 0;
i
--) {
print
(
player
, `${
i
}`);
await
sleep
(1000);
}
print
(
player
, "Вперёд!");
}

async и await

  • async-функция сразу возвращает Promise и выполняется до первого await. То, что она возвращает, становится значением промиса: async function count() { return 3; } даёт Promise<number>.
  • Вызов без await не ждёт: countdown(player) идёт сам по себе, а строка после вызова выполняется сразу.
  • await promise отдаёт значение промиса, когда оно появится. Остаток функции выполнится позже — на том кадре, когда пришло значение, — а сервер тем временем занят всем остальным. Даже уже выполненный промис сначала даёт закончиться текущему коду, как в JavaScript.
  • await работает в async-функциях, стрелках и методах.
  • Async-стрелка видит переменные вокруг себя и до, и после await, как любое замыкание.

sleep

await 
sleep
(1000); // одна секунда
await
sleep
(5000, {
signal
: AbortSignal.timeout(2000) }); // отклонится через 2 с

Промис, который выполнится через столько-то миллисекунд, — setTimeout для async-функции. Как любой таймер AMX Mod X, он ждёт не меньше 0,1 секунды и срабатывает на кадре сервера: sleep(10) длится около 100 мс.

Async-обработчики и команды

async-функция подходит везде, где нужен обработчик: server.addCommand, server.addEventListener, game.addEventListener, Forward.subscribe и "change" у квара. Но то, что она сообщает игре, учитывается только до первого await: ответ, который возвращает обработчик события игры, event.preventDefault(). Как только функция начала ждать, игра пошла дальше; сама функция просто продолжится позже.

game
.
addEventListener
("fallDamage", async (
event
) => {
if (
event
.
player
.
isBot
) return 0; // учитывается: ботам без урона от падения
await
sleep
(100);
return 0; // поздно - урон уже нанесён });

Promise

Глобальный Promise работает как в JavaScript: then, catch, finally, Promise.resolve, Promise.reject, Promise.all, Promise.allSettled, Promise.race и Promise.any.

async function compare(
a
: string,
b
: string) {
const [
first
,
second
] = await
Promise
.
all
([
fetch
(
a
),
fetch
(
b
)]);
console
.
log
(`${
first
.
status
} ${
second
.
status
}`);
}
fetch
("https://example.com/")
.
then
((
response
) =>
console
.
log
(`${
response
.
status
}`))
.
catch
((
error
) =>
console
.
error
(
error
.message));

Колбэк .then возвращает обычное значение. Чтобы выполнить один запрос вслед за другим, дождитесь их по очереди через await.

Свой промис

new Promise((resolve, reject) => ...) превращает в промис то, что работает через колбэк. Переданная функция выполняется сразу, а то, что она подготовила, вызывает resolve или reject позже:

function delay(
ms
: number) {
return new
Promise
<void>((
resolve
) => {
setTimeout
(() =>
resolve
(),
ms
);
}); }

Если ответ придёт откуда-то ещё — из меню, где игрок выбирает пункт, из строки в чате, — сохраните resolve до тех пор:

let 
answer
: ((
value
: string) => void) | null = null;
function askName(
player
: Player) {
print
(
player
, "Напиши в чат название клана");
return new
Promise
<string>((
resolve
) => {
answer
=
resolve
;
}); } function onChatLine(
text
: string) {
const
resolve
=
answer
;
if (
resolve
== null) return;
answer
= null;
resolve
(
text
);
}

Promise.all и allSettled

Промисы разных типов дают кортеж — каждое значение со своим типом, — как в TypeScript:

const 
url
= "https://example.com/stats";
const [
response
,
_
] = await
Promise
.
all
([
fetch
(
url
),
sleep
(1000)]); // Response, void
const [
count
,
name
] = await
Promise
.
all
([countAsync(), nameAsync()]); // number, string
const [
page
,
rank
] = await
Promise
.
allSettled
([
fetch
(a), rankAsync()]);
if (
page
.
status
== "fulfilled")
print
(
player
, await
page
.
value
.
text
());
if (
rank
.
status
== "rejected")
console
.
error
(
rank
.
reason
.message);
  • Промисы одного типа или список в переменной дают массив: (await Promise.all(list)).length.
  • Кортеж читается деструктуризацией или по индексу, записанному числом: results[0]. Индекс за его концом — ошибка, как в TypeScript. for...of, .map и .length — для массивов.
  • Один вызов принимает до 8 промисов разных типов.
  • Обычное значение берётся как промис, выполненный с ним: Promise.all([load(), 5]), и Promise.all(numbers) из number[].
  • allSettled выполняется, когда все промисы завершились, как бы ни завершились. У каждого результата есть status: "fulfilled" со value или "rejected" с reason — объектом Error.

Promise.race и any

race завершается так же, как первый завершившийся промис. any берёт первое значение и пропускает отказы; если отклонены все промисы, он отклоняется с AggregateError, в errors которого — причина каждого.

const 
url
= "https://example.com/stats";
const
response
= await
Promise
.
race
([
fetch
(
url
),
sleep
(3000)]); // Response | null
if (!
response
) return; // первым закончился sleep
print
(
player
, await
response
.
text
());
const
fastest
= await
Promise
.
any
([
fetch
(mirrorA),
fetch
(mirrorB)]);

Тип у результата один, поэтому он нужен и промисам:

  • один и тот же тип — результат этого типа;
  • Promise<void> (sleep, async-функция без возвращаемого значения) рядом с объектом или строкой — результат этого типа или null: проверяйте через if (!response);
  • классы с общим базовым классом — результат этого класса (Promise.race([cat(), dog()]) — это Animal).

Всё остальное — число и строка, число и sleep — не собирается: Promise.race of 'number' and 'string': their values share no type. Тогда дожидайтесь их по одному.

Ошибки

Ошибка в async-функции — throw или ошибка из того, что она вызывает, включая выход за границы массива, — отклоняет её промис этой ошибкой, как в JavaScript. Ошибку несёт отклонённый промис:

  • try/catch вокруг await её получает:
    const 
    url
    = "https://example.com/stats";
    try { const
    response
    = await
    fetch
    (
    url
    );
    print
    (
    player
    , await
    response
    .
    text
    ());
    } catch (
    error
    ) {
    console
    .
    log
    (`запрос не удался: ${
    error
    .message}`);
    }
  • .catch её получает.
  • await отклонённого промиса вне try завершает ожидающую функцию на этом месте, и её собственный промис отклоняется с той же ошибкой — и так до того, кто её поймает.
  • Если ошибку никто не обработал, в консоли сервера появится одна строка: Unhandled promise rejection: <сообщение>. Отмена — ошибка с именем AbortError или TimeoutError — ожидаема, о ней не пишется.

Вне async-функции отклоняйте через reject(new Error("сообщение")) в new Promise или Promise.reject(new Error("сообщение")). error.message и error.name читаются обратно.

Падение, которое не ошибка, — переполнение стека — завершает async-функцию: консоль называет плагин и падение, а плагин и сервер работают дальше. Тот, кто её ждал, получает ошибку с именем AbortError и сообщением the async function crashed.

Отмена: AbortController и AbortSignal

Как в браузере:

const 
url
= "https://example.com/stats";
const
controller
= new AbortController();
fetch
(
url
, {
signal
:
controller
.signal }).
catch
((
error
) =>
console
.
log
(
error
.name)); // "AbortError"
controller
.abort();
controller
.signal.aborted; // true
controller
.signal.reason; // Error, с которым отменили
controller
.signal.addEventListener("abort", () =>
console
.
log
("отменено"));
await
fetch
(
url
, {
signal
: AbortSignal.timeout(3000) }); // "TimeoutError" через 3 с
  • fetch(url, { signal }) отменяет запрос и отклоняется с причиной сигнала — ошибкой с именем AbortError, если в abort() не передали другую.
  • sleep(ms, { signal }) перестаёт ждать и отклоняется так же.
  • AbortSignal.timeout(ms) отменяется сам, с ошибкой TimeoutError; AbortSignal.abort() уже отменён; AbortSignal.any([a, b]) отменяется вместе с тем, кто отменится первым.

Когда игрок уходит

player.signal отменяется, когда игрок уходит с сервера; следующий игрок в этом слоте получает новый:

const 
url
= "https://example.com/stats";
const
stats
= await
fetch
(
url
, {
signal
:
player
.
signal
});

Async-обработчику команды или события про игрока (putInServer, connect, authorized и другие, у которых есть event.player) не нужно и этого: он и так выполняется под сигналом этого игрока. Каждый await в нём — запрос, sleep, любой промис — тихо завершается, когда игрок уходит: ничего в консоли и ничего не нужно прибирать:

server
.
addCommand
("/rank", async ({
player
}) => {
const
rank
= await
fetch
(`https://example.com/rank/${
player
.
steamId
}`); // отменится, если он уйдёт
print
(
player
, await
rank
.
text
()); // тогда не выполнится
});

Async-функции, вызванные из такого обработчика, работают под тем же сигналом, а signal, переданный вручную, действует вместе с ним: кто отменится первым. Обработчики событий игры и событий ухода игрока сигнала не получают.

После await

Функция, которая ждала, продолжается на одном из следующих кадров, а за это время многое могло измениться: раунд закончился, игрок умер — или ушёл. Под сигналом игрока его уход сам завершает функцию; в остальных местах после await проверяйте player.isConnected, прежде чем снова обращаться к игроку.

Ограничения

  • Async-генераторы (async function*) не собираются; for await обходит список промисов или значений.
  • Пока async-функция ждёт, она хранит до 4 КБ своего состояния; функция, которой нужно больше, останавливается с сообщением в консоли, где назван плагин.
  • Код, из которого можно дойти до await, в плотном цикле работает несколько медленнее. Плагин без async-функций за это не платит.

Чего ещё amxts пока не умеет — в Ограничениях.