@amxts/core 0.1.0 → 0.2.1

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.
Files changed (77) hide show
  1. package/README.md +21 -25
  2. package/README.ru.md +22 -24
  3. package/as/amxts.d.ts +63 -3
  4. package/as/constants.ts +684 -116
  5. package/as/entities.ts +1905 -1364
  6. package/as/events.ts +1696 -907
  7. package/as/facade.ts +1240 -220
  8. package/as/fetch.ts +1519 -0
  9. package/as/flags.ts +159 -145
  10. package/as/hlds.ts +1071 -0
  11. package/as/hooks.ts +5226 -5379
  12. package/as/kit.ts +15 -5
  13. package/as/lib/check.ts +1 -1
  14. package/as/natives.ts +2658 -2499
  15. package/as/remote.ts +39 -15
  16. package/as/tsconfig.json +7 -2
  17. package/as/vector.ts +12 -9
  18. package/includes/order.txt +22 -29
  19. package/includes/sources.json +0 -8
  20. package/node_modules/assemblyscript/std/assembly/index.d.ts +20 -11
  21. package/package.json +29 -7
  22. package/runtime/deps/assemblyscript/dist/asc.js +86 -16
  23. package/runtime/deps/assemblyscript/dist/assemblyscript.js +29 -29
  24. package/runtime/licenses/MPL-2.0.txt +373 -0
  25. package/runtime/licenses/README.md +5 -1
  26. package/runtime/licenses/curl-COPYING +22 -0
  27. package/runtime/licenses/libssh2-COPYING +43 -0
  28. package/runtime/licenses/mbedTLS-LICENSE +553 -0
  29. package/runtime/natives.txt +108 -105
  30. package/runtime/patches/assemblyscript-0.28.20-amxts.patch +752 -296
  31. package/runtime/patches/wamr-2.4.5-amxts.patch +148 -0
  32. package/scripts/apply-docs.ts +2 -2
  33. package/scripts/auto-imports.ts +2 -2
  34. package/scripts/build-identity.ts +115 -0
  35. package/scripts/build-wasm.ts +101 -50
  36. package/scripts/check.ts +17 -3
  37. package/scripts/client-messages.ts +246 -0
  38. package/scripts/compile-cache.ts +4 -2
  39. package/scripts/compile-pool.ts +128 -0
  40. package/scripts/compile-worker.ts +23 -0
  41. package/scripts/compile.ts +156 -59
  42. package/scripts/docker-server.ts +13 -3
  43. package/scripts/docs/as/amxts.ts +86 -0
  44. package/scripts/docs/as/facade.ts +688 -188
  45. package/scripts/docs/as/fetch.ts +805 -0
  46. package/scripts/docs/entities.ts +43 -144
  47. package/scripts/docs/events.ts +17 -28
  48. package/scripts/docs/game.ts +68 -22
  49. package/scripts/docs/messages.ts +330 -16
  50. package/scripts/editor-api.ts +101 -0
  51. package/scripts/hlds-events.ts +370 -0
  52. package/scripts/includes.ts +12 -10
  53. package/scripts/player-fields.ts +16 -15
  54. package/scripts/prebuilt.ts +65 -23
  55. package/scripts/prepare.ts +42 -28
  56. package/scripts/project.ts +120 -20
  57. package/scripts/reapi-events.ts +98 -0
  58. package/scripts/shared-modules.ts +85 -23
  59. package/scripts/source-map.ts +242 -0
  60. package/scripts/system.ts +1 -1
  61. package/scripts/typed-commands.ts +263 -0
  62. package/scripts/typed-configs.ts +23 -12
  63. package/scripts/ui.ts +3 -1
  64. package/scripts/upgrade-menus.ts +274 -0
  65. package/scripts/upgrade-names.ts +384 -0
  66. package/scripts/upgrade.ts +857 -0
  67. package/src/cli-api.mjs +37 -4
  68. package/src/system.mjs +74 -3
  69. package/src/testing/compile.ts +8 -5
  70. package/src/testing/index.ts +2 -1
  71. package/src/testing/natives.ts +199 -5
  72. package/src/testing/network.ts +531 -0
  73. package/src/testing/server.ts +572 -48
  74. package/src/testing/setup.ts +1 -1
  75. package/src/testing/tables.ts +25 -0
  76. package/as/modules/http.ts +0 -184
  77. package/scripts/docs/as/modules/http.ts +0 -66
@@ -16,7 +16,8 @@ import { compile } from './compile';
16
16
  import { Coroutines, nextTaskId } from './coroutines';
17
17
  import { installKitFor } from './kits';
18
18
  import { bitsFloat, floatBits, Memory, utf8Fit } from './memory';
19
- import { NATIVES, WEAPON_NAMES } from './natives';
19
+ import { fieldCell, NATIVES, setFieldCell, WEAPON_NAMES } from './natives';
20
+ import { FakeNetwork } from './network';
20
21
  import { constant, tables } from './tables';
21
22
 
22
23
  declare const WebAssembly: any;
@@ -64,6 +65,8 @@ interface Handler {
64
65
  shape: number;
65
66
  /** A closure's number: fn is then the plugin's dispatcher, called with it first (Handler.tag in module.cpp). */
66
67
  tag?: number;
68
+ /** on_cell's filter: the handler hears the forward only when this argument is `value`. */
69
+ where?: { arg: number; value: number };
67
70
  }
68
71
 
69
72
  /** A host public standing in for a handler: `__amxts_cb<index>`. */
@@ -72,6 +75,12 @@ interface Slot extends Handler {
72
75
  /** What the call answers when the handler says nothing. */
73
76
  fallback: number;
74
77
  key: string;
78
+ /**
79
+ * Switched off while what it delivers has no listener - by the plugin
80
+ * (slot_on), or a hook by DisableHookChain and DisableHamForward: a call
81
+ * answers the fallback without reaching the plugin.
82
+ */
83
+ off?: boolean;
75
84
  }
76
85
 
