@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kukson777
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PAWN.md ADDED
@@ -0,0 +1,33 @@
1
+ # Menu Core for Pawn plugins
2
+
3
+ [English](PAWN.md) | [Русский](PAWN.ru.md)
4
+
5
+ Menu Core serves the 30 natives of `menu_core.inc` (`mc_register_action`, `mc_show_menu`, `mc_add_menu_item` and the rest) with their signatures, so compiled `.amxx` plugins work unchanged. It takes the place of `menu_core.amxx`: comment that one out in `plugins.ini`, since two plugins cannot give the same natives.
6
+
7
+ Callbacks are named by their public function, and Menu Core calls them in the plugin that registered them.
8
+
9
+ The package's `include/menu_core.inc` declares:
10
+
11
+ - the menu properties as `enum MenuProperty { MP_LOCKED = 0, … MP_FILTER }`, which the three property natives take as `MenuProperty:property`;
12
+ - `mc_add_list_text(aItems, …)`, without the `Array:` tag;
13
+ - `mc_get_menu_text(id, out[], len)`: what the player's menu shows.
14
+
15
+ A plugin compiled against it gets a tag warning for a bare number where `MenuProperty:` is expected, and for an `Array:` passed to `mc_add_list_text`. The values a compiled plugin passes are the same either way.
16
+
17
+ ### How the natives behave
18
+
19
+ - No Pawn limits: names, titles and placeholders are as long as they are written, a menu keeps every item, the way back is as long as it gets, and a menu longer than 500 bytes is sent whole.
20
+ - `mc_get_menu_property_string(idx, MP_SECTION)` gives the menu's section.
21
+ - A condition filter applies wherever the condition is asked.
22
+ - A restriction's `message` is shown beside an item it greys out when the item has no message of its own.
23
+ - `IS_ALIVE`, `IS_DEAD`, `TEAM_<team>`, `IS_BOT`, `IS_ADMIN` and `FLAG_<letters>` are built-in conditions, answered while no plugin registers the name. A plugin that registers one of them answers instead.
24
+ - `ADMIN` and `ACCESS_ADMIN` are not built in: they are names like any other, and a menu that uses them needs a plugin that registers them (`mc_register_condition`, `mc_register_restriction`). Otherwise the check says the name is not registered and suggests `IS_ADMIN`. Without a plugin, write `IS_ADMIN` (any admin) or `FLAG_<letters>` (`FLAG_dluy`: ban, rcon, admin or menu access).
25
+ - A restriction `NAME:text` takes the rest of the line, spaces and all, as its text; names before it are restrictions of their own.
26
+ - Closing a menu because another opens over it tells the close callbacks its name.
27
+ - A locked menu greys out the items of any menu, not only list rows.
28
+ - `mc_show_menu` of a section nobody registered reads it from the file.
29
+ - `isCritical` of `mc_register_action` is accepted and does nothing.
30
+
31
+ - The menu file writes colours as tags (`!y`, `!r`, `!d`, `!w`, `!R`); a code there is warned of and left out. What a Pawn plugin gives — a native's title, item, placeholder value, list row or message — and the lang dictionaries keep their codes, read as the tags; `mc_get_menu_text` gives the text with codes.
32
+ - A flag of the menu file — `HIDE_BACK`, `HIDE_EXIT`, `LOCKED`, `GLOBAL` — is `YES` or `NO`; `true` or a number is warned of, with the word to write, and is `NO`.
33
+ - The placeholders are `%target%` and `%time%`; `%s` and `%TIME%` are left as written.
package/PAWN.ru.md ADDED
@@ -0,0 +1,33 @@
1
+ # Menu Core для Pawn-плагинов
2
+
3
+ [English](PAWN.md) | [Русский](PAWN.ru.md)
4
+
5
+ Menu Core отдаёт 30 нативов из `menu_core.inc` (`mc_register_action`, `mc_show_menu`, `mc_add_menu_item` и остальные) с их сигнатурами, так что собранные `.amxx`-плагины работают без изменений. Он встаёт на место `menu_core.amxx`: тот закомментируйте в `plugins.ini` — два плагина не могут отдавать одни и те же нативы.
6
+
7
+ Колбэки задаются именем публичной функции, и Menu Core вызывает их в том плагине, который их зарегистрировал.
8
+
9
+ `include/menu_core.inc` из пакета объявляет:
10
+
11
+ - свойства меню как `enum MenuProperty { MP_LOCKED = 0, … MP_FILTER }`, и три натива свойств принимают `MenuProperty:property`;
12
+ - `mc_add_list_text(aItems, …)` без тега `Array:`;
13
+ - `mc_get_menu_text(id, out[], len)`: что сейчас показывает меню игрока.
14
+
15
+ Плагин, собранный с этим инклудом, получит предупреждение о теге на голое число там, где ждут `MenuProperty:`, и на `Array:`, переданный в `mc_add_list_text`. Значения, которые передаёт собранный плагин, от этого не меняются.
16
+
17
+ ### Как работают нативы
18
+
19
+ - Нет ограничений Pawn: названия, заголовки и подстановки любой длины, меню хранит все пункты, путь назад любой длины, меню длиннее 500 байт отправляется целиком.
20
+ - `mc_get_menu_property_string(idx, MP_SECTION)` отдаёт секцию меню.
21
+ - Фильтр условия применяется везде, где спрашивают условие.
22
+ - `message` ограничения показывается рядом с посеревшим пунктом, если у пункта нет своего сообщения.
23
+ - `IS_ALIVE`, `IS_DEAD`, `TEAM_<команда>`, `IS_BOT`, `IS_ADMIN` и `FLAG_<буквы>` — встроенные условия, на них отвечает Menu Core, пока имя не регистрирует ни один плагин. Плагин, который регистрирует такое имя, отвечает вместо него.
24
+ - `ADMIN` и `ACCESS_ADMIN` не встроены: это такие же имена, как любые другие, и меню, где они есть, нужен плагин, который их регистрирует (`mc_register_condition`, `mc_register_restriction`). Иначе проверка скажет, что имя не зарегистрировано, и предложит `IS_ADMIN`. Без плагина пишите `IS_ADMIN` (любой админ) или `FLAG_<буквы>` (`FLAG_dluy` — доступ ban, rcon, admin или menu).
25
+ - Ограничение `NAME:текст` забирает остаток строки вместе с пробелами как свой текст; имена перед ним — отдельные ограничения.
26
+ - Когда меню закрывается, потому что поверх открылось другое, колбэки закрытия получают его имя.
27
+ - Заблокированное меню делает серыми пункты любого меню, а не только строки списков.
28
+ - `mc_show_menu` для секции, которую никто не регистрировал, читает её из файла.
29
+ - `isCritical` у `mc_register_action` принимается и ничего не делает.
30
+
31
+ - Файл меню пишет цвета метками (`!y`, `!r`, `!d`, `!w`, `!R`); код в нём — предупреждение, и сам код пропускается. То, что даёт Pawn-плагин, — заголовок, пункт, значение подстановки, строка списка или сообщение нативу, — и словари lang оставляют свои коды, они читаются как метки; `mc_get_menu_text` отдаёт текст с кодами.
32
+ - Флаг файла меню — `HIDE_BACK`, `HIDE_EXIT`, `LOCKED`, `GLOBAL` — это `YES` или `NO`; `true` или число — предупреждение со словом, которое написать, и это `NO`.
33
+ - Подстановки — `%target%` и `%time%`; `%s` и `%TIME%` остаются как написаны.
package/README.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
+ *An opinionated way to create menus*
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
+ [Features](#features) • [Installation](#installation) • [Usage](#usage) • [Menus in a file](#menus-in-a-file) • [Pawn plugins](#pawn-plugins) • [Testing](#testing)
13
+
14
+ **English** | [Русский](README.ru.md)
15
+
16
+ </div>
17
+
18
+ Describe a menu once, in a file — INI, YAML or JSON — or in code, and Menu Core handles the rest: pages, keys, the way back, countdowns, and items that appear, disappear or grey out depending on who is looking.
19
+
20
+ > [!WARNING]
21
+ > **In progress.** Menu Core has been tried in game only a few times, and its API may still change.
22
+
23
+ ## Features
24
+
25
+ - **Menus from a file or from code.** Admins edit `menu.ini`, `menu.yaml` or `menu.json` without touching a plugin; plugins add their own items at run time.
26
+ - **Mistakes said where they are.** An unknown key, a value of the wrong kind, a condition or an action nobody registered: the server console says so, with the file and the line.
27
+ - **Shown, and chosen.** An item says when it is shown at all (`visible`) and when it can be chosen (`enabled`): greyed out, with the message of the first requirement that fails beside it.
28
+ - **Text that follows the player.** A title, an item or a message can be a function — ``(player) => `Heal (${player.health} HP)` `` — read each time the menu is drawn; in `menu.ini`, `%hp%`-style placeholders do the same.
29
+ - **List menus.** A row per player or per item of your own list, with filters and a message when nothing is left.
30
+ - **Variants.** One item, several faces: the first variant whose `when` holds is shown.
31
+ - **Countdowns and locks.** Timed menus, one timer per player or one for everyone, and menus that cannot be closed or replaced.
32
+ - **One instance per server.** Every plugin, TypeScript or Pawn, fills and opens the same menus.
33
+ - **No Pawn limits.** Long names, many items, Cyrillic menus over 500 bytes.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ npx amxts module add menu-core
39
+ ```
40
+
41
+ It installs the package and [Config Core](https://github.com/amxts/config-core), which Menu Core reads its menus through, and adds both to `modules` in your project's `amxts.config.ts`. The module's options go beside it, under `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 or .jsonc
51
+ fallback: "menu", // configs/menu.* when the first one is empty
52
+ },
53
+ });
54
+ ```
55
+
56
+ The build loads Config Core first. A config that lists only Menu Core builds the same: the build brings Config Core along.
57
+
58
+ | Option | Default | What it does |
59
+ | --- | --- | --- |
60
+ | `file` | `"menu"` | The menu file under `configs/`; without an extension, the first of `.ini`, `.yaml`, `.yml`, `.json` and `.jsonc` that is there. |
61
+ | `fallback` | `""` | Read instead when `file` is empty or not there; `""` is none. |
62
+
63
+ ## Usage
64
+
65
+ A plugin uses the module as `menus`, without an import line: the build adds the import to the plugins that use it, and builds the module only when some plugin does.
66
+
67
+ ```ts
68
+ const shop = menus.create("SHOP", { title: (player) => `Shop for ${player.name}` });
69
+
70
+ shop.addItem((player) => `Heal (${player.health} HP)`, {
71
+ visible: (player) => player.health < 100,
72
+ onSelect: (player) => {
73
+ player.health = 100;
74
+ },
75
+ });
76
+ shop.addItem("Buy AWP", {
77
+ // Greyed out while one says no: the first that does gives its message.
78
+ enabled: [
79
+ { when: (player) => player.isAlive, message: "Only while alive" },
80
+ { when: (player) => player.account >= 4750, message: (player) => `Need $${4750 - player.account} more` },
81
+ ],
82
+ onSelect: (player) => {
83
+ player.account = player.account - 4750;
84
+ player.give("weapon_awp");
85
+ },
86
+ });
87
+ shop.addItem("Reset score", {
88
+ enabled: (player) => player.frags != 0,
89
+ message: "(already 0)",
90
+ onSelect: (player) => {
91
+ player.frags = 0;
92
+ },
93
+ });
94
+ shop.addItem("Close", { action: "CLOSE_MENU", spaceBefore: 1 });
95
+
96
+ server.addCommand("/shop", (player) => shop.show(player));
97
+ ```
98
+
99
+ Text — a title, an item, a message — is the text itself or a function that gives it for the player who looks. A plain string that is a lang key is translated for him.
100
+
101
+ An item says when it is shown and when it can be chosen:
102
+
103
+ - `visible` — is it shown at all; while it says no, the item is left out and takes no slot.
104
+ - `enabled` — can it be chosen; while it says no, the item is greyed out with `message` beside it. Or a list of requirements, `{ when, message }` each: the first that fails gives its message, and one without a message of its own has the item's.
105
+
106
+ In code a requirement is a function. Names — `IS_ALIVE`, `FLAG_d`, a restriction a plugin registered — are for menu files, which cannot hold a function.
107
+
108
+ Keys: **1–7** choose, **8** is the next page, **9** the previous page or back to the menu this one was opened from, **0** closes.
109
+
110
+ Text colours are written as tags, the same letters as in chat: `!y` yellow, `!r` red, `!d` grey, `!w` white, `!R` aligns right. Chat's own `!g`, `!b` and `!t` are dropped from a menu.
111
+
112
+ ### API
113
+
114
+ A menu is an object: `menus.create()` makes one, and its methods fill and open it.
115
+
116
+ | Method | What it does |
117
+ | --- | --- |
118
+ | `menu.addItem(text, options?)` | Adds an item: its text, or `(player, target) => text` — `target` is the row's in a list menu. Options: `onSelect`, `visible` (left out while it says no), `enabled` (greyed out while it says no — a test, or a list of `{ when, message }`), `message`, `at`, `spaceBefore`, `spaceAfter`; and for menus that name what plugins register, `action` and `placeholder`. |
119
+ | `menu.addFixedItem(slot, text, options?)` | An item that keeps slot 1–7 on every page. |
120
+ | `menu.addFilter(test, message?)` | A list menu leaves out the rows `test` says no to. |
121
+ | `menu.setListSource(rows)` | A list menu's own rows: `listRow(target, text)`, `textRow(text)`. |
122
+ | `menu.addEventListener("open" \| "close" \| "show", listener)` | This menu's events; `"show"` comes before it opens, `event.preventDefault()` stops it. |
123
+ | `menu.show(player, options?)` | Opens the menu; `false` when it does not open. Options: `time`, `target`, `resetHistory`, `force`, `skipHistory`. |
124
+ | `menu.refresh()` · `menu.close()` · `menu.clearItems()` | Redraw it or close it for whoever looks at it; remove its items. |
125
+ | `menu.setTimer(seconds)` · `menu.cancelTimer()` | The countdown everyone looking at it shares. |
126
+
127
+ Its fields — `title`, `time`, `hideBack`, `hideExit`, `locked`, `sharedTimer` — are set directly; `name`, `kind` and `countdown` are read.
128
+
129
+ | Function | What it does |
130
+ | --- | --- |
131
+ | `create(name, options?)` | A menu in code, or the existing one with that name. A name starting with `LIST_` makes a list menu. Options: `title` (text or a function), `time`, `hideBack`, `hideExit`, `locked`, `activeWhen`. |
132
+ | `find(name)` · `register(name)` | A menu by its name; `register` reads it from the file ahead of time. |
133
+ | `show(player, name, options?)` · `close(player)` · `activeMenu(player)` · `lock(player)` | The player's menu, whichever it is. |
134
+ | `addCondition(name, test)` · `addAction(name, handler)` · `addPlaceholder(name, value)` · `addRestriction(name, test, message?)` | What menu files and Pawn plugins name, answered by functions — `%name%` in their text is a placeholder. A restriction's `message` is said beside an item it greys out, unless the item or the requirement has its own. `menu.addPlaceholder(name, value)` gives one to a single menu. |
135
+ | `setListSource(name, rows)` · `refresh("A B")` · `conditionChanged(name)` · `addEventListener(type, listener)` | The same for menus by name, and every menu's events. |
136
+
137
+ ## Menus in a file
138
+
139
+ A menu is read from the file the first time it is asked for: by `register(name)`, or by `show` of a name Menu Core does not know yet. The file is INI, YAML or JSON: `menus: { file: "menu" }` reads the first of `menu.ini`, `menu.yaml`, `menu.yml`, `menu.json` and `menu.jsonc` that is there, and a menu means the same in each of them — except that INI cannot hide an item ([INI's columns](#inis-columns)).
140
+
141
+ ```yaml
142
+ # configs/menu.yaml
143
+ chatPrefix: MYPLUGIN_CHAT_PREFIX # chat prefix of the "nothing to list" message
144
+ labels:
145
+ exit: MYPLUGIN_MENU_EXIT # the buttons: a lang key or the text itself
146
+ number: MYPLUGIN_MENU_NUMBER # "!y[%d]!w" unless the dictionary says otherwise
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 # left out for everyone else
155
+ enabled: FLAG_d # greyed out, the message beside it
156
+ message: MYPLUGIN_MENU_NEEDS_FLAG_D
157
+ action: SHOW_ADMIN_MENU
158
+ - name: MYPLUGIN_MENU_MAIN_AWP
159
+ enabled: # the first that fails gives its message
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: # the first whose "when" holds is shown
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
+ ; name | placeholder | condition | action | restriction | message | spacing
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
+ ; name | condition | action | restriction | message
243
+ "%name%" "" "SWAP_WITH_SPECTATOR" "" ""
244
+ }
245
+ ```
246
+
247
+ ### The fields
248
+
249
+ | YAML, JSON | INI | What it is |
250
+ | --- | --- | --- |
251
+ | `chatPrefix` | `[MAIN]` `PREFIX` | The chat prefix of Menu Core's messages. |
252
+ | `labels`: `exit`, `back`, `next`, `number`, `disabled`, `page`, `time` | `[MAIN]` `KEY = { ... }` | The words of the buttons, the page and the countdown. |
253
+ | `menus`: `{ NAME: menu }` | `[NAME]` | The menus; a name starting with `LIST_` is a list menu. |
254
+ | `title` | `TITLE` | The title; a menu has one. |
255
+ | `activeOn` | `ACTIVE_ON` | Conditions the menu opens only under. |
256
+ | `hideBack` · `hideExit` | `HIDE_BACK` · `HIDE_EXIT` | `true` (INI: `YES`) leaves the button out. |
257
+ | `time` · `onTimeout` | `TIME` · `ON_TIMEOUT` | A countdown in seconds, and the actions run when it ends. |
258
+ | `locked` · `sharedTimer` | `LOCKED` · `GLOBAL` | Items cannot be chosen; one countdown for everyone. |
259
+ | `items` | `ITEMS` | An items menu's items. |
260
+ | `fixedItems` | `FIXED_ITEMS` | Items that keep their `slot`, 1–7, on every page. |
261
+ | `view` · `filters` | `VIEW` · `FILTER` | A list menu's row, and the filters its rows pass: `when`, `message`. |
262
+
263
+ An item — in `items`, `fixedItems` or as the `view` — has a `name`, and:
264
+
265
+ | Key | What it is |
266
+ | --- | --- |
267
+ | `placeholder` | Text after the name, placeholders and all: `"%hp%"`. |
268
+ | `action` | The actions run when it is chosen. |
269
+ | `visible` | Names it is shown under; while they do not hold, it is left out and takes no slot (a fixed item leaves its slot blank). Not in a `view`: a list menu leaves rows out with `filters`. |
270
+ | `enabled` | Names it can be chosen under, with `message` beside it while they do not hold — or a list of requirements, each a line of names or `{ when, message }`: the first that fails gives its message. |
271
+ | `message` | The text beside it while a requirement without a message of its own greys it out. |
272
+ | `spaceBefore` · `spaceAfter` | Blank lines before and after it. |
273
+ | `variants` | Several faces, `[{ name, when, action }, ...]`: the first whose `when` holds is shown; a variant without `when` always holds, and when none does, the first is shown greyed out. |
274
+
275
+ The message beside a greyed-out item goes from general to specific: the one a restriction was registered with (`addRestriction(name, test, message)`), the item's `message`, the requirement's own. Each is text or a lang key.
276
+
277
+ A `visible`, `when`, `enabled`, `activeOn`, `action` or `onTimeout` is a name, several space-separated, or a list: `activeOn: [IS_ALIVE, "!IS_SPECTATOR"]`. In YAML, quote a value that starts with `!` or `%`.
278
+
279
+ - **Names** in `visible`, `enabled` and `when` — a restriction a plugin registered, a condition, or else the `"*"` restriction's. `!NAME` turns one around; several must all hold. `NAME:param` hands the restriction's test the whole token and takes the rest of the line with it: `VIP:Only for VIP` is one name, so write it last. An unknown name does not hold.
280
+ - **Conditions:** `activeOn` names conditions only. These are built in, answered by Menu Core while no plugin registers the name (a plugin that does answers instead):
281
+
282
+ | Condition | Holds when the player |
283
+ | --- | --- |
284
+ | `IS_ALIVE` · `IS_DEAD` | is alive · is not (a spectator too) |
285
+ | `TEAM_CT` · `TEAM_TERRORIST` · `TEAM_SPECTATOR` · `TEAM_UNASSIGNED` | is in that team |
286
+ | `IS_BOT` | is a bot |
287
+ | `IS_ADMIN` | has any access but a plain user's `z` — AMX Mod X's `is_user_admin` |
288
+ | `FLAG_<letters>` | has any of those `users.ini` letters: `FLAG_ab` |
289
+
290
+ In a list menu, a condition of the view or a filter is asked of the row's player, and a restriction gets the row as its target. The names are case-insensitive.
291
+ - **Built-in actions:** `SHOW_<MENU>` opens that menu, `CLOSE_MENU` closes; an action line may list several.
292
+ - **Placeholders:** `%name%` (a list row's text), `%target%`, `%time%`, and any registered one.
293
+ - **List menus** draw their view per player, or per row of their list source, leaving out rows that fail a filter. With none left the menu does not open, and the player gets the filter's message.
294
+
295
+ > [!WARNING]
296
+ > **In a menu file:**
297
+ >
298
+ > - **Flags** of an INI menu — `HIDE_BACK`, `HIDE_EXIT`, `LOCKED`, `GLOBAL` — are `YES` or `NO`: `true`, `1` or `yes` is warned of, with the word to write, and is `NO`. In YAML `yes` is text: a flag there is `true` or `false`.
299
+ > - **Text with spaces** in an INI menu is quoted: `TITLE = "Main menu"`. Unquoted, only its first word is read.
300
+ > - **An item's name in YAML or JSON is not split on `|`:** its faces are written with `variants`.
301
+ > - **Colours** are tags in a menu file too: `!y`, `!r`, `!d`, `!w`, `!R`. Pawn's codes (`\y`, `\r`) are warned of, with the tag to write, and left out. Text from Pawn — a Pawn plugin's items and titles, a lang dictionary — keeps its codes, and Menu Core reads them as the tags.
302
+ > - **`%time%` and `%target%` are lower case:** `%TIME%` and `%s` are left as written.
303
+ > - **`ADMIN` and `ACCESS_ADMIN` are not built in:** a plugin registers them, or the file writes `IS_ADMIN` (any admin) or `FLAG_<letters>` (`FLAG_d`).
304
+
305
+ ### INI's columns
306
+
307
+ INI is the format Pawn plugins read too, so its columns are fixed. An item's row is `name | placeholder | condition | action | restriction | message | spacing`:
308
+
309
+ | INI | YAML, JSON |
310
+ | --- | --- |
311
+ | `"A\|B"` names, `"C1\|C2"` conditions, `"X\|Y"` actions | `variants: [{ name: A, when: C1, action: X }, { name: B, when: C2, action: Y }]` |
312
+ | a condition, one variant | `enabled: C` — greyed out without a reason |
313
+ | a restriction and a message | `enabled: R`, `message: M` — greyed out with the message |
314
+ | `"NAME:message\|NAME2:message"` | `enabled: [{ when: NAME, message: ... }, { when: NAME2, message: ... }]` |
315
+ | — | `visible` — INI has nothing that hides an item |
316
+
317
+ A condition column asks conditions only, and a restriction column asks restrictions first, then conditions. The `mc_*` natives add items the same way.
318
+
319
+ > [!TIP]
320
+ > A `menu.ini` written for Pawn colours its text with codes (`\y`, `\r`). The package's script rewrites them as tags, in INI, YAML and JSON: `bun node_modules/@amxts/menu-core/scripts/menu-colors.ts configs/menu.ini` (`--dry-run` says what it would change).
321
+
322
+ ### Checks
323
+
324
+ What does not fit a menu file is said in the server console with the file and the line — and the column in YAML and JSON — and left out; the rest of the menu is read.
325
+
326
+ - **When the file is read:** an unknown key, with the one it may be — `visible` or `enabled` for an item's `condition` or `restriction`, `when` for a variant's or a filter's `condition`; a value of the wrong kind (`hideBack: yes` — YAML's `yes` is text, the field takes `true`; `HIDE_BACK = 1` — an INI flag is `YES` or `NO`); a colour code (`\y`) where a menu file writes the tag, with the tag to write — the code is left out; a menu without a title, a menu with no items at all, an item without a name, `items` in a list menu, a slot outside 1–7. An item with no action is noted, not warned of: choosing it does nothing. An empty block, `ITEMS = { }`, is fine.
327
+ - **On the server's first frame:** every name, action and placeholder the file uses that nobody registered — TypeScript plugins, Pawn plugins through the `mc_*` natives, or Menu Core itself. By then every plugin has run `plugin_init` and `plugin_cfg`, so a name a Pawn plugin registers after the file was read is not taken for a mistake. A file read later — `setConfigFile()` — is checked as it is read. Then too, a line of names that says less than it seems: a name listed twice, case aside (`IS_ADMIN is listed more than once`), a name and its opposite (`IS_ALIVE and !IS_ALIVE together can never hold`) — each requirement, and each variant, on its own.
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
+ ### In the editor
335
+
336
+ The amxts extension for VS Code checks a menu file as you type — the same checks, in the same words — and completes the keys and the names your plugins register (TypeScript, Pawn and installed modules), with hover and go to definition to the registration. A name added in a plugin is offered in the menu file at once, before the plugin is saved. It is not on the Marketplace yet: install its `.vsix` with `code --install-extension amxts-vscode-<version>.vsix`.
337
+
338
+ > [!WARNING]
339
+ > The extension knows only names written as a string in the workspace: `menus.addAction(name, ...)` with the name in a variable, and a name only a Pawn plugin on the server registers, are a warning in the editor. The server's check on its first frame is the one that counts.
340
+
341
+ ## Pawn plugins
342
+
343
+ Pawn plugins use Menu Core through the 30 `mc_*` natives of `menu_core.inc`, which the package ships in `include/`; compiled `.amxx` plugins work unchanged. In `plugins.ini` it takes the place of `menu_core.amxx`. When no TypeScript plugin of the project uses the module, `pawn: ["@amxts/menu-core"]` in `amxts.config.ts` keeps it in the build for them. The details: [PAWN.md](PAWN.md).
344
+
345
+ ## Testing
346
+
347
+ Menu Core ships a test kit for the amxts fake server: `setup()` from `@amxts/core/test-utils` installs it, and `menusOf(server)` gives what the player's menu shows, the keys he presses, fake Pawn plugins and a dictionary:
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("an item runs the action a Pawn plugin registered", 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"); // what the player sees
376
+
377
+ menus.press(player, 1);
378
+ expect(calls).toEqual(["RESET_SCORE"]);
379
+ });
380
+ ```
381
+
382
+ The module's own tests are in `test/` (`npm test`); `playground/` is a project with Menu Core in it, which they load too.