@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 +21 -0
- package/PAWN.md +33 -0
- package/PAWN.ru.md +33 -0
- package/README.md +382 -0
- package/README.ru.md +382 -0
- package/include/menu_core.inc +213 -0
- package/package.json +72 -0
- package/prebuilt/linux/menu-core.aot +0 -0
- package/prebuilt/manifest.json +703 -0
- package/prebuilt/windows/menu-core.aot +0 -0
- package/scripts/docs/src/index.ts +298 -0
- package/scripts/docs/src/types.ts +266 -0
- package/scripts/menu-colors.ts +97 -0
- package/src/index.ts +1615 -0
- package/src/internal.ts +328 -0
- package/src/menu-file.ts +686 -0
- package/src/natives.ts +411 -0
- package/src/types.ts +158 -0
- package/testing/index.ts +375 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu Core — an opinionated way to create menus: from a menu file (INI, YAML
|
|
3
|
+
* or JSON) or in code, with conditions, placeholders and lists. How to use it:
|
|
4
|
+
* README.md.
|
|
5
|
+
*/
|
|
6
|
+
import { accessOf, Forward, Player, clearInterval, lang, print, server, setInterval, setTimeout } from "@amxts/core";
|
|
7
|
+
import { publicFor, showMenu } from "@amxts/core/kit";
|
|
8
|
+
import { GetLangTransKey, get_maxplayers, register_menucmd, register_menuid } from "~/natives";
|
|
9
|
+
import { ActionHandler, ActionTest, ConditionFilter, ConditionTest, ListRow, ListSource, MenuCoreOptions, MenuEventType, MenuItemOptions, MenuKind, MenuOptions, MenuShowOptions, MenuText, PlaceholderValue, RestrictionTest, RowTest } from "./types";
|
|
10
|
+
|
|
11
|
+
import { ConditionEntry, ActionEntry, PlaceholderEntry, RestrictionEntry, ActionCheck, FilterEntry, SourceEntry, Viewer, Listing, ListFilter, Screen, Labels, Check, MenuItem, Variant, ItemSpec, MenuFile, NameKind, NameUse, RequirementSpec, addNamedFilter, blankItem, checksOf, iniItem, insertItem, stateOf, textOf } from "./internal";
|
|
12
|
+
import { readMenuFile, suggestion, warn } from "./menu-file";
|
|
13
|
+
|
|
14
|
+
export * from "./types";
|
|
15
|
+
|
|
16
|
+
export default defineModule<MenuCoreOptions>({
|
|
17
|
+
meta: { name: "menu-core", configKey: "menus" },
|
|
18
|
+
requires: ["@amxts/config-core"],
|
|
19
|
+
imports: [{ from: "@amxts/menu-core", as: "menus" }],
|
|
20
|
+
defaults: { file: "menu", fallback: "" },
|
|
21
|
+
setup(options) {
|
|
22
|
+
setConfigFile(options.file, options.fallback);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A menu: read from a menu file, or made with `create()`. The fields are what
|
|
28
|
+
* the file sets; the methods fill the menu, open it and count it down.
|
|
29
|
+
*
|
|
30
|
+
* const shop = menus.create("SHOP", { title: "Shop" });
|
|
31
|
+
* shop.addItem("Heal", { onSelect: heal });
|
|
32
|
+
* shop.show(player);
|
|
33
|
+
*/
|
|
34
|
+
export class Menu {
|
|
35
|
+
/** The menu's kind, one of `"items"` (a list of items) or `"list"` (a row per player, or per row of a list source). A name starting with `LIST_` makes a list. */
|
|
36
|
+
readonly kind: MenuKind;
|
|
37
|
+
/** Hiding of the `"Back"` button: `true` leaves it out. */
|
|
38
|
+
hideBack = false;
|
|
39
|
+
/** Hiding of the `"Exit"` button: `true` leaves it out. */
|
|
40
|
+
hideExit = false;
|
|
41
|
+
/** A lock on the menu: while `true`, items cannot be chosen and no other menu replaces this one. */
|
|
42
|
+
locked = false;
|
|
43
|
+
/** One countdown for everyone looking at the menu (`true`), rather than one per player. */
|
|
44
|
+
sharedTimer = false;
|
|
45
|
+
/** Seconds on the countdown when the menu opens, e.g. `10`; `0` for none. */
|
|
46
|
+
time = 0;
|
|
47
|
+
/** Action names run when the countdown ends, e.g. `"CLOSE_MENU"`; `""` closes the menu. */
|
|
48
|
+
onTimeout = "";
|
|
49
|
+
/** Condition names the menu opens only under, space-separated, e.g. `"IS_ALIVE !IS_SPECTATOR"`; `""` for always. */
|
|
50
|
+
activeOn = "";
|
|
51
|
+
/** Seconds left on the shared countdown; `0` while none runs. */
|
|
52
|
+
countdown = 0;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
/** The menu's name - its name in the menu file, e.g. `"MAIN_MENU"`. */
|
|
56
|
+
readonly name: string,
|
|
57
|
+
/** The menu's title: the text - a lang key too - or a function that gives it for the player who looks. */
|
|
58
|
+
public title: MenuText,
|
|
59
|
+
) {
|
|
60
|
+
this.kind = name.startsWith("LIST_") ? "list" : "items";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Adds an item: its text, or a function that gives it for the player -
|
|
65
|
+
* `target` is the row's in a list menu, else the menu's.
|
|
66
|
+
*
|
|
67
|
+
* shop.addItem((player) => `Heal (${player.health} HP)`, { onSelect: heal });
|
|
68
|
+
*/
|
|
69
|
+
addItem(text: MenuText, options: MenuItemOptions = {}) {
|
|
70
|
+
insertItem(this.name, itemOf(this, text, options, -1), options.at ?? -1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Adds an item that takes the same slot on every page: `slot` is its key, `1` to `7`; the text as `addItem()` takes it. */
|
|
74
|
+
addFixedItem(slot: number, text: MenuText, options: MenuItemOptions = {}) {
|
|
75
|
+
const item = itemOf(this, text, options, slot - 1);
|
|
76
|
+
stateOf(this.name).fixed.push(item);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Removes every item of the menu, fixed ones too. */
|
|
80
|
+
clearItems() {
|
|
81
|
+
const state = stateOf(this.name);
|
|
82
|
+
state.items = [];
|
|
83
|
+
state.fixed = [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A filter of a list menu: rows `test` says no to are left out, and `message` is said when none is left. */
|
|
87
|
+
addFilter(test: RowTest, message?: string) {
|
|
88
|
+
stateOf(this.name).filters.push({ condition: "", when: "", test, message: message ?? "" });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A placeholder of this menu, for menu files and Pawn plugins: the text `%name%` stands for, before the ones registered with `addPlaceholder()`. In code the text is a function instead. */
|
|
92
|
+
addPlaceholder(name: string, value: PlaceholderValue) {
|
|
93
|
+
stateOf(this.name).placeholders.push({ name, value });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The source of this list menu's rows, instead of the players. */
|
|
97
|
+
setListSource(rows: ListSource) {
|
|
98
|
+
setListSource(this.name, rows);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Calls `listener` on this menu's events of `type`, one of `"open"`, `"close"` or `"show"` (before it opens). */
|
|
102
|
+
addEventListener(type: MenuEventType, listener: MenuListener) {
|
|
103
|
+
listeners.push({ type, listener, menu: this.name });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shows the menu to the player; `false` when it does not open - a `"show"`
|
|
108
|
+
* listener stopped it, it is not active, or the player's menu holds on.
|
|
109
|
+
*/
|
|
110
|
+
show(player: Player, options: MenuShowOptions = {}) {
|
|
111
|
+
return show(player, this.name, options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Draws the menu again for whoever looks at it; the number of players it was drawn for. */
|
|
115
|
+
refresh() {
|
|
116
|
+
return refresh(this.name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Closes the menu for whoever looks at it. */
|
|
120
|
+
close() {
|
|
121
|
+
for (const player of lookingAt(this)) close(player);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sets the shared countdown: starts it when none runs, or changes the
|
|
126
|
+
* seconds left - `0` stops it where it is. `false` when there is nothing to change.
|
|
127
|
+
*/
|
|
128
|
+
setTimer(seconds: number) {
|
|
129
|
+
if (this.countdown == 0 && seconds > 0) {
|
|
130
|
+
this.countdown = seconds;
|
|
131
|
+
this.sharedTimer = true;
|
|
132
|
+
startMenuTimer(this);
|
|
133
|
+
refresh(this.name);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (this.countdown <= 0) return false;
|
|
138
|
+
|
|
139
|
+
this.countdown = seconds;
|
|
140
|
+
if (seconds <= 0) stopMenuTimer(this);
|
|
141
|
+
else refresh(this.name);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Stops the shared countdown and closes the menu for everyone looking at it. `false` when none ran. */
|
|
146
|
+
cancelTimer() {
|
|
147
|
+
if (this.countdown == 0) return false;
|
|
148
|
+
stopMenuTimer(this);
|
|
149
|
+
this.countdown = 0;
|
|
150
|
+
this.close();
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A menu event: the `player`, the `menu`, and on `"close"` whether its `timeout` ran out. */
|
|
156
|
+
export class MenuEvent {
|
|
157
|
+
/** A mark of `preventDefault()`: `true` once it was called. */
|
|
158
|
+
defaultPrevented = false;
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
/** The player whose menu it is. */
|
|
162
|
+
public player: Player,
|
|
163
|
+
/** The menu the event is about. */
|
|
164
|
+
public menu: Menu,
|
|
165
|
+
/** On `"close"`: `true` when the menu closed because its time ran out. */
|
|
166
|
+
public timeout: boolean,
|
|
167
|
+
) {}
|
|
168
|
+
|
|
169
|
+
/** On `"show"`: keeps the menu from opening. */
|
|
170
|
+
preventDefault() {
|
|
171
|
+
this.defaultPrevented = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A listener of menu events, as `addEventListener()` calls it. */
|
|
176
|
+
export type MenuListener = (event: MenuEvent) => void;
|
|
177
|
+
|
|
178
|
+
interface ListenerEntry {
|
|
179
|
+
type: MenuEventType;
|
|
180
|
+
listener: MenuListener;
|
|
181
|
+
/** The menu it listens to, by name; "" for every one. */
|
|
182
|
+
menu: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const DEFAULTS: Labels = {
|
|
186
|
+
exit: "Exit",
|
|
187
|
+
back: "Back",
|
|
188
|
+
next: "Next",
|
|
189
|
+
number: "!y[%d]!w",
|
|
190
|
+
disabled: "!d[%d]",
|
|
191
|
+
page: "!y[!r%d!y | !y%d!y]",
|
|
192
|
+
time: "Time left !y[!r%d !wsec!y]",
|
|
193
|
+
prefix: "!g[MenuCore]!y",
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Items a page; 8 and 9 turn pages, 0 closes. */
|
|
197
|
+
const PAGE_SLOTS = 7;
|
|
198
|
+
/** A menu that opens a menu that opens a menu ... stops here. */
|
|
199
|
+
const MAX_DEPTH = 5;
|
|
200
|
+
const ALL_KEYS = 1023;
|
|
201
|
+
/** The conditions Menu Core answers itself while nobody registered them - TEAM_<team> and FLAG_<letters> besides. */
|
|
202
|
+
const BUILT_IN_CONDITIONS = ["IS_ALIVE", "IS_DEAD", "IS_BOT", "IS_ADMIN"];
|
|
203
|
+
/** The teams of TEAM_<team>: the names of `player.team`. */
|
|
204
|
+
const TEAMS = ["CT", "TERRORIST", "SPECTATOR", "UNASSIGNED"];
|
|
205
|
+
const KEY_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
|
206
|
+
|
|
207
|
+
const menus: Menu[] = [];
|
|
208
|
+
const menuByName = new Map<string, Menu>();
|
|
209
|
+
const viewers = new Map<number, Viewer>();
|
|
210
|
+
/** The shared countdown's timer of each menu that has one running, by name. */
|
|
211
|
+
const menuTimers = new Map<string, number>();
|
|
212
|
+
|
|
213
|
+
const conditions: ConditionEntry[] = [];
|
|
214
|
+
const actions: ActionEntry[] = [];
|
|
215
|
+
const placeholders: PlaceholderEntry[] = [];
|
|
216
|
+
const restrictions: RestrictionEntry[] = [];
|
|
217
|
+
const actionChecks: ActionCheck[] = [];
|
|
218
|
+
const conditionFilters: FilterEntry[] = [];
|
|
219
|
+
const sources: SourceEntry[] = [];
|
|
220
|
+
const listeners: ListenerEntry[] = [];
|
|
221
|
+
|
|
222
|
+
const labels: Labels = {
|
|
223
|
+
exit: DEFAULTS.exit,
|
|
224
|
+
back: DEFAULTS.back,
|
|
225
|
+
next: DEFAULTS.next,
|
|
226
|
+
number: DEFAULTS.number,
|
|
227
|
+
disabled: DEFAULTS.disabled,
|
|
228
|
+
page: DEFAULTS.page,
|
|
229
|
+
time: DEFAULTS.time,
|
|
230
|
+
prefix: DEFAULTS.prefix,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const timerExpired = new Forward<string>("mc_menu_timer_expired");
|
|
234
|
+
|
|
235
|
+
let configFile = "menu";
|
|
236
|
+
let fallbackFile = "";
|
|
237
|
+
let menuFile: MenuFile | null = null;
|
|
238
|
+
let started = false;
|
|
239
|
+
/** Whether the names a menu file uses are checked as it is read: once every plugin has registered its own. */
|
|
240
|
+
let checking = false;
|
|
241
|
+
/** Menus made before plugin_init: their keys are registered then. */
|
|
242
|
+
const waiting: Menu[] = [];
|
|
243
|
+
let selectCount = 0;
|
|
244
|
+
/** The placeholders every menu has. */
|
|
245
|
+
const BUILT_IN_PLACEHOLDERS = ["name", "target", "time"];
|
|
246
|
+
|
|
247
|
+
server.addEventListener("init", () => {
|
|
248
|
+
started = true;
|
|
249
|
+
for (const menu of waiting) listenForKeys(menu);
|
|
250
|
+
waiting.length = 0;
|
|
251
|
+
// Plugins register their conditions and actions in plugin_init and
|
|
252
|
+
// plugin_cfg - Pawn ones too, after this - and the first frame comes after
|
|
253
|
+
// every one of them.
|
|
254
|
+
setTimeout(startChecking);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
server.addEventListener("putinserver", (event) => {
|
|
258
|
+
const viewer = viewerOf(event.player.id);
|
|
259
|
+
stopPlayerTimer(viewer);
|
|
260
|
+
viewer.menu = "";
|
|
261
|
+
viewer.history = [];
|
|
262
|
+
viewer.page = 0;
|
|
263
|
+
viewer.locked = false;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
server.addEventListener("disconnected", (event) => {
|
|
267
|
+
close(event.player);
|
|
268
|
+
viewerOf(event.player.id).history = [];
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sets the file menus are read from, under `configs/`: without an extension,
|
|
273
|
+
* the first of `.ini`, `.yaml`, `.yml`, `.json` and `.jsonc` that is there. Read when
|
|
274
|
+
* a menu is first asked for; `fallback` is read instead when `file` is empty.
|
|
275
|
+
*/
|
|
276
|
+
export function setConfigFile(file: string, fallback?: string) {
|
|
277
|
+
configFile = file;
|
|
278
|
+
fallbackFile = fallback ?? "";
|
|
279
|
+
menuFile = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A menu by its name; `null` when there is none - `register()` reads one from the file. */
|
|
283
|
+
export function find(name: string) {
|
|
284
|
+
if (!menuByName.has(name)) return null;
|
|
285
|
+
return menuByName.get(name);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A menu's number among all of them - the one Pawn plugins know it by; `-1` for none. */
|
|
289
|
+
export function indexOf(menu: Menu | null) {
|
|
290
|
+
return menu != null ? menus.indexOf(menu) : -1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The menu with that number among all of them - the reverse of `indexOf()`; `null` when there is none. */
|
|
294
|
+
export function menuAt(index: number) {
|
|
295
|
+
if (index < 0 || index >= menus.length) return null;
|
|
296
|
+
return menus[index];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The menu of that name in the menu file, read now if it is not yet; `null` when the file has no such menu, or no items in it. */
|
|
300
|
+
export function register(name: string) {
|
|
301
|
+
const known = find(name);
|
|
302
|
+
if (known != null) return known;
|
|
303
|
+
const spec = loadedFile().menus.find(each => each.name == name);
|
|
304
|
+
if (spec == null) return null;
|
|
305
|
+
|
|
306
|
+
const menu = new Menu(name, spec.title);
|
|
307
|
+
menu.activeOn = spec.activeOn;
|
|
308
|
+
menu.hideBack = spec.hideBack;
|
|
309
|
+
menu.hideExit = spec.hideExit;
|
|
310
|
+
menu.locked = spec.locked;
|
|
311
|
+
menu.sharedTimer = spec.sharedTimer;
|
|
312
|
+
menu.time = spec.time;
|
|
313
|
+
menu.onTimeout = spec.onTimeout;
|
|
314
|
+
|
|
315
|
+
const state = stateOf(name);
|
|
316
|
+
for (const item of spec.items) state.items.push(itemOfSpec(item));
|
|
317
|
+
for (const item of spec.fixed) state.fixed.push(itemOfSpec(item));
|
|
318
|
+
for (const filter of spec.filters) {
|
|
319
|
+
if (filter.when.length > 0) state.filters.push({ condition: "", when: filter.when, test: null, message: filter.message });
|
|
320
|
+
else addNamedFilter(name, filter.condition, filter.message);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return add(menu);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A menu made in code - or the one of that name already there, as it is. A
|
|
328
|
+
* name starting with `LIST_` makes a list menu.
|
|
329
|
+
*/
|
|
330
|
+
export function create(name: string, options: MenuOptions = {}) {
|
|
331
|
+
const known = find(name);
|
|
332
|
+
if (known != null) return known;
|
|
333
|
+
|
|
334
|
+
const menu = new Menu(name, name);
|
|
335
|
+
const title = options.title;
|
|
336
|
+
if (title != null) menu.title = title;
|
|
337
|
+
menu.time = options.time ?? 0;
|
|
338
|
+
menu.hideBack = options.hideBack ?? false;
|
|
339
|
+
menu.hideExit = options.hideExit ?? false;
|
|
340
|
+
menu.locked = options.locked ?? false;
|
|
341
|
+
const activeWhen = options.activeWhen;
|
|
342
|
+
if (activeWhen != null) stateOf(name).activeWhen = activeWhen;
|
|
343
|
+
return add(menu);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The item addItem and addFixedItem add: in `slot`, -1 for the flow. */
|
|
347
|
+
function itemOf(menu: Menu, text: MenuText, options: MenuItemOptions, slot: number) {
|
|
348
|
+
const { placeholder = "", spaceBefore = 0, spaceAfter = 0 } = options;
|
|
349
|
+
const item = blankItem(text, actionOf(menu, options), slot);
|
|
350
|
+
item.placeholder = placeholder;
|
|
351
|
+
const visible = options.visible;
|
|
352
|
+
const enabled = options.enabled;
|
|
353
|
+
const message = options.message;
|
|
354
|
+
if (visible != null) item.visible = { test: visible, when: "", message: null };
|
|
355
|
+
if (enabled != null) item.enabled = checksOf(enabled);
|
|
356
|
+
if (message != null) item.message = message;
|
|
357
|
+
item.spaceBefore = spaceBefore;
|
|
358
|
+
item.spaceAfter = spaceAfter;
|
|
359
|
+
return item;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Registers a condition by name, for menu files and Pawn plugins; the first one registered under a name is the one asked - a built-in one (`IS_ALIVE`, `TEAM_CT`, ...) too. */
|
|
363
|
+
export function addCondition(name: string, test: ConditionTest) {
|
|
364
|
+
conditions.push({ name, test });
|
|
365
|
+
return conditions.length - 1;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Registers an action by name, for menu files and Pawn plugins; `SHOW_<MENU>` and `CLOSE_MENU` are built in. */
|
|
369
|
+
export function addAction(name: string, run: ActionHandler) {
|
|
370
|
+
actions.push({ name, run });
|
|
371
|
+
return actions.length - 1;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Registers a placeholder for menu files and Pawn plugins: the text `%name%` stands for in titles and items. A name registered twice keeps the first. In code the text is a function instead. */
|
|
375
|
+
export function addPlaceholder(name: string, value: PlaceholderValue) {
|
|
376
|
+
const known = placeholders.findIndex(entry => entry.name == name);
|
|
377
|
+
if (known >= 0) return known;
|
|
378
|
+
placeholders.push({ name, value });
|
|
379
|
+
return placeholders.length - 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Registers a restriction by name, for menu files to name in `enabled` and `when`: `message` is said beside an item it greys out, unless the item or the requirement has its own; `"*"` answers for every name nothing else does. */
|
|
383
|
+
export function addRestriction(name: string, test: RestrictionTest, message?: string) {
|
|
384
|
+
restrictions.push({ name, test, message: message ?? "" });
|
|
385
|
+
return restrictions.length - 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Greys out items with `action` in `menu` while `test` says no; `""` for either means every one. */
|
|
389
|
+
export function addActionCheck(menu: string, action: string, test: ActionTest) {
|
|
390
|
+
actionChecks.push({ menu, action, test });
|
|
391
|
+
return actionChecks.length - 1;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Registers a filter over the condition `name`, whoever registered it: it gets the condition's value and returns the one to use. */
|
|
395
|
+
export function addConditionFilter(name: string, filter: ConditionFilter) {
|
|
396
|
+
conditionFilters.push({ name, filter });
|
|
397
|
+
return conditionFilters.length - 1;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Sets the source of the rows of the list menu of that name, instead of the players; a second source replaces the first. */
|
|
401
|
+
export function setListSource(menu: string, rows: ListSource) {
|
|
402
|
+
const upper = menu.toUpperCase();
|
|
403
|
+
const known = sources.findIndex(source => source.menu == upper);
|
|
404
|
+
|
|
405
|
+
if (known >= 0) {
|
|
406
|
+
sources[known].rows = rows;
|
|
407
|
+
return known;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
sources.push({ menu: upper, rows });
|
|
411
|
+
return sources.length - 1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Calls `listener` on every menu event of `type`, one of `"open"`, `"close"` or `"show"` (before a menu opens). */
|
|
415
|
+
export function addEventListener(type: MenuEventType, listener: MenuListener) {
|
|
416
|
+
listeners.push({ type, listener, menu: "" });
|
|
417
|
+
return listeners.length - 1;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A row for a list source: its target, text, and optionally an action, a restriction and its message. */
|
|
421
|
+
export function listRow(target: number, text: string, action?: string, restriction?: string, restrictionMessage?: string) {
|
|
422
|
+
const row: ListRow = { kind: "item", target, text, action: action ?? "", restriction: restriction ?? "", restrictionMessage: restrictionMessage ?? "" };
|
|
423
|
+
return row;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A line of text among a list source's rows; `centered` pads it to the middle of the menu. */
|
|
427
|
+
export function textRow(text: string, centered = false) {
|
|
428
|
+
const pad = centered ? Math.floor((42 - text.length) / 2) : 0;
|
|
429
|
+
const row: ListRow = { kind: "text", target: 0, text: " ".repeat(pad > 0 ? pad : 0) + text, action: "", restriction: "", restrictionMessage: "" };
|
|
430
|
+
return row;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Shows the menu of that name - one made in code, or one of the file;
|
|
435
|
+
* `false` when it does not open: no such menu, a `"show"` listener stopped it,
|
|
436
|
+
* it is not active, or the player's menu holds on.
|
|
437
|
+
*/
|
|
438
|
+
export function show(player: Player, name: string, options: MenuShowOptions = {}) {
|
|
439
|
+
if (!player.isConnected) return false;
|
|
440
|
+
const viewer = viewerOf(player.id);
|
|
441
|
+
const menu = menuNamed(name);
|
|
442
|
+
if (menu == null) return false;
|
|
443
|
+
if (!allowed(player, menu)) return false;
|
|
444
|
+
|
|
445
|
+
const current = menuOf(viewer);
|
|
446
|
+
|
|
447
|
+
if (current != null && current != menu) {
|
|
448
|
+
if (!options.force && (viewer.timer > 0 || viewer.locked)) return false;
|
|
449
|
+
stopPlayerTimer(viewer);
|
|
450
|
+
viewer.timer = 0;
|
|
451
|
+
dispatch("close", player, current, false);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (options.resetHistory) {
|
|
455
|
+
viewer.history = [];
|
|
456
|
+
viewer.menu = "";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!opensFor(player, menu)) {
|
|
460
|
+
close(player);
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (viewer.depth >= MAX_DEPTH) return false;
|
|
465
|
+
viewer.depth++;
|
|
466
|
+
const shown = draw(player, viewer, menu, options);
|
|
467
|
+
viewer.depth--;
|
|
468
|
+
return shown;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Closes the player's menu; `timeout` tells the `"close"` listeners the time ran out. */
|
|
472
|
+
export function close(player: Player, timeout = false) {
|
|
473
|
+
const viewer = viewerOf(player.id);
|
|
474
|
+
const menu = menuOf(viewer);
|
|
475
|
+
if (menu == null) return;
|
|
476
|
+
|
|
477
|
+
stopPlayerTimer(viewer);
|
|
478
|
+
viewer.timer = 0;
|
|
479
|
+
viewer.locked = false;
|
|
480
|
+
viewer.menu = "";
|
|
481
|
+
viewer.page = 0;
|
|
482
|
+
viewer.target = 0;
|
|
483
|
+
viewer.history = [];
|
|
484
|
+
|
|
485
|
+
showMenu(player.id, 0, "\n", "");
|
|
486
|
+
dispatch("close", player, menu, timeout);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Draws the menus again for whoever looks at them; `names` are space-separated. The number of players they were drawn for. */
|
|
490
|
+
export function refresh(names: string) {
|
|
491
|
+
let count = 0;
|
|
492
|
+
for (const name of words(names)) {
|
|
493
|
+
const menu = find(name);
|
|
494
|
+
if (menu != null) count += refreshMenu(menu);
|
|
495
|
+
}
|
|
496
|
+
return count;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Draws a menu again for whoever looks at it. The number of players it was drawn for. */
|
|
500
|
+
function refreshMenu(menu: Menu) {
|
|
501
|
+
let count = 0;
|
|
502
|
+
for (const player of lookingAt(menu)) {
|
|
503
|
+
if (show(player, menu.name, { target: viewerOf(player.id).target })) count++;
|
|
504
|
+
}
|
|
505
|
+
return count;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Tells the menus a condition's value changed: the menus drawn with it are drawn again. */
|
|
509
|
+
export function conditionChanged(name: string) {
|
|
510
|
+
for (const menu of menus.filter(each => usesCondition(each, name))) refreshMenu(menu);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The menu the player looks at, or `null`. */
|
|
514
|
+
export function activeMenu(player: Player) {
|
|
515
|
+
return menuOf(viewerOf(player.id));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The text of the player's menu, as it was last drawn; `""` when none is open. */
|
|
519
|
+
export function shownText(player: Player) {
|
|
520
|
+
const viewer = viewerOf(player.id);
|
|
521
|
+
return viewer.menu.length > 0 ? viewer.text : "";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Locks the player's menu: no item can be chosen and no other menu replaces it until it is unlocked or closed. */
|
|
525
|
+
export function lock(player: Player, locked = true) {
|
|
526
|
+
viewerOf(player.id).locked = locked;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Whether the player's menu is locked - see `lock()`. */
|
|
530
|
+
export function isLocked(player: Player) {
|
|
531
|
+
return viewerOf(player.id).locked;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Sets the page the player's menu is drawn at next, from `0`. */
|
|
535
|
+
export function setPage(player: Player, page: number) {
|
|
536
|
+
viewerOf(player.id).page = page;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Whether an action of that name is registered. */
|
|
540
|
+
export function hasAction(name: string) {
|
|
541
|
+
return actionNamed(name) != null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Runs an action line: space-separated action names, `CLOSE_MENU` and `SHOW_<MENU>` among them. */
|
|
545
|
+
export function runActions(player: Player, line: string, target = 0) {
|
|
546
|
+
for (const name of words(line)) {
|
|
547
|
+
if (name == "CLOSE_MENU") {
|
|
548
|
+
close(player);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (name.startsWith("SHOW_")) {
|
|
553
|
+
show(player, name.slice(5), { time: 0, force: true });
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const action = actionNamed(name);
|
|
558
|
+
if (action != null) action.run(player, target, name);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The menu file, read when a menu is first asked for; the fallback file instead when it has nothing in it. */
|
|
563
|
+
function loadedFile() {
|
|
564
|
+
const known = menuFile;
|
|
565
|
+
if (known != null) return known;
|
|
566
|
+
let read = readMenuFile(configFile);
|
|
567
|
+
if (read.empty && fallbackFile.length > 0) read = readMenuFile(fallbackFile);
|
|
568
|
+
menuFile = read;
|
|
569
|
+
setLabels(read.labels);
|
|
570
|
+
if (checking) checkNames(read);
|
|
571
|
+
return read;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The file's words; one it leaves out stays as it was. */
|
|
575
|
+
function setLabels(read: Labels) {
|
|
576
|
+
if (read.exit.length > 0) labels.exit = read.exit;
|
|
577
|
+
if (read.back.length > 0) labels.back = read.back;
|
|
578
|
+
if (read.next.length > 0) labels.next = read.next;
|
|
579
|
+
if (read.number.length > 0) labels.number = read.number;
|
|
580
|
+
if (read.disabled.length > 0) labels.disabled = read.disabled;
|
|
581
|
+
if (read.page.length > 0) labels.page = read.page;
|
|
582
|
+
if (read.time.length > 0) labels.time = read.time;
|
|
583
|
+
if (read.prefix.length > 0) labels.prefix = read.prefix;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function itemOfSpec(spec: ItemSpec) {
|
|
587
|
+
const item = spec.ini ? iniItem(spec.name, spec.placeholder, spec.condition, spec.action, spec.restriction, spec.restrictionMessage, spec.slot) : blankItem(spec.name, spec.action, spec.slot);
|
|
588
|
+
item.placeholder = spec.placeholder;
|
|
589
|
+
if (spec.visible.length > 0) item.visible = { test: null, when: spec.visible, message: null };
|
|
590
|
+
item.enabled = spec.enabled.map(checkOfSpec);
|
|
591
|
+
if (spec.message.length > 0) item.message = spec.message;
|
|
592
|
+
item.spaceBefore = spec.spaceBefore;
|
|
593
|
+
item.spaceAfter = spec.spaceAfter;
|
|
594
|
+
item.variants = spec.variants;
|
|
595
|
+
return item;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** A requirement of a menu file, as an item holds it: "" for a message is the item's. */
|
|
599
|
+
function checkOfSpec(requirement: RequirementSpec) {
|
|
600
|
+
const check: Check = { test: null, when: requirement.when, message: null };
|
|
601
|
+
if (requirement.message.length > 0) check.message = requirement.message;
|
|
602
|
+
return check;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The first frame: every plugin has registered its names, so the menu file's are checked - now, and in every file read from now on. */
|
|
606
|
+
function startChecking() {
|
|
607
|
+
checking = true;
|
|
608
|
+
const known = menuFile;
|
|
609
|
+
if (known != null) checkNames(known);
|
|
610
|
+
else loadedFile();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Warns of each condition, action, restriction and placeholder a menu file names that nobody registered. */
|
|
614
|
+
function checkNames(file: MenuFile) {
|
|
615
|
+
for (const menu of file.menus) {
|
|
616
|
+
for (const use of menu.names) checkName(use, menu.name, file);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function checkName(use: NameUse, menu: string, file: MenuFile) {
|
|
621
|
+
if (use.kind == "placeholder") {
|
|
622
|
+
const problem = unknownName(use, use.name, menu, file);
|
|
623
|
+
if (problem.length > 0) warn(use.where, `${menu}: ${problem}`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// A line of restrictions or requirements keeps NAME:param whole; an INI
|
|
628
|
+
// item's conditions and actions are "A|B", a line a variant.
|
|
629
|
+
const whole = use.kind == "restriction" || use.kind == "requirement";
|
|
630
|
+
const lines = whole ? [restrictionTokens(use.name)] : (use.variants ? use.name.split("|") : [use.name]).map(words);
|
|
631
|
+
for (const line of lines) {
|
|
632
|
+
for (const token of line) {
|
|
633
|
+
const problem = unknownName(use, bare(token), menu, file);
|
|
634
|
+
if (problem.length > 0) warn(use.where, `${menu}: ${problem}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
for (const line of lines) {
|
|
638
|
+
for (const problem of listProblems(use.kind, line)) warn(use.where, `${menu}: ${problem}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* What is wrong with one line of names, a variant's own: a name listed twice,
|
|
644
|
+
* and a name with its opposite - "IS_ALIVE !IS_ALIVE" can never hold.
|
|
645
|
+
* Conditions and restrictions are compared case aside, actions as written.
|
|
646
|
+
*/
|
|
647
|
+
function listProblems(kind: NameKind, line: string[]) {
|
|
648
|
+
const problems: string[] = [];
|
|
649
|
+
const seen = new Map<string, string>();
|
|
650
|
+
for (const token of line) {
|
|
651
|
+
const negated = kind != "action" && token.startsWith("!");
|
|
652
|
+
const name = negated ? token.slice(1) : token;
|
|
653
|
+
const key = kind == "action" ? name : name.toUpperCase();
|
|
654
|
+
const opposite = `${negated ? "" : "!"}${key}`;
|
|
655
|
+
|
|
656
|
+
if (seen.has(opposite)) {
|
|
657
|
+
problems.push(`${seen.get(opposite)} and ${token} together can never hold`);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const own = `${negated ? "!" : ""}${key}`;
|
|
662
|
+
|
|
663
|
+
if (seen.has(own)) problems.push(`${token} is listed more than once`);
|
|
664
|
+
else seen.set(own, token);
|
|
665
|
+
}
|
|
666
|
+
return problems;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** What is wrong with a name a menu file uses - "the action "X" is not registered" - or "" when nothing is. */
|
|
670
|
+
function unknownName(use: NameUse, name: string, menu: string, file: MenuFile) {
|
|
671
|
+
if (use.kind == "condition") return knownCondition(name) ? "" : `the condition "${name}" is not registered${suggestion(name, conditionNames())}`;
|
|
672
|
+
if (use.kind == "restriction") return unknownRequirement(name, "restriction");
|
|
673
|
+
if (use.kind == "requirement") return unknownRequirement(name, "condition");
|
|
674
|
+
if (use.kind == "action") return unknownAction(name, file);
|
|
675
|
+
const own = stateOf(menu).placeholders.map(entry => entry.name);
|
|
676
|
+
const all = BUILT_IN_PLACEHOLDERS.concat(own).concat(placeholders.map(entry => entry.name));
|
|
677
|
+
return all.includes(name) ? "" : `the placeholder %${name}% is not registered${suggestion(name, all)}`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function knownCondition(name: string) {
|
|
681
|
+
return conditionNamed(name) != null || isBuiltInCondition(name);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function conditionNames() {
|
|
685
|
+
const teams = TEAMS.map(team => `TEAM_${team}`);
|
|
686
|
+
return conditions.map(entry => entry.name).concat(BUILT_IN_CONDITIONS).concat(teams);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* A name of a restriction line - asked among restrictions, then "*", then
|
|
691
|
+
* conditions, see check() - or of a requirement line - YAML's and JSON's
|
|
692
|
+
* `visible`, `enabled`, `when`, see answer() - that nothing answers. `what`
|
|
693
|
+
* is what the message calls it.
|
|
694
|
+
*/
|
|
695
|
+
function unknownRequirement(token: string, what: string) {
|
|
696
|
+
const name = nameOf(token);
|
|
697
|
+
if (restrictionNamed(name) != null || wildcardRestriction() != null || knownCondition(name)) return "";
|
|
698
|
+
const known = restrictions.map(entry => entry.name).concat(conditionNames());
|
|
699
|
+
return `the ${what} "${name}" is not registered${suggestion(name, known)}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** A token's name: "VIP" of "VIP:Only for VIP". */
|
|
703
|
+
function nameOf(token: string) {
|
|
704
|
+
const colon = token.indexOf(":");
|
|
705
|
+
return colon < 0 ? token : token.slice(0, colon);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function unknownAction(name: string, file: MenuFile) {
|
|
709
|
+
if (name == "CLOSE_MENU" || actionNamed(name) != null) return "";
|
|
710
|
+
const menuNames = file.menus.map(each => each.name).concat(menus.map(each => each.name));
|
|
711
|
+
|
|
712
|
+
if (name.startsWith("SHOW_")) {
|
|
713
|
+
const target = name.slice(5);
|
|
714
|
+
if (menuNames.includes(target)) return "";
|
|
715
|
+
return `${name} opens the menu "${target}", which is not there${suggestion(target, menuNames)}`;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// The actions items made in code were given ("SHOP#1") are no one's to name.
|
|
719
|
+
const known = actions.map(entry => entry.name).filter(each => !each.includes("#")).concat(["CLOSE_MENU"]);
|
|
720
|
+
return `the action "${name}" is not registered${suggestion(name, known)}`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function add(menu: Menu) {
|
|
724
|
+
menus.push(menu);
|
|
725
|
+
menuByName.set(menu.name, menu);
|
|
726
|
+
if (started) listenForKeys(menu);
|
|
727
|
+
else waiting.push(menu);
|
|
728
|
+
return menu;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** The menu's keys come to pressed(); a reload keeps the registration it had. */
|
|
732
|
+
function listenForKeys(menu: Menu) {
|
|
733
|
+
const name = publicFor(onMenuKey, `menu-core:${menu.name}`);
|
|
734
|
+
if (name.length > 0) register_menucmd(register_menuid(menu.name), ALL_KEYS, name);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function onMenuKey(id: number, key: number) {
|
|
738
|
+
const player = new Player(id);
|
|
739
|
+
pressed(player, key);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function actionOf(menu: Menu, options: MenuItemOptions) {
|
|
743
|
+
const handler = options.onSelect;
|
|
744
|
+
if (handler == null) return options.action ?? "";
|
|
745
|
+
selectCount++;
|
|
746
|
+
const name = `${menu.name}#${selectCount}`;
|
|
747
|
+
addAction(name, handler);
|
|
748
|
+
return name;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function pieces(text: string) {
|
|
752
|
+
return text.split("|").map(part => part.trim()).filter(part => part.length > 0);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function at(list: string[], index: number) {
|
|
756
|
+
return index >= 0 && index < list.length ? list[index] : "";
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* "A|B" names with "C1|C2" conditions and "X|Y" actions - INI's: a variant
|
|
761
|
+
* each. One name serves every action; one action serves every name.
|
|
762
|
+
*/
|
|
763
|
+
function variantsOf(name: string, condition: string, action: string) {
|
|
764
|
+
const names = pieces(name);
|
|
765
|
+
const conditionList = pieces(condition);
|
|
766
|
+
const actionList = pieces(action);
|
|
767
|
+
|
|
768
|
+
let count = 0;
|
|
769
|
+
if (actionList.length <= 1) count = Math.max(names.length, conditionList.length);
|
|
770
|
+
else if (names.length == 1) count = actionList.length;
|
|
771
|
+
else count = Math.min(names.length, actionList.length);
|
|
772
|
+
if (count == 0) count = names.length;
|
|
773
|
+
|
|
774
|
+
const variants: Variant[] = [];
|
|
775
|
+
for (let i = 0; i < count; i++) {
|
|
776
|
+
variants.push({
|
|
777
|
+
name: at(names, names.length == 1 ? 0 : i),
|
|
778
|
+
when: at(conditionList, i),
|
|
779
|
+
action: at(actionList, actionList.length == 1 ? 0 : i),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return variants;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Whether an item name of a menu file gives an item - e.g. `"A|B"` gives two variants; `""` and `"|"` give none. */
|
|
786
|
+
export function hasText(name: string) {
|
|
787
|
+
return pieces(name).length > 0;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The item's variants for the player: its own, or its text read now - "A|B" split with the conditions and actions when it is INI's. */
|
|
791
|
+
function variantsFor(item: MenuItem, player: Player, target: number) {
|
|
792
|
+
const own = item.variants;
|
|
793
|
+
if (own != null) return own;
|
|
794
|
+
const text = textOf(item.label, player, target);
|
|
795
|
+
|
|
796
|
+
if (!item.ini) {
|
|
797
|
+
const single: Variant[] = [{ name: text, when: "", action: item.action }];
|
|
798
|
+
return single;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const variants = variantsOf(text, item.condition, item.action);
|
|
802
|
+
if (variants.length == 0) variants.push({ name: "", when: "", action: pieces(item.action).length > 0 ? pieces(item.action)[0] : "" });
|
|
803
|
+
return variants;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Whether a line of names of the menu - ACTIVE_ON, a filter, an item's condition, `visible`, `enabled` or variant - names `name`. */
|
|
807
|
+
function usesCondition(menu: Menu, name: string) {
|
|
808
|
+
const state = stateOf(menu.name);
|
|
809
|
+
const lines = [menu.activeOn];
|
|
810
|
+
for (const filter of state.filters) {
|
|
811
|
+
lines.push(filter.condition);
|
|
812
|
+
lines.push(filter.when);
|
|
813
|
+
}
|
|
814
|
+
for (const item of state.items.concat(state.fixed)) {
|
|
815
|
+
for (const condition of pieces(item.condition)) lines.push(condition);
|
|
816
|
+
const visible = item.visible;
|
|
817
|
+
if (visible != null) lines.push(visible.when);
|
|
818
|
+
item.enabled.forEach(check => lines.push(check.when));
|
|
819
|
+
const variants = item.variants;
|
|
820
|
+
if (variants != null) variants.forEach(variant => lines.push(variant.when));
|
|
821
|
+
}
|
|
822
|
+
return lines.some(line => restrictionTokens(line).some(token => nameOf(bare(token)) == name));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function viewerOf(id: number) {
|
|
826
|
+
if (!viewers.has(id)) {
|
|
827
|
+
const made: Viewer = { menu: "", page: 0, target: 0, history: [], slots: [], rows: 0, text: "", locked: false, timer: 0, ticker: 0, depth: 0 };
|
|
828
|
+
viewers.set(id, made);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return viewers.get(id);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The menu a viewer looks at, or `null`. */
|
|
835
|
+
function menuOf(viewer: Viewer) {
|
|
836
|
+
return viewer.menu.length > 0 ? find(viewer.menu) : null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function menuNamed(name: string) {
|
|
840
|
+
return find(name) ?? register(name);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function lookingAt(menu: Menu) {
|
|
844
|
+
return Player.all().filter(player => viewerOf(player.id).menu == menu.name);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function words(text: string) {
|
|
848
|
+
return text.split(" ").map(word => word.trim()).filter(word => word.length > 0);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function perPage(menu: Menu) {
|
|
852
|
+
return Math.max(1, PAGE_SLOTS - stateOf(menu.name).fixed.length);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function historyIndex(viewer: Viewer, menu: Menu) {
|
|
856
|
+
return viewer.history.findIndex(step => step.menu == menu.name);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Whether the menu opens for the player: its ACTIVE_ON holds, and its activeWhen says yes. */
|
|
860
|
+
function opensFor(player: Player, menu: Menu) {
|
|
861
|
+
if (menu.activeOn.length > 0 && !check(player.id, player.id, menu.activeOn, false)) return false;
|
|
862
|
+
const test = stateOf(menu.name).activeWhen;
|
|
863
|
+
return test == null || test(player);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** The items of an items menu the player is shown: those whose `visible` does not say no. */
|
|
867
|
+
function shownItems(player: Player, viewer: Viewer, menu: Menu) {
|
|
868
|
+
return stateOf(menu.name).items.filter(item => isVisible(item, player, viewer.target));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function isVisible(item: MenuItem, player: Player, target: number) {
|
|
872
|
+
const check = item.visible;
|
|
873
|
+
return check == null || passes(check, player, player.id, target);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function draw(player: Player, viewer: Viewer, menu: Menu, options: MenuShowOptions) {
|
|
877
|
+
// A list menu nobody is left in does not open, and nothing of the viewer's changes.
|
|
878
|
+
const listing = menu.kind == "list" ? listOf(player, menu) : noListing();
|
|
879
|
+
|
|
880
|
+
if (menu.kind == "list" && listing.count == 0 && stateOf(menu.name).filters.length > 0) {
|
|
881
|
+
sayEmpty(player, menu);
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
viewer.target = options.target ?? 0;
|
|
886
|
+
const id = player.id;
|
|
887
|
+
const previous = menuOf(viewer);
|
|
888
|
+
const previousPage = viewer.page;
|
|
889
|
+
const returning = previous == menu;
|
|
890
|
+
|
|
891
|
+
// Back to a menu on the way back: the way back is cut there.
|
|
892
|
+
const back = historyIndex(viewer, menu);
|
|
893
|
+
|
|
894
|
+
if (back >= 0) {
|
|
895
|
+
viewer.page = viewer.history[back].page;
|
|
896
|
+
viewer.history = viewer.history.slice(0, back);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
let remember = !returning && previous != null && !options.skipHistory && back < 0;
|
|
900
|
+
if (remember && previous != null && previous.name.toUpperCase().includes("CONFIRM")) remember = false;
|
|
901
|
+
|
|
902
|
+
// Left out: the countdown running goes on, or the menu's own starts.
|
|
903
|
+
const asked = options.time;
|
|
904
|
+
const given = asked !== undefined;
|
|
905
|
+
if (given) stopPlayerTimer(viewer);
|
|
906
|
+
dispatch("open", player, menu, false);
|
|
907
|
+
if (!returning) viewer.locked = menu.locked;
|
|
908
|
+
|
|
909
|
+
let timer = asked ?? 0;
|
|
910
|
+
if (!given) timer = menu.countdown > 0 ? menu.countdown : viewer.timer;
|
|
911
|
+
if (timer <= 0) timer = menu.time;
|
|
912
|
+
|
|
913
|
+
if (!returning && !options.skipHistory && back < 0) viewer.page = 0;
|
|
914
|
+
|
|
915
|
+
const items = menu.kind == "list" ? noItems() : shownItems(player, viewer, menu);
|
|
916
|
+
const total = menu.kind == "list" ? listing.count : items.length;
|
|
917
|
+
const pages = Math.max(1, Math.ceil(total / perPage(menu)));
|
|
918
|
+
const page = Math.max(0, Math.min(viewer.page, pages - 1));
|
|
919
|
+
viewer.page = page;
|
|
920
|
+
viewer.rows = total;
|
|
921
|
+
|
|
922
|
+
if (timer > 0) startCountdown(viewer, id, menu, timer, given);
|
|
923
|
+
|
|
924
|
+
const screen: Screen = { text: header(id, viewer, menu, timer, page, pages), keys: [], slots: [] };
|
|
925
|
+
for (let slot = 0; slot < PAGE_SLOTS; slot++) screen.slots.push({ action: "", target: 0 });
|
|
926
|
+
|
|
927
|
+
if (menu.kind == "list") drawList(screen, player, viewer, menu, listing, page);
|
|
928
|
+
else drawItems(screen, player, viewer, menu, items, page);
|
|
929
|
+
|
|
930
|
+
const canGoBack = viewer.history.length > 0 || remember;
|
|
931
|
+
|
|
932
|
+
if (pages > 1) {
|
|
933
|
+
screen.text += "\n";
|
|
934
|
+
if (page < pages - 1) addNavigation(screen, id, 8, ui(id, labels.next, DEFAULTS.next));
|
|
935
|
+
else screen.text += "\n";
|
|
936
|
+
if (page > 0 || (canGoBack && !menu.hideBack)) addNavigation(screen, id, 9, ui(id, labels.back, DEFAULTS.back));
|
|
937
|
+
else screen.text += "\n";
|
|
938
|
+
} else if (canGoBack && !menu.hideBack && menu.countdown == 0) {
|
|
939
|
+
screen.text += "\n";
|
|
940
|
+
addNavigation(screen, id, 9, ui(id, labels.back, DEFAULTS.back));
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (menu.countdown == 0 && !menu.hideExit) {
|
|
944
|
+
screen.text += `\n${itemLabel(id, 0, ui(id, labels.exit, DEFAULTS.exit), false)}`;
|
|
945
|
+
screen.keys.push(0);
|
|
946
|
+
}
|
|
947
|
+
// A locked menu with nothing to press still has to be a menu the client shows.
|
|
948
|
+
if (viewer.locked && screen.keys.length == 0) screen.keys.push(0);
|
|
949
|
+
|
|
950
|
+
if (remember && previous != null) viewer.history.push({ menu: previous.name, page: previousPage });
|
|
951
|
+
viewer.menu = menu.name;
|
|
952
|
+
viewer.slots = screen.slots;
|
|
953
|
+
viewer.text = screen.text;
|
|
954
|
+
showMenu(id, keyMask(screen.keys), screen.text, menu.name);
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function noListing() {
|
|
959
|
+
const listing: Listing = { rows: [], fromSource: false, count: 0 };
|
|
960
|
+
return listing;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function noItems() {
|
|
964
|
+
const items: MenuItem[] = [];
|
|
965
|
+
return items;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** `given`: the seconds were asked for when the menu was shown - else the countdown running goes on. */
|
|
969
|
+
function startCountdown(viewer: Viewer, id: number, menu: Menu, timer: number, given: boolean) {
|
|
970
|
+
if (menu.sharedTimer) {
|
|
971
|
+
if (menu.countdown == 0 && given) {
|
|
972
|
+
menu.countdown = timer;
|
|
973
|
+
startMenuTimer(menu);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (!given) return;
|
|
980
|
+
viewer.timer = timer;
|
|
981
|
+
startPlayerTimer(viewer, id);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function header(id: number, viewer: Viewer, menu: Menu, timer: number, page: number, pages: number) {
|
|
985
|
+
const player = new Player(id);
|
|
986
|
+
const title = translate(id, textOf(menu.title, player, viewer.target));
|
|
987
|
+
const timed = title.includes("%time%");
|
|
988
|
+
let text = fill(id, viewer.target, title, "", menu);
|
|
989
|
+
if (pages > 1) text += ` ${numbered(ui(id, labels.page, DEFAULTS.page), [page + 1, pages])}`;
|
|
990
|
+
text += "\n";
|
|
991
|
+
if (timer > 0 && !timed) text += `${numbered(ui(id, labels.time, DEFAULTS.time), [timer])}\n`;
|
|
992
|
+
return `${text}\n`;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Key 1-9 and 0 as show_menu's bits: 1 is the lowest, 0 the tenth. */
|
|
996
|
+
function keyMask(keys: number[]) {
|
|
997
|
+
let mask = 0;
|
|
998
|
+
for (const key of keys) mask += 2 ** ((key + 9) % 10);
|
|
999
|
+
return mask;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** "%d" in a label, filled in order. */
|
|
1003
|
+
function numbered(format: string, values: number[]) {
|
|
1004
|
+
let text = format;
|
|
1005
|
+
for (const value of values) text = text.replace("%d", `${value}`);
|
|
1006
|
+
return text;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** A line with its key's number before it; `text` is translated already. */
|
|
1010
|
+
function itemLabel(id: number, key: number, text: string, disabled: boolean) {
|
|
1011
|
+
const format = disabled ? ui(id, labels.disabled, DEFAULTS.disabled) : ui(id, labels.number, DEFAULTS.number);
|
|
1012
|
+
return `${numbered(format, [key])} ${text}`;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function addNavigation(screen: Screen, id: number, key: number, text: string) {
|
|
1016
|
+
screen.text += `${itemLabel(id, key, text, false)}\n`;
|
|
1017
|
+
screen.keys.push(key);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function addLine(screen: Screen, id: number, slot: number, text: string, enabled: boolean, reason: string) {
|
|
1021
|
+
const shown = reason.length > 0 ? `${text}!y${reason}` : text;
|
|
1022
|
+
screen.text += `${itemLabel(id, slot + 1, shown, !enabled)}\n`;
|
|
1023
|
+
if (enabled) screen.keys.push(slot + 1);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function fixedAt(menu: Menu, slot: number) {
|
|
1027
|
+
return stateOf(menu.name).fixed.find(item => item.slot == slot);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Draws the fixed item of a slot - a blank line while it is hidden; false for a slot that has none. */
|
|
1031
|
+
function drawFixed(screen: Screen, player: Player, viewer: Viewer, menu: Menu, slot: number, target: number) {
|
|
1032
|
+
const fixed = fixedAt(menu, slot);
|
|
1033
|
+
if (fixed == null) return false;
|
|
1034
|
+
|
|
1035
|
+
if (isVisible(fixed, player, viewer.target)) drawItem(screen, player, viewer, menu, fixed, slot, target);
|
|
1036
|
+
else screen.text += "\n";
|
|
1037
|
+
return true;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* The first of the variants whose `when` holds; -1 when none does. INI's are
|
|
1042
|
+
* condition lines, asked of `subject` with `player` as the viewer; the rest
|
|
1043
|
+
* are lines of requirements - see meets().
|
|
1044
|
+
*/
|
|
1045
|
+
function variantFor(item: MenuItem, variants: Variant[], player: number, subject: number, target: number) {
|
|
1046
|
+
return variants.findIndex((variant) => {
|
|
1047
|
+
if (variant.when.length == 0) return true;
|
|
1048
|
+
return item.ini ? check(subject, player, variant.when, false) : meets(player, subject, target, variant.when);
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Why the item cannot be chosen, as said beside it - " message", or "" -
|
|
1054
|
+
* or null when it can: INI's restriction with its message; no variant that
|
|
1055
|
+
* holds, without a reason; the first requirement of `enabled` that says no,
|
|
1056
|
+
* with its message; an action check, with INI's message.
|
|
1057
|
+
*/
|
|
1058
|
+
function refusal(item: MenuItem, found: number, action: string, player: Player, subject: number, target: number, restrictionTarget: number, menu: Menu) {
|
|
1059
|
+
const id = player.id;
|
|
1060
|
+
const restriction = restrictionFailure(id, restrictionTarget, item.restriction);
|
|
1061
|
+
if (restriction.length > 0) return reasonFor(item, restriction);
|
|
1062
|
+
if (found < 0) return "";
|
|
1063
|
+
|
|
1064
|
+
const failed = item.enabled.find(check => !passes(check, player, subject, target));
|
|
1065
|
+
if (failed != null) return requirementReason(failed, item, player, subject, target);
|
|
1066
|
+
|
|
1067
|
+
if (!actionAllowed(id, menu.name, action)) return reasonFor(item, "ACTION_CONDITION");
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** An item of an items menu, or a fixed one: its conditions and restrictions are the viewer's own. */
|
|
1072
|
+
function drawItem(screen: Screen, player: Player, viewer: Viewer, menu: Menu, item: MenuItem, slot: number, target: number) {
|
|
1073
|
+
const id = player.id;
|
|
1074
|
+
screen.text += "\n".repeat(item.spaceBefore);
|
|
1075
|
+
|
|
1076
|
+
const variants = variantsFor(item, player, viewer.target);
|
|
1077
|
+
const found = variantFor(item, variants, id, id, viewer.target);
|
|
1078
|
+
const variant = variants[Math.max(0, found)];
|
|
1079
|
+
const name = translate(id, variant.name);
|
|
1080
|
+
const text = fill(id, target, item.placeholder.length > 0 ? `${name} ${item.placeholder}` : name, "", menu);
|
|
1081
|
+
|
|
1082
|
+
// INI's restriction is asked with the viewer as its target, as menu_core asks it.
|
|
1083
|
+
const reason = refusal(item, found, variant.action, player, id, viewer.target, id, menu);
|
|
1084
|
+
const enabled = reason == null && !viewer.locked;
|
|
1085
|
+
addLine(screen, id, slot, text, enabled, reason ?? "");
|
|
1086
|
+
if (enabled) screen.slots[slot] = { action: variant.action, target: viewer.target };
|
|
1087
|
+
|
|
1088
|
+
screen.text += "\n".repeat(item.spaceAfter);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function drawItems(screen: Screen, player: Player, viewer: Viewer, menu: Menu, items: MenuItem[], page: number) {
|
|
1092
|
+
let next = page * perPage(menu);
|
|
1093
|
+
for (let slot = 0; slot < PAGE_SLOTS; slot++) {
|
|
1094
|
+
if (drawFixed(screen, player, viewer, menu, slot, 0)) continue;
|
|
1095
|
+
|
|
1096
|
+
if (next >= items.length) {
|
|
1097
|
+
screen.text += "\n";
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
drawItem(screen, player, viewer, menu, items[next], slot, 0);
|
|
1102
|
+
next++;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function drawList(screen: Screen, player: Player, viewer: Viewer, menu: Menu, listing: Listing, page: number) {
|
|
1107
|
+
const items = stateOf(menu.name).items;
|
|
1108
|
+
const template = items.length > 0 ? items[0] : null;
|
|
1109
|
+
const rows = listing.rows;
|
|
1110
|
+
const start = page * perPage(menu);
|
|
1111
|
+
let drawn = 0;
|
|
1112
|
+
|
|
1113
|
+
for (let slot = 0; slot < PAGE_SLOTS; slot++) {
|
|
1114
|
+
if (drawFixed(screen, player, viewer, menu, slot, viewer.target)) continue;
|
|
1115
|
+
|
|
1116
|
+
// Text lines take no slot: they are drawn before the row that follows them.
|
|
1117
|
+
while (start + drawn < rows.length && rows[start + drawn].kind == "text") {
|
|
1118
|
+
screen.text += `${rows[start + drawn].text}\n`;
|
|
1119
|
+
drawn++;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const index = start + drawn;
|
|
1123
|
+
|
|
1124
|
+
if (template == null || index >= rows.length) {
|
|
1125
|
+
if (!listing.fromSource) screen.text += "\n";
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
drawn++;
|
|
1130
|
+
drawRow(screen, player, viewer, menu, template, rows[index], slot);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function drawRow(screen: Screen, player: Player, viewer: Viewer, menu: Menu, template: MenuItem, row: ListRow, slot: number) {
|
|
1135
|
+
const id = player.id;
|
|
1136
|
+
const variants = variantsFor(template, player, row.target);
|
|
1137
|
+
const found = variantFor(template, variants, id, row.target, row.target);
|
|
1138
|
+
const variant = variants[Math.max(0, found)];
|
|
1139
|
+
const text = fill(id, row.target, translate(id, variant.name), row.text, menu);
|
|
1140
|
+
|
|
1141
|
+
let reason: string | null = null;
|
|
1142
|
+
if (row.restriction.length > 0 && !check(id, row.target, row.restriction, true)) reason = reasonFor(template, row.restriction);
|
|
1143
|
+
reason ??= refusal(template, found, variant.action, player, row.target, row.target, row.target, menu);
|
|
1144
|
+
|
|
1145
|
+
const enabled = reason == null && !viewer.locked;
|
|
1146
|
+
let shown = "";
|
|
1147
|
+
if (!enabled) shown = row.restrictionMessage.length > 0 ? ` ${row.restrictionMessage}` : (reason != null && reason.length > 0 ? reason : reasonFor(template, ""));
|
|
1148
|
+
addLine(screen, id, slot, text, enabled, shown);
|
|
1149
|
+
if (!enabled) return;
|
|
1150
|
+
|
|
1151
|
+
// A row's own action wins when it is one this menu can run.
|
|
1152
|
+
const own = row.action;
|
|
1153
|
+
const usable = own.length > 0 && (hasAction(own) || own == "CLOSE_MENU" || own.startsWith("SHOW_"));
|
|
1154
|
+
screen.slots[slot] = { action: usable ? own : variant.action, target: row.target };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* A restriction line's tokens: names, space-separated; "NAME:text" takes the
|
|
1159
|
+
* rest of the line, spaces and all - "VIP:Only for VIP" is one restriction.
|
|
1160
|
+
*/
|
|
1161
|
+
function restrictionTokens(line: string) {
|
|
1162
|
+
const colon = line.indexOf(":");
|
|
1163
|
+
if (colon < 0) return words(line);
|
|
1164
|
+
const start = line.lastIndexOf(" ", colon) + 1;
|
|
1165
|
+
const tokens = words(line.slice(0, start));
|
|
1166
|
+
tokens.push(line.slice(start).trim());
|
|
1167
|
+
return tokens;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** The first restriction token that does not pass; "" when all do. */
|
|
1171
|
+
function restrictionFailure(player: number, target: number, restriction: string) {
|
|
1172
|
+
return restrictionTokens(restriction).find(token => fails(token, passesRestriction(player, target, bare(token)))) ?? "";
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** A name without the "!" that turns it around. */
|
|
1176
|
+
function bare(token: string) {
|
|
1177
|
+
return token.startsWith("!") ? token.slice(1) : token;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Whether a token fails with its name's answer - 1 holds, 0 does not, -1 nobody knows the name - its "!" counted. */
|
|
1181
|
+
function fails(token: string, value: number) {
|
|
1182
|
+
return value < 0 || (value == 1) == token.startsWith("!");
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* " message" beside an INI item greyed out by the restriction `failed`: the
|
|
1187
|
+
* one for it from "NAME:message|...", the one message there is, or the
|
|
1188
|
+
* restriction's own.
|
|
1189
|
+
*/
|
|
1190
|
+
function reasonFor(item: MenuItem, failed: string) {
|
|
1191
|
+
for (const pair of pieces(item.restrictionMessage)) {
|
|
1192
|
+
const colon = pair.indexOf(":");
|
|
1193
|
+
if (colon < 0) return ` ${pair}`;
|
|
1194
|
+
if (pair.slice(0, colon).trim() == failed) return ` ${pair.slice(colon + 1)}`;
|
|
1195
|
+
}
|
|
1196
|
+
const restriction = restrictionNamed(failed);
|
|
1197
|
+
return restriction != null && restriction.message.length > 0 ? ` ${restriction.message}` : "";
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Whether a requirement holds: its test, or its line of names - see meets(). */
|
|
1201
|
+
function passes(check: Check, player: Player, subject: number, target: number) {
|
|
1202
|
+
const test = check.test;
|
|
1203
|
+
if (test != null) return test(player, target);
|
|
1204
|
+
return meets(player.id, subject, target, check.when);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* " message" beside an item a requirement greys out, from general to
|
|
1209
|
+
* specific: the one the name that says no was registered with, the item's
|
|
1210
|
+
* `message`, the requirement's own - read for the player now, a lang key
|
|
1211
|
+
* translated.
|
|
1212
|
+
*/
|
|
1213
|
+
function requirementReason(check: Check, item: MenuItem, player: Player, subject: number, target: number) {
|
|
1214
|
+
const message = check.message ?? item.message;
|
|
1215
|
+
let text = "";
|
|
1216
|
+
if (message != null) text = textOf(message, player, target);
|
|
1217
|
+
else if (check.when.length > 0) text = registeredMessage(failingToken(player.id, subject, target, check.when));
|
|
1218
|
+
const translated = translate(player.id, text);
|
|
1219
|
+
return translated.length > 0 ? ` ${translated}` : "";
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** The message a restriction was registered with, for a name of a requirement's line - "*"'s for a name only it answers; "" for none. */
|
|
1223
|
+
function registeredMessage(token: string) {
|
|
1224
|
+
const name = bare(token);
|
|
1225
|
+
const restriction = restrictionNamed(name);
|
|
1226
|
+
if (restriction != null) return restriction.message;
|
|
1227
|
+
const wildcard = wildcardRestriction();
|
|
1228
|
+
return wildcard != null && !knownCondition(name) ? wildcard.message : "";
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function listOf(viewer: Player, menu: Menu) {
|
|
1232
|
+
const listing = noListing();
|
|
1233
|
+
const source = sourceFor(menu.name);
|
|
1234
|
+
const given = source != null ? source.rows(viewer, menu.name) : null;
|
|
1235
|
+
|
|
1236
|
+
if (given != null) {
|
|
1237
|
+
listing.fromSource = true;
|
|
1238
|
+
listing.rows = given.filter(row => row.kind == "text" || passesFilters(menu, row.target, viewer.id));
|
|
1239
|
+
} else if (stateOf(menu.name).items.length > 0) {
|
|
1240
|
+
listing.rows = Player.all()
|
|
1241
|
+
.filter(player => passesFilters(menu, player.id, viewer.id))
|
|
1242
|
+
.map(player => listRow(player.id, player.name));
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
listing.count = listing.rows.filter(row => row.kind == "item").length;
|
|
1246
|
+
return listing;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function passesFilters(menu: Menu, target: number, viewer: number) {
|
|
1250
|
+
return stateOf(menu.name).filters.every(filter => passesFilter(filter, target, viewer));
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** A filter by its test with the row's player and whoever looks, by a file's `when`, or by INI's condition names. */
|
|
1254
|
+
function passesFilter(filter: ListFilter, target: number, viewer: number) {
|
|
1255
|
+
const test = filter.test;
|
|
1256
|
+
|
|
1257
|
+
if (test != null) {
|
|
1258
|
+
const row = new Player(target);
|
|
1259
|
+
const looking = new Player(viewer);
|
|
1260
|
+
return test(row, looking);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (filter.when.length > 0) return meets(viewer, target, target, filter.when);
|
|
1264
|
+
return check(target, viewer, filter.condition, false);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function sourceFor(menu: string) {
|
|
1268
|
+
const upper = menu.toUpperCase();
|
|
1269
|
+
return sources.find(source => source.menu == upper);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** An empty list menu does not open: the player is told why - the filter nobody passes, or the first one. */
|
|
1273
|
+
function sayEmpty(player: Player, menu: Menu) {
|
|
1274
|
+
const filters = stateOf(menu.name).filters;
|
|
1275
|
+
for (const filter of filters) {
|
|
1276
|
+
const passing = Player.all().some(target => passesFilter(filter, target.id, player.id));
|
|
1277
|
+
|
|
1278
|
+
if (!passing && filter.message.length > 0) {
|
|
1279
|
+
say(player, filter.message);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (filters[0].message.length > 0) say(player, filters[0].message);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function say(player: Player, message: string) {
|
|
1287
|
+
print(player, `${ui(player.id, labels.prefix, DEFAULTS.prefix)} ${translate(player.id, message)}`);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function pressed(player: Player, key: number) {
|
|
1291
|
+
const viewer = viewerOf(player.id);
|
|
1292
|
+
const menu = menuOf(viewer);
|
|
1293
|
+
if (menu == null) return;
|
|
1294
|
+
|
|
1295
|
+
if (key == 7) {
|
|
1296
|
+
const pages = Math.max(1, Math.ceil(viewer.rows / perPage(menu)));
|
|
1297
|
+
if (viewer.page >= pages - 1) return;
|
|
1298
|
+
viewer.page++;
|
|
1299
|
+
show(player, menu.name, { skipHistory: true, target: viewer.target });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (key == 9) {
|
|
1304
|
+
close(player);
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (key == 8) {
|
|
1309
|
+
goBack(player, viewer, menu);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (key < 0 || key >= viewer.slots.length) return;
|
|
1314
|
+
|
|
1315
|
+
const slot = viewer.slots[key];
|
|
1316
|
+
if (slot.action.length == 0) return;
|
|
1317
|
+
runActions(player, slot.action, slot.target);
|
|
1318
|
+
if (viewer.menu == menu.name) show(player, menu.name, { target: viewer.target });
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function goBack(player: Player, viewer: Viewer, menu: Menu) {
|
|
1322
|
+
if (menu.countdown > 0) return;
|
|
1323
|
+
|
|
1324
|
+
if (viewer.page > 0) {
|
|
1325
|
+
viewer.page--;
|
|
1326
|
+
show(player, menu.name, { skipHistory: true, target: viewer.target });
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (viewer.history.length > 0) {
|
|
1331
|
+
const step = viewer.history.pop();
|
|
1332
|
+
viewer.page = step.page;
|
|
1333
|
+
show(player, step.menu, { skipHistory: true });
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
if (!menu.hideBack) close(player);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function conditionNamed(name: string) {
|
|
1341
|
+
const upper = name.toUpperCase();
|
|
1342
|
+
return conditions.find(entry => entry.name.toUpperCase() == upper);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function actionNamed(name: string) {
|
|
1346
|
+
return actions.find(entry => entry.name == name);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function restrictionNamed(name: string) {
|
|
1350
|
+
const colon = name.indexOf(":");
|
|
1351
|
+
const upper = (colon < 0 ? name : name.slice(0, colon)).toUpperCase();
|
|
1352
|
+
return restrictions.find(entry => entry.name != "*" && entry.name.toUpperCase() == upper);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function wildcardRestriction() {
|
|
1356
|
+
return restrictions.find(entry => entry.name == "*");
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* A condition line: space-separated names, all of which must hold; "!NAME"
|
|
1361
|
+
* turns one around. A restriction is looked for among restrictions, then "*",
|
|
1362
|
+
* then conditions; a restriction line keeps "NAME:text" whole. The built-in
|
|
1363
|
+
* conditions are answered by Menu Core when nobody registered them.
|
|
1364
|
+
*/
|
|
1365
|
+
function check(player: number, viewer: number, line: string, restriction: boolean) {
|
|
1366
|
+
if (restriction) return restrictionFailure(player, viewer, line).length == 0;
|
|
1367
|
+
return !words(line).some(token => fails(token, holds(player, viewer, bare(token))));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* A line of requirements - a menu file's `visible`, `enabled`, a variant's
|
|
1372
|
+
* or a filter's `when`: names, all of which must hold; "!NAME" turns one
|
|
1373
|
+
* around, "NAME:param" takes the rest of the line. `player` looks; a
|
|
1374
|
+
* condition is asked of `subject` - the row's player in a list menu, else
|
|
1375
|
+
* `player` - and a restriction gets `target`, the row's or the menu's.
|
|
1376
|
+
*/
|
|
1377
|
+
function meets(player: number, subject: number, target: number, line: string) {
|
|
1378
|
+
return failingToken(player, subject, target, line).length == 0;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** The first name of a line of requirements that does not hold; "" when all do. */
|
|
1382
|
+
function failingToken(player: number, subject: number, target: number, line: string) {
|
|
1383
|
+
return restrictionTokens(line).find(token => fails(token, answer(player, subject, target, bare(token)))) ?? "";
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** A name of a line of requirements: a restriction, a condition, or else "*"'s. 1 holds, 0 does not, -1 nobody knows the name. */
|
|
1387
|
+
function answer(player: number, subject: number, target: number, name: string) {
|
|
1388
|
+
const looking = new Player(player);
|
|
1389
|
+
const restriction = restrictionNamed(name);
|
|
1390
|
+
if (restriction != null) return restriction.test(looking, name, target) ? 1 : 0;
|
|
1391
|
+
const known = holds(subject, player, name);
|
|
1392
|
+
if (known >= 0) return known;
|
|
1393
|
+
const wildcard = wildcardRestriction();
|
|
1394
|
+
if (wildcard != null) return wildcard.test(looking, name, target) ? 1 : 0;
|
|
1395
|
+
return -1;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** 1 holds, 0 does not, -1 nobody knows the name. */
|
|
1399
|
+
function holds(id: number, viewerId: number, name: string) {
|
|
1400
|
+
const player = new Player(id);
|
|
1401
|
+
const viewer = new Player(viewerId);
|
|
1402
|
+
const entry = conditionNamed(name);
|
|
1403
|
+
if (entry != null) return filtered(player, viewer, name, entry.test(player, viewer, name)) ? 1 : 0;
|
|
1404
|
+
if (isBuiltInCondition(name)) return filtered(player, viewer, name, builtInHolds(player, name)) ? 1 : 0;
|
|
1405
|
+
return -1;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function passesRestriction(id: number, target: number, name: string) {
|
|
1409
|
+
const player = new Player(id);
|
|
1410
|
+
const entry = restrictionNamed(name);
|
|
1411
|
+
if (entry != null) return entry.test(player, name, target) ? 1 : 0;
|
|
1412
|
+
const wildcard = wildcardRestriction();
|
|
1413
|
+
if (wildcard != null) return wildcard.test(player, name, target) ? 1 : 0;
|
|
1414
|
+
const known = holds(id, target, name);
|
|
1415
|
+
return known < 0 ? 0 : known;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function filtered(player: Player, viewer: Player, name: string, value: boolean) {
|
|
1419
|
+
let result = value;
|
|
1420
|
+
const upper = name.toUpperCase();
|
|
1421
|
+
for (const entry of conditionFilters) {
|
|
1422
|
+
if (entry.name.toUpperCase() == upper) result = entry.filter(player, viewer, name, result);
|
|
1423
|
+
}
|
|
1424
|
+
return result;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/** IS_ALIVE, TEAM_CT, IS_ADMIN, FLAG_abc ... - a condition Menu Core answers itself, case aside. */
|
|
1428
|
+
function isBuiltInCondition(name: string) {
|
|
1429
|
+
const upper = name.toUpperCase();
|
|
1430
|
+
if (BUILT_IN_CONDITIONS.includes(upper)) return true;
|
|
1431
|
+
if (upper.startsWith("TEAM_")) return TEAMS.includes(upper.slice(5));
|
|
1432
|
+
return upper.startsWith("FLAG_");
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** A built-in condition's answer for the player. */
|
|
1436
|
+
function builtInHolds(player: Player, name: string) {
|
|
1437
|
+
const upper = name.toUpperCase();
|
|
1438
|
+
if (upper == "IS_ALIVE") return player.isAlive;
|
|
1439
|
+
if (upper == "IS_DEAD") return !player.isAlive;
|
|
1440
|
+
if (upper == "IS_BOT") return player.isBot;
|
|
1441
|
+
if (upper == "IS_ADMIN") return isAdmin(player);
|
|
1442
|
+
if (upper.startsWith("TEAM_")) return player.team == upper.slice(5);
|
|
1443
|
+
return hasAccess(player, name);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** IS_ADMIN: any access but a plain user's "z" - AMX Mod X's is_user_admin. */
|
|
1447
|
+
function isAdmin(player: Player) {
|
|
1448
|
+
const access = player.access;
|
|
1449
|
+
return access.length > 0 && !access.includes("User");
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** FLAG_abc: any of those users.ini letters. */
|
|
1453
|
+
function hasAccess(player: Player, name: string) {
|
|
1454
|
+
const access = player.access;
|
|
1455
|
+
return accessOf(name.slice(5)).some(each => access.includes(each));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function actionAllowed(id: number, menu: string, action: string) {
|
|
1459
|
+
if (action.length == 0) return true;
|
|
1460
|
+
const player = new Player(id);
|
|
1461
|
+
for (const entry of actionChecks) {
|
|
1462
|
+
if (entry.menu.length > 0 && entry.menu != menu) continue;
|
|
1463
|
+
if (entry.action.length > 0 && entry.action != action) continue;
|
|
1464
|
+
if (!entry.test(player, menu, action)) return false;
|
|
1465
|
+
}
|
|
1466
|
+
return true;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** Whether a listener hears an event: of its type, and of its menu when it has one. */
|
|
1470
|
+
function hears(entry: ListenerEntry, type: MenuEventType, menu: Menu) {
|
|
1471
|
+
return entry.type == type && (entry.menu.length == 0 || entry.menu == menu.name);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function allowed(player: Player, menu: Menu) {
|
|
1475
|
+
const event = new MenuEvent(player, menu, false);
|
|
1476
|
+
for (const entry of listeners) {
|
|
1477
|
+
if (hears(entry, "show", menu)) entry.listener(event);
|
|
1478
|
+
if (event.defaultPrevented) return false;
|
|
1479
|
+
}
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function dispatch(type: MenuEventType, player: Player, menu: Menu, timeout: boolean) {
|
|
1484
|
+
const event = new MenuEvent(player, menu, timeout);
|
|
1485
|
+
for (const entry of listeners) {
|
|
1486
|
+
if (hears(entry, type, menu)) entry.listener(event);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function isLangKey(text: string) {
|
|
1491
|
+
return text.length > 0 && GetLangTransKey(text) != -1;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/** A lang key's translation for the player, its colour codes made tags; any other text as it is. */
|
|
1495
|
+
function translate(id: number, text: string) {
|
|
1496
|
+
if (!isLangKey(text)) return text;
|
|
1497
|
+
const player = new Player(id);
|
|
1498
|
+
return lang.translate(player, text);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function looksLikeKey(text: string) {
|
|
1502
|
+
if (text.length == 0) return false;
|
|
1503
|
+
for (const letter of text.split("")) {
|
|
1504
|
+
if (!KEY_LETTERS.includes(letter)) return false;
|
|
1505
|
+
}
|
|
1506
|
+
return true;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** A label from [MAIN]: its translation, the default when it names a key nobody loaded, else the text. */
|
|
1510
|
+
function ui(id: number, key: string, fallback: string) {
|
|
1511
|
+
if (isLangKey(key)) {
|
|
1512
|
+
const player = new Player(id);
|
|
1513
|
+
const found = lang.translate(player, key);
|
|
1514
|
+
return found != key ? found : fallback;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
return looksLikeKey(key) ? fallback : key;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function secondsLeft(id: number, menu: Menu) {
|
|
1521
|
+
const viewer = viewerOf(id);
|
|
1522
|
+
return viewer.timer > 0 ? viewer.timer : menu.countdown;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* The text - translated already - with its placeholders filled: %name% from
|
|
1527
|
+
* `name`, the menu's own placeholders, then the registered ones, %time%, and
|
|
1528
|
+
* %target% as the target's name.
|
|
1529
|
+
*/
|
|
1530
|
+
function fill(id: number, target: number, input: string, name: string, menu: Menu) {
|
|
1531
|
+
let text = input;
|
|
1532
|
+
if (!text.includes("%")) return text;
|
|
1533
|
+
|
|
1534
|
+
if (name.length > 0 && text.includes("%name%")) text = text.replaceAll("%name%", translate(id, name));
|
|
1535
|
+
|
|
1536
|
+
const player = new Player(id);
|
|
1537
|
+
for (const entry of stateOf(menu.name).placeholders.concat(placeholders)) {
|
|
1538
|
+
const tag = `%${entry.name}%`;
|
|
1539
|
+
if (text.includes(tag)) text = text.replaceAll(tag, entry.value(player, target, entry.name));
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
if (text.includes("%time%")) text = text.replaceAll("%time%", `${secondsLeft(id, menu)}`);
|
|
1543
|
+
|
|
1544
|
+
if (target > 0 && target <= get_maxplayers() && text.includes("%target%")) {
|
|
1545
|
+
const targetPlayer = new Player(target);
|
|
1546
|
+
text = text.replaceAll("%target%", targetPlayer.name);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
return text;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function startPlayerTimer(viewer: Viewer, id: number) {
|
|
1553
|
+
if (viewer.ticker != 0) return;
|
|
1554
|
+
viewer.ticker = setInterval(() => onPlayerSecond(viewer, id), 1000);
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function stopPlayerTimer(viewer: Viewer) {
|
|
1558
|
+
if (viewer.ticker == 0) return;
|
|
1559
|
+
clearInterval(viewer.ticker);
|
|
1560
|
+
viewer.ticker = 0;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function onPlayerSecond(viewer: Viewer, id: number) {
|
|
1564
|
+
const player = new Player(id);
|
|
1565
|
+
|
|
1566
|
+
if (!player.isConnected || viewer.timer <= 0) {
|
|
1567
|
+
stopPlayerTimer(viewer);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
viewer.timer--;
|
|
1572
|
+
const menu = menuOf(viewer);
|
|
1573
|
+
|
|
1574
|
+
if (viewer.timer > 0) {
|
|
1575
|
+
if (menu != null) show(player, menu.name, { target: viewer.target, skipHistory: true });
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
stopPlayerTimer(viewer);
|
|
1580
|
+
if (menu == null) return;
|
|
1581
|
+
if (menu.onTimeout.length > 0) runActions(player, menu.onTimeout, viewer.target);
|
|
1582
|
+
if (player.isConnected && viewer.menu == menu.name) close(player, true);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function startMenuTimer(menu: Menu) {
|
|
1586
|
+
if (menuTimers.has(menu.name)) return;
|
|
1587
|
+
menuTimers.set(menu.name, setInterval(() => onMenuSecond(menu), 1000));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function stopMenuTimer(menu: Menu) {
|
|
1591
|
+
if (!menuTimers.has(menu.name)) return;
|
|
1592
|
+
clearInterval(menuTimers.get(menu.name));
|
|
1593
|
+
menuTimers.delete(menu.name);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function onMenuSecond(menu: Menu) {
|
|
1597
|
+
if (menu.countdown <= 0) {
|
|
1598
|
+
stopMenuTimer(menu);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
menu.countdown--;
|
|
1603
|
+
|
|
1604
|
+
if (menu.countdown > 0) {
|
|
1605
|
+
refresh(menu.name);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
stopMenuTimer(menu);
|
|
1610
|
+
timerExpired.emit(menu.name);
|
|
1611
|
+
for (const player of lookingAt(menu)) {
|
|
1612
|
+
if (menu.onTimeout.length > 0) runActions(player, menu.onTimeout, viewerOf(player.id).target);
|
|
1613
|
+
else close(player, true);
|
|
1614
|
+
}
|
|
1615
|
+
}
|