@amxts/core 0.2.4 → 0.3.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.
Files changed (71) hide show
  1. package/README.md +5 -5
  2. package/README.ru.md +5 -5
  3. package/as/amxts.d.ts +13 -3
  4. package/as/entities.ts +571 -501
  5. package/as/events.ts +3323 -557
  6. package/as/facade.ts +3079 -718
  7. package/as/fetch.ts +2 -2
  8. package/as/fs.ts +352 -59
  9. package/as/hlds.ts +121 -120
  10. package/as/hooks.ts +10585 -5177
  11. package/as/lib/check.ts +3 -2
  12. package/as/natives.ts +856 -1272
  13. package/as/promise.ts +37 -27
  14. package/as/vector.ts +94 -14
  15. package/includes/order.txt +17 -19
  16. package/node_modules/assemblyscript/std/assembly/index.d.ts +1 -1
  17. package/package.json +9 -6
  18. package/runtime/deps/assemblyscript/dist/asc.js +136 -42
  19. package/runtime/deps/assemblyscript/dist/assemblyscript.js +141 -141
  20. package/runtime/licenses/README.md +9 -7
  21. package/runtime/licenses/ReGameDLL-LICENSE +21 -0
  22. package/runtime/licenses/ReHLDS-LICENSE +21 -0
  23. package/runtime/licenses/zlib-LICENSE +22 -0
  24. package/runtime/natives.txt +618 -523
  25. package/runtime/patches/README.md +87 -1
  26. package/runtime/patches/assemblyscript-0.28.20-amxts.patch +4838 -416
  27. package/runtime/patches/wamr-2.4.5-amxts.patch +417 -5
  28. package/scripts/auto-imports.ts +1 -1
  29. package/scripts/build-identity.ts +102 -65
  30. package/scripts/build-wasm.ts +12 -12
  31. package/scripts/check.ts +4 -1
  32. package/scripts/client-messages.ts +73 -1
  33. package/scripts/compile-cache.ts +11 -8
  34. package/scripts/compile-pool.ts +6 -3
  35. package/scripts/compile-worker.ts +23 -23
  36. package/scripts/compile.ts +142 -19
  37. package/scripts/docs/as/amxts.ts +10 -4
  38. package/scripts/docs/as/facade.ts +1779 -275
  39. package/scripts/docs/as/fetch.ts +2 -2
  40. package/scripts/docs/as/fs.ts +190 -6
  41. package/scripts/docs/as/vector.ts +81 -0
  42. package/scripts/docs/entities.ts +12 -0
  43. package/scripts/docs/events.ts +49 -13
  44. package/scripts/docs/game.ts +6 -0
  45. package/scripts/docs/messages.ts +10 -1
  46. package/scripts/hlds-events.ts +11 -11
  47. package/scripts/includes.ts +3 -4
  48. package/scripts/leaf-natives.ts +84 -0
  49. package/scripts/player-fields.ts +1 -1
  50. package/scripts/plugin-natives.ts +1 -1
  51. package/scripts/prebuilt.ts +41 -10
  52. package/scripts/project.ts +2 -3
  53. package/scripts/reapi-events.ts +9 -9
  54. package/scripts/server-env.ts +145 -0
  55. package/scripts/shared-modules.ts +23 -7
  56. package/scripts/system.ts +1 -1
  57. package/scripts/tracked-fs.ts +11 -2
  58. package/scripts/typed-commands.ts +153 -15
  59. package/scripts/upgrade-calls.ts +225 -0
  60. package/scripts/upgrade-names.ts +1 -4
  61. package/scripts/upgrade.ts +26 -11
  62. package/src/cli-api.mjs +3 -2
  63. package/src/system.mjs +21 -11
  64. package/src/testing/compile.ts +31 -8
  65. package/src/testing/coroutines.ts +0 -10
  66. package/src/testing/memory.ts +89 -2
  67. package/src/testing/natives.ts +67 -30
  68. package/src/testing/server.ts +1094 -155
  69. package/src/testing/tables.ts +23 -6
  70. package/src/typecheck.mjs +110 -0
  71. package/runtime/licenses/Bun-LICENSE.md +0 -84
@@ -1,22 +1,25 @@
1
1
  import type { PluginNative } from '../../scripts/plugin-natives';
2
2
  import type { Native, NativeCall } from './natives';
3
3
  import type { HookShape } from './tables';
4
- import { pawnLayout } from '../../scripts/plugin-natives';
5
4
  // A game server in TypeScript, standing where runtime/src/module.cpp and AMX
6
5
  // Mod X stand on a real one.
7
6
  //
8
7
  // A plugin's wasm imports two things from "env": the bridge externals the
9
8
  // facade declares (slot, clcmd, task, on, hook, call, arg_text, say_text ...)
10
9
  // and the natives as/natives.ts declares, which take Pawn cells. The bridge is
11
- // implemented here the way module.cpp implements it - callback slots, the
10
+ // implemented here the way module.cpp implements it - publicFor names, the
12
11
  // events registry, Fire with its outcome - and the natives in natives.ts,
13
12
  // against the players and entities below. An import neither covers throws,
14
13
  // naming itself, the first time the plugin calls it.
14
+ import { basename } from 'node:path';
15
+ import { inflateRawSync } from 'node:zlib';
16
+ import { pawnLayout } from '../../scripts/plugin-natives';
17
+ import { envProblems, parseDotenv } from '../../scripts/server-env';
15
18
  import { compile } from './compile';
16
- import { Coroutines, nextTaskId } from './coroutines';
19
+ import { Coroutines } from './coroutines';
17
20
  import { installKitFor } from './kits';
18
21
  import { bitsFloat, floatBits, Memory, utf8Fit } from './memory';
19
- import { fieldCell, NATIVES, setFieldCell, WEAPON_NAMES } from './natives';
22
+ import { AUTH_TYPES, clientCommand, fieldCell, giveWeapon, NATIVES, setFieldCell, switchTo, WEAPON_NAMES } from './natives';
20
23
  import { FakeNetwork } from './network';
21
24
  import { constant, tables } from './tables';
22
25
 
@@ -48,6 +51,13 @@ export function normalizePath(path: string): string {
48
51
  return path.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
49
52
  }
50
53
 
54
+ /** A path of the game folder, or null when it would leave it - absolute, a drive, a `..` - as the module's GamePath. */
55
+ function gamePath(path: string): string | null {
56
+ const normal = normalizePath(path);
57
+ if (normal === '' || path.startsWith('/') || path.startsWith('\\') || path.includes(':') || normal.split('/').includes('..')) return null;
58
+ return normal;
59
+ }
60
+
51
61
  /** A parameter's include default as the value Pawn passes when the call leaves it out. */