77
86
  interface Task {
@@ -94,7 +103,7 @@ interface Cvar {
94
103
  export interface Chain {
95
104
  shape: HookShape;
96
105
  answer: Value;
97
- args: Value[];
106
+ args: HookArg[];
98
107
  /** SetHookChainReturn ran: reapi lets a chain that answers be stopped only then. */
99
108
  answered?: boolean;
100
109
  }
@@ -106,9 +115,12 @@ export interface HookResult {
106
115
  /** The chain's answer after every listener: a number, a boolean or text, by the chain. */
107
116
  result: Value;
108
117
  /** The arguments as the listeners left them (`event.damage = 10`). */
109
- args: Value[];
118
+ args: HookArg[];
110
119
  }
111
120
 
121
+ /** A hookchain's argument: a number, a boolean, text, or a vector as `[x, y, z]`. */
122
+ export type HookArg = Value | number[];
123
+
112
124
  /** A user message a plugin sent: ScreenFade, StatusIcon, ... */
113
125
  export interface UserMessage {
114
126
  name: string;
@@ -129,6 +141,36 @@ export interface SentForward {
129
141
  args: (Value | number[])[];
130
142
  }
131
143
 
144
+ /** A menu of AMX Mod X's own, made by menu_create, as the fake keeps it. */
145
+ export interface FakeMenu {
146
+ id: number;
147
+ title: string;
148
+ /** The public menu_create was given: called with (player, menu, item). */
149
+ handler: Slot;
150
+ /** Each item's name and its callback's number from menu_makecallback, or -1. */
151
+ items: { name: string; callback: number }[];
152
+ /** MPROP_PERPAGE: `0` is one page, without Back and More. */
153
+ perPage: number;
154
+ /** MPROP_EXIT is not MEXIT_NEVER. */
155
+ exit: boolean;
156
+ back: string;
157
+ next: string;
158
+ exitName: string;
159
+ numberColor: string;
160
+ }
161
+
162
+ /** A menu of AMX Mod X's own on a player's screen, as menu_display drew it. */
163
+ export interface MenuScreen {
164
+ menu: number;
165
+ page: number;
166
+ /** What he sees, with the game's colour codes: `\r1.\w Armor`. */
167
+ text: string;
168
+ /** The keys that answer: 1-9 and 0. */
169
+ keys: number[];
170
+ /** The keys of the items drawn grey, which do nothing. */
171
+ disabled: number[];
172
+ }
173
+
132
174
  /** One line a player was shown. */
133
175
  export interface Message {
134
176
  variant: 'chat' | 'center' | 'console' | 'notify' | 'hud';
@@ -147,12 +189,14 @@ export interface ServerOptions {
147
189
  /** Files in the game folder before the plugin loads, by path: `{ "addons/amxmodx/configs/x.ini": "..." }`. */
148
190
  files?: Record<string, string>;
149
191
  /**
150
- * The system the server runs on, as `~/os` finds it: "win32" puts the amxts
192
+ * The system the server runs on, as `@amxts/core/os` finds it: "win32" puts the amxts
151
193
  * module's amxts_amxx.dll in the modules folder. "linux" unless said.
152
194
  */
153
195
  platform?: 'win32' | 'linux';
154
196
  /** The server's time zone, what Date's local getters read: "Asia/Yerevan". This machine's unless said. */
155
197
  timeZone?: string;
198
+ /** Whether reapi finds Reunion, so a player's `authType`, `protocol` and `authKey` are his own. Not unless said. */
199
+ reunion?: boolean;
156
200
  }
157
201
 
158
202
  export type TeamName = 'UNASSIGNED' | 'TERRORIST' | 'CT' | 'SPECTATOR';
@@ -165,8 +209,15 @@ export interface JoinOptions {
165
209
  armor?: number;
166
210
  alive?: boolean;
167
211
  bot?: boolean;
168
- authid?: string;
212
+ /** His SteamID: "STEAM_0:0:<id>" unless said, "BOT" for a bot. */
213
+ steamId?: string;
169
214
  ip?: string;
215
+ /** How Reunion says his game proved who he is, by its name in the player's API: "steam" unless said. */
216
+ authType?: string;
217
+ /** His game's protocol, as Reunion says: 48 unless said. */
218
+ protocol?: number;
219
+ /** The key Reunion read from his game: "" unless said. */
220
+ authKey?: string;
170
221
  /** Admin flags as users.ini writes them: "abcdefghijklmnopqrstu". "z" - a plain user - unless said. */
171
222
  flags?: string;
172
223
  origin?: number[];
@@ -176,11 +227,16 @@ export interface JoinOptions {
176
227
 
177
228
  const SHAPE_NARROW = 0;
178
229
  const SHAPE_WIDE = 1;
230
+ /** A slot taken back after a reload: SLOT_REUSED in module.cpp. */
231
+ const SLOT_REUSED = 0x10000;
232
+ /** A request that tells an owner a plugin which called it stopped: RPC_GONE in module.cpp, KIND_GONE in as/remote.ts. */
233
+ const RPC_GONE = 2;
179
234
  const PLUGIN_HANDLED = 1;
180
235
  const HC_SUPERCEDE = 1;
181
236
  const HC_BREAK = 2;
182
- // A Ham Sandwich hook's answer that blocks the game's function.
237
+ // A Ham Sandwich hook's answer that blocks the game's function, and fakemeta's.
183
238
  const HAM_SUPERCEDE = 4;
239
+ const FMRES_SUPERCEDE = 4;
184
240
  // A message argument's types, as get_msg_argtype answers.
185
241
  const ARG_BYTE = 1;
186
242
  const ARG_COORD = 6;
@@ -266,8 +322,11 @@ export class FakePlayer extends FakeEntity {
266
322
  connected = true;
267
323
  alive: boolean;
268
324
  readonly bot: boolean;
269
- readonly authid: string;
325
+ readonly steamId: string;
270
326
  readonly ip: string;
327
+ readonly authType: string;
328
+ readonly protocol: number;
329
+ readonly authKey: string;
271
330
  /** Admin flags as bits: ADMIN_* . */
272
331
  flags: number;
273
332
  /** SPEAK_* flags: `muted` is SPEAK_MUTED. */
@@ -280,14 +339,22 @@ export class FakePlayer extends FakeEntity {
280
339
  readonly commands: string[] = [];
281
340
  /** His userinfo keys - `lang` says his language. */
282
341
  readonly info = new Map<string, string>();
342
+ /** The menu of AMX Mod X's own on his screen (menu_display), or null; `menuselect <key>` answers it. */
343
+ menu: MenuScreen | null = null;
344
+ /** His userid, the number `#12` names him by in a command: the server counts them up as players come. */
345
+ readonly userid: number;
283
346
 
284
347
  constructor(server: FakeServer, id: number, public name: string, options: JoinOptions) {
285
348
  super(server, id, 'player');
286
349
  this.alive = options.alive ?? true;
287
350
  this.bot = options.bot ?? false;
288
- this.authid = options.authid ?? (this.bot ? 'BOT' : `STEAM_0:0:${id}`);
351
+ this.steamId = options.steamId ?? (this.bot ? 'BOT' : `STEAM_0:0:${id}`);
289
352
  this.ip = options.ip ?? `127.0.0.${id}:27005`;
353
+ this.authType = options.authType ?? 'steam';
354
+ this.protocol = options.protocol ?? 48;
355
+ this.authKey = options.authKey ?? '';
290
356
  this.flags = flagBits(options.flags ?? 'z');
357
+ this.userid = server.nextUserid();
291
358
  this.team = options.team ?? 'CT';
292
359
  this.health = options.health ?? 100;
293
360
  this.armor = options.armor ?? 0;
@@ -408,9 +475,14 @@ export class FakePlayer extends FakeEntity {
408
475
  return false;
409
476
  }
410
477
 
411
- /** A console command, split the way the engine splits it: `amx_slap "Some One" 5`. True if handled. */
478
+ /**
479
+ * A console command, split the way the engine splits it: `amx_slap "Some One" 5`.
480
+ * `menuselect <key>` goes to his menu first, as AMX Mod X takes it. True if handled.
481
+ */
412
482
  command(line: string): boolean {
413
- return this.server.clientCommand(this, splitCommand(line));
483
+ const argv = splitCommand(line);
484
+ if (argv[0] === 'menuselect' && this.menu && this.server.selectMenu(this, Number(argv[1]))) return true;
485
+ return this.server.clientCommand(this, argv);
414
486
  }
415
487
 
416
488
  /** Leaves the server: the disconnect events, then the slot is free. */
@@ -452,12 +524,34 @@ function splitCommand(line: string): string[] {
452
524
  return [...line.matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2]);
453
525
  }
454
526
 
527
+ /**
528
+ * A log line split as AMX Mod X splits it for read_logargv: a quoted part
529
+ * and a part in brackets each one argument, the text between them another.
530
+ */
531
+ export function splitLog(line: string): string[] {
532
+ // The spaces between two parts are none.
533
+ return [...line.matchAll(/"([^"]*)"|\(([^)]*)\)|([^"(]+)/g)]
534
+ .filter(m => m[3] === undefined || m[3].trim() !== '')
535
+ .map(m => m[1] ?? m[2] ?? m[3].trim());
536
+ }
537
+
455
538
  /** A loaded plugin: its instance and its memory. */
539
+ /** The runs handed out so far: each plugin loaded is a new one. */
540
+ let runs = 0;
541
+
456
542
  export class PluginInstance {
457
543
  instance: any;
458
544
  memory!: Memory;
459
545
  /** What plugin() said about it. */
460
546
  info = { name: '', version: '', author: '', description: '' };
547
+ /** Taken off the server (FakeServer.unload): its slots answer nothing until the same file takes them back. */
548
+ unloaded = false;
549
+ /** This run of the plugin, as module.cpp's Plugin.run: what the other side of a shared module's call knows it by. */
550
+ readonly run = ++runs;
551
+ /** The shared modules it calls (Plugin.uses): their owners hear when it is unloaded. */
552
+ readonly uses: string[] = [];
553
+ /** It called back a function of a plugin unloaded since, and was told so: once. */
554
+ toldGone = false;
461
555
 
462
556
  /** The coroutine scheduler, for a plugin that awaits; null for one that does not. */
463
557
  readonly coroutines: Coroutines | null;
@@ -492,7 +586,7 @@ export class PluginInstance {
492
586
 
493
587
  if (this.server.hasNative(name)) return (...args: number[]) => this.server.callNativeImpl(this, name, args);
494
588
 
495
- // Another plugin's `export function`, called through ~/natives as AMX
589
+ // Another plugin's `export function`, called through @amxts/core/natives as AMX
496
590
  // Mod X would route it; whether one exports it is known only at the call.
497
591
  return (...cells: number[]) => {
498
592
  if (this.server.exported.has(name)) return this.server.callFromPlugin(this, name, cells);
@@ -515,6 +609,8 @@ export class FakeServer {
515
609
  readonly map: string;
516
610
  readonly maxPlayers: number;
517
611
  readonly modules: Set<string>;
612
+ /** Whether reapi finds Reunion (has_reunion). */
613
+ readonly reunion: boolean;
518
614
  /** Milliseconds since the map started; advance() moves it. */
519
615
  time = 0;
520
616
  /** Date.now() when the map started. */
@@ -575,6 +671,8 @@ export class FakeServer {
575
671
 
576
672
  /** The game rules' members a plugin set (set_member_game), by member. */
577
673
  readonly rules = new Map<number, number>();
674
+ /** The members plugins asked member_slot for, by slot: each one's reapi constant. */
675
+ private readonly memberSlots: number[] = [];
578
676
  /** Rounds a plugin ended with rg_round_end, in order. */
579
677
  readonly roundEnds: { status: number; event: number; delay: number; message: string; sound: string; trigger: boolean }[] = [];
580
678
  /** Sounds played: from an entity (emit_sound, rh_emit_sound2), or to a player alone (SendAudio, rg_send_audio). */
@@ -610,8 +708,37 @@ export class FakeServer {
610
708
  hookedMessage: { args: (number | string)[]; types: number[] } | null = null;
611
709
  /** register_touch's callbacks. */
612
710
  readonly touches: { touched: string; toucher: string; slot: Slot }[] = [];
711
+ /** register_logevent's callbacks: the number of arguments a line has, and the filters it passes. @internal */
712
+ readonly logEvents: { argc: number; filters: string[]; slot: Slot }[] = [];
713
+ /** The log line logevent callbacks are reading, as AMX Mod X splits it. @internal */
714
+ logArgs: string[] = [];
715
+ /** That line whole, as read_logdata reads it. @internal */
716
+ logLine = '';
717
+ /** register_event's callbacks, by the message's name, with their conditions ("1=0"). @internal */
718
+ readonly messageEvents: { name: string; conditions: string[]; slot: Slot }[] = [];
719
+ /** register_forward's callbacks, by `<FM_* number>:pre|post`. */
720
+ readonly fakemetaForwards = new Map<string, Slot[]>();
721
+ /** What a fakemeta callback answered with forward_return, and what get_orig_retval reads. @internal */
722
+ forwardAnswer: number | string | null = null;
723
+ origRetval = 0;
724
+ /** The engine's EngFunc_SetClientListening calls plugins made: listener, sender, whether he hears. */
725
+ readonly listening: number[][] = [];
726
+ /** The engine's and the game's functions plugins called through engfunc and dllfunc, each as a line: `TraceLine 1,2,3 4,5,6 1 7 0`. */
727
+ readonly engineCalls: string[] = [];
613
728
  /** query_client_cvar's questions, waiting for answerCvar(). */
614
729
  readonly cvarQueries: { player: number; cvar: string; slot: Slot }[] = [];
730
+ /** The menus menu_create made and menu_destroy has not taken away, by id. */
731
+ readonly menus = new Map<number, FakeMenu>();
732
+ /** menu_makecallback's publics, by the number it gave. @internal */
733
+ readonly menuCallbacks: Slot[] = [];
734
+ private menuIds = 0;
735
+ private userids = 1;
736
+
737
+ /** A userid for a player who comes: one more than the last. @internal */
738
+ nextUserid(): number {
739
+ return this.userids++;
740
+ }
741
+
615
742
  /** What precache_model and precache_sound were asked for, in order: the index is the place + 1. */
616
743
  readonly precached: string[] = [];
617
744
  /**
@@ -623,7 +750,7 @@ export class FakeServer {
623
750
  /** Which of those texts are lists of player ids ("3,5"), by `slot:key`: a player who leaves goes from them. */
624
751
  readonly playerLists = new Set<string>();
625
752
  /**
626
- * The playerchange listeners, as the module keeps them: per plugin, the
753
+ * The playerChange listeners, as the module keeps them: per plugin, the
627
754
  * fields it hears ("" for every one) and the trampoline that hands a change
628
755
  * to its listeners.
629
756
  */
@@ -640,13 +767,15 @@ export class FakeServer {
640
767
  private outcome = 0;
641
768
  private outcomeSaid = false;
642
769
  private taskOrder = 0;
643
- private hookHandles = 0;
770
+ /** The slots of RegisterHookChain's and RegisterHam's handles, which DisableHookChain and the rest take. */
771
+ private readonly hookSlots = new Map<number, Slot>();
644
772
  private entityIds: number;
645
773
 
646
774
  constructor(options: ServerOptions = {}) {
647
775
  this.map = options.map ?? 'de_dust2';
648
776
  this.maxPlayers = options.maxPlayers ?? 32;
649
777
  this.modules = new Set(options.modules ?? ['reapi', 'cstrike', 'fun', 'hamsandwich', 'engine', 'fakemeta', 'nvault', 'resemiclip']);
778
+ this.reunion = options.reunion ?? false;
650
779
  this.entityIds = this.maxPlayers + 1;
651
780
  this.timeZone = options.timeZone;
652
781
  // AMX Mod X's own, which a test may set: the languages.
@@ -697,11 +826,17 @@ export class FakeServer {
697
826
  return plugin;
698
827
  }
699
828
 
700
- /** plugin_precache, plugin_init and plugin_cfg, as a map start sends them. */
829
+ /**
830
+ * What a map start sends, in AMX Mod X's order: plugin_precache,
831
+ * plugin_init, plugin_cfg, then OnAutoConfigsBuffered and
832
+ * OnConfigsExecuted, once the server has run its configs.
833
+ */
701
834
  start(): void {
702
835
  this.fire('plugin_precache');
703
836
  this.fire('plugin_init');
704
837
  this.fire('plugin_cfg');
838
+ this.fire('OnAutoConfigsBuffered');
839
+ this.fire('OnConfigsExecuted');
705
840
  }
706
841
 
707
842
  // ------------------------------------------------------------ what a test reads
@@ -870,11 +1005,29 @@ export class FakeServer {
870
1005
  this.entities.set(id, player);
871
1006
 
872
1007
  this.fire('client_connect', id);
873
- this.fire('client_authorized', id, player.authid);
1008
+ this.fire('client_authorized', id, player.steamId);
874
1009
  this.fire('client_putinserver', id);
875
1010
  return player;
876
1011
  }
877
1012
 
1013
+ /** The module's network client: the requests plugins sent with fetch. @internal */
1014
+ readonly network = new FakeNetwork(this);
1015
+
1016
+ /**
1017
+ * Waits for the responses to the requests the plugins sent with `fetch`,
1018
+ * and hands each to its plugin as the server's next frame would; a
1019
+ * request sent on the way is waited for too. A timer between two tries
1020
+ * waits for `advance()`.
1021
+ *
1022
+ * ```ts
1023
+ * player.say("/weather");
1024
+ * await server.responses();
1025
+ * ```
1026
+ */
1027
+ async responses(): Promise<void> {
1028
+ await this.network.settle();
1029
+ }
1030
+
878
1031
  /**
879
1032
  * Moves the clock forward and runs every timer that comes due on the way,
880
1033
  * in order - a repeating one as many times as it fits.
@@ -913,6 +1066,7 @@ export class FakeServer {
913
1066
 
914
1067
  let result = 0;
915
1068
  for (const handler of [...(this.events.get(name) ?? [])]) {
1069
+ if (handler.where && Number(args[handler.where.arg]) !== handler.where.value) continue;
916
1070
  const one = this.call(handler, [first], 0);
917
1071
  if (one > result) result = one;
918
1072
  }
@@ -932,24 +1086,26 @@ export class FakeServer {
932
1086
  *
933
1087
  * ```ts
934
1088
  * server.fireHook("takeDamage", [victim.id, 0, attacker.id, 30.0, 2]);
935
- * server.fireHook("flPlayerFallDamage", [player.id], { result: 40 });
1089
+ * server.fireHook("fallDamage", [player.id], { result: 40 });
936
1090
  * ```
937
1091
  *
938
1092
  * `event` is the name game.addEventListener takes, or reapi's short one
939
1093
  * ("take_damage"). `args` are the chain's arguments in order; a float is
940
- * written as a number and goes as one. `result` is what the game's own
1094
+ * written as a number and goes as one, a vector as `[x, y, z]`. `result` is what the game's own
941
1095
  * function answers when it runs - what a post listener reads as
942
1096
  * event.result.
943
1097
  */
944
- fireHook(event: string, args: Value[] = [], options: { result?: Value } = {}): HookResult {
1098
+ fireHook(event: string, args: HookArg[] = [], options: { result?: Value } = {}): HookResult {
945
1099
  const shape = tables().hooks.get(event);
946
1100
  if (!shape) throw new Error(`no hookchain named "${event}" in as/hooks.ts`);
947
1101
 
948
- const cells = args.map((a, i) => typeof a === 'string'
949
- ? a
950
- : typeof a === 'boolean'
951
- ? (a ? 1 : 0)
952
- : shape.floats.has(i) ? floatBits(a) : a | 0);
1102
+ const cells: ArgValue[] = args.map((a, i) => Array.isArray(a)
1103
+ ? a.map(floatBits)
1104
+ : typeof a === 'string'
1105
+ ? a
1106
+ : typeof a === 'boolean'
1107
+ ? (a ? 1 : 0)
1108
+ : shape.floats.has(i) ? floatBits(a) : a | 0);
953
1109
  const handed = cells.slice(0, 4).map(c => typeof c === 'number' ? c : 0);
954
1110
 
955
1111
  const nothing: Value = shape.answer === constant('ATYPE_BOOL') ? false : shape.answer === constant('ATYPE_STRING') ? '' : 0;
@@ -980,6 +1136,12 @@ export class FakeServer {
980
1136
  if (!prevented) chain.answer = options.result ?? nothing;
981
1137
 
982
1138
  if (!broken) {
1139
+ // A vector is an array reapi copies back into the game's: what
1140
+ // a pre listener wrote in it is what the game and the post
1141
+ // listeners get.
1142
+ cells.forEach((cell, i) => {
1143
+ if (Array.isArray(cell)) chain.args[i] = cell.map(bitsFloat);
1144
+ });
983
1145
  for (const slot of [...listeners.post]) {
984
1146
  this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
985
1147
  }
@@ -1001,8 +1163,11 @@ export class FakeServer {
1001
1163
  * const sent = server.sendMessage("RoundTime", [120]); // sent.args: [90] with a listener that writes 90
1002
1164
  * ```
1003
1165
  *
1004
- * A text argument is a string; a whole number is written as a byte, a
1005
- * fraction as a coordinate, unless `types` gives each argument's ARG_*.
1166
+ * `name` is the game's name of the message, `"TextMsg"` for the
1167
+ * `"text"` that server.addMessageListener takes, and `args` are its
1168
+ * arguments in the game's order. A text argument is a string; a whole
1169
+ * number is written as a byte, a fraction as a coordinate, unless `types`
1170
+ * gives each argument's ARG_*.
1006
1171
  * Returns whether a callback stopped it, and the arguments as they left.
1007
1172
  */
1008
1173
  sendMessage(name: string, args: (number | string)[], options: { player?: FakePlayer; types?: number[] } = {}): { prevented: boolean; args: (number | string)[] } {
@@ -1020,6 +1185,14 @@ export class FakeServer {
1020
1185
  const handed = [id ?? 0, receiver ? 1 : 0, receiver, 0];
1021
1186
  if (this.withCallArgs(handed, () => this.call(slot, handed, 0)) === PLUGIN_HANDLED) prevented = true;
1022
1187
  }
1188
+ // register_event's callbacks hear it once it is sent, when its conditions hold.
1189
+ const holds = (condition: string) => {
1190
+ const match = condition.match(/^(\d+)=(.*)$/);
1191
+ return !match || String(message.args[Number(match[1]) - 1]) === match[2];
1192
+ };
1193
+ for (const { slot } of this.messageEvents.filter(one => one.name === name && one.conditions.every(holds))) {
1194
+ if (!prevented) this.withCallArgs([receiver], () => this.call(slot, [receiver, 0, 0, 0], 0));
1195
+ }
1023
1196
  return { prevented, args: message.args };
1024
1197
  } finally {
1025
1198
  this.hookedMessage = previous;
@@ -1041,7 +1214,7 @@ export class FakeServer {
1041
1214
  * function's arguments after the entity, a float as a number and a vector
1042
1215
  * as an array of three.
1043
1216
  */
1044
- fireHam(event: string, entity: FakeEntity | number, args: Value[] = [], options: { result?: Value } = {}): HookResult {
1217
+ fireHam(event: string, entity: FakeEntity | number, args: HookArg[] = [], options: { result?: Value } = {}): HookResult {
1045
1218
  const shape = tables().hooks.get(event);
1046
1219
  if (shape?.ham === undefined) throw new Error(`no Ham Sandwich function under the event "${event}" in as/hooks.ts`);
1047
1220
  return this.runHam(shape, typeof entity === 'number' ? entity : entity.id, args, options.result);
@@ -1051,7 +1224,7 @@ export class FakeServer {
1051
1224
  * fireHam's work, and ExecuteHamB's: `args` without the entity.
1052
1225
  * @internal
1053
1226
  */
1054
- runHam(shape: HookShape, id: number, args: (Value | number[])[], result?: Value): HookResult {
1227
+ runHam(shape: HookShape, id: number, args: HookArg[], result?: Value): HookResult {
1055
1228
  const classname = this.entities.get(id)?.classname ?? (id >= 1 && id <= this.maxPlayers ? 'player' : '');
1056
1229
  const all = [id, ...args];
1057
1230
  const cells: ArgValue[] = all.map((a, i) => Array.isArray(a)
@@ -1063,7 +1236,7 @@ export class FakeServer {
1063
1236
  : shape.floats.has(i) ? floatBits(a) : a | 0);
1064
1237
  const handed = cells.slice(0, 4).map(c => typeof c === 'number' ? c : 0);
1065
1238
  const nothing: Value = shape.answer === constant('ATYPE_STRING') ? '' : 0;
1066
- const chain: Chain = { shape, answer: nothing, args: all.map(a => Array.isArray(a) ? 0 : a) };
1239
+ const chain: Chain = { shape, answer: nothing, args: all };
1067
1240
  const listeners = (post: boolean) => this.hams.get(`${shape.ham}:${classname}:${post ? 'post' : 'pre'}`) ?? [];
1068
1241
 
1069
1242
  const previous = this.chain;
@@ -1281,7 +1454,7 @@ export class FakeServer {
1281
1454
 
1282
1455
  /**
1283
1456
  * A plugin calling another plugin's `export function` native
1284
- * through its ~/natives wrapper: the cells it pushed - text and buffers as
1457
+ * through its @amxts/core/natives wrapper: the cells it pushed - text and buffers as
1285
1458
  * addresses in its memory - turned into the frame the native reads, and
1286
1459
  * what the native wrote into a buffer copied back, as AMX Mod X does.
1287
1460
  * @internal
@@ -1344,6 +1517,9 @@ export class FakeServer {
1344
1517
 
1345
1518
  /** Fire in module.cpp: one handler, its outcome, and the state put back. @internal */
1346
1519
  call(handler: Handler, args: number[], fallback: number): number {
1520
+ // An orphan answers PLUGIN_CONTINUE, as in n_callback: a command passes on to the plugin loaded since.
1521
+ if (handler.plugin.unloaded) return 0;
1522
+ if ((handler as Slot).off) return fallback;
1347
1523
  const fn = handler.plugin.table.get(handler.fn);
1348
1524
  const cells = handler.shape === SHAPE_WIDE ? [0, 1, 2, 3].map(i => args[i] ?? 0) : [args[0] ?? 0];
1349
1525
  if (handler.tag) cells.unshift(handler.tag);
@@ -1431,6 +1607,208 @@ export class FakeServer {
1431
1607
  }
1432
1608
  }
1433
1609
 
1610
+ /**
1611
+ * Takes a plugin off the server, as `amxts_reload` does before it loads
1612
+ * the plugins again: its timers are removed, and a call to one of its
1613
+ * other slots answers nothing, until a plugin of the same file asks for
1614
+ * the same key and takes the slot back. `load` the file again for the
1615
+ * reload's second half.
1616
+ */
1617
+ unload(plugin: PluginInstance): void {
1618
+ plugin.unloaded = true;
1619
+ this.plugins.splice(this.plugins.indexOf(plugin), 1);
1620
+ for (let i = this.tasks.length - 1; i >= 0; i--) {
1621
+ if (this.tasks[i].slot.plugin === plugin) this.tasks.splice(i, 1);
1622
+ }
1623
+
1624
+ // The modules it served answer nothing; the owners of those it called drop what it gave them (TellOwners).
1625
+ for (const service of this.services.filter(each => each.plugin === plugin)) service.plugin = null;
1626
+ const owners = this.services.filter(each => plugin.uses.includes(each.name) && each.plugin && each.plugin !== plugin);
1627
+ const gone = new Uint8Array(new Int32Array([RPC_GONE, 0]).buffer);
1628
+ for (const owner of owners) this.request(owner.plugin!, plugin.run, gone);
1629
+ }
1630
+
1631
+ /** A menu by its id, or the error AMX Mod X gives for another number. @internal */
1632
+ menu(id: number): FakeMenu {
1633
+ const menu = this.menus.get(id);
1634
+ if (!menu) throw new Error(`Invalid menu id ${id}`);
1635
+ return menu;
1636
+ }
1637
+
1638
+ /** menu_create: a menu of seven items a page, with Back, More and Exit. @internal */
1639
+ createMenu(title: string, handler: Slot): number {
1640
+ const id = this.menuIds++;
1641
+ this.menus.set(id, { id, title, handler, items: [], perPage: 7, exit: true, back: 'Back', next: 'More', exitName: 'Exit', numberColor: '\\r' });
1642
+ return id;
1643
+ }
1644
+
1645
+ /** menu_destroy: whoever has it open sees it close, and its handler hears MENU_EXIT. @internal */
1646
+ destroyMenu(id: number): void {
1647
+ const menu = this.menu(id);
1648
+ this.menus.delete(id);
1649
+ for (const player of this.players.filter(one => one.menu?.menu === id)) {
1650
+ player.menu = null;
1651
+ this.menuAnswer(menu, player, constant('MENU_EXIT'));
1652
+ }
1653
+ }
1654
+
1655
+ /**
1656
+ * menu_display: a page drawn for a player. An item with a callback is
1657
+ * asked as AMX Mod X asks it, as it is drawn - the callback may name it
1658
+ * for him, and its answer greys it out. Another menu he had open is
1659
+ * closed first: its handler hears MENU_EXIT. @internal
1660
+ */
1661
+ displayMenu(player: FakePlayer, menu: FakeMenu, page: number): void {
1662
+ const before = player.menu;
1663
+ player.menu = null;
1664
+ const replaced = before && before.menu !== menu.id ? this.menus.get(before.menu) : undefined;
1665
+ if (replaced) this.menuAnswer(replaced, player, constant('MENU_EXIT'));
1666
+
1667
+ const perPage = menu.perPage === 0 ? Math.min(menu.items.length, 10) : menu.perPage;
1668
+ const pages = Math.max(1, Math.ceil(menu.items.length / Math.max(perPage, 1)));
1669
+ const at = Math.min(Math.max(page, 0), pages - 1);
1670
+ const lines = [`${menu.title}${pages > 1 ? ` ${at + 1}/${pages}` : ''}`, ''];
1671
+ const keys: number[] = [];
1672
+ const disabled: number[] = [];
1673
+ const line = (key: number, text: string) => lines.push(`${menu.numberColor}${key}.\\w ${text}`);
1674
+
1675
+ for (let i = 0; i < perPage && at * perPage + i < menu.items.length; i++) {
1676
+ const item = at * perPage + i;
1677
+ const callback = this.menuCallbacks[menu.items[item].callback];
1678
+ const args = [player.id, menu.id, item];
1679
+ const answer = callback ? this.withCallArgs(args, () => this.call(callback, args, constant('ITEM_IGNORE'))) : constant('ITEM_IGNORE');
1680
+ const key = (i + 1) % 10;
1681
+ keys.push(key);
1682
+ if (answer !== constant('ITEM_DISABLED')) {
1683
+ line(key, menu.items[item].name);
1684
+ continue;
1685
+ }
1686
+ disabled.push(key);
1687
+ lines.push(`\\d${key}. ${menu.items[item].name}`);
1688
+ }
1689
+
1690
+ const paged = menu.perPage > 0;
1691
+ if (paged) lines.push('');
1692
+ if (paged && at > 0) {
1693
+ keys.push(8);
1694
+ line(8, menu.back);
1695
+ }
1696
+ if (paged && at < pages - 1) {
1697
+ keys.push(9);
1698
+ line(9, menu.next);
1699
+ }
1700
+ if (paged && menu.exit) {
1701
+ keys.push(0);
1702
+ line(0, menu.exitName);
1703
+ }
1704
+
1705
+ player.menu = { menu: menu.id, page: at, text: lines.join('\n'), keys, disabled };
1706
+ }
1707
+
1708
+ /** A key on a menu of AMX Mod X's own: an item to the handler, Back and More a page, Exit MENU_EXIT. Whether the menu took it. @internal */
1709
+ selectMenu(player: FakePlayer, key: number): boolean {
1710
+ const screen = player.menu;
1711
+ const menu = screen ? this.menus.get(screen.menu) : undefined;
1712
+ if (!screen || !menu || !screen.keys.includes(key)) return false;
1713
+
1714
+ const paged = menu.perPage > 0;
1715
+ const turn = paged && key === 8 ? -1 : paged && key === 9 ? 1 : 0;
1716
+ if (turn !== 0 || screen.disabled.includes(key)) {
1717
+ this.displayMenu(player, menu, screen.page + turn);
1718
+ return true;
1719
+ }
1720
+
1721
+ player.menu = null;
1722
+ const perPage = paged ? menu.perPage : 10;
1723
+ this.menuAnswer(menu, player, paged && key === 0 ? constant('MENU_EXIT') : screen.page * perPage + (key === 0 ? 10 : key) - 1);
1724
+ return true;
1725
+ }
1726
+
1727
+ private menuAnswer(menu: FakeMenu, player: FakePlayer, item: number): void {
1728
+ const args = [player.id, menu.id, item];
1729
+ this.withCallArgs(args, () => this.call(menu.handler, args, 0));
1730
+ }
1731
+
1732
+ /**
1733
+ * A line of the game's log, as the engine hands it to AMX Mod X: split
1734
+ * into its arguments as AMX Mod X splits it, and every logevent callback
1735
+ * whose filters it passes is called.
1736
+ *
1737
+ * ```ts
1738
+ * server.gameLog('World triggered "Round_End"');
1739
+ * server.gameLog(`"${alice.name}<${alice.userid}><STEAM_0:0:1><TERRORIST>" triggered "Planted_The_Bomb"`);
1740
+ * ```
1741
+ */
1742
+ gameLog(line: string): void {
1743
+ // AMX Mod X keeps one parsed line: one logged while a callback runs
1744
+ // replaces it, and the walk of the outer line's callbacks goes on with it.
1745
+ this.logLine = line;
1746
+ this.logArgs = splitLog(line);
1747
+ const argc = this.logArgs.length;
1748
+ const passes = (filter: string) => {
1749
+ const match = filter.match(/^(\d+)([=&!])(.*)$/);
1750
+ if (!match) return true;
1751
+ const value = this.logArgs[Number(match[1])] ?? '';
1752
+ return match[2] === '=' ? value === match[3] : match[2] === '&' ? value.includes(match[3]) : value !== match[3];
1753
+ };
1754
+ for (const event of [...this.logEvents]) {
1755
+ if (event.argc === argc && event.filters.every(passes)) this.withCallArgs([], () => this.call(event.slot, [0, 0, 0, 0], 0));
1756
+ }
1757
+ }
1758
+
1759
+ /**
1760
+ * A fakemeta function the game calls, through the register_forward
1761
+ * callbacks of it: the pre ones, then, unless one of them superseded it,
1762
+ * the post ones with `result` as get_orig_retval reads it. Returns whether
1763
+ * it was superseded and what a callback answered with forward_return.
1764
+ *
1765
+ * ```ts
1766
+ * server.fireForward('FM_EmitSound', [alice.id, 2, 'player/die1.wav', 1.0, 0.8, 0, 100]);
1767
+ * ```
1768
+ */
1769
+ fireForward(name: string, args: ArgValue[], result = 0): { superseded: boolean; answer: number | string | null } {
1770
+ const fn = constant(name);
1771
+ const handed = args.slice(0, 4).map(a => typeof a === 'number' ? a : 0);
1772
+ const cells = args.map(a => typeof a === 'number' && !Number.isInteger(a) ? floatBits(a) : a);
1773
+ this.forwardAnswer = null;
1774
+ let superseded = false;
1775
+ for (const slot of [...(this.fakemetaForwards.get(`${fn}:pre`) ?? [])]) {
1776
+ if (this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback)) >= FMRES_SUPERCEDE) superseded = true;
1777
+ }
1778
+ if (!superseded) {
1779
+ this.origRetval = result;
1780
+ for (const slot of [...(this.fakemetaForwards.get(`${fn}:post`) ?? [])]) this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
1781
+ }
1782
+ return { superseded, answer: this.forwardAnswer };
1783
+ }
1784
+
1785
+ /** Switches a hook off or on by the handle RegisterHookChain or RegisterHam gave. @internal */
1786
+ switchHook(handle: number, on: boolean): number {
1787
+ const slot = this.hookSlots.get(handle);
1788
+ if (!slot) throw new Error(`the fake server knows no hook handle ${handle}`);
1789
+ slot.off = !on;
1790
+ return 1;
1791
+ }
1792
+
1793
+ /**
1794
+ * Whether a game event's hook calls the plugin - reapi's chain, or Ham
1795
+ * Sandwich's function on a class: false while the plugin has switched it
1796
+ * off, for having no listener; undefined when it was never registered.
1797
+ *
1798
+ * ```ts
1799
+ * server.hooked('resetMaxSpeed'); // the chain, before the game
1800
+ * server.hooked('spawn', { classname: 'player', post: true });
1801
+ * ```
1802
+ */
1803
+ hooked(event: string, options: { post?: boolean; classname?: string } = {}): boolean | undefined {
1804
+ const shape = tables().hooks.get(event);
1805
+ if (!shape) throw new Error(`no hookchain named "${event}" in as/hooks.ts`);
1806
+ const slots = options.classname !== undefined
1807
+ ? this.hams.get(`${shape.ham}:${options.classname}:${options.post ? 'post' : 'pre'}`)
1808
+ : this.hookchains.get(shape.kind)?.[options.post ? 'post' : 'pre'];
1809
+ return slots?.length ? slots.some(slot => !slot.off) : undefined;
1810
+ }
1811
+
1434
1812
  /** A slot by the public name a native was given: "__amxts_cb3". @internal */
1435
1813
  slotByPublic(name: string): Slot {
1436
1814
  const match = name.match(/^__amxts_cb(\d+)$/);
@@ -1507,11 +1885,21 @@ export class FakeServer {
1507
1885
  }
1508
1886
 
1509
1887
  /** Modules a plugin runs for the others (module.cpp's g_services), and the bytes of the call in flight. @internal */
1510
- private services: { name: string; hash: number; plugin: number }[] = [];
1888
+ private services: { name: string; hash: number; plugin: PluginInstance | null }[] = [];
1511
1889
  private rpcRequest = new Uint8Array(0);
1512
1890
  private rpcReply = new Uint8Array(0);
1513
1891
  private rpcResult = new Uint8Array(0);
1514
1892
 
1893
+ /** Runs a request in `callee`'s __amxts_rpc, from the run `from` (module.cpp's RunRequest): its answer, or null. */
1894
+ private request(callee: PluginInstance, from: number, request: Uint8Array<ArrayBuffer>): Uint8Array | null {
1895
+ const rpc = callee.instance.exports.__amxts_rpc;
1896
+ if (!rpc) return null;
1897
+ this.rpcRequest = request;
1898
+ this.rpcReply = new Uint8Array(0);
1899
+ this.within(callee, () => rpc(this.rpcRequest.length, from));
1900
+ return this.rpcReply;
1901
+ }
1902
+
1515
1903
  /**
1516
1904
  * The externals the facade declares, as module.cpp registers
1517
1905
  * them in g_wasmNatives. Each is called with the plugin that called it.
@@ -1521,30 +1909,32 @@ export class FakeServer {
1521
1909
  // Shared modules: module.cpp's w_serve, w_owner, w_rpc and the three that move the bytes.
1522
1910
  amxts_serve(this: FakeServer, plugin: PluginInstance, name: number, hash: number) {
1523
1911
  const wanted = plugin.memory.string(name);
1524
- const index = this.plugins.indexOf(plugin);
1525
1912
  const known = this.services.find(s => s.name === wanted);
1526
- if (known) Object.assign(known, { hash, plugin: index });
1527
- else this.services.push({ name: wanted, hash, plugin: index });
1913
+ if (known) Object.assign(known, { hash, plugin });
1914
+ else this.services.push({ name: wanted, hash, plugin });
1528
1915
  },
1529
1916
 
1530
1917
  amxts_owner(this: FakeServer, plugin: PluginInstance, name: number, hash: number) {
1531
1918
  const wanted = plugin.memory.string(name);
1919
+ if (!plugin.uses.includes(wanted)) plugin.uses.push(wanted);
1532
1920
  const index = this.services.findIndex(s => s.name === wanted);
1533
- if (index < 0 || this.services[index].plugin < 0) return -1;
1921
+ if (index < 0 || !this.services[index].plugin) return -1;
1534
1922
  return this.services[index].hash === hash ? index : -2;
1535
1923
  },
1536
1924
 
1537
1925
  amxts_rpc(this: FakeServer, plugin: PluginInstance, service: number, target: number, data: number, length: number) {
1538
- const index = service >= 0 ? (this.services[service]?.plugin ?? -1) : target;
1539
- const callee = this.plugins[index];
1540
- const rpc = callee?.instance.exports.__amxts_rpc;
1541
- if (!rpc) return -1;
1542
- this.rpcRequest = new Uint8Array(plugin.instance.exports.memory.buffer, data, length).slice();
1543
- this.rpcReply = new Uint8Array(0);
1544
- this.within(callee, () => rpc(length, this.plugins.indexOf(plugin)));
1545
- const answer = new Uint8Array(4 + this.rpcReply.length);
1546
- new DataView(answer.buffer).setInt32(0, index, true);
1547
- answer.set(this.rpcReply, 4);
1926
+ const callee = service >= 0 ? this.services[service]?.plugin : this.plugins.find(one => one.run === target);
1927
+
1928
+ if (service < 0 && !callee && !plugin.toldGone) {
1929
+ plugin.toldGone = true;
1930
+ this.logLines.push(`[amxts] ${plugin.source} called back a function of a plugin that was unloaded or reloaded since - the call answers nothing`);
1931
+ }
1932
+
1933
+ const reply = callee ? this.request(callee, plugin.run, new Uint8Array(plugin.instance.exports.memory.buffer, data, length).slice()) : null;
1934
+ if (!callee || !reply) return -1;
1935
+ const answer = new Uint8Array(4 + reply.length);
1936
+ new DataView(answer.buffer).setInt32(0, callee.run, true);
1937
+ answer.set(reply, 4);
1548
1938
  this.rpcResult = answer;
1549
1939
  return answer.length;
1550
1940
  },
@@ -1563,11 +1953,58 @@ export class FakeServer {
1563
1953
  this.rpcResult = new Uint8Array(0);
1564
1954
  },
1565
1955
 
1956
+ // The network client: module.cpp's w_net_*, over Bun's fetch and FTP and SFTP clients (network.ts).
1957
+ net_open(this: FakeServer, plugin: PluginInstance, url: number) {
1958
+ return this.network.open(plugin, plugin.memory.string(url));
1959
+ },
1960
+ net_option(this: FakeServer, plugin: PluginInstance, id: number, name: number, value: number) {
1961
+ return this.network.option(plugin, id, plugin.memory.string(name), plugin.memory.string(value));
1962
+ },
1963
+ net_body(this: FakeServer, plugin: PluginInstance, id: number, data: number, length: number) {
1964
+ this.network.body(plugin, id, new Uint8Array(plugin.instance.exports.memory.buffer, data, length).slice());
1965
+ },
1966
+ net_send(this: FakeServer, plugin: PluginInstance, id: number, fn: number) {
1967
+ return this.network.send(plugin, id, fn);
1968
+ },
1969
+ net_cancel(this: FakeServer, plugin: PluginInstance, id: number) {
1970
+ this.network.cancel(plugin, id);
1971
+ },
1972
+ net_close(this: FakeServer, plugin: PluginInstance, id: number) {
1973
+ this.network.cancel(plugin, id);
1974
+ },
1975
+ net_status(this: FakeServer, plugin: PluginInstance, id: number) {
1976
+ return this.network.status(plugin, id);
1977
+ },
1978
+ net_redirects(this: FakeServer, plugin: PluginInstance, id: number) {
1979
+ return this.network.redirects(plugin, id);
1980
+ },
1981
+ net_text(this: FakeServer, plugin: PluginInstance, id: number, what: number, out: number, max: number) {
1982
+ return this.network.text(plugin, id, what, out, max);
1983
+ },
1984
+ net_size(this: FakeServer, plugin: PluginInstance, id: number) {
1985
+ return this.network.size(plugin, id);
1986
+ },
1987
+ net_read(this: FakeServer, plugin: PluginInstance, id: number, out: number, max: number) {
1988
+ return this.network.read(plugin, id, out, max);
1989
+ },
1990
+ net_reply(this: FakeServer, plugin: PluginInstance, id: number) {
1991
+ return this.network.reply(plugin, id);
1992
+ },
1993
+
1566
1994
  abort(this: FakeServer, plugin: PluginInstance, message: number, file: number, line: number, column: number) {
1567
1995
  const text = message ? plugin.memory.string(message) : 'assertion failed';
1568
1996
  throw new Error(`abort: ${text} (${file ? plugin.memory.string(file) : '?'}:${line}:${column})`);
1569
1997
  },
1570
1998
 
1999
+ // An error's stack: the server reads frames WAMR keeps, which a
2000
+ // plugin run here has none of - `stack` is the error's first line.
2001
+ stack_frames() {
2002
+ return 0;
2003
+ },
2004
+ stack_text() {
2005
+ return 0;
2006
+ },
2007
+
1571
2008
  seed() {
1572
2009
  return Date.now();
1573
2010
  },
@@ -1636,6 +2073,22 @@ export class FakeServer {
1636
2073
  list.push({ plugin, fn, shape });
1637
2074
  },
1638
2075
 
2076
+ // The plugin's handler of a forward taken off, once its event has no listener left.
2077
+ off(this: FakeServer, plugin: PluginInstance, name: number, fn: number) {
2078
+ const list = this.events.get(plugin.memory.string(name)) ?? [];
2079
+ for (let i = list.length - 1; i >= 0; i--) {
2080
+ if (list[i].plugin === plugin && list[i].fn === fn) list.splice(i, 1);
2081
+ }
2082
+ },
2083
+
2084
+ // on, for the calls whose argument `arg` is `value` alone - compared before the plugin, as module.cpp does.
2085
+ on_cell(this: FakeServer, plugin: PluginInstance, name: number, fn: number, shape: number, arg: number, value: number) {
2086
+ const event = plugin.memory.string(name);
2087
+ let list = this.events.get(event);
2088
+ if (!list) this.events.set(event, list = []);
2089
+ list.push({ plugin, fn, shape, where: { arg, value } });
2090
+ },
2091
+
1639
2092
  subscribe(this: FakeServer, plugin: PluginInstance, name: number, fn: number, tag: number) {
1640
2093
  const forward = plugin.memory.string(name);
1641
2094
  let list = this.subscriptions.get(forward);
@@ -1661,8 +2114,19 @@ export class FakeServer {
1661
2114
  this.withCallArgs(args, () => this.deliver(forward));
1662
2115
  },
1663
2116
 
2117
+ // A slot a reload left takes the same file's plugin back, as SLOT_REUSED says in module.cpp.
1664
2118
  slot(this: FakeServer, plugin: PluginInstance, fn: number, shape: number, key: number, fallback: number) {
1665
- return this.takeSlot(plugin, fn, shape, plugin.memory.string(key), fallback).index;
2119
+ const name = plugin.memory.string(key);
2120
+ const left = name ? this.slots.find(one => one.key === name && one.plugin.unloaded && one.plugin.source === plugin.source) : undefined;
2121
+ if (!left) return this.takeSlot(plugin, fn, shape, name, fallback).index;
2122
+ Object.assign(left, { plugin, fn, shape, fallback, tag: this.takeTag() });
2123
+ return left.index | SLOT_REUSED;
2124
+ },
2125
+
2126
+ // One of the plugin's slots switched off while what it delivers has no listener, or back on.
2127
+ slot_on(this: FakeServer, plugin: PluginInstance, index: number, on: number) {
2128
+ const slot = this.slots[index];
2129
+ if (slot?.plugin === plugin) slot.off = on === 0;
1666
2130
  },
1667
2131
 
1668
2132
  clcmd(this: FakeServer, plugin: PluginInstance, pattern: number, fn: number, flags: number, info: number, shape: number) {
@@ -1698,7 +2162,8 @@ export class FakeServer {
1698
2162
  let chain = this.hookchains.get(kind);
1699
2163
  if (!chain) this.hookchains.set(kind, chain = { pre: [], post: [] });
1700
2164
  (post ? chain.post : chain.pre).push(slot);
1701
- return ++this.hookHandles;
2165
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2166
+ return this.hookSlots.size;
1702
2167
  },
1703
2168
 
1704
2169
  // RegisterHam: kept by the function's id and the class, for hamCall().
@@ -1708,7 +2173,8 @@ export class FakeServer {
1708
2173
  const list = this.hams.get(key) ?? [];
1709
2174
  list.push(slot);
1710
2175
  this.hams.set(key, list);
1711
- return ++this.hookHandles;
2176
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2177
+ return this.hookSlots.size;
1712
2178
  },
1713
2179
 
1714
2180
  tag(this: FakeServer, _plugin: PluginInstance, tag: number) {
@@ -1788,6 +2254,64 @@ export class FakeServer {
1788
2254
  return length;
1789
2255
  },
1790
2256
 
2257
+ // Entity fields and members, as the module reads them where the game
2258
+ // keeps them (runtime/src/fields.h): an entvar by its offset in
2259
+ // entvars_t, a member by the slot member_slot gave its gamedata name,
2260
+ // the game rules' by the id -1.
2261
+ ent_get(this: FakeServer, plugin: PluginInstance, id: number, offset: number) {
2262
+ const at = tables().entvarAt.get(offset);
2263
+ return at ? fieldCell(this.entities.get(id), at.field, at.component) : 0;
2264
+ },
2265
+
2266
+ ent_set(this: FakeServer, plugin: PluginInstance, id: number, offset: number, cell: number) {
2267
+ const at = tables().entvarAt.get(offset);
2268
+ if (at) setFieldCell(this.entities.get(id), at.field, cell, at.component);
2269
+ },
2270
+
2271
+ ent_entity(this: FakeServer, plugin: PluginInstance, id: number, offset: number) {
2272
+ const at = tables().entvarAt.get(offset);
2273
+ return at ? fieldCell(this.entities.get(id), at.field) : 0;
2274
+ },
2275
+
2276
+ ent_set_entity(this: FakeServer, plugin: PluginInstance, id: number, offset: number, index: number) {
2277
+ const at = tables().entvarAt.get(offset);
2278
+ if (at) setFieldCell(this.entities.get(id), at.field, Math.max(index, 0));
2279
+ },
2280
+
2281
+ member_slot(this: FakeServer, plugin: PluginInstance, className: number, name: number) {
2282
+ const key = `${plugin.memory.string(className)}::${plugin.memory.string(name)}`;
2283
+ const field = tables().memberNamed.get(key);
2284
+ if (field === undefined) throw new Error(`no reapi member is ${key} in the gamedata`);
2285
+ const slot = this.memberSlots.indexOf(field);
2286
+ return slot >= 0 ? slot : this.memberSlots.push(field) - 1;
2287
+ },
2288
+
2289
+ member_get(this: FakeServer, plugin: PluginInstance, id: number, slot: number, element: number) {
2290
+ const field = this.memberSlots[slot];
2291
+ if (id === -1) return this.rules.get(field) ?? 0;
2292
+ return fieldCell(this.entities.get(id), field, element);
2293
+ },
2294
+
2295
+ member_set(this: FakeServer, plugin: PluginInstance, id: number, slot: number, element: number, cell: number) {
2296
+ const field = this.memberSlots[slot];
2297
+ if (id === -1) this.rules.set(field, cell);
2298
+ else setFieldCell(this.entities.get(id), field, cell, element);
2299
+ },
2300
+
2301
+ member_text(this: FakeServer, plugin: PluginInstance, id: number, slot: number, out: number, max: number) {
2302
+ const value = this.entities.get(id)?.fields.get(FakeEntity.key(this.memberSlots[slot]));
2303
+ return plugin.memory.setUtf8(out, max, typeof value === 'string' ? value : '');
2304
+ },
2305
+
2306
+ member_set_text(this: FakeServer, plugin: PluginInstance, id: number, slot: number, text: number) {
2307
+ this.entities.get(id)?.fields.set(FakeEntity.key(this.memberSlots[slot]), plugin.memory.string(text));
2308
+ },
2309
+
2310
+ // The fake keeps its game rules where the module would find them.
2311
+ game_rules() {
2312
+ return 1;
2313
+ },
2314
+
1791
2315
  // The fields plugins add to Player: the imports their generated accessors call.
1792
2316
  player_data_get(this: FakeServer, plugin: PluginInstance, id: number, key: number) {
1793
2317
  return this.playerNumber(id, plugin.memory.string(key));
@@ -1811,7 +2335,7 @@ export class FakeServer {
1811
2335
  this.setPlayerData(id, plugin.memory.string(key), plugin.memory.string(value), true);
1812
2336
  },
1813
2337
 
1814
- // playerchange: the module wakes a plugin for the fields it listens for.
2338
+ // playerChange: the module wakes a plugin for the fields it listens for.
1815
2339
  player_change_listen(this: FakeServer, plugin: PluginInstance, field: number, fn: number) {
1816
2340
  const name = plugin.memory.string(field);
1817
2341
  const known = this.fieldListeners.find(one => one.plugin === plugin);