@amxts/menu-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ru.md ADDED
@@ -0,0 +1,382 @@
1
+ <div align="center">
2
+
3
+ <img src="assets/logo.svg" width="96" alt="Menu Core">
4
+
5
+ # Menu Core
6
+
7
+ *Удобный способ создавать меню*
8
+
9
+ [![amxts module](https://img.shields.io/badge/amxts-module-3178c6?style=flat-square)](https://amxts.github.io/)
10
+ [![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](LICENSE)
11
+
12
+ [Возможности](#возможности) • [Установка](#установка) • [Использование](#использование) • [Меню в файле](#меню-в-файле) • [Pawn-плагины](#pawn-плагины) • [Тесты](#тесты)
13
+
14
+ [English](README.md) | **Русский**
15
+
16
+ </div>
17
+
18
+ Опишите меню один раз — в файле INI, YAML или JSON или в коде, — а остальное Menu Core сделает сам: страницы, клавиши, возврат назад, обратный отсчёт и пункты, которые появляются, скрываются или становятся серыми в зависимости от того, кто смотрит.
19
+
20
+ > [!WARNING]
21
+ > **В работе.** В игре Menu Core пробовали всего несколько раз, и API ещё может измениться.
22
+
23
+ ## Возможности
24
+
25
+ - **Меню из файла или из кода.** Админ правит `menu.ini`, `menu.yaml` или `menu.json`, не трогая плагин; плагины добавляют свои пункты на ходу.
26
+ - **Ошибки — с местом.** Незнакомый ключ, значение не того вида, условие или действие, которое никто не зарегистрировал: консоль сервера говорит об этом, с файлом и строкой.
27
+ - **Виден и доступен.** Пункт говорит, когда он вообще показан (`visible`) и когда его можно выбрать (`enabled`): иначе он серый, а рядом — сообщение первого требования, которое не выполнено.
28
+ - **Текст, который зависит от игрока.** Заголовок, пункт или сообщение могут быть функцией — ``(player) => `Лечение (${player.health} HP)` `` — она читается при каждой отрисовке; в `menu.ini` то же делают плейсхолдеры вроде `%hp%`.
29
+ - **Меню-списки.** Строка на каждого игрока или на каждый элемент своего списка, с фильтрами и сообщением, если никого не осталось.
30
+ - **Варианты.** Один пункт, несколько видов: показывается первый вариант, чьё `when` выполняется.
31
+ - **Отсчёт и блокировка.** Меню с таймером — свой у каждого игрока или один на всех — и меню, которое нельзя закрыть или заменить.
32
+ - **Один экземпляр на сервер.** Все плагины, на TypeScript и на Pawn, наполняют и открывают одни и те же меню.
33
+ - **Без ограничений Pawn.** Длинные названия, сколько угодно пунктов, меню на кириллице длиннее 500 байт.
34
+
35
+ ## Установка
36
+
37
+ ```bash
38
+ npx amxts module add menu-core
39
+ ```
40
+
41
+ Команда ставит пакет и [Config Core](https://github.com/amxts/config-core), через который Menu Core читает меню, и добавляет оба в `modules` в `amxts.config.ts` проекта. Настройки модуля пишутся рядом, в `menus`:
42
+
43
+ ```ts
44
+ export default defineConfig({
45
+ modules: [
46
+ "@amxts/menu-core",
47
+ "@amxts/config-core", // needed by menu-core
48
+ ],
49
+ menus: {
50
+ file: "myserver/menu", // configs/myserver/menu.ini, .yaml, .yml, .json или .jsonc
51
+ fallback: "menu", // configs/menu.*, если первый пуст
52
+ },
53
+ });
54
+ ```
55
+
56
+ Сборка загружает Config Core первым. Конфиг, где перечислен только Menu Core, собирается так же: сборка приводит Config Core сама.
57
+
58
+ | Опция | По умолчанию | Что делает |
59
+ | --- | --- | --- |
60
+ | `file` | `"menu"` | Файл меню в `configs/`; без расширения — первый из `.ini`, `.yaml`, `.yml`, `.json` и `.jsonc`, который есть. |
61
+ | `fallback` | `""` | Читается вместо `file`, если тот пуст или его нет; `""` — без запасного. |
62
+
63
+ ## Использование
64
+
65
+ Плагин пользуется модулем как `menus`, без строки импорта: сборка добавляет импорт в те плагины, которые им пользуются, и собирает модуль, только когда им пользуется хоть один.
66
+
67
+ ```ts
68
+ const shop = menus.create("SHOP", { title: (player) => `Магазин для ${player.name}` });
69
+
70
+ shop.addItem((player) => `Лечение (${player.health} HP)`, {
71
+ visible: (player) => player.health < 100,
72
+ onSelect: (player) => {
73
+ player.health = 100;
74
+ },
75
+ });
76
+ shop.addItem("Купить AWP", {
77
+ // Серый, пока одно отвечает «нет»: первое такое даёт своё сообщение.
78
+ enabled: [
79
+ { when: (player) => player.isAlive, message: "Только живым" },
80
+ { when: (player) => player.account >= 4750, message: (player) => `Не хватает $${4750 - player.account}` },
81
+ ],
82
+ onSelect: (player) => {
83
+ player.account = player.account - 4750;
84
+ player.give("weapon_awp");
85
+ },
86
+ });
87
+ shop.addItem("Сбросить счёт", {
88
+ enabled: (player) => player.frags != 0,
89
+ message: "(уже 0)",
90
+ onSelect: (player) => {
91
+ player.frags = 0;
92
+ },
93
+ });
94
+ shop.addItem("Закрыть", { action: "CLOSE_MENU", spaceBefore: 1 });
95
+
96
+ server.addCommand("/shop", (player) => shop.show(player));
97
+ ```
98
+
99
+ Текст — заголовок, пункт, сообщение — это сам текст или функция, которая даёт его для игрока, который смотрит. Обычная строка, если это ключ словаря, переводится для него.
100
+
101
+ Пункт говорит, когда он показан и когда его можно выбрать:
102
+
103
+ - `visible` — показан ли он вообще; пока отвечает «нет», пункта нет и слот он не занимает.
104
+ - `enabled` — можно ли его выбрать; пока отвечает «нет», пункт серый, а рядом `message`. Или список требований, каждое `{ when, message }`: первое невыполненное даёт своё сообщение, а требование без своего сообщения — сообщение пункта.
105
+
106
+ В коде требование — функция. Имена — `IS_ALIVE`, `FLAG_d`, ограничение, которое зарегистрировал плагин, — для файлов меню: функцию файл не удержит.
107
+
108
+ Клавиши: **1–7** выбирают, **8** — следующая страница, **9** — предыдущая страница или назад, в меню, из которого открыли это, **0** закрывает.
109
+
110
+ Цвета в тексте пишутся метками, теми же буквами, что в чате: `!y` жёлтый, `!r` красный, `!d` серый, `!w` белый, `!R` — выравнивание вправо. Метки только для чата — `!g`, `!b` и `!t` — из меню убираются.
111
+
112
+ ### API
113
+
114
+ Меню — объект: `menus.create()` его делает, а методы наполняют и открывают.
115
+
116
+ | Метод | Что делает |
117
+ | --- | --- |
118
+ | `menu.addItem(text, options?)` | Добавляет пункт: его текст или `(player, target) => текст` — `target` — цель строки в меню-списке. Опции: `onSelect`, `visible` (пункта нет, пока отвечает «нет»), `enabled` (пункт серый, пока отвечает «нет», — проверка или список `{ when, message }`), `message`, `at`, `spaceBefore`, `spaceAfter`; а для меню, которые называют зарегистрированное плагинами, — `action` и `placeholder`. |
119
+ | `menu.addFixedItem(slot, text, options?)` | Пункт, который на каждой странице занимает слот 1–7. |
120
+ | `menu.addFilter(test, message?)` | Меню-список пропускает строки, на которые `test` отвечает «нет». |
121
+ | `menu.setListSource(rows)` | Свои строки меню-списка: `listRow(target, text)`, `textRow(text)`. |
122
+ | `menu.addEventListener("open" \| "close" \| "show", listener)` | События этого меню; `"show"` приходит до открытия, `event.preventDefault()` его отменяет. |
123
+ | `menu.show(player, options?)` | Открывает меню; `false`, если оно не открылось. Опции: `time`, `target`, `resetHistory`, `force`, `skipHistory`. |
124
+ | `menu.refresh()` · `menu.close()` · `menu.clearItems()` | Перерисовать или закрыть у всех, кто его смотрит; убрать пункты. |
125
+ | `menu.setTimer(seconds)` · `menu.cancelTimer()` | Общий отсчёт для всех, кто его смотрит. |
126
+
127
+ Его поля — `title`, `time`, `hideBack`, `hideExit`, `locked`, `sharedTimer` — задаются напрямую; `name`, `kind` и `countdown` читаются.
128
+
129
+ | Функция | Что делает |
130
+ | --- | --- |
131
+ | `create(name, options?)` | Меню в коде или уже существующее с таким именем. Имя с `LIST_` делает меню-список. Опции: `title` (текст или функция), `time`, `hideBack`, `hideExit`, `locked`, `activeWhen`. |
132
+ | `find(name)` · `register(name)` | Меню по имени; `register` заранее читает его из файла. |
133
+ | `show(player, name, options?)` · `close(player)` · `activeMenu(player)` · `lock(player)` | Меню игрока, какое бы оно ни было. |
134
+ | `addCondition(name, test)` · `addAction(name, handler)` · `addPlaceholder(name, value)` · `addRestriction(name, test, message?)` | То, что называют файлы меню и Pawn-плагины, — ответы функциями; `%name%` в их тексте — плейсхолдер. `message` ограничения пишется рядом с пунктом, который оно гасит, если у пункта или требования нет своего. `menu.addPlaceholder(name, value)` задаёт его одному меню. |
135
+ | `setListSource(name, rows)` · `refresh("A B")` · `conditionChanged(name)` · `addEventListener(type, listener)` | То же для меню по имени и события всех меню. |
136
+
137
+ ## Меню в файле
138
+
139
+ Меню читается из файла, когда его впервые запрашивают: через `register(name)` или через `show` с именем, которого Menu Core ещё не знает. Файл — INI, YAML или JSON: `menus: { file: "menu" }` читает первый из `menu.ini`, `menu.yaml`, `menu.yml`, `menu.json` и `menu.jsonc`, который есть, и меню значит одно и то же в любом из них — только INI не умеет скрывать пункт ([столбцы INI](#столбцы-ini)).
140
+
141
+ ```yaml
142
+ # configs/menu.yaml
143
+ chatPrefix: MYPLUGIN_CHAT_PREFIX # префикс в чате для сообщения «некого показать»
144
+ labels:
145
+ exit: MYPLUGIN_MENU_EXIT # кнопки: ключ словаря или сам текст
146
+ number: MYPLUGIN_MENU_NUMBER # "!y[%d]!w", если словарь не говорит иначе
147
+
148
+ menus:
149
+ MAIN_MENU:
150
+ title: MYPLUGIN_MENU_MAIN_TITLE
151
+ hideBack: true
152
+ items:
153
+ - name: MYPLUGIN_MENU_MAIN_ADMIN
154
+ visible: IS_ADMIN # у остальных его нет
155
+ enabled: FLAG_d # серый, рядом сообщение
156
+ message: MYPLUGIN_MENU_NEEDS_FLAG_D
157
+ action: SHOW_ADMIN_MENU
158
+ - name: MYPLUGIN_MENU_MAIN_AWP
159
+ enabled: # первое невыполненное даёт своё сообщение
160
+ - when: VIP
161
+ message: MYPLUGIN_MENU_VIP_ONLY
162
+ - when: LEVEL:5
163
+ message: MYPLUGIN_MENU_LEVEL_5
164
+ action: BUY_AWP
165
+ - variants: # показан первый, чьё "when" выполняется
166
+ - { name: MYPLUGIN_MENU_MAIN_SPECTATE, when: "!IS_SPECTATOR", action: JOIN_SPECTATE }
167
+ - { name: MYPLUGIN_MENU_MAIN_JOIN, when: IS_SPECTATOR, action: JOIN_TEAM }
168
+
169
+ LIST_SPECTATORS_MENU:
170
+ title: MYPLUGIN_MENU_SPECTATORS_TITLE
171
+ activeOn: IS_ROUND_RUNNING
172
+ filters:
173
+ - { when: IS_SPECTATOR, message: MYPLUGIN_CHAT_NO_SPECTATORS }
174
+ view:
175
+ name: "%name%"
176
+ action: SWAP_WITH_SPECTATOR
177
+ ```
178
+
179
+ ```jsonc
180
+ // configs/menu.json
181
+ {
182
+ "chatPrefix": "MYPLUGIN_CHAT_PREFIX",
183
+ "labels": { "exit": "MYPLUGIN_MENU_EXIT", "number": "MYPLUGIN_MENU_NUMBER" },
184
+ "menus": {
185
+ "MAIN_MENU": {
186
+ "title": "MYPLUGIN_MENU_MAIN_TITLE",
187
+ "hideBack": true,
188
+ "items": [
189
+ { "name": "MYPLUGIN_MENU_MAIN_ADMIN", "visible": "IS_ADMIN", "enabled": "FLAG_d", "message": "MYPLUGIN_MENU_NEEDS_FLAG_D", "action": "SHOW_ADMIN_MENU" },
190
+ {
191
+ "name": "MYPLUGIN_MENU_MAIN_AWP",
192
+ "enabled": [
193
+ { "when": "VIP", "message": "MYPLUGIN_MENU_VIP_ONLY" },
194
+ { "when": "LEVEL:5", "message": "MYPLUGIN_MENU_LEVEL_5" }
195
+ ],
196
+ "action": "BUY_AWP"
197
+ },
198
+ {
199
+ "variants": [
200
+ { "name": "MYPLUGIN_MENU_MAIN_SPECTATE", "when": "!IS_SPECTATOR", "action": "JOIN_SPECTATE" },
201
+ { "name": "MYPLUGIN_MENU_MAIN_JOIN", "when": "IS_SPECTATOR", "action": "JOIN_TEAM" }
202
+ ]
203
+ }
204
+ ]
205
+ },
206
+ "LIST_SPECTATORS_MENU": {
207
+ "title": "MYPLUGIN_MENU_SPECTATORS_TITLE",
208
+ "activeOn": "IS_ROUND_RUNNING",
209
+ "filters": [{ "when": "IS_SPECTATOR", "message": "MYPLUGIN_CHAT_NO_SPECTATORS" }],
210
+ "view": { "name": "%name%", "action": "SWAP_WITH_SPECTATOR" }
211
+ }
212
+ }
213
+ }
214
+ ```
215
+
216
+ ```ini
217
+ ; configs/menu.ini
218
+ [MAIN]
219
+ PREFIX = MYPLUGIN_CHAT_PREFIX
220
+ KEY = {
221
+ EXIT = MYPLUGIN_MENU_EXIT
222
+ NUMBER = MYPLUGIN_MENU_NUMBER
223
+ }
224
+
225
+ [MAIN_MENU]
226
+ TITLE = MYPLUGIN_MENU_MAIN_TITLE
227
+ HIDE_BACK = YES
228
+ ITEMS = {
229
+ ; название | подстановка | условие | действие | ограничение | сообщение | отступ
230
+ "MYPLUGIN_MENU_MAIN_ADMIN" "" "IS_ADMIN" "SHOW_ADMIN_MENU" "FLAG_d" "MYPLUGIN_MENU_NEEDS_FLAG_D" ""
231
+ "MYPLUGIN_MENU_MAIN_AWP" "" "" "BUY_AWP" "VIP LEVEL:5" "VIP:MYPLUGIN_MENU_VIP_ONLY|MYPLUGIN_MENU_LEVEL_5" ""
232
+ "MYPLUGIN_MENU_MAIN_SPECTATE|MYPLUGIN_MENU_MAIN_JOIN" "" "!IS_SPECTATOR|IS_SPECTATOR" "JOIN_SPECTATE|JOIN_TEAM" "" "" ""
233
+ }
234
+
235
+ [LIST_SPECTATORS_MENU]
236
+ TITLE = MYPLUGIN_MENU_SPECTATORS_TITLE
237
+ ACTIVE_ON = IS_ROUND_RUNNING
238
+ FILTER = {
239
+ "IS_SPECTATOR" "MYPLUGIN_CHAT_NO_SPECTATORS"
240
+ }
241
+ VIEW = {
242
+ ; название | условие | действие | ограничение | сообщение
243
+ "%name%" "" "SWAP_WITH_SPECTATOR" "" ""
244
+ }
245
+ ```
246
+
247
+ ### Поля
248
+
249
+ | YAML, JSON | INI | Что это |
250
+ | --- | --- | --- |
251
+ | `chatPrefix` | `[MAIN]` `PREFIX` | Префикс сообщений Menu Core в чате. |
252
+ | `labels`: `exit`, `back`, `next`, `number`, `disabled`, `page`, `time` | `[MAIN]` `KEY = { ... }` | Слова кнопок, страницы и отсчёта. |
253
+ | `menus`: `{ NAME: меню }` | `[NAME]` | Меню; имя на `LIST_` — меню-список. |
254
+ | `title` | `TITLE` | Заголовок; он у меню обязателен. |
255
+ | `activeOn` | `ACTIVE_ON` | Условия, при которых меню открывается. |
256
+ | `hideBack` · `hideExit` | `HIDE_BACK` · `HIDE_EXIT` | `true` (INI: `YES`) убирает кнопку. |
257
+ | `time` · `onTimeout` | `TIME` · `ON_TIMEOUT` | Отсчёт в секундах и действия, когда он закончился. |
258
+ | `locked` · `sharedTimer` | `LOCKED` · `GLOBAL` | Пункты нельзя выбрать; один отсчёт на всех. |
259
+ | `items` | `ITEMS` | Пункты обычного меню. |
260
+ | `fixedItems` | `FIXED_ITEMS` | Пункты, которые держат свой `slot`, 1–7, на каждой странице. |
261
+ | `view` · `filters` | `VIEW` · `FILTER` | Строка меню-списка и фильтры, которые проходят его строки: `when`, `message`. |
262
+
263
+ У пункта — в `items`, в `fixedItems` или в `view` — есть `name`, а ещё:
264
+
265
+ | Ключ | Что это |
266
+ | --- | --- |
267
+ | `placeholder` | Текст после названия, с плейсхолдерами: `"%hp%"`. |
268
+ | `action` | Действия, которые выполняются при выборе. |
269
+ | `visible` | Имена, при которых пункт показан; пока они не выполняются, пункта нет и слот он не занимает (у фиксированного пункта слот остаётся пустым). Не в `view`: меню-список пропускает строки через `filters`. |
270
+ | `enabled` | Имена, при которых пункт можно выбрать, а пока они не выполняются, рядом `message`, — или список требований, каждое строка имён или `{ when, message }`: первое невыполненное даёт своё сообщение. |
271
+ | `message` | Текст рядом с пунктом, пока его гасит требование без своего сообщения. |
272
+ | `spaceBefore` · `spaceAfter` | Пустые строки до и после пункта. |
273
+ | `variants` | Несколько видов, `[{ name, when, action }, ...]`: показан первый, чьё `when` выполняется; вариант без `when` выполняется всегда, а если не выполняется ни один, показан первый — серым. |
274
+
275
+ Сообщение рядом с серым пунктом идёт от общего к частному: то, с которым зарегистрировано ограничение (`addRestriction(name, test, message)`), `message` пункта, собственное сообщение требования. Каждое — текст или ключ словаря.
276
+
277
+ `visible`, `when`, `enabled`, `activeOn`, `action` и `onTimeout` — это имя, несколько имён через пробел или список: `activeOn: [IS_ALIVE, "!IS_SPECTATOR"]`. В YAML значение, которое начинается с `!` или `%`, берётся в кавычки.
278
+
279
+ - **Имена** в `visible`, `enabled` и `when` — ограничение, которое зарегистрировал плагин, условие, а иначе ответ ограничения `"*"`. `!NAME` переворачивает имя; несколько должны выполняться все. `NAME:параметр` передаёт проверке ограничения весь токен и забирает с собой остаток строки: `VIP:Only for VIP` — одно имя, поэтому пишите его последним. Незнакомое имя не выполняется.
280
+ - **Условия:** `activeOn` называет только условия. Эти встроены — на них отвечает Menu Core, пока имя не регистрирует ни один плагин (а плагин, который регистрирует, отвечает вместо него):
281
+
282
+ | Условие | Выполняется, когда игрок |
283
+ | --- | --- |
284
+ | `IS_ALIVE` · `IS_DEAD` | жив · не жив (зритель тоже) |
285
+ | `TEAM_CT` · `TEAM_TERRORIST` · `TEAM_SPECTATOR` · `TEAM_UNASSIGNED` | в этой команде |
286
+ | `IS_BOT` | бот |
287
+ | `IS_ADMIN` | имеет любой доступ, кроме `z` простого игрока, — `is_user_admin` AMX Mod X |
288
+ | `FLAG_<буквы>` | имеет любую из этих букв `users.ini`: `FLAG_ab` |
289
+
290
+ В меню-списке условие вида или фильтра спрашивается у игрока строки, а ограничение получает строку как цель. Регистр в именах не важен.
291
+ - **Встроенные действия:** `SHOW_<MENU>` открывает это меню, `CLOSE_MENU` закрывает; в строке действия их может быть несколько.
292
+ - **Подстановки:** `%name%` (текст строки списка), `%target%`, `%time%` и любые зарегистрированные.
293
+ - **Меню-список** рисует свою строку на каждого игрока или на каждую строку своего источника и пропускает те, что не прошли фильтр. Если никого не осталось, меню не открывается, а игрок получает сообщение фильтра.
294
+
295
+ > [!WARNING]
296
+ > **В файле меню:**
297
+ >
298
+ > - **Флаги** INI-меню — `HIDE_BACK`, `HIDE_EXIT`, `LOCKED`, `GLOBAL` — это `YES` или `NO`: на `true`, `1` или `yes` будет предупреждение с правильным словом, и это `NO`. В YAML `yes` — текст: флаг там — `true` или `false`.
299
+ > - **Текст с пробелами** в INI-меню берётся в кавычки: `TITLE = "Main menu"`. Без кавычек читается только первое слово.
300
+ > - **Имя пункта в YAML или JSON не делится по `|`:** его варианты пишутся через `variants`.
301
+ > - **Цвета** и в файле меню пишутся метками: `!y`, `!r`, `!d`, `!w`, `!R`. На коды Pawn (`\y`, `\r`) будет предупреждение с меткой, которую писать, и они выбрасываются. Текст из Pawn — пункты и заголовки Pawn-плагина, словарь lang — сохраняет свои коды, и Menu Core читает их как метки.
302
+ > - **`%time%` и `%target%` пишутся строчными:** `%TIME%` и `%s` остаются как написаны.
303
+ > - **`ADMIN` и `ACCESS_ADMIN` не встроены:** их регистрирует плагин, или файл пишет `IS_ADMIN` (любой админ) или `FLAG_<буквы>` (`FLAG_d`).
304
+
305
+ ### Столбцы INI
306
+
307
+ INI — формат, который читают и Pawn-плагины, поэтому его столбцы неизменны. Строка пункта — `name | placeholder | condition | action | restriction | message | spacing`:
308
+
309
+ | INI | YAML, JSON |
310
+ | --- | --- |
311
+ | названия `"A\|B"`, условия `"C1\|C2"`, действия `"X\|Y"` | `variants: [{ name: A, when: C1, action: X }, { name: B, when: C2, action: Y }]` |
312
+ | условие, один вариант | `enabled: C` — серый без причины |
313
+ | ограничение и сообщение | `enabled: R`, `message: M` — серый с сообщением |
314
+ | `"NAME:message\|NAME2:message"` | `enabled: [{ when: NAME, message: ... }, { when: NAME2, message: ... }]` |
315
+ | — | `visible` — в INI нечем скрыть пункт |
316
+
317
+ Столбец условия спрашивает только условия, а столбец ограничения — сначала ограничения, затем условия. Нативы `mc_*` добавляют пункты так же.
318
+
319
+ > [!TIP]
320
+ > `menu.ini`, написанный для Pawn, красит текст кодами (`\y`, `\r`). Скрипт из пакета переписывает их метками — в INI, YAML и JSON: `bun node_modules/@amxts/menu-core/scripts/menu-colors.ts configs/menu.ini` (`--dry-run` говорит, что он изменил бы).
321
+
322
+ ### Проверки
323
+
324
+ То, что не подходит файлу меню, консоль сервера называет с файлом и строкой — а в YAML и JSON и со столбцом — и пропускает; остальное меню читается.
325
+
326
+ - **Когда файл читается:** незнакомый ключ — с тем, который, может быть, имелся в виду: `visible` или `enabled` вместо `condition` и `restriction` пункта, `when` вместо `condition` варианта или фильтра; значение не того вида (`hideBack: yes` — в YAML `yes` это текст, а поле ждёт `true`; `HIDE_BACK = 1` — флаг INI это `YES` или `NO`); цветовой код (`\y`) там, где файл меню пишет метку, — с меткой, которую написать, а сам код пропускается; меню без заголовка, меню совсем без пунктов, пункт без названия, `items` в меню-списке, слот вне 1–7. Пункт без действия отмечается, но не как предупреждение: выбор его ничего не делает. Пустой блок `ITEMS = { }` — это нормально.
327
+ - **На первом кадре сервера:** каждое имя, действие и подстановка из файла, которые никто не зарегистрировал, — ни TypeScript-плагины, ни Pawn-плагины через нативы `mc_*`, ни сам Menu Core. К этому моменту все плагины прошли `plugin_init` и `plugin_cfg`, так что имя, которое Pawn-плагин регистрирует после чтения файла, за ошибку не принимается. Файл, прочитанный позже, — `setConfigFile()`, — проверяется сразу при чтении. Тогда же — строка имён, которая говорит меньше, чем кажется: имя дважды, регистр не важен (`IS_ADMIN is listed more than once`), имя и его противоположность (`IS_ALIVE and !IS_ALIVE together can never hold`) — каждое требование и каждый вариант отдельно.
328
+
329
+ ```
330
+ [MenuCore] addons/amxmodx/configs/menu.yaml:12:9: MAIN_MENU: the condition "IS_SPECTATR" is not registered - did you mean "IS_SPECTATOR"?
331
+ [MenuCore] addons/amxmodx/configs/menu.yaml:14:9: MAIN_MENU: SHOW_ADMN_MENU opens the menu "ADMN_MENU", which is not there - did you mean "ADMIN_MENU"?
332
+ ```
333
+
334
+ ### В редакторе
335
+
336
+ Расширение amxts для VS Code проверяет файл меню прямо при наборе — теми же проверками и теми же словами — и подсказывает ключи и имена, которые регистрируют ваши плагины (TypeScript, Pawn и установленные модули), с подсказкой при наведении и переходом к регистрации. Имя, добавленное в плагине, сразу появляется в подсказках файла меню, ещё до сохранения плагина. В Marketplace его пока нет: установите `.vsix` командой `code --install-extension amxts-vscode-<версия>.vsix`.
337
+
338
+ > [!WARNING]
339
+ > Расширение знает только имена, записанные строкой в рабочей области: `menus.addAction(name, ...)` с именем в переменной и имя, которое регистрирует только плагин на Pawn на сервере, — предупреждение в редакторе. В счёт идёт проверка сервера на его первом кадре.
340
+
341
+ ## Pawn-плагины
342
+
343
+ Pawn-плагины работают с Menu Core через 30 нативов `mc_*` из `menu_core.inc`, который лежит в пакете в `include/`; собранные `.amxx`-плагины работают без изменений. В `plugins.ini` он встаёт на место `menu_core.amxx`. Когда модулем не пользуется ни один плагин проекта на TypeScript, `pawn: ["@amxts/menu-core"]` в `amxts.config.ts` оставляет его в сборке для них. Подробности — в [PAWN.ru.md](PAWN.ru.md).
344
+
345
+ ## Тесты
346
+
347
+ Menu Core поставляет тестовый набор для поддельного сервера amxts: его ставит `setup()` из `@amxts/core/test-utils`, а `menusOf(server)` даёт то, что показывает меню игрока, клавиши, которые он нажимает, поддельные Pawn-плагины и словарь:
348
+
349
+ ```ts
350
+ import { expect, test } from "bun:test";
351
+ import { setup } from "@amxts/core/test-utils";
352
+ import { menusOf } from "@amxts/menu-core/testing";
353
+
354
+ const menu = `
355
+ [MAIN_MENU]
356
+ TITLE = "Main menu"
357
+ ITEMS = {
358
+ "Reset score" "" "" "RESET_SCORE" "" "" ""
359
+ }
360
+ `;
361
+
362
+ test("пункт выполняет действие, которое зарегистрировал плагин на Pawn", async () => {
363
+ const server = await setup({ files: { "addons/amxmodx/configs/menu.ini": menu } });
364
+ const menus = menusOf(server);
365
+ const player = server.join("Alice");
366
+ const calls: string[] = [];
367
+ const pawn = menus.pawnPlugin("myplugin.amxx", {
368
+ OnAction: (id: number, action: string) => {
369
+ calls.push(action);
370
+ },
371
+ });
372
+
373
+ pawn.native("mc_register_action", "RESET_SCORE", "OnAction");
374
+ pawn.native("mc_show_menu", player.id, "MAIN_MENU");
375
+ expect(menus.screen(player)?.text).toContain("Reset score"); // что видит игрок
376
+
377
+ menus.press(player, 1);
378
+ expect(calls).toEqual(["RESET_SCORE"]);
379
+ });
380
+ ```
381
+
382
+ Тесты самого модуля — в `test/` (`npm test`); `playground/` — проект с Menu Core внутри, его они тоже загружают.
@@ -0,0 +1,213 @@
1
+ /*
2
+ * menu-core - natives
3
+ * Author: kukson777
4
+ *
5
+ * Generated by amxts from menu-core.ts. Do not edit it: change the plugin
6
+ * and build it again.
7
+ *
8
+ * A string result is written into out[], len being its size (charsmax).
9
+ * The native returns the string's length - or, when there may be no
10
+ * string, bool: whether there was one. An array result goes into out[],
11
+ * size being its size, and the native returns how many cells it wrote.
12
+ */
13
+
14
+ #if defined _menu_core_included
15
+ #endinput
16
+ #endif
17
+ #define _menu_core_included
18
+
19
+ /**
20
+ * Menu properties: mc_set_menu_property takes MP_LOCKED .. MP_HIDE_EXIT,
21
+ * mc_set_menu_property_string MP_ON_TIMEOUT .. MP_FILTER, and
22
+ * mc_get_menu_property_string MP_SECTION.
23
+ */
24
+ enum MenuProperty {
25
+ MP_LOCKED = 0,
26
+ MP_GLOBAL_TIMER = 1,
27
+ MP_TIMER_DURATION = 2,
28
+ MP_HIDE_BACK = 3,
29
+ MP_HIDE_EXIT = 4,
30
+ MP_SECTION = 5,
31
+ MP_ON_TIMEOUT = 5,
32
+ MP_ACTIVE_ON = 6,
33
+ MP_FILTER = 7
34
+ }
35
+
36
+ /**
37
+ * Registers a condition menus refer to - an item's condition, ACTIVE_ON, a
38
+ * FILTER. The first plugin to register a name answers for it (case-insensitive).
39
+ * Callback: public callback(id, viewerId, const name[]) - true when it holds.
40
+ * Returns the condition's id.
41
+ */
42
+ native mc_register_condition(const name[], const callback[]);
43
+
44
+ /**
45
+ * Registers an action run when an item is chosen. Built in, not registered:
46
+ * SHOW_<SECTION> opens that menu, CLOSE_MENU closes it; an action line may
47
+ * list several, space-separated. Names are case-sensitive; the first wins.
48
+ * Callback: public callback(id, const action[]) in an items menu,
49
+ * public callback(id, targetId) in a LIST_ menu. isCritical is accepted and
50
+ * does nothing. Returns the action's id.
51
+ */
52
+ native mc_register_action(const name[], const callback[], bool:isCritical = false);
53
+
54
+ /**
55
+ * Registers a placeholder, %name% in titles and items, without the %.
56
+ * Callback: public callback(id, targetId, value[], len) - fills value[].
57
+ * A name registered again keeps the first. Returns the placeholder's id.
58
+ */
59
+ native mc_register_placeholder(const name[], const callback[]);
60
+
61
+ /**
62
+ * Registers the config file's [section] as a menu. Returns the menu's id, or
63
+ * -1 when the section is missing, has no TITLE or no items, or is MAIN.
64
+ */
65
+ native mc_register_menu(const section[]);
66
+
67
+ /**
68
+ * Shows a menu. time: countdown seconds, -1 for the menu's TIME (or the
69
+ * countdown running). targetId: who a LIST_ menu is about. resetHistory
70
+ * starts the way back anew; forceOpen opens over a menu that holds on (a
71
+ * countdown, a lock); ignoreHistory leaves this menu out of the way back.
72
+ * Returns 1 when it opened; 0 when the player is not in the game, there is no
73
+ * such menu, a show filter or ACTIVE_ON stopped it.
74
+ */
75
+ native mc_show_menu(id, const section[], time = -1, targetId = 0, bool:resetHistory = false, bool:forceOpen = false, bool:ignoreHistory = false);
76
+
77
+ /**
78
+ * Sets the menu's shared countdown: starts one of `time` seconds when none
79
+ * runs, or changes the seconds left (0 stops it). Returns 1, or 0 when there
80
+ * is no such menu or nothing to change.
81
+ */
82
+ native mc_set_menu_timer(const section[], time);
83
+
84
+ /** Draws again every menu that uses the condition, for whoever looks at it. */
85
+ native mc_notify_condition_changed(const condition[]);
86
+
87
+ /** Draws the menus again (space-separated names) for whoever looks at them. Returns how many were drawn. */
88
+ native mc_refresh_menu(const sections[]);
89
+
90
+ /**
91
+ * Registers a restriction items name in their restriction column; "*"
92
+ * answers for every name no other restriction does. Callback:
93
+ * public callback(id, const restrictName[], targetId) - true when the player
94
+ * passes. message is said beside an item it greys out, when the item has no
95
+ * message of its own. Returns the restriction's id.
96
+ */
97
+ native mc_register_restriction(const name[], const callback[], const message[] = "");
98
+
99
+ /**
100
+ * Greys out items whose action is actionName in menuSection while the
101
+ * callback says no; "" for either matches all. Callback:
102
+ * public callback(id, const menuSection[], const actionName[]). Returns 1.
103
+ */
104
+ native mc_register_action_condition(const menuSection[], const actionName[], const callback[]);
105
+
106
+ /**
107
+ * Registers a filter over a condition someone else registered: it gets the
108
+ * condition's value and returns the one to use. Callback:
109
+ * public bool:callback(id, viewerId, const condition[], bool:currentVal).
110
+ * Returns the filter's id.
111
+ */
112
+ native mc_register_condition_filter(const condition[], const callback[]);
113
+
114
+ /**
115
+ * The rows of a LIST_ menu, instead of the players. Callback:
116
+ * public callback(id, Array:items) - pushes 289-cell items and returns 1:
117
+ * [0] the target the action gets (-2: a line of text), [1..64] an action of
118
+ * its own, [65..128] the text (%name%), [129..160] a restriction,
119
+ * [161..288] the message when it greys the row out. A second source for the
120
+ * menu replaces the first. Returns the source's id.
121
+ */
122
+ native mc_register_list_data_source(const menuName[], const callback[]);
123
+
124
+ /** Called when a menu opens. Callback: public callback(id, const section[]). Returns its id. */
125
+ native mc_register_menu_open_callback(const callback[]);
126
+
127
+ /**
128
+ * Called when a menu closes - by a key, a timeout, another menu opening over
129
+ * it, the player leaving. Callback: public callback(id, const section[], bool:isTimeout).
130
+ * Returns its id.
131
+ */
132
+ native mc_register_menu_close_callback(const callback[]);
133
+
134
+ /**
135
+ * Asked before any menu opens; returning false stops it (mc_show_menu returns
136
+ * 0) - the place to tell the player why. Callback:
137
+ * public bool:callback(id, const section[]). Returns its id.
138
+ */
139
+ native mc_register_show_filter(const callback[]);
140
+
141
+ /** Stops the menu's shared countdown and closes it for everyone. Returns 1, or 0 when none ran. */
142
+ native mc_cancel_menu_timer(const section[]);
143
+
144
+ /** Locks (or unlocks) the player's menu: nothing can be chosen, and no other menu replaces it. */
145
+ native mc_lock_menu(id, bool:lock = true);
146
+
147
+ /** Closes the player's menu. */
148
+ native mc_hide_menu(id);
149
+
150
+ /** Whether the player's menu is locked. */
151
+ native bool:mc_is_menu_locked(id);
152
+
153
+ /** The id of the menu the player looks at, or -1. */
154
+ native mc_get_active_menu(id);
155
+
156
+ /**
157
+ * A string property of a menu by its id (mc_get_active_menu): MP_SECTION, the
158
+ * section name, into out[]. Returns its length; 0 when the id or the property
159
+ * is not one.
160
+ */
161
+ native mc_get_menu_property_string(menuIdx, MenuProperty:property, out[], len);
162
+
163
+ /**
164
+ * Sets MP_LOCKED, MP_GLOBAL_TIMER, MP_TIMER_DURATION, MP_HIDE_BACK or
165
+ * MP_HIDE_EXIT. Returns 1, or 0 when there is no such menu or property.
166
+ */
167
+ native mc_set_menu_property(const section[], MenuProperty:property, value);
168
+
169
+ /**
170
+ * Sets MP_ON_TIMEOUT (the action when the countdown ends), MP_ACTIVE_ON (the
171
+ * condition the menu opens under) or MP_FILTER ("CONDITION|MESSAGE", added to
172
+ * a LIST_ menu's filters). Returns 1, or 0 when there is no such menu or
173
+ * property.
174
+ */
175
+ native mc_set_menu_property_string(const section[], MenuProperty:property, const value[]);
176
+
177
+ /**
178
+ * Adds an item to a menu. name, condition and action take "A|B" variants: the
179
+ * first whose condition holds is shown. iPosition: its place among the items,
180
+ * -1 the end; emptyBefore/emptyAfter: blank lines around it. Returns 1, or 0
181
+ * when there is no such menu or the name gives no item.
182
+ */
183
+ native mc_add_menu_item(const section[], const name[], const placeholder[] = "", const condition[] = "", const action[] = "", const restriction[] = "", const restrictMsg[] = "", iPosition = -1, emptyBefore = 0, emptyAfter = 0);
184
+
185
+ /**
186
+ * Adds an item that always takes slot 1-7 of every page. Returns 1, or 0 when
187
+ * there is no such menu or the name gives no item.
188
+ */
189
+ native mc_add_fixed_menu_item(const section[], slot, const name[], const placeholder[] = "", const action[] = "", const condition[] = "", const restriction[] = "", emptyBefore = 0, emptyAfter = 0);
190
+
191
+ /**
192
+ * Adds a line of text - a header, a separator - to the items a data source
193
+ * fills; call it from the data source callback. Returns 1.
194
+ */
195
+ native mc_add_list_text(aItems, const text[], bool:centered = false);
196
+
197
+ /** Removes every item of a menu, fixed ones too. Returns 1, or 0 when there is no such menu. */
198
+ native mc_clear_menu_items(const section[]);
199
+
200
+ /**
201
+ * Makes a menu in code, without a config section; a name starting with LIST_
202
+ * makes a list menu. Returns 1, or 0 when a menu of that name is there.
203
+ */
204
+ native mc_create_menu(const section[], const title[]);
205
+
206
+ /** The page, from 0, the player's menu is drawn at next. */
207
+ native mc_set_menu_page(id, page);
208
+
209
+ /**
210
+ * The text the player's menu shows at the moment, colour codes and all (""
211
+ * when none is open) - for a test or a log to see what the player sees.
212
+ */
213
+ native mc_get_menu_text(id, out[], len);