52
62
  function pawnDefaultValue(text: string | null): Value {
53
63
  if (text === null) return 0;
@@ -67,6 +77,13 @@ interface Handler {
67
77
  tag?: number;
68
78
  /** on_cell's filter: the handler hears the forward only when this argument is `value`. */
69
79
  where?: { arg: number; value: number };
80
+ /**
81
+ * The plugin's one listener, called in place of the handler with the
82
+ * event's object (Handler.via in module.cpp, set by on_direct and
83
+ * hook_direct): its table index, its closure's variables, the object, and
84
+ * whether the first cell is the player an async function runs under.
85
+ */
86
+ via?: { fn: number; env: number; arg: number; player: boolean };
70
87
  }
71
88
 
72
89
  /** A host public standing in for a handler: `__amxts_cb<index>`. */
@@ -81,11 +98,14 @@ interface Slot extends Handler {
81
98
  * answers the fallback without reaching the plugin.
82
99
  */
83
100
  off?: boolean;
101
+ /** A command's admin flags: a player with none of them does not reach it. */
102
+ access?: number;
84
103
  }
85
104
 
86
105
  interface Task {
87
106
  slot: Slot;
88
- id: number;
107
+ /** The timer's own slot, which task hands the plugin: the lowest free one, as module.cpp reuses them. */
108
+ timer: number;
89
109
  interval: number;
90
110
  due: number;
91
111
  repeat: boolean;
@@ -99,13 +119,17 @@ interface Cvar {
99
119
  hooks: Slot[];
100
120
  }
101
121
 
102
- /** The hookchain being run, for GetHookChainReturn and friends. */
122
+ /** The hooked call being run: what chain_set writes and arg(-1) reads. */
103
123
  export interface Chain {
104
124
  shape: HookShape;
105
125
  answer: Value;
106
126
  args: HookArg[];
107
- /** SetHookChainReturn ran: reapi lets a chain that answers be stopped only then. */
127
+ /** The arguments as cells, which arg() reads: a float as its bits, a vector as three. */
128
+ cells: ArgValue[];
129
+ /** A listener answered (chain_set(-1)): the game's own answer is not taken. */
108
130
  answered?: boolean;
131
+ /** A Ham Sandwich function's call: a boolean answer is a number, as Ham Sandwich gives it. */
132
+ ham?: boolean;
109
133
  }
110
134
 
111
135
  /** What firing a hookchain came to. */
@@ -128,9 +152,9 @@ export interface UserMessage {
128
152
  player: number;
129
153
  /** What was written, in order: bytes, shorts and longs as numbers, strings as text. */
130
154
  args: (number | string)[];
131
- /** For a temporary effect (`SVC_TEMPENTITY`): who it went to, as `MSG_*` - `0` everyone, `4` those who see `origin`, `8` one player. */
155
+ /** For the engine's own message (`SVC_TEMPENTITY`, `SVC_NEWMOVEVARS`, ...): who it went to, as `MSG_*` - `0` everyone, `4` those who see `origin`, `8` one player. */
132
156
  dest?: number;
133
- /** For a temporary effect: the point it is seen from, with `dest` `4`. */
157
+ /** For the engine's own message: the point it is seen from, with `dest` `4`. */
134
158
  origin?: number[];
135
159
  }
136
160
 
@@ -174,7 +198,10 @@ export interface MenuScreen {
174
198
  /** One line a player was shown. */
175
199
  export interface Message {
176
200
  variant: 'chat' | 'center' | 'console' | 'notify' | 'hud';
201
+ /** The text, colour codes taken out. */
177
202
  text: string;
203
+ /** The line as the client got it, colour codes `\x01`-`\x04` kept. */
204
+ raw: string;
178
205
  }
179
206
 
180
207
  export interface ServerOptions {
@@ -188,6 +215,8 @@ export interface ServerOptions {
188
215
  cvars?: Record<string, string>;
189
216
  /** Files in the game folder before the plugin loads, by path: `{ "addons/amxmodx/configs/x.ini": "..." }`. */
190
217
  files?: Record<string, string>;
218
+ /** The server's environment, which `env()` reads over `addons/amxts/.env`: `{ MYPLUGIN_TOKEN: "abc" }`. None unless said. */
219
+ env?: Record<string, string>;
191
220
  /**
192
221
  * The system the server runs on, as `@amxts/core/os` finds it: "win32" puts the amxts
193
222
  * module's amxts_amxx.dll in the modules folder. "linux" unless said.
@@ -195,8 +224,14 @@ export interface ServerOptions {
195
224
  platform?: 'win32' | 'linux';
196
225
  /** The server's time zone, what Date's local getters read: "Asia/Yerevan". This machine's unless said. */
197
226
  timeZone?: string;
198
- /** Whether reapi finds Reunion, so a player's `authType`, `protocol` and `authKey` are his own. Not unless said. */
227
+ /** Whether the server has Reunion, so a player's `authType`, `protocol` and `authKey` are his own: the first two through ReHLDS (`chains`), the key through reapi. Not unless said. */
199
228
  reunion?: boolean;
229
+ /**
230
+ * Whether the server has ReGameDLL and ReHLDS - their hookchains, which the
231
+ * module hooks itself, and ReGameDLL's API, through which it acts. As it has
232
+ * reapi (`modules`) unless said: a server without reapi is plain HLDS's model.
233
+ */
234
+ chains?: boolean;
200
235
  }
201
236
 
202
237
  export type TeamName = 'UNASSIGNED' | 'TERRORIST' | 'CT' | 'SPECTATOR';
@@ -228,20 +263,30 @@ export interface JoinOptions {
228
263
  const SHAPE_NARROW = 0;
229
264
  const SHAPE_WIDE = 1;
230
265
  /** A slot taken back after a reload: SLOT_REUSED in module.cpp. */
231
- const SLOT_REUSED = 0x10000;
266
+ const SLOT_REUSED = 0x40000000;
232
267
  /** A request that tells an owner a plugin which called it stopped: RPC_GONE in module.cpp, KIND_GONE in as/remote.ts. */
233
268
  const RPC_GONE = 2;
234
269
  const PLUGIN_HANDLED = 1;
235
- const HC_SUPERCEDE = 1;
236
- const HC_BREAK = 2;
237
- // A Ham Sandwich hook's answer that blocks the game's function, and fakemeta's.
238
- const HAM_SUPERCEDE = 4;
270
+ // What a hooked function's listener says: the game's function does not run;
271
+ // and the listeners after it do not either (OUTCOME_* in gamehooks.h).
272
+ const OUTCOME_BREAK = 2;
273
+ // fakemeta's answer that blocks the game's function.
239
274
  const FMRES_SUPERCEDE = 4;
240
275
  // A message argument's types, as get_msg_argtype answers.
241
276
  const ARG_BYTE = 1;
277
+ const ARG_ANGLE = 5;
242
278
  const ARG_COORD = 6;
243
279
  const ARG_STRING = 7;
244
280
 
281
+ // A message's destination, as the module hands it to a message's hook.
282
+ const MSG_ONE = 1;
283
+ const MSG_ALL = 2;
284
+
285
+ // The functions stock_hook takes, by number (STOCK_* in as/facade.ts), and
286
+ // the fakemeta function each is.
287
+ const STOCK_FUNCTIONS = ['FM_SetModel', 'FM_EmitSound', 'FM_Voice_SetClientListening', 'FM_PrecacheModel', 'FM_PrecacheSound', 'FM_PrecacheGeneric', 'FM_GetGameDescription'];
288
+ const STOCK_CVAR_ANSWER = 7;
289
+
245
290
  /**
246
291
  * Date#getTimezoneOffset() in a time zone: minutes from its wall clock at
247
292
  * that moment to UTC, UTC+4 being -240. This machine's without one.
@@ -303,11 +348,52 @@ export class FakeWeapon extends FakeEntity {
303
348
  constructor(server: FakeServer, id: number, readonly kind: string, owner: number) {
304
349
  super(server, id, kind);
305
350
  this.set('m_iId', WEAPON_NAMES.indexOf(kind));
351
+ this.set('m_Weapon_iPrimaryAmmoType', ammoTypeOf(kind));
306
352
  this.set('var_owner', owner);
307
353
  this.set('m_pPlayer', owner);
308
354
  }
309
355
  }
310
356
 
357
+ /** A player's backpack ammo by weapon name, read and written in his m_rgAmmo. */
358
+ class AmmoMap extends Map<string, number> {
359
+ constructor(private readonly owner: FakeEntity) {
360
+ super();
361
+ }
362
+
363
+ override get(weapon: string): number {
364
+ return ammoTypeOf(weapon) < 0 ? 0 : Number(this.owner.get('m_rgAmmo', ammoTypeOf(weapon)));
365
+ }
366
+
367
+ override set(weapon: string, amount: number): this {
368
+ if (ammoTypeOf(weapon) >= 0) this.owner.set('m_rgAmmo', amount, ammoTypeOf(weapon));
369
+ return this;
370
+ }
371
+ }
372
+
373
+ /** The kind of ammo a weapon takes, its place in m_rgAmmo, as the game numbers them; -1 for none. */
374
+ function ammoTypeOf(kind: string): number {
375
+ const at = AMMO_KINDS.findIndex(weapons => weapons.split(' ').includes(kind.replace('weapon_', '')));
376
+ return at < 0 ? -1 : at + 1;
377
+ }
378
+
379
+ /** The weapons each kind of ammo is for, from 1: .338 Magnum, 7.62 NATO, ... the bomb. */
380
+ const AMMO_KINDS = [
381
+ 'awp',
382
+ 'ak47 scout g3sg1',
383
+ 'm249',
384
+ 'aug sg550 galil famas m4a1 sg552',
385
+ 'm3 xm1014',
386
+ 'usp mac10 ump45',
387
+ 'p90 fiveseven',
388
+ 'deagle',
389
+ 'p228',
390
+ 'glock18 mp5navy tmp elite',
391
+ 'flashbang',
392
+ 'hegrenade',
393
+ 'smokegrenade',
394
+ 'c4',
395
+ ];
396
+
311
397
  /** Which of m_rgpPlayerItems a weapon goes in: 1 primary, 2 pistol, 3 knife, 4 grenades, 5 C4. */
312
398
  function slotOf(kind: string): number {
313
399
  if (kind === 'weapon_knife') return 3;
@@ -331,12 +417,16 @@ export class FakePlayer extends FakeEntity {
331
417
  flags: number;
332
418
  /** SPEAK_* flags: `muted` is SPEAK_MUTED. */
333
419
  speak = 0;
334
- /** Backpack ammo by weapon name. */
335
- readonly ammo = new Map<string, number>();
420
+ /** Backpack ammo by weapon name: his m_rgAmmo at the weapon's kind of ammo, as the game keeps it. */
421
+ readonly ammo: Map<string, number> = new AmmoMap(this);
336
422
  /** Every line he was shown, in order. */
337
423
  readonly messages: Message[] = [];
338
424
  /** What was run in his console: client_cmd, engclient_cmd. */
339
425
  readonly commands: string[] = [];
426
+ /** Whether his steps make no sound (player_silent). */
427
+ silentSteps = false;
428
+ /** When he connected, by the server's clock (`server.time`, milliseconds). */
429
+ readonly connectedAt: number;
340
430
  /** His userinfo keys - `lang` says his language. */
341
431
  readonly info = new Map<string, string>();
342
432
  /** The menu of AMX Mod X's own on his screen (menu_display), or null; `menuselect <key>` answers it. */
@@ -344,8 +434,22 @@ export class FakePlayer extends FakeEntity {
344
434
  /** His userid, the number `#12` names him by in a command: the server counts them up as players come. */
345
435
  readonly userid: number;
346
436
 
347
- constructor(server: FakeServer, id: number, public name: string, options: JoinOptions) {
437
+ /** His name. A new one reaches the plugins as AMX Mod X's does: their facades read it again. */
438
+ get name(): string {
439
+ return this.named;
440
+ }
441
+
442
+ set name(value: string) {
443
+ this.named = value;
444
+ this.set('var_netname', value);
445
+ this.server.countNameChange(this.id);
446
+ }
447
+
448
+ private named: string;
449
+
450
+ constructor(server: FakeServer, id: number, name: string, options: JoinOptions) {
348
451
  super(server, id, 'player');
452
+ this.named = name;
349
453
  this.alive = options.alive ?? true;
350
454
  this.bot = options.bot ?? false;
351
455
  this.steamId = options.steamId ?? (this.bot ? 'BOT' : `STEAM_0:0:${id}`);
@@ -355,6 +459,7 @@ export class FakePlayer extends FakeEntity {
355
459
  this.authKey = options.authKey ?? '';
356
460
  this.flags = flagBits(options.flags ?? 'z');
357
461
  this.userid = server.nextUserid();
462
+ this.connectedAt = server.time;
358
463
  this.team = options.team ?? 'CT';
359
464
  this.health = options.health ?? 100;
360
465
  this.armor = options.armor ?? 0;
@@ -387,6 +492,11 @@ export class FakePlayer extends FakeEntity {
387
492
 
388
493
  /** The chat lines he was shown, one a line, colour bytes taken out. */
389
494
  get chat(): string { return this.lines('chat'); }
495
+ /**
496
+ * The chat lines as the client got them, colour codes `\x01`-`\x04` kept:
497
+ * the client draws no colour in a line that does not start with one.
498
+ */
499
+ get chatRaw(): string { return this.messages.filter(m => m.variant === 'chat').map(m => m.raw).join('\n'); }
390
500
  /** What was printed in the middle of his screen. */
391
501
  get center(): string { return this.lines('center'); }
392
502
  /** What was printed in his console. */
@@ -402,7 +512,7 @@ export class FakePlayer extends FakeEntity {
402
512
  show(variant: Message['variant'], text: string): void {
403
513
  // The colour codes ^1-^4 a chat line carries are not text.
404
514
  // oxlint-disable-next-line no-control-regex
405
- this.messages.push({ variant, text: text.replace(/[\x01-\x04]/g, '') });
515
+ this.messages.push({ variant, text: text.replace(/[\x01-\x04]/g, ''), raw: text });
406
516
  }
407
517
 
408
518
  /** Forgets what he was shown, for a test that checks one step at a time. */
@@ -448,6 +558,21 @@ export class FakePlayer extends FakeEntity {
448
558
  return weapon;
449
559
  }
450
560
 
561
+ /** Takes a weapon out of his slots, as the game's RemovePlayerItem does: it is no longer his. */
562
+ takeWeapon(weapon: FakeEntity): void {
563
+ const slot = slotOf(weapon.classname);
564
+ const first = Number(this.get('m_rgpPlayerItems', slot));
565
+ if (first === weapon.id) {
566
+ this.set('m_rgpPlayerItems', Number(weapon.get('m_pNext')), slot);
567
+ } else {
568
+ for (let at = this.server.entities.get(first); at; at = this.server.entities.get(Number(at.get('m_pNext')))) {
569
+ if (Number(at.get('m_pNext')) === weapon.id) at.set('m_pNext', Number(weapon.get('m_pNext')));
570
+ }
571
+ }
572
+ weapon.set('m_pNext', 0);
573
+ if (Number(this.get('m_pActiveItem')) === weapon.id) this.set('m_pActiveItem', 0);
574
+ }
575
+
451
576
  /** Takes every weapon away. */
452
577
  stripWeapons(): void {
453
578
  for (const weapon of this.items) this.server.entities.delete(weapon.id);
@@ -477,12 +602,17 @@ export class FakePlayer extends FakeEntity {
477
602
 
478
603
  /**
479
604
  * 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.
605
+ * `menuselect <key>` goes to his menu of AMX Mod X's own first, as AMX Mod
606
+ * X takes it, and last to the menu a plugin's Menu shows him, as the
607
+ * module takes it; the key `0` or `10`. True if handled.
481
608
  */
482
609
  command(line: string): boolean {
483
610
  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);
611
+ const select = argv[0] === 'menuselect';
612
+ const key = Number(argv[1]) % 10;
613
+ const pressed = select ? this.server.takeMenuKey(this, key) : null;
614
+ if (select && this.menu && this.server.selectMenu(this, key)) return true;
615
+ return this.server.clientCommand(this, argv) || (pressed !== null && this.server.pressMenu(this, pressed));
486
616
  }
487
617
 
488
618
  /** Leaves the server: the disconnect events, then the slot is free. */
@@ -491,6 +621,7 @@ export class FakePlayer extends FakeEntity {
491
621
  const reason = options.reason ?? 'Client sent \'drop\'';
492
622
  this.server.fire('client_disconnected', this.id, dropped, reason, 192);
493
623
  this.server.clearPlayerData(this.id);
624
+ this.server.shownMenus.delete(this.id);
494
625
  this.server.fire('client_disconnect', this.id);
495
626
  this.connected = false;
496
627
  this.alive = false;
@@ -520,6 +651,10 @@ function unsimulated(name: string): Error {
520
651
  }
521
652
 
522
653
  /** A command line as the engine splits it: on spaces, a quoted part kept whole. */
654
+ function addTo(commands: Map<string, Slot[]>, name: string, slot: Slot): void {
655
+ commands.set(name, [...(commands.get(name) ?? []), slot]);
656
+ }
657
+
523
658
  function splitCommand(line: string): string[] {
524
659
  return [...line.matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2]);
525
660
  }
@@ -539,6 +674,12 @@ export function splitLog(line: string): string[] {
539
674
  /** The runs handed out so far: each plugin loaded is a new one. */
540
675
  let runs = 0;
541
676
 
677
+ /** A trace's start and end, six numbers (f64) where the plugin's facade put them. */
678
+ function tracePoints(plugin: PluginInstance, at: number): number[][] {
679
+ const numbers = Array.from({ length: 6 }, (_, i) => plugin.memory.number(at + i * 8));
680
+ return [numbers.slice(0, 3), numbers.slice(3)];
681
+ }
682
+
542
683
  export class PluginInstance {
543
684
  instance: any;
544
685
  memory!: Memory;
@@ -552,6 +693,10 @@ export class PluginInstance {
552
693
  readonly uses: string[] = [];
553
694
  /** It called back a function of a plugin unloaded since, and was told so: once. */
554
695
  toldGone = false;
696
+ /** Where its facade's table of the slots a new player took is (player_slots); 0 until it gives one. */
697
+ playerSlots = 0;
698
+ /** Where its facade's count of each slot's name changes is (player_names); 0 until it gives one. */
699
+ playerNames = 0;
555
700
 
556
701
  /** The coroutine scheduler, for a plugin that awaits; null for one that does not. */
557
702
  readonly coroutines: Coroutines | null;
@@ -584,12 +729,13 @@ export class PluginInstance {
584
729
  return (...args: number[]) => bridge.call(this.server, this, ...args);
585
730
  }
586
731
 
587
- if (this.server.hasNative(name)) return (...args: number[]) => this.server.callNativeImpl(this, name, args);
732
+ const marks = tables().crossings.get(name) ?? [];
733
+ if (this.server.hasNative(name)) return (...args: number[]) => this.memory.across(marks, args, cells => this.server.callNativeImpl(this, name, cells));
588
734
 
589
735
  // Another plugin's `export function`, called through @amxts/core/natives as AMX
590
736
  // Mod X would route it; whether one exports it is known only at the call.
591
- return (...cells: number[]) => {
592
- if (this.server.exported.has(name)) return this.server.callFromPlugin(this, name, cells);
737
+ return (...args: number[]) => {
738
+ if (this.server.exported.has(name)) return this.memory.across(marks, args, cells => this.server.callFromPlugin(this, name, cells));
593
739
  throw unsimulated(name);
594
740
  };
595
741
  }
@@ -609,8 +755,30 @@ export class FakeServer {
609
755
  readonly map: string;
610
756
  readonly maxPlayers: number;
611
757
  readonly modules: Set<string>;
758
+ /** The GeoIP database, by address: `server.countries.set("1.2.3.4", { code: "DE", name: "Germany" })`. */
759
+ readonly countries = new Map<string, { code: string; name: string }>();
760
+ /** The server's environment, which `env()` reads over `addons/amxts/.env` (a file in `files`). */
761
+ env: Record<string, string>;
762
+ /** The model a plugin put on each player (player.model), by his id. */
763
+ readonly models = new Map<number, string>();
764
+ /** The entity each player sees through (player.view), by his id; 0 or none for his own eyes. */
765
+ readonly views = new Map<number, number>();
766
+ /** The maps the fake server has: map_valid and change_level know these. */
767
+ readonly maps: string[] = ['de_dust2', 'de_inferno', 'cs_office'];
768
+ /**
769
+ * Walls of the fake world a trace stops at: a plane at `x`, with the
770
+ * entity a trace that meets it hits (0, the world, when left out) and the
771
+ * hit group.
772
+ */
773
+ readonly walls: { x: number; entity?: number; hitGroup?: number }[] = [];
774
+ /** What plugins asked of plugins (server.plugins), in order: "stop shop.aot", "reload shop.aot". */
775
+ readonly pluginActions: string[] = [];
776
+ /** What the game rules were asked to run, in order: "restartRound", "checkWinConditions". */
777
+ readonly rulesRuns: string[] = [];
612
778
  /** Whether reapi finds Reunion (has_reunion). */
613
779
  readonly reunion: boolean;
780
+ /** Whether the module finds ReGameDLL's and ReHLDS's hookchains (game_api). */
781
+ readonly chains: boolean;
614
782
  /** Milliseconds since the map started; advance() moves it. */
615
783
  time = 0;
616
784
  /** Date.now() when the map started. */
@@ -666,13 +834,28 @@ export class FakeServer {
666
834
  readonly exported = new Map<string, Handler>();
667
835
  readonly cvars = new Map<string, Cvar>();
668
836
  readonly vaultHandles: string[] = [];
837
+ /** The Storages plugins opened, by handle: their names. */
838
+ readonly storeNames: string[] = [];
839
+ /** When each Storage key was last set, by "name\0key": the fake's clock, in seconds. */
840
+ readonly storeTimes = new Map<string, number>();
669
841
  readonly forwardHandles: { name: string; types: number[] }[] = [];
670
- hud = { color: [200, 100, 0], x: -1, y: 0.35, hold: 12, channel: -1 };
842
+ /** How the last HUD message was sent: its colour, place, hold, channel (-1 automatic), line (0 none) and size. */
843
+ hud = { color: [200, 100, 0], x: -1, y: 0.35, hold: 12, channel: -1, line: 0, large: false };
844
+ /** The HUD lines plugins made. */
845
+ hudLines = 0;
671
846
 
672
847
  /** The game rules' members a plugin set (set_member_game), by member. */
673
848
  readonly rules = new Map<number, number>();
674
849
  /** The members plugins asked member_slot for, by slot: each one's reapi constant. */
675
850
  private readonly memberSlots: number[] = [];
851
+ /**
852
+ * What playbackEvent's listeners asked of the last event `fire` raised:
853
+ * stopped (`preventDefault()`), or played only to these player ids
854
+ * (`recipients`), `null` for all it reaches.
855
+ */
856
+ playback: { blocked: boolean; recipients: number[] | null } = { blocked: false, recipients: null };
857
+ /** The map's light, as `server.lightStyle` last set it: `"m"` the map's own. */
858
+ lightStyle = 'm';
676
859
  /** Rounds a plugin ended with rg_round_end, in order. */
677
860
  readonly roundEnds: { status: number; event: number; delay: number; message: string; sound: string; trigger: boolean }[] = [];
678
861
  /** Sounds played: from an entity (emit_sound, rh_emit_sound2), or to a player alone (SendAudio, rg_send_audio). */
@@ -682,6 +865,12 @@ export class FakeServer {
682
865
  * went to (0 for everyone), and what was written, write_* by write_*.
683
866
  */
684
867
  readonly userMessages: UserMessage[] = [];
868
+ /**
869
+ * The player's movement a movement event (`airMove`, `jumpMovement`, ...)
870
+ * reads: `event.touchCount` and `event.frameTime`. Set it before
871
+ * fireHook.
872
+ */
873
+ readonly playerMove = { touchCount: 0, frameTime: 0 };
685
874
  /** The ids get_user_msgid handed out, by name - ShowMenu's is the game's 96. @internal */
686
875
  readonly messageIds = new Map<string, number>([['ShowMenu', 96]]);
687
876
  /** The message between message_begin and message_end. @internal */
@@ -704,10 +893,16 @@ export class FakeServer {
704
893
  readonly hamCalls: { fn: number; entity: number; args: number[]; hooks: boolean }[] = [];
705
894
  /** register_message's callbacks, by the message's id. */
706
895
  readonly messageHooks = new Map<number, Slot[]>();
896
+ /** The module's hooks of a message (msg_hook), by its id: they hear it after register_message's. @internal */
897
+ readonly moduleMessageHooks = new Map<number, Slot[]>();
707
898
  /** The message register_message's callbacks are reading and writing: its arguments and their ARG_* types. @internal */
708
899
  hookedMessage: { args: (number | string)[]; types: number[] } | null = null;
709
900
  /** register_touch's callbacks. */
710
901
  readonly touches: { touched: string; toucher: string; slot: Slot }[] = [];
902
+ /** The entityState listeners' hooks: the class each is about, "" any. */
903
+ readonly stateHooks: { classname: string; slot: Slot }[] = [];
904
+ /** The state being sent while its listeners run (sendState), by the module's field numbers. */
905
+ sending: number[] | null = null;
711
906
  /** register_logevent's callbacks: the number of arguments a line has, and the filters it passes. @internal */
712
907
  readonly logEvents: { argc: number; filters: string[]; slot: Slot }[] = [];
713
908
  /** The log line logevent callbacks are reading, as AMX Mod X splits it. @internal */
@@ -718,6 +913,8 @@ export class FakeServer {
718
913
  readonly messageEvents: { name: string; conditions: string[]; slot: Slot }[] = [];
719
914
  /** register_forward's callbacks, by `<FM_* number>:pre|post`. */
720
915
  readonly fakemetaForwards = new Map<string, Slot[]>();
916
+ /** The module's hooks of the same functions (stock_hook), by `<STOCK_* number>:pre|post`. @internal */
917
+ readonly stockHooks = new Map<string, Slot[]>();
721
918
  /** What a fakemeta callback answered with forward_return, and what get_orig_retval reads. @internal */
722
919
  forwardAnswer: number | string | null = null;
723
920
  origRetval = 0;
@@ -725,12 +922,15 @@ export class FakeServer {
725
922
  readonly listening: number[][] = [];
726
923
  /** 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
924
  readonly engineCalls: string[] = [];
728
- /** query_client_cvar's questions, waiting for answerCvar(). */
729
- readonly cvarQueries: { player: number; cvar: string; slot: Slot }[] = [];
925
+ /** query_client_cvar's questions, and the module's (query_cvar) by their ids, waiting for answerCvar(). */
926
+ readonly cvarQueries: { player: number; cvar: string; slot?: Slot; request?: number }[] = [];
927
+ private cvarRequests = 0;
730
928
  /** The menus menu_create made and menu_destroy has not taken away, by id. */
731
929
  readonly menus = new Map<number, FakeMenu>();
732
930
  /** menu_makecallback's publics, by the number it gave. @internal */
733
931
  readonly menuCallbacks: Slot[] = [];
932
+ /** The menu a plugin's Menu shows each player (menu_open), by his id: its handler of the keys, and the keys it takes as show_menu's bits. @internal */
933
+ readonly shownMenus = new Map<number, Handler & { keys: number }>();
734
934
  private menuIds = 0;
735
935
  private userids = 1;
736
936
 
@@ -776,10 +976,15 @@ export class FakeServer {
776
976
  this.maxPlayers = options.maxPlayers ?? 32;
777
977
  this.modules = new Set(options.modules ?? ['reapi', 'cstrike', 'fun', 'hamsandwich', 'engine', 'fakemeta', 'nvault', 'resemiclip']);
778
978
  this.reunion = options.reunion ?? false;
979
+ this.chains = options.chains ?? this.modules.has('reapi');
980
+ // The voice manager counts the server's slots, as the game sets it up.
981
+ this.rules.set(constant('m_nMaxPlayers'), this.maxPlayers);
779
982
  this.entityIds = this.maxPlayers + 1;
780
983
  this.timeZone = options.timeZone;
781
- // AMX Mod X's own, which a test may set: the languages.
782
- const cvars = { amx_language: 'en', amx_client_languages: '1', ...options.cvars };
984
+ this.env = { ...options.env };
985
+ // AMX Mod X's own, which a test may set: the languages; and the game's
986
+ // a plugin often sets, at the game's defaults.
987
+ const cvars = { amx_language: 'en', amx_client_languages: '1', mp_freezetime: '6', mp_round_infinite: '0', ...options.cvars };
783
988
  for (const [name, value] of Object.entries(cvars)) this.createCvar(name, value);
784
989
  for (const [path, text] of Object.entries(options.files ?? {})) this.writeFile(path, text);
785
990
  if (options.platform === 'win32') this.writeFile('addons/amxmodx/modules/amxts_amxx.dll', '');
@@ -816,7 +1021,10 @@ export class FakeServer {
816
1021
  */
817
1022
  async load(source: string): Promise<PluginInstance> {
818
1023
  await installKitFor(this, source);
819
- const { module, natives, binary, async } = await compile(source);
1024
+ const { module, natives, binary, async, env } = await compile(source);
1025
+ // A variable it requires that neither the environment nor .env has, or one of another kind, keeps it from starting, as the module does.
1026
+ const missing = envProblems(env, this.variables());
1027
+ if (missing.length) throw new Error(`[amxts] ${basename(source, '.ts')} needs ${missing.join(', ')} in addons/amxts/.env`);
820
1028
  const plugin = new PluginInstance(this, source, module, natives, async ? binary : undefined);
821
1029
  this.plugins.push(plugin);
822
1030
  for (const native of natives) {
@@ -861,6 +1069,13 @@ export class FakeServer {
861
1069
  return this.cvars.get(name.toLowerCase())?.value;
862
1070
  }
863
1071
 
1072
+ /** What `env()` reads: `addons/amxts/.env`, and the environment over it. @internal */
1073
+ variables(): Map<string, string> {
1074
+ const variables = parseDotenv(this.file('addons/amxts/.env') ?? '');
1075
+ for (const [name, value] of Object.entries(this.env)) variables.set(name, value);
1076
+ return variables;
1077
+ }
1078
+
864
1079
  /** Sets a cvar as the console would: `mp_freezetime 5`, and its change listeners hear it. */
865
1080
  setCvar(name: string, value: string | number): void {
866
1081
  const cvar = this.cvars.get(name.toLowerCase()) ?? this.createCvar(name, '');
@@ -880,8 +1095,12 @@ export class FakeServer {
880
1095
 
881
1096
  // ------------------------------------------------------------ dictionaries
882
1097
 
883
- /** The dictionaries' lines by language, then key: what register_dictionary loaded and translate() added. */
884
- readonly dictionary = new Map<string, Map<string, string>>();
1098
+ /**
1099
+ * The dictionaries' lines by language, then key: what register_dictionary
1100
+ * loaded and translate() added. It starts with the languages AMX Mod X's
1101
+ * own dictionaries bring, as a server's does.
1102
+ */
1103
+ readonly dictionary = new Map<string, Map<string, string>>(['en', 'de', 'sr', 'tr', 'fr', 'sv', 'da', 'pl', 'nl', 'es', 'bp', 'cz', 'fi', 'ru'].map(code => [code, new Map()]));
885
1104
  /** Every key in the order it came, as GetLangTransKey numbers them. */
886
1105
  private readonly langKeys: string[] = [];
887
1106
 
@@ -986,11 +1205,36 @@ export class FakeServer {
986
1205
  return array.items.map(item => array.cellSize === 1 ? item[0] : [...item]);
987
1206
  }
988
1207
 
989
- /** A storage's nVault, as a Map a test can fill before the plugin reads it. */
990
- vault(name: string): Map<string, string> {
991
- let vault = this.vaults.get(name);
992
- if (!vault) this.vaults.set(name, vault = new Map());
993
- return vault;
1208
+ /** A trace from `start` to `end` through the fake world, written as the module writes it: stopped at the first wall across it. @internal */
1209
+ writeTrace(plugin: PluginInstance, out: number, start: number[], end: number[]): void {
1210
+ let fraction = 1;
1211
+ let wall: { x: number; entity?: number; hitGroup?: number } | null = null;
1212
+ for (const each of this.walls) {
1213
+ const at = end[0] === start[0] ? -1 : (each.x - start[0]) / (end[0] - start[0]);
1214
+ if (at >= 0 && at < fraction) {
1215
+ fraction = at;
1216
+ wall = each;
1217
+ }
1218
+ }
1219
+ const stop = start.map((v, i) => v + (end[i] - v) * fraction);
1220
+ const normal = wall ? [Math.sign(start[0] - end[0]), 0, 0] : [0, 0, 0];
1221
+ const values = [fraction, ...stop, ...normal, wall ? (wall.entity ?? 0) : -1, 4, wall?.hitGroup ?? 0];
1222
+ values.forEach((value, i) => plugin.memory.setNumber(out + i * 8, value));
1223
+ }
1224
+
1225
+ /** A Storage's values by key, as a Map a test can fill before the plugin reads it - a Pawn plugin's nVault of that name too. */
1226
+ storage(name: string): Map<string, string> {
1227
+ let storage = this.vaults.get(name);
1228
+ if (!storage) this.vaults.set(name, storage = new Map());
1229
+ return storage;
1230
+ }
1231
+
1232
+ /** A bot's move, through engfunc or the module's own call: written to engineCalls, and the bot carried along its yaw. @internal */
1233
+ runPlayerMove(id: number, angles: number[], forward: number, side: number, up: number, buttons: number, impulse: number, msec: number): void {
1234
+ this.engineCalls.push(`RunPlayerMove ${id} ${angles.join(',')} ${forward} ${side} ${up} ${buttons} ${impulse} ${msec}`);
1235
+ const bot = this.players.find(each => each.id === id);
1236
+ const turn = angles[1] * Math.PI / 180;
1237
+ if (bot) bot.origin = [bot.origin[0] + Math.cos(turn) * forward * msec / 1000, bot.origin[1] + Math.sin(turn) * forward * msec / 1000, bot.origin[2]];
994
1238
  }
995
1239
 
996
1240
  // ------------------------------------------------------------ what a test does
@@ -1045,14 +1289,14 @@ export class FakeServer {
1045
1289
  if (due.repeat) due.due += due.interval;
1046
1290
  else this.tasks.splice(this.tasks.indexOf(due), 1);
1047
1291
 
1048
- this.withCallArgs([due.id], () => this.call(due.slot, [due.id], 0));
1292
+ this.withCallArgs([due.timer], () => this.call(due.slot, [due.timer], 0));
1049
1293
  }
1050
1294
 
1051
1295
  this.time = until;
1052
1296
  }
1053
1297
 
1054
1298
  /**
1055
- * Raises a forward the host plugin relays - "client_putinserver",
1299
+ * Raises a forward the module raises - "client_putinserver",
1056
1300
  * "plugin_cfg" - or one a Pawn plugin sends: every handler and every
1057
1301
  * Forward.subscribe() of that name hears it. Text arrives as text, a
1058
1302
  * boolean as 1 or 0, an array as its cells (a Float's as its bits). As in
@@ -1061,6 +1305,8 @@ export class FakeServer {
1061
1305
  */
1062
1306
  fire(name: string, ...args: ArgValue[]): number {
1063
1307
  const first = typeof args[0] === 'number' || typeof args[0] === 'boolean' ? Number(args[0]) : 0;
1308
+ if (name === 'client_connect') this.newPlayer(first);
1309
+ if (name === 'pfn_playbackevent') this.playback = { blocked: false, recipients: null };
1064
1310
  return this.withCallArgs(args, () => {
1065
1311
  this.deliver(name);
1066
1312
 
@@ -1074,6 +1320,22 @@ export class FakeServer {
1074
1320
  });
1075
1321
  }
1076
1322
 
1323
+ /** A player connects to slot `id`: each plugin's facade makes him a Player of his own, as the module's NewPlayer has it. */
1324
+ private newPlayer(id: number): void {
1325
+ for (const plugin of this.plugins) {
1326
+ if (plugin.playerSlots && id >= 0 && id <= 32) plugin.memory.setCell(plugin.playerSlots + id * 4, 1);
1327
+ }
1328
+ this.countNameChange(id);
1329
+ }
1330
+
1331
+ /** Player `id`'s name changed: each plugin's facade reads it again, as the module's NameChanges has it. */
1332
+ countNameChange(id: number): void {
1333
+ for (const plugin of this.plugins) {
1334
+ const at = plugin.playerNames + id * 4;
1335
+ if (plugin.playerNames && id >= 0 && id <= 32) plugin.memory.setCell(at, plugin.memory.cell(at) + 2);
1336
+ }
1337
+ }
1338
+
1077
1339
  /** Every Forward.subscribe() of a forward, called with its tag; the arguments are the context's. */
1078
1340
  private deliver(forward: string): void {
1079
1341
  for (const { handler, tag } of [...(this.subscriptions.get(forward) ?? [])]) this.call(handler, [tag], 0);
@@ -1098,7 +1360,18 @@ export class FakeServer {
1098
1360
  fireHook(event: string, args: HookArg[] = [], options: { result?: Value } = {}): HookResult {
1099
1361
  const shape = tables().hooks.get(event);
1100
1362
  if (!shape) throw new Error(`no hookchain named "${event}" in as/hooks.ts`);
1363
+ const listeners = this.hookchains.get(shape.kind) ?? { pre: [], post: [] };
1364
+ return this.runChain(shape, listeners.pre, listeners.post, args, options.result, false);
1365
+ }
1101
1366
 
1367
+ /**
1368
+ * A hooked call, as the module's hook runs it (gamehooks.h): the pre
1369
+ * listeners - one that answers or blocks keeps the game's function from
1370
+ * running, one that breaks stops every listener after it - then the
1371
+ * game's function, then the post listeners. The listeners read the
1372
+ * arguments as cells and write them (chain_set); -1 is the answer.
1373
+ */
1374
+ private runChain(shape: HookShape, pre: Slot[], post: Slot[], args: HookArg[], result: Value | undefined, ham: boolean): HookResult {
1102
1375
  const cells: ArgValue[] = args.map((a, i) => Array.isArray(a)
1103
1376
  ? a.map(floatBits)
1104
1377
  : typeof a === 'string'
@@ -1107,10 +1380,8 @@ export class FakeServer {
1107
1380
  ? (a ? 1 : 0)
1108
1381
  : shape.floats.has(i) ? floatBits(a) : a | 0);
1109
1382
  const handed = cells.slice(0, 4).map(c => typeof c === 'number' ? c : 0);
1110
-
1111
- const nothing: Value = shape.answer === constant('ATYPE_BOOL') ? false : shape.answer === constant('ATYPE_STRING') ? '' : 0;
1112
- const chain: Chain = { shape, answer: nothing, args: [...args] };
1113
- const listeners = this.hookchains.get(shape.kind) ?? { pre: [], post: [] };
1383
+ const nothing: Value = shape.answer === 'text' ? '' : shape.answer === 'bool' && !ham ? false : 0;
1384
+ const chain: Chain = { shape, answer: nothing, args: [...args], cells, ham };
1114
1385
 
1115
1386
  const previous = this.chain;
1116
1387
  this.chain = chain;
@@ -1118,41 +1389,54 @@ export class FakeServer {
1118
1389
  try {
1119
1390
  let prevented = false;
1120
1391
  let broken = false;
1121
-
1122
- for (const slot of [...listeners.pre]) {
1123
- const said = this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
1124
- // What reapi answers a plugin that blocks a chain with a return value
1125
- // before setting one - a run time error, and the block does not hold.
1126
- if ((said === HC_SUPERCEDE || said === HC_BREAK) && shape.answer >= 0 && !chain.answered) {
1127
- throw new Error(`${event}: Can't suppress original function call without new return value set, so you must call SetHookChainReturn.`);
1392
+ const walk = (slots: Slot[], before: boolean) => {
1393
+ for (const slot of [...slots]) {
1394
+ if (slot.off) continue;
1395
+ const said = this.withCallArgs(cells, () => this.call(slot, handed, 0));
1396
+ if (said === OUTCOME_BREAK) {
1397
+ broken = true;
1398
+ return;
1399
+ }
1400
+ if (said !== 0 && before) prevented = true;
1128
1401
  }
1129
- if (said === HC_SUPERCEDE) prevented = true;
1130
- if (said === HC_BREAK) {
1131
- prevented = broken = true;
1132
- break;
1133
- }
1134
- }
1135
-
1136
- if (!prevented) chain.answer = options.result ?? nothing;
1137
-
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
- });
1145
- for (const slot of [...listeners.post]) {
1146
- this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
1147
- }
1148
- }
1402
+ };
1149
1403
 
1150
- return { prevented, result: chain.answer, args: chain.args };
1404
+ walk(pre, true);
1405
+ if (!prevented && !broken && !chain.answered) chain.answer = result ?? nothing;
1406
+ // A vector is written where the game keeps it: what a pre listener
1407
+ // wrote in it is what the game and the post listeners get.
1408
+ cells.forEach((cell, i) => {
1409
+ if (Array.isArray(cell)) chain.args[i] = cell.map(bitsFloat);
1410
+ });
1411
+ if (!broken) walk(post, false);
1412
+ return { prevented: prevented || broken, result: chain.answer, args: chain.args };
1151
1413
  } finally {
1152
1414
  this.chain = previous;
1153
1415
  }
1154
1416
  }
1155
1417
 
1418
+ /** What a listener wrote through chain_set: argument `index`, or the answer at -1. @internal */
1419
+ chainSet(index: number, value: ArgValue): void {
1420
+ const chain = this.chain;
1421
+ if (!chain) throw new Error('chain_set outside a hooked call');
1422
+ if (index < 0) {
1423
+ const answer = chain.shape.answer;
1424
+ chain.answer = typeof value !== 'number' ? value as Value : answer === 'float' ? bitsFloat(value) : answer === 'bool' && !chain.ham ? value !== 0 : value;
1425
+ chain.answered = true;
1426
+ return;
1427
+ }
1428
+ chain.cells[index] = value;
1429
+ chain.args[index] = typeof value !== 'number' ? value as HookArg : chain.shape.floats.has(index) ? bitsFloat(value) : value;
1430
+ }
1431
+
1432
+ /** The answer of the hooked call as a cell, as arg(-1) reads it. @internal */
1433
+ chainAnswer(): ArgValue {
1434
+ const answer = this.chain?.answer ?? 0;
1435
+ if (typeof answer === 'boolean') return answer ? 1 : 0;
1436
+ if (typeof answer === 'number') return this.chain?.shape.answer === 'float' ? floatBits(answer) : answer | 0;
1437
+ return answer;
1438
+ }
1439
+
1156
1440
  /**
1157
1441
  * Sends a message to a client the way the game would, through the
1158
1442
  * register_message callbacks of its name: they read and change its
@@ -1185,6 +1469,11 @@ export class FakeServer {
1185
1469
  const handed = [id ?? 0, receiver ? 1 : 0, receiver, 0];
1186
1470
  if (this.withCallArgs(handed, () => this.call(slot, handed, 0)) === PLUGIN_HANDLED) prevented = true;
1187
1471
  }
1472
+ // The module's hooks: the receiver, the id, the destination; any answer blocks it.
1473
+ for (const slot of [...(id !== undefined ? this.moduleMessageHooks.get(id) ?? [] : [])]) {
1474
+ const handed = [receiver, id ?? 0, receiver ? MSG_ONE : MSG_ALL, 0];
1475
+ if (this.withCallArgs(handed, () => this.call(slot, handed, 0)) !== 0) prevented = true;
1476
+ }
1188
1477
  // register_event's callbacks hear it once it is sent, when its conditions hold.
1189
1478
  const holds = (condition: string) => {
1190
1479
  const match = condition.match(/^(\d+)=(.*)$/);
@@ -1226,33 +1515,8 @@ export class FakeServer {
1226
1515
  */
1227
1516
  runHam(shape: HookShape, id: number, args: HookArg[], result?: Value): HookResult {
1228
1517
  const classname = this.entities.get(id)?.classname ?? (id >= 1 && id <= this.maxPlayers ? 'player' : '');
1229
- const all = [id, ...args];
1230
- const cells: ArgValue[] = all.map((a, i) => Array.isArray(a)
1231
- ? a.map(floatBits)
1232
- : typeof a === 'string'
1233
- ? a
1234
- : typeof a === 'boolean'
1235
- ? (a ? 1 : 0)
1236
- : shape.floats.has(i) ? floatBits(a) : a | 0);
1237
- const handed = cells.slice(0, 4).map(c => typeof c === 'number' ? c : 0);
1238
- const nothing: Value = shape.answer === constant('ATYPE_STRING') ? '' : 0;
1239
- const chain: Chain = { shape, answer: nothing, args: all };
1240
1518
  const listeners = (post: boolean) => this.hams.get(`${shape.ham}:${classname}:${post ? 'post' : 'pre'}`) ?? [];
1241
-
1242
- const previous = this.chain;
1243
- this.chain = chain;
1244
-
1245
- try {
1246
- let prevented = false;
1247
- for (const slot of [...listeners(false)]) {
1248
- if (this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback)) >= HAM_SUPERCEDE) prevented = true;
1249
- }
1250
- if (!prevented) chain.answer = result ?? nothing;
1251
- for (const slot of [...listeners(true)]) this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
1252
- return { prevented, result: chain.answer, args: chain.args };
1253
- } finally {
1254
- this.chain = previous;
1255
- }
1519
+ return this.runChain(shape, listeners(false), listeners(true), [id, ...args], result, true);
1256
1520
  }
1257
1521
 
1258
1522
  /**
@@ -1529,7 +1793,8 @@ export class FakeServer {
1529
1793
  this.outcomeSaid = false;
1530
1794
 
1531
1795
  try {
1532
- this.within(handler.plugin, () => fn(...cells));
1796
+ const via = handler.via;
1797
+ this.within(handler.plugin, () => (via ? this.callVia(handler.plugin, via, args[0] ?? 0) : fn(...cells)));
1533
1798
  return this.outcomeSaid ? this.outcome : fallback;
1534
1799
  } finally {
1535
1800
  this.outcomeSaid = said;
@@ -1537,6 +1802,26 @@ export class FakeServer {
1537
1802
  }
1538
1803
  }
1539
1804
 
1805
+ /** CallVia in module.cpp: the one listener, with what the plugin's own call of it sets first. */
1806
+ private callVia(plugin: PluginInstance, via: NonNullable<Handler['via']>, first: number): void {
1807
+ const globals = plugin.instance.exports;
1808
+ if (globals.__env) globals.__env.value = via.env;
1809
+ if (globals.__argumentsLength) globals.__argumentsLength.value = 1;
1810
+ const ambient = via.player ? globals.__co_ambient_player : undefined;
1811
+ const before = ambient?.value;
1812
+ if (ambient) ambient.value = first;
1813
+ try {
1814
+ plugin.table.get(via.fn)(via.arg);
1815
+ } finally {
1816
+ if (ambient) ambient.value = before;
1817
+ }
1818
+ }
1819
+
1820
+ /** Has `handler` call its plugin's one listener `fn` itself (Handler.via); 0 the handler again. */
1821
+ private direct(handler: Handler, fn: number, env: number, arg: number, player: boolean): void {
1822
+ handler.via = fn ? { fn, env, arg, player } : undefined;
1823
+ }
1824
+
1540
1825
  /** Runs `body` as `plugin`: what its natives are called on behalf of. @internal */
1541
1826
  within<T>(plugin: PluginInstance, body: () => T): T {
1542
1827
  const previous = this.current;
@@ -1590,8 +1875,11 @@ export class FakeServer {
1590
1875
 
1591
1876
  /** The handlers of the command in argv, in order, until one takes it. */
1592
1877
  private handleCommand(handlers: Map<string, Slot[]>, id: number): boolean {
1878
+ const flags = this.players.find(each => each.id === id)?.flags ?? 0;
1593
1879
  return [...(handlers.get(this.argv[0].toLowerCase()) ?? [])].some((slot) => {
1594
- const args = [id, 0, slot.index];
1880
+ if (slot.access && !(flags & slot.access)) return false;
1881
+ // The module's own handlers read the count of the command's words, its name counted, last.
1882
+ const args = [id, 0, slot.index, this.argv.length];
1595
1883
  return this.withCallArgs(args, () => this.call(slot, args, slot.fallback)) >= PLUGIN_HANDLED;
1596
1884
  });
1597
1885
  }
@@ -1628,6 +1916,29 @@ export class FakeServer {
1628
1916
  for (const owner of owners) this.request(owner.plugin!, plugin.run, gone);
1629
1917
  }
1630
1918
 
1919
+ /**
1920
+ * `amxts_reload <plugin>`: the plugin hears `"end"` and goes, its file is
1921
+ * loaded again, and the new one hears the map's start - `"init"`,
1922
+ * `"pluginsLoaded"`, `"configsQueued"`, `"configsExecuted"` - as a
1923
+ * reload on a running map does.
1924
+ */
1925
+ async reload(plugin: PluginInstance): Promise<PluginInstance> {
1926
+ this.fireTo(plugin, 'plugin_end');
1927
+ this.unload(plugin);
1928
+ const fresh = await this.load(plugin.source);
1929
+ for (const name of ['plugin_init', 'plugin_cfg', 'OnAutoConfigsBuffered', 'OnConfigsExecuted']) this.fireTo(fresh, name);
1930
+ return fresh;
1931
+ }
1932
+
1933
+ /** A forward with no arguments to one plugin's handlers, as the module's RaiseTo. */
1934
+ private fireTo(plugin: PluginInstance, name: string): void {
1935
+ this.withCallArgs([], () => {
1936
+ for (const handler of [...(this.events.get(name) ?? [])]) {
1937
+ if (handler.plugin === plugin) this.call(handler, [0], 0);
1938
+ }
1939
+ });
1940
+ }
1941
+
1631
1942
  /** A menu by its id, or the error AMX Mod X gives for another number. @internal */
1632
1943
  menu(id: number): FakeMenu {
1633
1944
  const menu = this.menus.get(id);
@@ -1705,11 +2016,54 @@ export class FakeServer {
1705
2016
  player.menu = { menu: menu.id, page: at, text: lines.join('\n'), keys, disabled };
1706
2017
  }
1707
2018
 
2019
+ /**
2020
+ * show_menu: the text on the player's screen, or every player's for 0,
2021
+ * with the keys it takes; a menu of AMX Mod X's own he had open is closed
2022
+ * first, its handler hearing MENU_EXIT. @internal
2023
+ */
2024
+ showMenu(id: number, keys: number, text: string): void {
2025
+ for (const player of this.players.filter(one => one.connected && (id === 0 || one.id === id))) {
2026
+ // The module's show_menu: a menu over a plugin's Menu takes its keys.
2027
+ this.shownMenus.delete(player.id);
2028
+ const before = player.menu;
2029
+ player.menu = null;
2030
+ const replaced = before ? this.menus.get(before.menu) : undefined;
2031
+ if (replaced) this.menuAnswer(replaced, player, constant('MENU_EXIT'));
2032
+ const pressable = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0].filter(key => keys & (1 << (key === 0 ? 9 : key - 1)));
2033
+ player.menu = { menu: -1, page: 0, text, keys: pressable, disabled: [] };
2034
+ }
2035
+ }
2036
+
2037
+ /**
2038
+ * A key of the menu a plugin's Menu shows the player, taken off as the
2039
+ * module takes it: its handler and the key, 0 for 1 and 9 for 0 - or null
2040
+ * when he has none from a running plugin, or it does not take the key. @internal
2041
+ */
2042
+ takeMenuKey(player: FakePlayer, key: number): { handler: Handler; index: number } | null {
2043
+ const shown = this.shownMenus.get(player.id);
2044
+ const index = key === 0 ? 9 : key - 1;
2045
+ if (!shown || shown.plugin.unloaded || index < 0 || index > 9 || !(shown.keys & (1 << index))) return null;
2046
+ this.shownMenus.delete(player.id);
2047
+ return { handler: shown, index };
2048
+ }
2049
+
2050
+ /** A key of a Menu's page to its plugin, as the module hands it on. @internal */
2051
+ pressMenu(player: FakePlayer, pressed: { handler: Handler; index: number }): boolean {
2052
+ const args = [player.id, pressed.index, 0, 0];
2053
+ this.withCallArgs(args, () => this.call(pressed.handler, args, 0));
2054
+ return true;
2055
+ }
2056
+
1708
2057
  /** 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
2058
  selectMenu(player: FakePlayer, key: number): boolean {
1710
2059
  const screen = player.menu;
1711
- const menu = screen ? this.menus.get(screen.menu) : undefined;
1712
- if (!screen || !menu || !screen.keys.includes(key)) return false;
2060
+ if (!screen || !screen.keys.includes(key)) return false;
2061
+ const menu = this.menus.get(screen.menu);
2062
+ // show_menu's: the screen goes, and the key passes on.
2063
+ if (!menu) {
2064
+ player.menu = null;
2065
+ return false;
2066
+ }
1713
2067
 
1714
2068
  const paged = menu.perPage > 0;
1715
2069
  const turn = paged && key === 8 ? -1 : paged && key === 9 ? 1 : 0;
@@ -1730,9 +2084,10 @@ export class FakeServer {
1730
2084
  }
1731
2085
 
1732
2086
  /**
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.
2087
+ * A line of the game's log, as the engine hands it to the module: split
2088
+ * into its arguments as AMX Mod X splits them, and every log hook whose
2089
+ * filters it passes is called. A line logged while one runs is heard by
2090
+ * its own, and read_logargv reads the outer line again after it.
1736
2091
  *
1737
2092
  * ```ts
1738
2093
  * server.gameLog('World triggered "Round_End"');
@@ -1740,19 +2095,25 @@ export class FakeServer {
1740
2095
  * ```
1741
2096
  */
1742
2097
  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;
2098
+ const [outerLine, outerArgs] = [this.logLine, this.logArgs];
2099
+ const args = splitLog(line);
1748
2100
  const passes = (filter: string) => {
1749
2101
  const match = filter.match(/^(\d+)([=&!])(.*)$/);
1750
2102
  if (!match) return true;
1751
- const value = this.logArgs[Number(match[1])] ?? '';
2103
+ const value = args[Number(match[1])] ?? '';
1752
2104
  return match[2] === '=' ? value === match[3] : match[2] === '&' ? value.includes(match[3]) : value !== match[3];
1753
2105
  };
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));
2106
+ try {
2107
+ // The log event hears every line, as the module raises it.
2108
+ [this.logLine, this.logArgs] = [line, args];
2109
+ this.fire('plugin_log');
2110
+ for (const event of [...this.logEvents]) {
2111
+ if (event.argc !== args.length || !event.filters.every(passes)) continue;
2112
+ [this.logLine, this.logArgs] = [line, args];
2113
+ this.withCallArgs([], () => this.call(event.slot, [0, 0, 0, 0], 0));
2114
+ }
2115
+ } finally {
2116
+ [this.logLine, this.logArgs] = [outerLine, outerArgs];
1756
2117
  }
1757
2118
  }
1758
2119
 
@@ -1775,14 +2136,38 @@ export class FakeServer {
1775
2136
  for (const slot of [...(this.fakemetaForwards.get(`${fn}:pre`) ?? [])]) {
1776
2137
  if (this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback)) >= FMRES_SUPERCEDE) superseded = true;
1777
2138
  }
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));
2139
+
2140
+ // The module's own hooks of the function: any answer blocks it, and
2141
+ // chain_set writes an argument or (at -1) its answer.
2142
+ const stock = STOCK_FUNCTIONS.indexOf(name);
2143
+ const chain: Chain = { shape: { kind: name, floats: new Set(), texts: new Set(), answer: typeof result === 'string' ? 'text' : 'int' }, answer: result, args: [...args] as HookArg[], cells };
2144
+ const previous = this.chain;
2145
+ this.chain = chain;
2146
+ try {
2147
+ for (const slot of [...(this.stockHooks.get(`${stock}:pre`) ?? [])]) {
2148
+ if (this.withCallArgs(cells, () => this.call(slot, handed, 0)) !== 0) superseded = true;
2149
+ }
2150
+ if (chain.answered) this.forwardAnswer = chain.answer as number | string;
2151
+ if (!superseded) {
2152
+ this.origRetval = result;
2153
+ for (const slot of [...(this.fakemetaForwards.get(`${fn}:post`) ?? [])]) this.withCallArgs(cells, () => this.call(slot, handed, slot.fallback));
2154
+ for (const slot of [...(this.stockHooks.get(`${stock}:post`) ?? [])]) this.withCallArgs(cells, () => this.call(slot, handed, 0));
2155
+ }
2156
+ } finally {
2157
+ this.chain = previous;
1781
2158
  }
1782
2159
  return { superseded, answer: this.forwardAnswer };
1783
2160
  }
1784
2161
 
1785
- /** Switches a hook off or on by the handle RegisterHookChain or RegisterHam gave. @internal */
2162
+ /** A handler of one of the module's hooks, under `key` in `hooks`; its handle for hook_on. @internal */
2163
+ addModuleHook<K>(plugin: PluginInstance, fn: number, hooks: Map<K, Slot[]>, key: K): number {
2164
+ const slot = this.takeSlot(plugin, fn, SHAPE_WIDE, '', 0);
2165
+ hooks.set(key, [...(hooks.get(key) ?? []), slot]);
2166
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2167
+ return this.hookSlots.size;
2168
+ }
2169
+
2170
+ /** Switches a hook off or on by its handle: the module's (hook_on), or a raw RegisterHookChain's or RegisterHam's. @internal */
1786
2171
  switchHook(handle: number, on: boolean): number {
1787
2172
  const slot = this.hookSlots.get(handle);
1788
2173
  if (!slot) throw new Error(`the fake server knows no hook handle ${handle}`);
@@ -1817,6 +2202,26 @@ export class FakeServer {
1817
2202
  return slot;
1818
2203
  }
1819
2204
 
2205
+ /**
2206
+ * What the server sends `host` of `entity` (AddToFullPack): its listeners
2207
+ * run on the state - sixteen numbers, by the module's field numbers:
2208
+ * origin, angles, render mode, amount, colour, fx, effects, model index,
2209
+ * body, skin - and say what was sent, or that it was hidden.
2210
+ */
2211
+ sendState(host: FakePlayer, entity: FakeEntity, state: number[] = Array.from({ length: 16 }).fill(0) as number[]): { hidden: boolean; state: number[] } {
2212
+ const sent = [...state];
2213
+ let hidden = false;
2214
+ this.sending = sent;
2215
+ try {
2216
+ for (const { slot } of this.stateHooks.filter(h => h.classname === '' || h.classname === entity.classname)) {
2217
+ if (this.withCallArgs([host.id, entity.id], () => this.call(slot, [host.id, entity.id], 0)) > 0) hidden = true;
2218
+ }
2219
+ } finally {
2220
+ this.sending = null;
2221
+ }
2222
+ return { hidden, state: sent };
2223
+ }
2224
+
1820
2225
  /**
1821
2226
  * One entity moving into another: the register_touch callbacks whose
1822
2227
  * classes match ("*" or "" is any), called as the engine module calls
@@ -1831,10 +2236,21 @@ export class FakeServer {
1831
2236
  return answer;
1832
2237
  }
1833
2238
 
1834
- /** A client answering query_client_cvar: every question about that cvar it had. */
2239
+ /**
2240
+ * A client answering query_client_cvar and the module's questions: every
2241
+ * question about that cvar it had. The module's go to its hooks of the
2242
+ * answer, (player, id, cvar, value), once.
2243
+ */
1835
2244
  answerCvar(player: FakePlayer, cvar: string, value: string): void {
1836
2245
  for (const query of this.cvarQueries.filter(q => q.player === player.id && q.cvar === cvar)) {
1837
- this.withCallArgs([player.id, cvar, value], () => this.call(query.slot, [player.id, 0, 0, 0], 0));
2246
+ if (query.slot) {
2247
+ const slot = query.slot;
2248
+ this.withCallArgs([player.id, cvar, value], () => this.call(slot, [player.id, 0, 0, 0], 0));
2249
+ continue;
2250
+ }
2251
+ this.cvarQueries.splice(this.cvarQueries.indexOf(query), 1);
2252
+ const args = [player.id, query.request ?? 0, cvar, value];
2253
+ for (const slot of [...(this.stockHooks.get(`${STOCK_CVAR_ANSWER}:pre`) ?? [])]) this.withCallArgs(args, () => this.call(slot, [player.id, query.request ?? 0, 0, 0], 0));
1838
2254
  }
1839
2255
  }
1840
2256
 
@@ -1987,6 +2403,36 @@ export class FakeServer {
1987
2403
  net_read(this: FakeServer, plugin: PluginInstance, id: number, out: number, max: number) {
1988
2404
  return this.network.read(plugin, id, out, max);
1989
2405
  },
2406
+
2407
+ // A file's bytes, in files: as the module's fs_read and fs_write, a path that leaves the game folder is refused.
2408
+ fs_read(this: FakeServer, plugin: PluginInstance, name: number, out: number, max: number) {
2409
+ const path = gamePath(plugin.memory.string(name));
2410
+ const bytes = path === null ? undefined : this.files.get(path);
2411
+ if (!bytes) return -1;
2412
+ if (out && max > 0) plugin.memory.setRaw(out, bytes.subarray(0, max));
2413
+ return bytes.length;
2414
+ },
2415
+ fs_write(this: FakeServer, plugin: PluginInstance, name: number, data: number, length: number, append: number) {
2416
+ const path = gamePath(plugin.memory.string(name));
2417
+ if (path === null || !this.folderExists(path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '')) return 0;
2418
+ const bytes = new Uint8Array(plugin.instance.exports.memory.buffer, data, length);
2419
+ const before = append ? this.files.get(path) ?? new Uint8Array(0) : new Uint8Array(0);
2420
+ const file = new Uint8Array(before.length + length);
2421
+ file.set(before);
2422
+ file.set(bytes, before.length);
2423
+ this.files.set(path, file);
2424
+ return 1;
2425
+ },
2426
+ zip_inflate(this: FakeServer, plugin: PluginInstance, data: number, length: number, out: number, max: number) {
2427
+ try {
2428
+ const inflated = inflateRawSync(new Uint8Array(plugin.instance.exports.memory.buffer, data, length));
2429
+ if (inflated.length > max) return -1;
2430
+ plugin.memory.setRaw(out, inflated);
2431
+ return inflated.length;
2432
+ } catch {
2433
+ return -1;
2434
+ }
2435
+ },
1990
2436
  net_reply(this: FakeServer, plugin: PluginInstance, id: number) {
1991
2437
  return this.network.reply(plugin, id);
1992
2438
  },
@@ -2073,6 +2519,13 @@ export class FakeServer {
2073
2519
  list.push({ plugin, fn, shape });
2074
2520
  },
2075
2521
 
2522
+ // The plugin's handler of a forward calls its one listener itself, or the handler again.
2523
+ on_direct(this: FakeServer, plugin: PluginInstance, name: number, fn: number, target: number, env: number, arg: number, player: number) {
2524
+ for (const handler of this.events.get(plugin.memory.string(name)) ?? []) {
2525
+ if (handler.plugin === plugin && handler.fn === fn) this.direct(handler, target, env, arg, player !== 0);
2526
+ }
2527
+ },
2528
+
2076
2529
  // The plugin's handler of a forward taken off, once its event has no listener left.
2077
2530
  off(this: FakeServer, plugin: PluginInstance, name: number, fn: number) {
2078
2531
  const list = this.events.get(plugin.memory.string(name)) ?? [];
@@ -2096,8 +2549,7 @@ export class FakeServer {
2096
2549
  list.push({ handler: { plugin, fn, shape: SHAPE_NARROW }, tag });
2097
2550
  },
2098
2551
 
2099
- // No forward is relayed by a host plugin here, so every one a
2100
- // TypeScript plugin emits reaches its subscribers this way: `s` a
2552
+ // A forward a TypeScript plugin emits reaches its subscribers this way: `s` a
2101
2553
  // string, `a` an array as its count and then its cells, the rest cells.
2102
2554
  emit_local(this: FakeServer, plugin: PluginInstance, name: number, mask: number, cells: number, argc: number) {
2103
2555
  const forward = plugin.memory.string(name);
@@ -2123,35 +2575,318 @@ export class FakeServer {
2123
2575
  return left.index | SLOT_REUSED;
2124
2576
  },
2125
2577
 
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;
2578
+ // The module's own table of commands, as ClientCommand and the engine's server commands walk it.
2579
+ clcmd(this: FakeServer, plugin: PluginInstance, name: number, fn: number, flags: number, shape: number) {
2580
+ const slot = this.takeSlot(plugin, fn, shape, '', PLUGIN_HANDLED);
2581
+ slot.access = flags;
2582
+ addTo(this.clientCommands, plugin.memory.string(name).toLowerCase(), slot);
2130
2583
  },
2131
2584
 
2132
- clcmd(this: FakeServer, plugin: PluginInstance, pattern: number, fn: number, flags: number, info: number, shape: number) {
2133
- const name = plugin.memory.string(pattern);
2134
- const slot = this.takeSlot(plugin, fn, shape, `clcmd:${name}`, PLUGIN_HANDLED);
2135
- let list = this.clientCommands.get(name.toLowerCase());
2136
- if (!list) this.clientCommands.set(name.toLowerCase(), list = []);
2137
- list.push(slot);
2138
- return slot.index;
2585
+ srvcmd(this: FakeServer, plugin: PluginInstance, name: number, fn: number, shape: number) {
2586
+ addTo(this.serverCommands, plugin.memory.string(name).toLowerCase(), this.takeSlot(plugin, fn, shape, '', PLUGIN_HANDLED));
2587
+ },
2588
+
2589
+ // The menu a plugin's Menu shows a player, said after show_menu; no keys for none, 0 for every player.
2590
+ menu_open(this: FakeServer, plugin: PluginInstance, id: number, keys: number, fn: number) {
2591
+ const tag = this.takeTag();
2592
+ if (id === 0) this.shownMenus.clear();
2593
+ else this.shownMenus.delete(id);
2594
+ if (id > 0 && keys & 0x3FF) this.shownMenus.set(id, { plugin, fn, shape: SHAPE_WIDE, tag, keys: keys & 0x3FF });
2595
+ },
2596
+
2597
+ // A client's command, as one he sent: a bot's line, the team menu's choice, a weapon's.
2598
+ bot_cmd(this: FakeServer, plugin: PluginInstance, id: number, line: number) {
2599
+ const target = this.players.find(each => each.id === id);
2600
+ const text = plugin.memory.string(line);
2601
+ if (target?.bot) target.command(text);
2602
+ else clientCommand(target, text);
2603
+ },
2604
+
2605
+ // What a player is given and does, as the module does it on ReGameDLL.
2606
+ // -1 for a name the game has no class of: the fake's game has its weapons and every item_ and ammo_ class.
2607
+ player_give(this: FakeServer, plugin: PluginInstance, id: number, name: number) {
2608
+ const item = plugin.memory.string(name);
2609
+ return WEAPON_NAMES.includes(item) || /^(?:item|ammo)_\w+$/.test(item) ? giveWeapon(this.player(id), item) : -1;
2610
+ },
2611
+ player_strip(this: FakeServer, plugin: PluginInstance, id: number) {
2612
+ this.player(id)?.stripWeapons();
2613
+ },
2614
+ player_respawn(this: FakeServer, plugin: PluginInstance, id: number) {
2615
+ NATIVES.rg_round_respawn({ server: this, plugin, memory: plugin.memory }, [id]);
2616
+ },
2617
+ player_speed() {},
2618
+ player_switch(this: FakeServer, plugin: PluginInstance, id: number, name: number) {
2619
+ return switchTo(this.player(id), plugin.memory.string(name));
2620
+ },
2621
+ player_join(this: FakeServer, plugin: PluginInstance, id: number, team: number) {
2622
+ if (!this.chains) return -1;
2623
+ return NATIVES.rg_join_team({ server: this, plugin, memory: plugin.memory }, [id, team]);
2624
+ },
2625
+ player_observe(this: FakeServer, plugin: PluginInstance, id: number, mode: number) {
2626
+ if (!this.chains) return -1;
2627
+ return NATIVES.rg_set_observer_mode({ server: this, plugin, memory: plugin.memory }, [id, mode]);
2628
+ },
2629
+ player_team(this: FakeServer, plugin: PluginInstance, id: number, team: number) {
2630
+ NATIVES.rg_set_user_team({ server: this, plugin, memory: plugin.memory }, [id, team]);
2631
+ },
2632
+ // The game rules' RestartRound (1) and CheckWinConditions (2), counted.
2633
+ game_rules_run(this: FakeServer, plugin: PluginInstance, action: number) {
2634
+ this.rulesRuns.push(action === 1 ? 'restartRound' : 'checkWinConditions');
2635
+ return 1;
2636
+ },
2637
+ // A slot's items taken with their ammo, as ReGameDLL's RemovePlayerItemEx takes each.
2638
+ player_remove_slot(this: FakeServer, plugin: PluginInstance, id: number, slot: number) {
2639
+ const target = this.player(id);
2640
+ if (!target) return 0;
2641
+ for (const weapon of target.items.filter(w => slotOf(w.kind) === slot)) {
2642
+ target.ammo.set(weapon.kind, 0);
2643
+ target.takeWeapon(weapon);
2644
+ this.entities.delete(weapon.id);
2645
+ }
2646
+ return 1;
2647
+ },
2648
+ // A weapon dropped: no longer his, left in the world.
2649
+ player_drop(this: FakeServer, plugin: PluginInstance, id: number, name: number) {
2650
+ const target = this.player(id);
2651
+ const weapon = target?.items.find(w => w.kind === plugin.memory.string(name));
2652
+ if (!target || !weapon || weapon.kind === 'weapon_knife') return;
2653
+ target.takeWeapon(weapon);
2654
+ weapon.set('var_owner', 0);
2655
+ weapon.set('m_pPlayer', 0);
2656
+ },
2657
+ // The user id (1), the ping (2), the seconds since he came (4).
2658
+ player_stat(this: FakeServer, plugin: PluginInstance, id: number, what: number) {
2659
+ const target = this.player(id);
2660
+ if (!target) return 0;
2661
+ return what === 1 ? target.userid : what === 4 ? Math.floor((this.time - target.connectedAt) / 1000) : 0;
2662
+ },
2663
+ // env(): a variable's UTF-8 length, as much of it as fits into `max`; -1 for none - the module's env_get.
2664
+ env_get(this: FakeServer, plugin: PluginInstance, name: number, out: number, max: number) {
2665
+ const value = this.variables().get(plugin.memory.string(name));
2666
+ return value === undefined ? -1 : plugin.memory.setUtf8(out, max, value);
2667
+ },
2668
+ info_get(this: FakeServer, plugin: PluginInstance, id: number, key: number, out: number, max: number) {
2669
+ return plugin.memory.setUtf8(out, max, this.player(id)?.info.get(plugin.memory.string(key)) ?? '');
2670
+ },
2671
+ info_set(this: FakeServer, plugin: PluginInstance, id: number, key: number, value: number) {
2672
+ this.player(id)?.info.set(plugin.memory.string(key), plugin.memory.string(value));
2673
+ },
2674
+ player_silent(this: FakeServer, plugin: PluginInstance, id: number, on: number) {
2675
+ const target = this.player(id);
2676
+ if (!target) return 0;
2677
+ if (on < 0) return target.silentSteps ? 1 : 0;
2678
+ target.silentSteps = on !== 0;
2679
+ return 1;
2680
+ },
2681
+ // An address's country (code 1, name 2) from `server.countries`; -1 for one it has not.
2682
+ geo_country(this: FakeServer, plugin: PluginInstance, ip: number, what: number, out: number, max: number) {
2683
+ const country = this.countries.get(plugin.memory.string(ip));
2684
+ return country ? plugin.memory.setUtf8(out, max, what === 1 ? country.code : country.name) : -1;
2685
+ },
2686
+ // The player to the other side, as the game swaps sides; a spectator stays.
2687
+ player_switch_team(this: FakeServer, plugin: PluginInstance, id: number) {
2688
+ const target = this.player(id);
2689
+ if (target?.team === 'TERRORIST') target.team = 'CT';
2690
+ else if (target?.team === 'CT') target.team = 'TERRORIST';
2691
+ },
2692
+ // The plugins: the TypeScript ones loaded here, by their source's file; no Pawn plugin.
2693
+ plugins_count(this: FakeServer) {
2694
+ return this.plugins.length;
2695
+ },
2696
+ plugin_text(this: FakeServer, plugin: PluginInstance, index: number, what: number, out: number, max: number) {
2697
+ const target = this.plugins[index];
2698
+ const text = !target ? '' : what === 1 ? target.source.split(/[\\/]/).pop()! : what === 2 ? target.info.name : what === 3 ? target.info.version : what === 4 ? target.info.author : '';
2699
+ return plugin.memory.setUtf8(out, max, text);
2700
+ },
2701
+ plugin_state(this: FakeServer, plugin: PluginInstance, index: number) {
2702
+ const target = this.plugins[index];
2703
+ return !target ? -1 : target.unloaded ? 1 : 0;
2704
+ },
2705
+ // What was asked of a plugin, done at the next frame on the server: recorded here.
2706
+ plugin_action(this: FakeServer, plugin: PluginInstance, name: number, action: number) {
2707
+ this.pluginActions.push(`${['', 'stop', 'start', 'reload'][action]} ${plugin.memory.string(name)}`);
2708
+ },
2709
+ pawn_plugins_count() {
2710
+ return 0;
2711
+ },
2712
+ pawn_plugin_text(this: FakeServer, plugin: PluginInstance, index: number, what: number, out: number, max: number) {
2713
+ return plugin.memory.setUtf8(out, max, '');
2714
+ },
2715
+ pawn_plugin_pause() {
2716
+ return 0;
2717
+ },
2718
+ // A Storage: the map storage(name) gives, by a handle a name.
2719
+ store_open(this: FakeServer, plugin: PluginInstance, name: number) {
2720
+ const wanted = plugin.memory.string(name);
2721
+ if (!wanted || /[/\\:*?"<>|]/.test(wanted)) return -1;
2722
+ this.storage(wanted);
2723
+ const at = this.storeNames.indexOf(wanted);
2724
+ return at >= 0 ? at : this.storeNames.push(wanted) - 1;
2725
+ },
2726
+ store_get(this: FakeServer, plugin: PluginInstance, handle: number, key: number, out: number, max: number) {
2727
+ const value = this.storage(this.storeNames[handle]).get(plugin.memory.string(key));
2728
+ if (value === undefined) return -1;
2729
+ const length = Buffer.byteLength(value);
2730
+ return length > max ? length : plugin.memory.setUtf8(out, max, value);
2731
+ },
2732
+ store_set(this: FakeServer, plugin: PluginInstance, handle: number, key: number, value: number) {
2733
+ const name = this.storeNames[handle];
2734
+ this.storage(name).set(plugin.memory.string(key), plugin.memory.string(value));
2735
+ this.storeTimes.set(`${name}\0${plugin.memory.string(key)}`, (this.startedAt + this.time) / 1000);
2736
+ },
2737
+ store_delete(this: FakeServer, plugin: PluginInstance, handle: number, key: number) {
2738
+ return +this.storage(this.storeNames[handle]).delete(plugin.memory.string(key));
2739
+ },
2740
+ store_count(this: FakeServer, plugin: PluginInstance, handle: number) {
2741
+ return this.storage(this.storeNames[handle]).size;
2742
+ },
2743
+ store_keys(this: FakeServer, plugin: PluginInstance, handle: number, out: number, max: number) {
2744
+ const keys = [...this.storage(this.storeNames[handle]).keys()].sort().map(key => `${key}\0`).join('');
2745
+ const length = Buffer.byteLength(keys);
2746
+ if (length > max || length === 0) return length;
2747
+ plugin.memory.setUtf8(out, max, keys);
2748
+ return length;
2749
+ },
2750
+ // What was set before `before` (seconds since 1970, on the fake clock) goes; a value a test put in has no time and stays.
2751
+ store_prune(this: FakeServer, plugin: PluginInstance, handle: number, before: number) {
2752
+ const name = this.storeNames[handle];
2753
+ let removed = 0;
2754
+ for (const key of [...this.storage(name).keys()]) {
2755
+ const time = this.storeTimes.get(`${name}\0${key}`);
2756
+ if (time !== undefined && time < before) {
2757
+ this.storage(name).delete(key);
2758
+ removed++;
2759
+ }
2760
+ }
2761
+ return removed;
2762
+ },
2763
+ // The server's game and versions: ReHLDS's and ReGameDLL's APIs where the hookchains are.
2764
+ server_text(this: FakeServer, plugin: PluginInstance, what: number, out: number, max: number) {
2765
+ const text = ['', 'cstrike', '0.3.0', this.chains ? '3.14' : '', this.chains ? '5.28' : ''][what] ?? '';
2766
+ return plugin.memory.setUtf8(out, max, text);
2767
+ },
2768
+ map_valid(this: FakeServer, plugin: PluginInstance, name: number) {
2769
+ return +this.maps.includes(plugin.memory.string(name));
2770
+ },
2771
+ change_level(this: FakeServer, plugin: PluginInstance, name: number) {
2772
+ const map = plugin.memory.string(name);
2773
+ if (!this.maps.includes(map)) return 0;
2774
+ this.engineCalls.push(`ChangeLevel ${map}`);
2775
+ return 1;
2776
+ },
2777
+ playback_block(this: FakeServer) {
2778
+ this.playback.blocked = true;
2779
+ },
2780
+ playback_to(this: FakeServer, plugin: PluginInstance, ids: number, count: number) {
2781
+ this.playback.recipients = Array.from({ length: count }, (_, i) => plugin.memory.cell(ids + i * 4));
2782
+ },
2783
+ light_style(this: FakeServer, plugin: PluginInstance, text: number) {
2784
+ this.lightStyle = plugin.memory.string(text) || 'm';
2785
+ this.engineCalls.push(`LightStyle 0 ${this.lightStyle}`);
2786
+ },
2787
+ // The engine's lightstyle message to one player, as a message he was sent: "" is the server's light.
2788
+ player_light_style(this: FakeServer, plugin: PluginInstance, id: number, text: number) {
2789
+ const player = this.player(id);
2790
+ if (!player || player.bot) return;
2791
+ this.userMessages.push({ name: 'LightStyle', player: id, args: [0, plugin.memory.string(text) || this.lightStyle] });
2792
+ },
2793
+ // HUD messages: the text to each player, as his messages' "hud" line; how it was set, in server.hud.
2794
+ hud_line(this: FakeServer) {
2795
+ return ++this.hudLines;
2796
+ },
2797
+ hud_show(this: FakeServer, plugin: PluginInstance, id: number, line: number, params: number, text: number) {
2798
+ const p = Array.from({ length: 12 }, (_, i) => plugin.memory.number(params + i * 8));
2799
+ this.hud = { color: [p[3], p[4], p[5]], x: p[0], y: p[1], hold: p[7], channel: p[10], line, large: p[11] !== 0 };
2800
+ const message = plugin.memory.string(text);
2801
+ for (const target of id > 0 ? [this.player(id)] : this.players) {
2802
+ if (target && !target.bot) target.show('hud', message);
2803
+ }
2804
+ },
2805
+ hud_clear(this: FakeServer, plugin: PluginInstance, id: number, line: number) {
2806
+ this.engineCalls.push(`HudClear ${id} ${line}`);
2807
+ },
2808
+ // The model a plugin put on each player, by his id: written into his userinfo as the module does.
2809
+ player_model(this: FakeServer, plugin: PluginInstance, id: number, model: number, index: number) {
2810
+ const target = this.player(id);
2811
+ if (!target) return;
2812
+ const name = plugin.memory.string(model);
2813
+ if (name) this.models.set(id, name);
2814
+ else this.models.delete(id);
2815
+ target.info.set('model', name || 'urban');
2816
+ this.engineCalls.push(`Model ${id} ${name || '-'} ${index}`);
2817
+ },
2818
+ player_model_get(this: FakeServer, plugin: PluginInstance, id: number, out: number, max: number) {
2819
+ return plugin.memory.setUtf8(out, max, this.models.get(id) ?? '');
2820
+ },
2821
+ // What each player sees through, by his id: 0 his own eyes.
2822
+ player_view(this: FakeServer, plugin: PluginInstance, id: number, target: number) {
2823
+ this.engineCalls.push(`SetView ${id} ${target || id}`);
2824
+ this.views.set(id, target === id ? 0 : target);
2825
+ },
2826
+ player_view_get(this: FakeServer, plugin: PluginInstance, id: number) {
2827
+ return this.views.get(id) ?? 0;
2828
+ },
2829
+ // Traces: the fake world is empty, unless a test puts a wall (server.walls)
2830
+ // across the way; each trace is written to engineCalls.
2831
+ world_trace_line(this: FakeServer, plugin: PluginInstance, points: number, flags: number, ignore: number, out: number) {
2832
+ const [start, end] = tracePoints(plugin, points);
2833
+ this.engineCalls.push(`TraceLine ${start.join(',')} ${end.join(',')} ${flags} ${ignore}`);
2834
+ this.writeTrace(plugin, out, start, end);
2835
+ },
2836
+ world_trace_hull(this: FakeServer, plugin: PluginInstance, points: number, hull: number, flags: number, ignore: number, out: number) {
2837
+ const [start, end] = tracePoints(plugin, points);
2838
+ this.engineCalls.push(`TraceHull ${start.join(',')} ${end.join(',')} ${hull} ${flags} ${ignore}`);
2839
+ this.writeTrace(plugin, out, start, end);
2840
+ },
2841
+ // Inside a wall is solid; the walls are planes across x.
2842
+ world_contents(this: FakeServer, plugin: PluginInstance, point: number) {
2843
+ const x = tracePoints(plugin, point)[0][0];
2844
+ return this.walls.some(wall => wall.x === x) ? -2 : -1;
2845
+ },
2846
+ entity_drop(this: FakeServer, plugin: PluginInstance, id: number) {
2847
+ this.engineCalls.push(`DropToFloor ${id}`);
2848
+ return 1;
2849
+ },
2850
+ // The log line being told (-1) or one of its arguments, and how many it has.
2851
+ log_text(this: FakeServer, plugin: PluginInstance, index: number, out: number, max: number) {
2852
+ return plugin.memory.setUtf8(out, max, index < 0 ? this.logLine : this.logArgs[index] ?? '');
2853
+ },
2854
+ log_count(this: FakeServer) {
2855
+ return this.logArgs.length;
2856
+ },
2857
+ // Reunion's answers: -1 without it.
2858
+ reunion(this: FakeServer, plugin: PluginInstance, what: number, id: number) {
2859
+ const target = this.players.find(each => each.id === id);
2860
+ if (!this.reunion || !this.chains || !target) return -1;
2861
+ return what === 1 ? target.protocol : Math.max(0, AUTH_TYPES.indexOf(target.authType));
2862
+ },
2863
+ reunion_key(this: FakeServer, plugin: PluginInstance, id: number, out: number, max: number) {
2864
+ const target = this.players.find(each => each.id === id);
2865
+ return plugin.memory.setUtf8(out, max, this.reunion && this.chains ? target?.authKey ?? '' : '');
2866
+ },
2867
+
2868
+ // engfunc(EngFunc_RunPlayerMove, ...) made by the module itself, the angles and the speeds Floats' bits; 0 for no player there, which engfunc then takes.
2869
+ run_player_move(this: FakeServer, plugin: PluginInstance, id: number, pitch: number, yaw: number, roll: number, forward: number, side: number, up: number, buttons: number, impulse: number, msec: number) {
2870
+ if (!this.players.some(each => each.id === id)) return 0;
2871
+ this.runPlayerMove(id, [bitsFloat(pitch), bitsFloat(yaw), bitsFloat(roll)], bitsFloat(forward), bitsFloat(side), bitsFloat(up), buttons, impulse, msec);
2872
+ return 1;
2139
2873
  },
2140
2874
 
2141
- task(this: FakeServer, plugin: PluginInstance, secondsBits: number, fn: number, id: number, repeat: number) {
2875
+ task(this: FakeServer, plugin: PluginInstance, secondsBits: number, fn: number, repeat: number) {
2142
2876
  const slot = this.takeSlot(plugin, fn, 0, '', 0);
2143
2877
  const interval = Math.round(bitsFloat(secondsBits) * 1000);
2144
- this.tasks.push({ slot, id, interval, due: this.time + interval, repeat: repeat !== 0, order: this.taskOrder++ });
2145
- return slot.index;
2878
+ let timer = 0;
2879
+ while (this.tasks.some(task => task.timer === timer)) timer++;
2880
+ this.tasks.push({ slot, timer, interval, due: this.time + interval, repeat: repeat !== 0, order: this.taskOrder++ });
2881
+ return timer;
2146
2882
  },
2147
2883
 
2148
- // remove_task with `outside`: every task of that id, whoever armed it.
2149
- stop_task(this: FakeServer, plugin: PluginInstance, id: number) {
2150
- const before = this.tasks.length;
2151
- for (let i = this.tasks.length - 1; i >= 0; i--) {
2152
- if (this.tasks[i].id === id) this.tasks.splice(i, 1);
2153
- }
2154
- return before - this.tasks.length;
2884
+ // The calling plugin's timer in that slot.
2885
+ stop_task(this: FakeServer, plugin: PluginInstance, timer: number) {
2886
+ const at = this.tasks.findIndex(task => task.timer === timer && task.slot.plugin === plugin);
2887
+ if (at < 0) return 0;
2888
+ this.tasks.splice(at, 1);
2889
+ return 1;
2155
2890
  },
2156
2891
 
2157
2892
  hook(this: FakeServer, plugin: PluginInstance, id: number, fn: number, post: number) {
@@ -2166,7 +2901,7 @@ export class FakeServer {
2166
2901
  return this.hookSlots.size;
2167
2902
  },
2168
2903
 
2169
- // RegisterHam: kept by the function's id and the class, for hamCall().
2904
+ // A Ham Sandwich function hooked on a class: kept by the function's id and the class, for runHam().
2170
2905
  ham(this: FakeServer, plugin: PluginInstance, id: number, entityClass: number, fn: number, post: number) {
2171
2906
  const slot = this.takeSlot(plugin, fn, SHAPE_WIDE, `ham:${id}:${post}`, 0);
2172
2907
  const key = `${id}:${plugin.memory.string(entityClass)}:${post ? 'post' : 'pre'}`;
@@ -2177,13 +2912,158 @@ export class FakeServer {
2177
2912
  return this.hookSlots.size;
2178
2913
  },
2179
2914
 
2180
- tag(this: FakeServer, _plugin: PluginInstance, tag: number) {
2181
- this.pendingTag = tag;
2915
+ hook_on(this: FakeServer, _plugin: PluginInstance, handle: number, on: number) {
2916
+ this.switchHook(handle, on !== 0);
2917
+ },
2918
+
2919
+ // A hook's handler calls its plugin's one listener itself, or the handler again.
2920
+ hook_direct(this: FakeServer, _plugin: PluginInstance, handle: number, target: number, env: number, arg: number) {
2921
+ const slot = this.hookSlots.get(handle);
2922
+ if (slot) this.direct(slot, target, env, arg, false);
2923
+ },
2924
+
2925
+ // The module's hooks of the engine's and the game's functions
2926
+ // (runtime/src/enginehooks.h), kept with the natives' callbacks of the
2927
+ // same things: sendMessage, gameLog, changeCvar, touch, fireForward
2928
+ // and answerCvar call both.
2929
+ msg_hook(this: FakeServer, plugin: PluginInstance, id: number, fn: number) {
2930
+ return this.addModuleHook(plugin, fn, this.moduleMessageHooks, id);
2931
+ },
2932
+
2933
+ msg_argc(this: FakeServer, _plugin: PluginInstance) {
2934
+ return this.hookedMessage?.args.length ?? 0;
2935
+ },
2936
+
2937
+ msg_arg_type(this: FakeServer, _plugin: PluginInstance, index: number) {
2938
+ return this.hookedMessage?.types[index] ?? 0;
2939
+ },
2940
+
2941
+ msg_number(this: FakeServer, _plugin: PluginInstance, index: number) {
2942
+ const message = this.hookedMessage;
2943
+ const value = Number(message?.args[index] ?? 0) || 0;
2944
+ const type = message?.types[index];
2945
+ return type === ARG_COORD || type === ARG_ANGLE ? value : Math.trunc(value);
2946
+ },
2947
+
2948
+ msg_text(this: FakeServer, plugin: PluginInstance, index: number, out: number, max: number) {
2949
+ const value = this.hookedMessage?.args[index];
2950
+ return plugin.memory.setBytes(out, max, typeof value === 'string' ? value : '');
2951
+ },
2952
+
2953
+ msg_set_number(this: FakeServer, _plugin: PluginInstance, index: number, value: number) {
2954
+ const message = this.hookedMessage;
2955
+ const type = message?.types[index];
2956
+ if (!message || type === undefined || type === ARG_STRING) return;
2957
+ message.args[index] = type === ARG_COORD || type === ARG_ANGLE ? value : Math.trunc(value);
2958
+ },
2959
+
2960
+ msg_set_text(this: FakeServer, plugin: PluginInstance, index: number, text: number) {
2961
+ const message = this.hookedMessage;
2962
+ if (message?.types[index] === ARG_STRING) message.args[index] = plugin.memory.string(text);
2963
+ },
2964
+
2965
+ log_hook(this: FakeServer, plugin: PluginInstance, argc: number, filter: number, fn: number) {
2966
+ const text = plugin.memory.string(filter);
2967
+ const slot = this.takeSlot(plugin, fn, SHAPE_WIDE, '', 0);
2968
+ this.logEvents.push({ argc, filters: text ? [text] : [], slot });
2969
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2970
+ return this.hookSlots.size;
2971
+ },
2972
+
2973
+ cvar_hook(this: FakeServer, plugin: PluginInstance, name: number, fn: number) {
2974
+ const cvar = this.cvars.get(plugin.memory.string(name).toLowerCase());
2975
+ if (!cvar) return 0;
2976
+ const slot = this.takeSlot(plugin, fn, SHAPE_WIDE, '', 0);
2977
+ cvar.hooks.push(slot);
2978
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2979
+ return this.hookSlots.size;
2980
+ },
2981
+
2982
+ // Every cvar's change is heard as it is made here.
2983
+ cvar_exact() {
2984
+ return 1;
2985
+ },
2986
+
2987
+ state_hook(this: FakeServer, plugin: PluginInstance, classname: number, fn: number) {
2988
+ const slot = this.takeSlot(plugin, fn, SHAPE_WIDE, '', 0);
2989
+ this.stateHooks.push({ classname: plugin.memory.string(classname), slot });
2990
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
2991
+ return this.hookSlots.size;
2992
+ },
2993
+ // A movement event's touchCount (0) and frameTime (1), from server.playerMove.
2994
+ move_get(this: FakeServer, plugin: PluginInstance, field: number) {
2995
+ return field === 0 ? this.playerMove.touchCount : field === 1 ? this.playerMove.frameTime : 0;
2996
+ },
2997
+ state_get(this: FakeServer, plugin: PluginInstance, field: number) {
2998
+ return this.sending?.[field] ?? 0;
2999
+ },
3000
+ state_set(this: FakeServer, plugin: PluginInstance, field: number, value: number) {
3001
+ if (this.sending) this.sending[field] = value;
3002
+ },
3003
+
3004
+ touch_hook(this: FakeServer, plugin: PluginInstance, touched: number, toucher: number, fn: number) {
3005
+ const slot = this.takeSlot(plugin, fn, SHAPE_WIDE, '', 0);
3006
+ this.touches.push({ touched: plugin.memory.string(touched), toucher: plugin.memory.string(toucher), slot });
3007
+ this.hookSlots.set(this.hookSlots.size + 1, slot);
3008
+ return this.hookSlots.size;
3009
+ },
3010
+
3011
+ stock_hook(this: FakeServer, plugin: PluginInstance, fn: number, handler: number, post: number) {
3012
+ return this.addModuleHook(plugin, handler, this.stockHooks, `${fn}:${post ? 'post' : 'pre'}`);
3013
+ },
3014
+
3015
+ // A question to the client: answerCvar() answers it by its id.
3016
+ query_cvar(this: FakeServer, plugin: PluginInstance, id: number, name: number) {
3017
+ if (!this.players.some(each => each.id === id)) return 0;
3018
+ const request = ++this.cvarRequests;
3019
+ this.cvarQueries.push({ player: id, cvar: plugin.memory.string(name).toLowerCase(), request });
3020
+ return request;
2182
3021
  },
2183
3022
 
2184
- // A plugin that never awaits still takes its timers' handles from here.
2185
- co_id(this: FakeServer, _plugin: PluginInstance) {
2186
- return nextTaskId();
3023
+ chain_set(this: FakeServer, _plugin: PluginInstance, index: number, value: number) {
3024
+ this.chainSet(index, value);
3025
+ },
3026
+
3027
+ chain_set_text(this: FakeServer, plugin: PluginInstance, index: number, text: number) {
3028
+ this.chainSet(index, plugin.memory.string(text));
3029
+ },
3030
+
3031
+ game_api(this: FakeServer, _plugin: PluginInstance) {
3032
+ return this.chains ? 3 : 0;
3033
+ },
3034
+
3035
+ // One phase of a chain's listeners for game.endRound's dispatch, as module.cpp's chain_dispatch.
3036
+ chain_dispatch(this: FakeServer, plugin: PluginInstance, id: number, post: number, at: number, count: number, result: number) {
3037
+ const kind = tables().hookNames.get(id);
3038
+ const shape = kind === undefined ? undefined : tables().hooks.get(kind);
3039
+ if (!kind || !shape) return 0;
3040
+ const cells: ArgValue[] = Array.from({ length: count }, (_, i) => plugin.memory.cell(at + i * 4));
3041
+ const args: HookArg[] = cells.map((cell, i) => shape.floats.has(i) ? bitsFloat(cell as number) : cell as number);
3042
+ const previous = this.chain;
3043
+ this.chain = { shape, answer: result !== 0, args, cells };
3044
+ let said = 0;
3045
+ try {
3046
+ for (const slot of [...(this.hookchains.get(kind)?.[post ? 'post' : 'pre'] ?? [])]) {
3047
+ if (slot.off) continue;
3048
+ const outcome = this.withCallArgs(cells, () => this.call(slot, cells.slice(0, 4).map(Number), 0));
3049
+ if (outcome === OUTCOME_BREAK) {
3050
+ said |= 2;
3051
+ break;
3052
+ }
3053
+ if (outcome !== 0 && !post) said |= 1;
3054
+ }
3055
+ } finally {
3056
+ this.chain = previous;
3057
+ }
3058
+ cells.forEach((cell, i) => plugin.memory.setCell(at + i * 4, Number(cell)));
3059
+ return said;
3060
+ },
3061
+
3062
+ // ExecuteHam, which ham_bypass comes before, runs no listener here anyway.
3063
+ ham_bypass(this: FakeServer, _plugin: PluginInstance, _fn: number, _id: number) {},
3064
+
3065
+ tag(this: FakeServer, _plugin: PluginInstance, tag: number) {
3066
+ this.pendingTag = tag;
2187
3067
  },
2188
3068
 
2189
3069
  export(this: FakeServer, plugin: PluginInstance, name: number, fn: number) {
@@ -2197,12 +3077,12 @@ export class FakeServer {
2197
3077
  },
2198
3078
 
2199
3079
  arg(this: FakeServer, plugin: PluginInstance, index: number) {
2200
- const value = this.callArgs?.[index];
3080
+ const value = index === -1 && this.chain ? this.chainAnswer() : this.callArgs?.[index];
2201
3081
  return typeof value === 'number' ? value | 0 : typeof value === 'boolean' ? +value : 0;
2202
3082
  },
2203
3083
 
2204
3084
  arg_text(this: FakeServer, plugin: PluginInstance, index: number, out: number, max: number) {
2205
- const value = this.callArgs?.[index];
3085
+ const value = index === -1 && this.chain ? this.chainAnswer() : this.callArgs?.[index];
2206
3086
  return plugin.memory.setBytes(out, max, typeof value === 'string' ? value : '');
2207
3087
  },
2208
3088
 
@@ -2220,7 +3100,7 @@ export class FakeServer {
2220
3100
  },
2221
3101
 
2222
3102
  arg_array(this: FakeServer, plugin: PluginInstance, index: number, out: number, count: number) {
2223
- const given = this.callArgs?.[index];
3103
+ const given = index === -1 && this.chain ? [0, 0, 0] : this.callArgs?.[index];
2224
3104
  const value = given instanceof Pointer
2225
3105
  ? Array.from({ length: count }, (_, i) => given.plugin.memory.cell(given.at + i * 4))
2226
3106
  : given;
@@ -2230,6 +3110,10 @@ export class FakeServer {
2230
3110
  },
2231
3111
 
2232
3112
  set_arg_array(this: FakeServer, plugin: PluginInstance, index: number, cells: number, count: number) {
3113
+ if (index === -1 && this.chain) {
3114
+ this.chainSet(-1, 0);
3115
+ return count;
3116
+ }
2233
3117
  const value = this.callArgs?.[index];
2234
3118
  if (!Array.isArray(value) || count < 0) return 0;
2235
3119
  for (let i = 0; i < count; i++) value[i] = plugin.memory.cell(cells + i * 4);
@@ -2268,6 +3152,44 @@ export class FakeServer {
2268
3152
  if (at) setFieldCell(this.entities.get(id), at.field, cell, at.component);
2269
3153
  },
2270
3154
 
3155
+ // A vector entvar into the plugin's three numbers at `out`.
3156
+ ent_vector(this: FakeServer, plugin: PluginInstance, id: number, offset: number, out: number) {
3157
+ for (let i = 0; i < 3; i++) {
3158
+ const at = tables().entvarAt.get(offset + i * 4);
3159
+ plugin.memory.setNumber(out + i * 8, at ? bitsFloat(fieldCell(this.entities.get(id), at.field, at.component)) : 0);
3160
+ }
3161
+ },
3162
+
3163
+ // What a field's setter sends with the field: emessage_* to one player in the game.
3164
+ send_one(this: FakeServer, plugin: PluginInstance, player: number, type: number) {
3165
+ if (this.callNativeImpl(plugin, 'is_user_connected', [player]) === 0) return 0;
3166
+ return this.callNativeImpl(plugin, 'emessage_begin', [MSG_ONE, type, 0, player]);
3167
+ },
3168
+
3169
+ send_byte(this: FakeServer, plugin: PluginInstance, value: number) {
3170
+ this.writing?.args.push(value);
3171
+ },
3172
+
3173
+ send_long(this: FakeServer, plugin: PluginInstance, value: number) {
3174
+ this.writing?.args.push(value);
3175
+ },
3176
+
3177
+ send_string(this: FakeServer, plugin: PluginInstance, text: number) {
3178
+ this.writing?.args.push(plugin.memory.string(text));
3179
+ },
3180
+
3181
+ send_end(this: FakeServer, plugin: PluginInstance) {
3182
+ this.callNativeImpl(plugin, 'emessage_end', []);
3183
+ },
3184
+
3185
+ // A message of one or two numbers in one call: each written as its two bits of `kinds` say, 0 for none.
3186
+ send_cells(this: FakeServer, plugin: PluginInstance, player: number, type: number, first: number, second: number, kinds: number) {
3187
+ if (this.callNativeImpl(plugin, 'is_user_connected', [player]) === 0 || this.callNativeImpl(plugin, 'emessage_begin', [MSG_ONE, type, 0, player]) === 0) return;
3188
+ if (kinds & 3) this.writing?.args.push(first);
3189
+ if ((kinds >> 2) & 3) this.writing?.args.push(second);
3190
+ this.callNativeImpl(plugin, 'emessage_end', []);
3191
+ },
3192
+
2271
3193
  ent_entity(this: FakeServer, plugin: PluginInstance, id: number, offset: number) {
2272
3194
  const at = tables().entvarAt.get(offset);
2273
3195
  return at ? fieldCell(this.entities.get(id), at.field) : 0;
@@ -2280,7 +3202,11 @@ export class FakeServer {
2280
3202
 
2281
3203
  member_slot(this: FakeServer, plugin: PluginInstance, className: number, name: number) {
2282
3204
  const key = `${plugin.memory.string(className)}::${plugin.memory.string(name)}`;
2283
- const field = tables().memberNamed.get(key);
3205
+ // A member of ReGameDLL's own is not on a server without it.
3206
+ const regameOnly = ['m_bSkipShowMenu', 'm_bNeededPlayers', 'm_flEscapeRatio', 'm_flTimeLimit', 'm_flGameStartTime', 'm_bTeamBalanced'];
3207
+ if (!this.chains && regameOnly.includes(plugin.memory.string(name))) return -1;
3208
+ // A member the facade reads by its own name (m_iTeam) has reapi's name too.
3209
+ const field = tables().memberNamed.get(key) ?? tables().constants.get(plugin.memory.string(name));
2284
3210
  if (field === undefined) throw new Error(`no reapi member is ${key} in the gamedata`);
2285
3211
  const slot = this.memberSlots.indexOf(field);
2286
3212
  return slot >= 0 ? slot : this.memberSlots.push(field) - 1;
@@ -2335,6 +3261,14 @@ export class FakeServer {
2335
3261
  this.setPlayerData(id, plugin.memory.string(key), plugin.memory.string(value), true);
2336
3262
  },
2337
3263
 
3264
+ player_slots(this: FakeServer, plugin: PluginInstance, at: number) {
3265
+ plugin.playerSlots = at;
3266
+ },
3267
+
3268
+ player_names(this: FakeServer, plugin: PluginInstance, at: number) {
3269
+ plugin.playerNames = at;
3270
+ },
3271
+
2338
3272
  // playerChange: the module wakes a plugin for the fields it listens for.
2339
3273
  player_change_listen(this: FakeServer, plugin: PluginInstance, field: number, fn: number) {
2340
3274
  const name = plugin.memory.string(field);
@@ -2368,16 +3302,21 @@ export class FakeServer {
2368
3302
  // The dispatcher: a native with a `...` tail, its arguments laid out as
2369
3303
  // cells. Every mask letter but `n` is an address in the plugin's memory,
2370
3304
  // which is what a Pawn frame holds too - so the native gets the cells as
2371
- // they are, and reads through the addresses itself.
3305
+ // they are, and reads through the addresses itself; an `s` is the
3306
+ // plugin's string, which crosses as a Pawn string on the heap.
2372
3307
  call(this: FakeServer, plugin: PluginInstance, id: number, argsPtr: number, maskPtr: number, argc: number) {
2373
3308
  const name = tables().dispatched.get(id);
2374
3309
  if (!name) throw new Error(`the dispatcher has no native with id ${id}`);
2375
3310
 
2376
3311
  const args: number[] = [];
2377
- for (let i = 0; i < argc; i++) args.push(plugin.memory.cell(argsPtr + i * 4));
3312
+ const marks: string[] = [];
3313
+ for (let i = 0; i < argc; i++) {
3314
+ args.push(plugin.memory.cell(argsPtr + i * 4));
3315
+ marks.push(String.fromCharCode(plugin.memory.byte(maskPtr + i)));
3316
+ }
2378
3317
 
2379
3318
  if (!this.hasNative(name)) throw unsimulated(name);
2380
- return this.callNativeImpl(plugin, name, args);
3319
+ return plugin.memory.across(marks, args, cells => this.callNativeImpl(plugin, name, cells));
2381
3320
  },
2382
3321
  };
2383
3322