@s2script/sdk 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/clients.d.ts CHANGED
@@ -60,13 +60,8 @@ export declare class Client {
60
60
  * or a name that is not a registered console **command** (a ConVar such as `mp_friendlyfire` is
61
61
  * refused: use `Server.command` for those).
62
62
  *
63
- * KNOWN LIMITATION: engine commands (`say`, `kill`, …) execute. A command registered by an
64
- * s2script plugin is dispatched by the engine, but its JS handler will NOT run — the core holds
65
- * the isolate borrow across all JS, so re-entering the dispatcher hits the documented
66
- * re-entrancy skip. (This is not a permissions issue: every s2script command is registered
67
- * client-executable, which is why a player can type it in their own console.) To invoke another
68
- * plugin's behaviour, use a cross-plugin interface rather than faking its command. See
69
- * docs/superpowers/specs/2026-07-25-client-command-design.md §12.
63
+ * Engine commands (`say`, `kill`, …) execute. A command registered by an s2script plugin runs
64
+ * that plugin's JS handler before this returns.
70
65
  *
71
66
  * @example
72
67
  * client.fakeCommand("say hello"); // as though the player typed it
package/commands.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** @s2script/commands — register server commands. NO runtime code (injected at load). */
2
+ import type { PhraseKey } from "./phrases";
2
3
 
3
4
  import type { HookResultValue } from "./events";
4
5
 
@@ -72,9 +73,13 @@ export interface CommandInvocation {
72
73
  * is printed immediately. The server console (`callerSlot` `-1`) prints to the server console.
73
74
  */
74
75
  replyToConsole(message: string): void;
75
- /** reply to the caller, translated for THEIR language (SM's `%t` on the reply path). Soft-deps
76
- * `@s2script/translations` — degrades to the raw `key` if it isn't loaded. */
77
- replyT(key: string, ...args: (string | number)[]): void;
76
+ /** Reply to the caller, translated for THEIR language (SM's `%t` on the reply path). Soft-deps
77
+ * `@s2script/translations` — degrades to the raw `key` if it isn't loaded.
78
+ *
79
+ * `key` is checked against this plugin's phrase file plus the shared one (see
80
+ * `@s2script/sdk/phrases`); it widens to `string` in a plugin that has no phrase file, so this is
81
+ * never in the way. */
82
+ replyT(key: PhraseKey, ...args: (string | number)[]): void;
78
83
  }
79
84
  /** A parsed chat trigger: which command + args, and whether it was the silent (`/`) trigger. */
80
85
  export interface ChatTrigger {
package/dist/cli.js CHANGED
@@ -3766,8 +3766,12 @@ function localDeclarationFiles(pluginDir) {
3766
3766
  return readdirSync2(srcDir).filter((f) => f.endsWith(".d.ts")).map((f) => join7(srcDir, f));
3767
3767
  }
3768
3768
  function generatedDeclarationFiles(pluginDir) {
3769
- const gd = join7(pluginDir, ".s2script", "gamedata.d.ts");
3770
- return existsSync6(gd) ? [gd] : [];
3769
+ const out = [];
3770
+ for (const name of ["gamedata.d.ts", "hooks.d.ts"]) {
3771
+ const p = join7(pluginDir, ".s2script", name);
3772
+ if (existsSync6(p)) out.push(p);
3773
+ }
3774
+ return out;
3771
3775
  }
3772
3776
  function gamePackageDeclarationFiles(pkg, packagesDir) {
3773
3777
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
@@ -4205,25 +4209,31 @@ function scanPluginProgram(program, pluginDir) {
4205
4209
 
4206
4210
  // src/gamedata/types.ts
4207
4211
  var PLATFORM = "linuxsteamrt64";
4208
- var ARG_KINDS = ["bool", "int", "float", "string", "vector", "entity"];
4212
+ var ARG_KINDS = ["bool", "int", "float", "string", "utlstring", "vector", "entity"];
4209
4213
  var RET_KINDS = ["void", "bool", "int", "float", "entity"];
4210
- var INT_CLASS_ARGS = /* @__PURE__ */ new Set(["bool", "int", "string", "vector", "entity"]);
4214
+ var INT_CLASS_ARGS = /* @__PURE__ */ new Set(["bool", "int", "string", "utlstring", "vector", "entity"]);
4211
4215
  var MAX_INT_ARGS = 9;
4212
4216
  var MAX_FLOAT_ARGS = 8;
4217
+ var HOOK_SHAPES = ["this_void", "this_f32_i32_i32_i32", "this_f32_i32_i64_i64", "this_i64_i32_i64", "this_i64_i64_i64"];
4218
+ function hookShapeArity(shape) {
4219
+ if (!HOOK_SHAPES.includes(shape)) return void 0;
4220
+ if (shape === "this_void") return 0;
4221
+ return shape.slice("this_".length).split("_").length;
4222
+ }
4213
4223
 
4214
4224
  // src/gamedata/validate.ts
4215
- var ALLOWED_SECTIONS = /* @__PURE__ */ new Set(["signatures", "calls"]);
4225
+ var ALLOWED_SECTIONS = /* @__PURE__ */ new Set(["signatures", "calls", "hooks"]);
4216
4226
  var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
4217
4227
  var RESERVED_CALL_NAMES = /* @__PURE__ */ new Set(["constructor", "prototype", "__proto__"]);
4218
4228
  var RESOLVE_KINDS = ["direct", "ctor-body-xref", "lea-disp"];
4219
- var KNOWN_PERMISSIONS = ["engine:calls"];
4229
+ var KNOWN_PERMISSIONS = ["engine:calls", "engine:hooks"];
4220
4230
  function validatePluginGamedata(gd, opts) {
4221
4231
  const errs = [];
4222
4232
  if (gd == null || typeof gd !== "object" || Array.isArray(gd)) return ["gamedata must be an object"];
4223
4233
  const g = gd;
4224
4234
  for (const key of Object.keys(g)) {
4225
4235
  if (!ALLOWED_SECTIONS.has(key)) {
4226
- errs.push(`gamedata section '${key}' is not supported in v1 (allowed: signatures, calls)`);
4236
+ errs.push(`gamedata section '${key}' is not supported in v1 (allowed: signatures, calls, hooks)`);
4227
4237
  }
4228
4238
  }
4229
4239
  const sigs = g.signatures ?? {};
@@ -4358,8 +4368,247 @@ function validatePluginGamedata(gd, opts) {
4358
4368
  errs.push(`${where2}: 'returns' must be one of ${RET_KINDS.join(", ")}`);
4359
4369
  }
4360
4370
  }
4371
+ validateHooks(g, sigs, callNames, opts.permissions, errs);
4361
4372
  return errs;
4362
4373
  }
4374
+ function isNonEmptyObject(v) {
4375
+ return v != null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length > 0;
4376
+ }
4377
+ function validateHooks(g, sigs, callNames, permissions, errs) {
4378
+ if (g.hooks === void 0) return;
4379
+ if (g.hooks == null || typeof g.hooks !== "object" || Array.isArray(g.hooks)) {
4380
+ errs.push("hooks must be an object");
4381
+ return;
4382
+ }
4383
+ const hooks = g.hooks;
4384
+ const hookNames = Object.keys(hooks);
4385
+ if (hookNames.length && !permissions.includes("engine:hooks")) {
4386
+ errs.push('gamedata declares a `hooks` section but the manifest does not declare permission "engine:hooks"');
4387
+ }
4388
+ for (const [name, rawDecl] of Object.entries(hooks)) {
4389
+ const where2 = `hook '${name}'`;
4390
+ if (!IDENTIFIER.test(name)) {
4391
+ errs.push(
4392
+ `${where2}: hook name must be a plain identifier matching ${IDENTIFIER.source} \u2014 it is emitted verbatim as a TypeScript interface member in the generated .s2script/hooks.d.ts`
4393
+ );
4394
+ continue;
4395
+ }
4396
+ if (RESERVED_CALL_NAMES.has(name)) {
4397
+ errs.push(`${where2}: hook name '${name}' is reserved`);
4398
+ continue;
4399
+ }
4400
+ if (rawDecl == null || typeof rawDecl !== "object" || Array.isArray(rawDecl)) {
4401
+ errs.push(`${where2}: must be an object`);
4402
+ continue;
4403
+ }
4404
+ const decl = rawDecl;
4405
+ const ctx = decl.expose?.ctx;
4406
+ if (typeof ctx !== "string" || !ctx.length) {
4407
+ errs.push(`${where2}: missing 'expose.ctx' \u2014 nothing could subscribe to this hook`);
4408
+ } else if (!IDENTIFIER.test(ctx)) {
4409
+ errs.push(`${where2}: 'expose.ctx' must be a plain identifier, got ${JSON.stringify(ctx)}`);
4410
+ }
4411
+ const shape = decl.shape;
4412
+ if (typeof shape !== "string" || !HOOK_SHAPES.includes(shape)) {
4413
+ errs.push(
4414
+ `${where2}: unknown hook shape ${JSON.stringify(shape)} (this build compiles thunks for: ${HOOK_SHAPES.join(", ")})`
4415
+ );
4416
+ }
4417
+ const params = decl.params ?? [];
4418
+ if (!Array.isArray(params)) {
4419
+ errs.push(`${where2}: 'params' must be an array`);
4420
+ } else {
4421
+ const seen = /* @__PURE__ */ new Set();
4422
+ for (let i2 = 0; i2 < params.length; i2++) {
4423
+ const p = params[i2];
4424
+ if (typeof p !== "string" || !IDENTIFIER.test(p)) {
4425
+ errs.push(`${where2}: params[${i2}] = ${JSON.stringify(p)} must be a plain identifier`);
4426
+ continue;
4427
+ }
4428
+ if (seen.has(p)) errs.push(`${where2}: param name ${JSON.stringify(p)} is declared more than once`);
4429
+ seen.add(p);
4430
+ }
4431
+ const arity = typeof shape === "string" ? hookShapeArity(shape) : void 0;
4432
+ if (arity !== void 0 && params.length > arity) {
4433
+ errs.push(
4434
+ `${where2}: declares ${params.length} 'params' but shape ${JSON.stringify(shape)} passes only ${arity}`
4435
+ );
4436
+ }
4437
+ if (decl.mutable !== void 0) {
4438
+ const mutable = decl.mutable;
4439
+ if (!Array.isArray(mutable)) {
4440
+ errs.push(`${where2}: 'mutable' must be an array`);
4441
+ } else {
4442
+ for (const m of mutable) {
4443
+ if (typeof m !== "string" || !params.includes(m)) {
4444
+ errs.push(`${where2}: 'mutable' names ${JSON.stringify(m)}, which is not one of this hook's params`);
4445
+ }
4446
+ }
4447
+ }
4448
+ }
4449
+ }
4450
+ const recv = decl.receiver;
4451
+ if (recv !== void 0) {
4452
+ if (recv == null || typeof recv !== "object") {
4453
+ errs.push(`${where2}: 'receiver' must be an object`);
4454
+ } else {
4455
+ const rkind = recv.kind ?? "none";
4456
+ if (rkind !== "none" && rkind !== "entity") {
4457
+ errs.push(`${where2}: receiver.kind must be "none" or "entity"`);
4458
+ } else if (rkind === "entity") {
4459
+ if (typeof recv.as !== "string" || !IDENTIFIER.test(recv.as)) {
4460
+ errs.push(`${where2}: receiver.kind "entity" needs a plain-identifier 'as' name`);
4461
+ } else if (Array.isArray(params) && params.includes(recv.as)) {
4462
+ errs.push(`${where2}: receiver 'as' name ${JSON.stringify(recv.as)} collides with a param name`);
4463
+ }
4464
+ }
4465
+ }
4466
+ }
4467
+ if (decl.bypassWith !== void 0) {
4468
+ if (typeof decl.bypassWith !== "string" || !decl.bypassWith.length) {
4469
+ errs.push(`${where2}: 'bypassWith' must be a non-empty call name`);
4470
+ } else if (!callNames.includes(decl.bypassWith)) {
4471
+ errs.push(
4472
+ `${where2}: 'bypassWith' names ${JSON.stringify(decl.bypassWith)}, which is not a 'calls' descriptor in this owner's gamedata`
4473
+ );
4474
+ }
4475
+ }
4476
+ const target = decl.target;
4477
+ if (!target) {
4478
+ errs.push(`${where2}: missing 'target'`);
4479
+ continue;
4480
+ }
4481
+ if (target.kind === "signature") {
4482
+ if (typeof target.name !== "string") {
4483
+ errs.push(`${where2}: target.name must be a string`);
4484
+ } else if (!(target.name in sigs)) {
4485
+ errs.push(`${where2}: target.name '${target.name}' has no entry in 'signatures'`);
4486
+ }
4487
+ const sigPlat = typeof target.name === "string" ? sigs[target.name]?.[PLATFORM] : void 0;
4488
+ const inherited = sigPlat?.validate;
4489
+ if (!isNonEmptyObject(target.validate) && !isNonEmptyObject(inherited)) {
4490
+ errs.push(
4491
+ `${where2}: a hook target MUST carry a non-empty 'validate' (inline, or inherited from its named signature) \u2014 a wrong detour address overwrites the prologue of whatever is actually there`
4492
+ );
4493
+ }
4494
+ } else if (target.kind === "vtable") {
4495
+ if (typeof target.class !== "string") errs.push(`${where2}: vtable target requires 'class'`);
4496
+ const plat = target[PLATFORM];
4497
+ if (!plat) {
4498
+ errs.push(`${where2}: vtable target missing platform '${PLATFORM}'`);
4499
+ } else {
4500
+ if (!Number.isInteger(plat.index) || plat.index < 0) {
4501
+ errs.push(`${where2}: vtable index must be a non-negative integer`);
4502
+ }
4503
+ const prologue = plat.validate?.prologue;
4504
+ if (typeof prologue !== "string" || !prologue.length) {
4505
+ errs.push(`${where2}: a vtable target REQUIRES validate.prologue (a bare borrowed index is never trusted)`);
4506
+ }
4507
+ }
4508
+ } else {
4509
+ errs.push(`${where2}: target.kind must be "signature" or "vtable"`);
4510
+ }
4511
+ }
4512
+ }
4513
+
4514
+ // src/hookgen/model.ts
4515
+ function capitalize(name) {
4516
+ return name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1);
4517
+ }
4518
+ function hookDescriptor(name, decl) {
4519
+ const paramNames = decl.params ?? [];
4520
+ const mutableSet = new Set(decl.mutable ?? []);
4521
+ const params = paramNames.map((p) => ({ name: p, mutable: mutableSet.has(p) }));
4522
+ const receiverAs = decl.receiver?.kind === "entity" ? decl.receiver.as ?? null : null;
4523
+ return { name, shape: decl.shape ?? "", viewIface: `${capitalize(name)}View`, params, receiverAs };
4524
+ }
4525
+ function buildPluginHookList(hooks) {
4526
+ return Object.keys(hooks).sort().map((name) => hookDescriptor(name, hooks[name] ?? {}));
4527
+ }
4528
+ function buildHookModel(hooks) {
4529
+ const byNs = /* @__PURE__ */ new Map();
4530
+ for (const name of Object.keys(hooks).sort()) {
4531
+ const decl = hooks[name] ?? {};
4532
+ const ns = decl.expose?.ctx;
4533
+ if (!ns) continue;
4534
+ if (decl.expose?.handwritten) continue;
4535
+ const list = byNs.get(ns) ?? [];
4536
+ list.push(hookDescriptor(name, decl));
4537
+ byNs.set(ns, list);
4538
+ }
4539
+ return Array.from(byNs.keys()).sort().map((ns) => ({ ns, ctxIface: `Ctx${capitalize(ns)}`, hooks: byNs.get(ns) }));
4540
+ }
4541
+
4542
+ // src/hookgen/emit-dts.ts
4543
+ var HEADER = "// GENERATED by `s2script gen-hooks` from gamedata/cs2/game.cs2.jsonc's `hooks` \u2014 DO NOT EDIT.";
4544
+ var STRING_PARAMS = {
4545
+ this_i64_i64_i64: /* @__PURE__ */ new Set([0])
4546
+ };
4547
+ function emitViewIface(h2) {
4548
+ const out = [`export interface ${h2.viewIface} {`];
4549
+ const strings = STRING_PARAMS[h2.shape] ?? /* @__PURE__ */ new Set();
4550
+ h2.params.forEach((p, i2) => {
4551
+ const mod = p.mutable ? "" : "readonly ";
4552
+ out.push(` ${mod}${p.name}: ${strings.has(i2) ? "string" : "number"};`);
4553
+ });
4554
+ if (h2.receiverAs !== null) {
4555
+ out.push(` readonly ${h2.receiverAs}: EntityRef | null;`);
4556
+ }
4557
+ out.push("}", "");
4558
+ return out;
4559
+ }
4560
+ function emitHookDts(model) {
4561
+ const out = [
4562
+ HEADER,
4563
+ 'import type { HookResultValue } from "@s2script/sdk/events";',
4564
+ 'import type { EntityRef } from "@s2script/sdk/entity";',
4565
+ ""
4566
+ ];
4567
+ for (const { hooks } of model) {
4568
+ for (const h2 of hooks) {
4569
+ out.push(...emitViewIface(h2));
4570
+ }
4571
+ }
4572
+ for (const { ctxIface, hooks } of model) {
4573
+ out.push(`export interface ${ctxIface} {`);
4574
+ for (const h2 of hooks) {
4575
+ out.push(` ${h2.name}(handler: (view: ${h2.viewIface}) => HookResultValue | void): void;`);
4576
+ }
4577
+ out.push("}", "");
4578
+ }
4579
+ out.push('declare module "@s2script/sdk/plugin" {');
4580
+ out.push(" interface PluginContext {");
4581
+ for (const { ns, ctxIface } of model) {
4582
+ out.push(` readonly ${ns}: ${ctxIface};`);
4583
+ }
4584
+ out.push(" }");
4585
+ out.push("}");
4586
+ return out.join("\n") + "\n";
4587
+ }
4588
+ var PLUGIN_HEADER = "// GENERATED by `s2s build` from the plugin's gamedata \u2014 DO NOT EDIT.";
4589
+ function emitPluginHookDts(hooks) {
4590
+ const list = buildPluginHookList(hooks);
4591
+ const out = [
4592
+ PLUGIN_HEADER,
4593
+ "// The EntityRef / HookResultValue imports are REQUIRED: a `declare module` augmentation",
4594
+ "// resolves type names in its own file scope.",
4595
+ 'import type { HookResultValue } from "@s2script/sdk/events";',
4596
+ 'import type { EntityRef } from "@s2script/sdk/entity";',
4597
+ ""
4598
+ ];
4599
+ for (const h2 of list) {
4600
+ out.push(...emitViewIface(h2));
4601
+ }
4602
+ out.push('declare module "@s2script/sdk/unsafe" {');
4603
+ out.push(" interface EngineHooks {");
4604
+ for (const h2 of list) {
4605
+ out.push(` ${h2.name}: (handler: (view: ${h2.viewIface}) => HookResultValue | void) => void;`);
4606
+ }
4607
+ out.push(" }");
4608
+ out.push("}");
4609
+ out.push("");
4610
+ return out.join("\n");
4611
+ }
4363
4612
 
4364
4613
  // src/gamedata/gen-types.ts
4365
4614
  var VECTOR_TS = "{ readonly x: number; readonly y: number; readonly z: number }";
@@ -4368,6 +4617,7 @@ var ARG_TS = {
4368
4617
  int: "number",
4369
4618
  float: "number",
4370
4619
  string: "string",
4620
+ utlstring: "string",
4371
4621
  vector: VECTOR_TS,
4372
4622
  entity: "EntityRef | null"
4373
4623
  };
@@ -4405,6 +4655,9 @@ ${lines.join("\n")}
4405
4655
  ""
4406
4656
  ].join("\n");
4407
4657
  }
4658
+ function generateHookTypes(gd) {
4659
+ return emitPluginHookDts(gd.hooks ?? {});
4660
+ }
4408
4661
 
4409
4662
  // src/gamedata/jsonc.ts
4410
4663
  function stripJsonComments(src) {
@@ -4514,8 +4767,14 @@ async function buildPlugin(dir, packagesDir) {
4514
4767
  const genDir = join11(absDir, ".s2script");
4515
4768
  mkdirSync2(genDir, { recursive: true });
4516
4769
  writeFileSync3(join11(genDir, "gamedata.d.ts"), generateGamedataTypes(gamedata));
4770
+ if (gamedata.hooks && Object.keys(gamedata.hooks).length) {
4771
+ writeFileSync3(join11(genDir, "hooks.d.ts"), generateHookTypes(gamedata));
4772
+ } else {
4773
+ rmSync2(join11(genDir, "hooks.d.ts"), { force: true });
4774
+ }
4517
4775
  } else {
4518
4776
  rmSync2(join11(absDir, ".s2script", "gamedata.d.ts"), { force: true });
4777
+ rmSync2(join11(absDir, ".s2script", "hooks.d.ts"), { force: true });
4519
4778
  }
4520
4779
  const declaredDepNames = [
4521
4780
  ...Object.keys(s2.pluginDependencies ?? {}),
@@ -8837,7 +9096,7 @@ function buildModel(catalog, requested, embeddable = [], enumCatalog = {}) {
8837
9096
  }
8838
9097
 
8839
9098
  // src/schemagen/emit-dts.ts
8840
- var HEADER = "// GENERATED by `s2script gen-schema` from schema-catalog.json \u2014 DO NOT EDIT.";
9099
+ var HEADER2 = "// GENERATED by `s2script gen-schema` from schema-catalog.json \u2014 DO NOT EDIT.";
8841
9100
  function emitDts(model) {
8842
9101
  const enumTs = new Map(model.enums.map((e) => [e.className, e.tsName]));
8843
9102
  const tsType = (f) => {
@@ -8845,7 +9104,7 @@ function emitDts(model) {
8845
9104
  return id ? `${id} | null` : TSTYPE[f.accessorKind];
8846
9105
  };
8847
9106
  const names = new Set(model.classes.map((c2) => c2.className));
8848
- const out = [HEADER, 'import type { EntityRef } from "@s2script/sdk/entity";'];
9107
+ const out = [HEADER2, 'import type { EntityRef } from "@s2script/sdk/entity";'];
8849
9108
  const vecClasses = /* @__PURE__ */ new Set();
8850
9109
  for (const c2 of model.classes) for (const f of c2.ownFields) {
8851
9110
  const vi = VEC_INFO[f.accessorKind];
@@ -8913,7 +9172,7 @@ function emitDts(model) {
8913
9172
  }
8914
9173
 
8915
9174
  // src/schemagen/emit-js.ts
8916
- var HEADER2 = "// GENERATED by `s2script gen-schema` from schema-catalog.json \u2014 DO NOT EDIT.";
9175
+ var HEADER3 = "// GENERATED by `s2script gen-schema` from schema-catalog.json \u2014 DO NOT EDIT.";
8917
9176
  var S = JSON.stringify;
8918
9177
  function resolveExpr(f) {
8919
9178
  const base = `off(${S(f.declaringClass)},${S(f.rawName)})`;
@@ -8940,7 +9199,7 @@ function accessorEntry(f, resolve22, guard2) {
8940
9199
  return entry;
8941
9200
  }
8942
9201
  function emitJs(model) {
8943
- const out = [HEADER2, "(function () {", " var off = __s2_schema_offset;", " var A = {};", " var E = {};"];
9202
+ const out = [HEADER3, "(function () {", " var off = __s2_schema_offset;", " var A = {};", " var E = {};"];
8944
9203
  const vecClasses = /* @__PURE__ */ new Set();
8945
9204
  for (const c2 of model.classes) for (const f of c2.ownFields) {
8946
9205
  const vi = VEC_INFO[f.accessorKind];
@@ -9102,7 +9361,7 @@ function buildEventModel(catalog) {
9102
9361
  }
9103
9362
 
9104
9363
  // src/eventgen/emit-dts.ts
9105
- var HEADER3 = "// GENERATED by `s2script gen-events` from event-catalog.json \u2014 DO NOT EDIT.";
9364
+ var HEADER4 = "// GENERATED by `s2script gen-events` from event-catalog.json \u2014 DO NOT EDIT.";
9106
9365
  var GETTER_RET = {
9107
9366
  getBool: "boolean",
9108
9367
  getFloat: "number",
@@ -9113,7 +9372,7 @@ var GETTER_RET = {
9113
9372
  };
9114
9373
  function emitEventDts(model) {
9115
9374
  const out = [
9116
- HEADER3,
9375
+ HEADER4,
9117
9376
  'import type { GameEvent, HookResultValue } from "@s2script/sdk/events";',
9118
9377
  ""
9119
9378
  ];
@@ -9232,7 +9491,7 @@ function buildNavModel(config, catalog) {
9232
9491
  }
9233
9492
 
9234
9493
  // src/navgen/emit-js.ts
9235
- var HEADER4 = "// GENERATED by `s2script gen-nav` from nav-targets.json \u2014 DO NOT EDIT.";
9494
+ var HEADER5 = "// GENERATED by `s2script gen-nav` from nav-targets.json \u2014 DO NOT EDIT.";
9236
9495
  var S2 = JSON.stringify;
9237
9496
  var READ_VIA = {
9238
9497
  f32: "readFloat32Via",
@@ -9250,7 +9509,7 @@ var WRITE_VIA = {
9250
9509
  i32: "writeInt32Via"
9251
9510
  };
9252
9511
  function emitNavJs(model) {
9253
- const out = [HEADER4, "(function () {", " var off = __s2_schema_offset;"];
9512
+ const out = [HEADER5, "(function () {", " var off = __s2_schema_offset;"];
9254
9513
  const vecClasses = /* @__PURE__ */ new Set();
9255
9514
  for (const w of model.wrappers) {
9256
9515
  for (const f of w.fields) {
@@ -9332,7 +9591,7 @@ function emitNavJs(model) {
9332
9591
  }
9333
9592
 
9334
9593
  // src/navgen/emit-dts.ts
9335
- var HEADER5 = "// GENERATED by `s2script gen-nav` from nav-targets.json \u2014 DO NOT EDIT.";
9594
+ var HEADER6 = "// GENERATED by `s2script gen-nav` from nav-targets.json \u2014 DO NOT EDIT.";
9336
9595
  function emitNavDts(model) {
9337
9596
  let needsEntityRef = false;
9338
9597
  let needsVector = false;
@@ -9345,7 +9604,7 @@ function emitNavDts(model) {
9345
9604
  if (vi?.cls === "QAngle") needsQAngle = true;
9346
9605
  }
9347
9606
  }
9348
- const out = [HEADER5];
9607
+ const out = [HEADER6];
9349
9608
  const mathTypes = [];
9350
9609
  if (needsVector) mathTypes.push("Vector");
9351
9610
  if (needsQAngle) mathTypes.push("QAngle");
@@ -9416,72 +9675,6 @@ function runGenNav(repoRoot2, opts) {
9416
9675
  // src/hookgen/gen.ts
9417
9676
  import { readFileSync as readFileSync22, writeFileSync as writeFileSync17 } from "node:fs";
9418
9677
  import { join as join27 } from "node:path";
9419
-
9420
- // src/hookgen/model.ts
9421
- function capitalize(name) {
9422
- return name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1);
9423
- }
9424
- function buildHookModel(hooks) {
9425
- const byNs = /* @__PURE__ */ new Map();
9426
- for (const name of Object.keys(hooks).sort()) {
9427
- const decl = hooks[name] ?? {};
9428
- const ns = decl.expose?.ctx;
9429
- if (!ns) continue;
9430
- const paramNames = decl.params ?? [];
9431
- const mutableSet = new Set(decl.mutable ?? []);
9432
- const params = paramNames.map((p) => ({ name: p, mutable: mutableSet.has(p) }));
9433
- const receiverAs = decl.receiver?.kind === "entity" ? decl.receiver.as ?? null : null;
9434
- const list = byNs.get(ns) ?? [];
9435
- list.push({ name, viewIface: `${capitalize(name)}View`, params, receiverAs });
9436
- byNs.set(ns, list);
9437
- }
9438
- return Array.from(byNs.keys()).sort().map((ns) => ({ ns, ctxIface: `Ctx${capitalize(ns)}`, hooks: byNs.get(ns) }));
9439
- }
9440
-
9441
- // src/hookgen/emit-dts.ts
9442
- var HEADER6 = "// GENERATED by `s2script gen-hooks` from gamedata/cs2/game.cs2.jsonc's `hooks` \u2014 DO NOT EDIT.";
9443
- function emitViewIface(h2) {
9444
- const out = [`export interface ${h2.viewIface} {`];
9445
- for (const p of h2.params) {
9446
- const mod = p.mutable ? "" : "readonly ";
9447
- out.push(` ${mod}${p.name}: number;`);
9448
- }
9449
- if (h2.receiverAs !== null) {
9450
- out.push(` readonly ${h2.receiverAs}: EntityRef | null;`);
9451
- }
9452
- out.push("}", "");
9453
- return out;
9454
- }
9455
- function emitHookDts(model) {
9456
- const out = [
9457
- HEADER6,
9458
- 'import type { HookResultValue } from "@s2script/sdk/events";',
9459
- 'import type { EntityRef } from "@s2script/sdk/entity";',
9460
- ""
9461
- ];
9462
- for (const { hooks } of model) {
9463
- for (const h2 of hooks) {
9464
- out.push(...emitViewIface(h2));
9465
- }
9466
- }
9467
- for (const { ctxIface, hooks } of model) {
9468
- out.push(`export interface ${ctxIface} {`);
9469
- for (const h2 of hooks) {
9470
- out.push(` ${h2.name}(handler: (view: ${h2.viewIface}) => HookResultValue | void): void;`);
9471
- }
9472
- out.push("}", "");
9473
- }
9474
- out.push('declare module "@s2script/sdk/plugin" {');
9475
- out.push(" interface PluginContext {");
9476
- for (const { ns, ctxIface } of model) {
9477
- out.push(` readonly ${ns}: ${ctxIface};`);
9478
- }
9479
- out.push(" }");
9480
- out.push("}");
9481
- return out.join("\n") + "\n";
9482
- }
9483
-
9484
- // src/hookgen/gen.ts
9485
9678
  var GAMEDATA_PATH = "gamedata/cs2/game.cs2.jsonc";
9486
9679
  var DTS_OUT4 = "packages/cs2/hooks.generated.d.ts";
9487
9680
  function runGenHooks(repoRoot2, opts) {
package/entity.d.ts CHANGED
@@ -158,6 +158,40 @@ export declare class EntityRef {
158
158
  * `trigger_multiple` needs a model to build the physics volume that fires touch. Returns false
159
159
  * if the op is unavailable or the ref is stale. */
160
160
  setModel(name: string): boolean;
161
+ /** Set this entity's gravity multiplier (`CBaseEntity::SetGravityScale`). 1 is normal, 0 is
162
+ * weightless.
163
+ *
164
+ * Use this rather than writing `m_flGravityScale`: the engine setter early-returns when the value
165
+ * is unchanged and maintains a second field (`m_flActualGravityScale`), so a raw field write
166
+ * appears to succeed and does nothing. Returns false if the op is unavailable, the ref is stale,
167
+ * or `scale` is not finite. */
168
+ setGravityScale(scale: number): boolean;
169
+ /** Add to this entity's velocity, physics-aware (`CBaseEntity::ApplyAbsVelocityImpulse`) — for
170
+ * knockback, boosts, explosions.
171
+ *
172
+ * Additive, unlike `teleport(null, null, velocity)` which sets velocity absolutely; and unlike a
173
+ * direct `m_vecAbsVelocity` write, which skips the partition/physics update. `impulse` is an
174
+ * [x,y,z] triple. A zero impulse is a legal no-op. Returns false if the op is unavailable, the ref
175
+ * is stale, or any component is not finite. */
176
+ applyAbsVelocityImpulse(impulse: number[]): boolean;
177
+ /** Stop a sound playing on this entity (`CBaseEntity::StopSound`). `Sound.stop(name, { entity })`
178
+ * in `@s2script/sdk/sound` is the same call spelled next to `Sound.emit`, and
179
+ * `pawn.stopSound(name)` in `@s2script/cs2` is the pawn-shaped one. Returns false if the op is
180
+ * unavailable or the ref is stale. */
181
+ stopSound(name: string): boolean;
182
+ /** Set a model body group by name (`CBaseModelEntity::SetBodyGroupByName`).
183
+ *
184
+ * There is no schema equivalent — `m_bodyGroupChoices` is a `CUtlOrderedMap`, not a writable
185
+ * scalar. `group` is 32-bit engine-side. Returns false if the op is unavailable, the ref is stale,
186
+ * or `group` is out of 32-bit range. */
187
+ setBodyGroupByName(name: string, group: number): boolean;
188
+ /** Set this entity's model scale (`CBaseModelEntity::SetModelScale`).
189
+ *
190
+ * The argument shape is confirmed against the pinned build; the function's NAME is a catalogue
191
+ * attribution its body does not itself prove, so verify the effect before relying on this in a
192
+ * shipped plugin — see the gamedata comment. Calling it is safe either way. Returns false if the
193
+ * op is unavailable, the ref is stale, or `scale` is not finite. */
194
+ setModelScale(scale: number): boolean;
161
195
  /** Read a CUtlVector<CHandle> at (ptrOffs chain -> vectorOff) as live liveness-gated EntityRefs.
162
196
  * Follows the pointer chain, reads count@+0 / elements@+8, caps at maxCount. [] if stale/unresolved. */
163
197
  readHandleVector(ptrOffs: number[], vectorOff: number, maxCount?: number): EntityRef[];
package/events.d.ts CHANGED
@@ -52,9 +52,9 @@ export type HookResultValue = 0 | 1 | 2 | 3;
52
52
  * Events.fire("player_changename", { userid: p.userId, oldname, newname });
53
53
  */
54
54
  export declare const Events: {
55
- /** Fire a game event. Returns the engine FireEvent result. */
55
+ /** Fire a game event (SourceMod `FireEvent`). Other plugins' `on` / `onPre`
56
+ * handlers run before this returns. Returns the engine FireEvent result. */
56
57
  fire(name: string, fields?: Record<string, number | string | boolean | bigint>, dontBroadcast?: boolean): boolean;
57
- /** Fire a game event to ONE client (SourceMod FireToClient). Same field type-inference as `fire`. Returns false on miss. */
58
58
  /**
59
59
  * Restrict the event currently being pre-dispatched to `slots`, instead of hiding it from everyone.
60
60
  *
@@ -73,6 +73,7 @@ export declare const Events: {
73
73
  * });
74
74
  */
75
75
  setRecipients(slots: readonly number[]): void;
76
-
76
+ /** Fire a game event to ONE client (SourceMod `FireToClient`). Does not go through
77
+ * `IGameEventManager2::FireEvent` (no `onPre`). Returns false on miss. */
77
78
  fireToClient(slot: number, name: string, fields?: Record<string, string | number | boolean | bigint>): boolean;
78
79
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s2script/sdk",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "license": "MIT OR Apache-2.0",
5
5
  "description": "s2script SDK — the builtin capability types (imported as @s2script/sdk/entity, …) plus the `s2s` CLI for building Source 2 / CS2 plugins.",
6
6
  "type": "module",
package/phrases.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @s2script/phrases — the phrase keys your plugin is allowed to use.
3
+ *
4
+ * **You do not import this module.** It exists so that `cmd.replyT(...)` and
5
+ * `Translations.translate(...)` know which keys are valid in your plugin, and the build fills it in
6
+ * from the phrase files you load.
7
+ *
8
+ * @example
9
+ * export default plugin((ctx) => {
10
+ * ctx.translations.load("basecomm", "common");
11
+ *
12
+ * cmd.replyT("Usage Gag"); // ok — in translations/basecomm.phrases.json
13
+ * cmd.replyT("No matching players"); // ok — in translations/common.phrases.json
14
+ * cmd.replyT("Usage Gagg"); // compile error — no such key in either
15
+ * });
16
+ *
17
+ * ## Writing phrases
18
+ *
19
+ * Create `translations/<your-plugin>.phrases.json` — a flat key to English map. `{1}`, `{2}` … are
20
+ * positional slots filled by the trailing arguments; `{green}`, `{default}` … are colour tags
21
+ * expanded on output (see `@s2script/sdk/chat`). Keys are human-readable English on purpose: an
22
+ * unresolved key renders as the key itself, so it should still read as a sentence.
23
+ *
24
+ * ```json
25
+ * { "Gagged Player": "{green}[SM]{default} Gagged {1} player." }
26
+ * ```
27
+ *
28
+ * A translator adds `translations/<code>/<your-plugin>.phrases.json` — no code change, no rebuild.
29
+ *
30
+ * ## How the checking works
31
+ *
32
+ * The build reads your `ctx.translations.load(...)` call and writes `src/phrases.generated.d.ts`,
33
+ * filling in {@link PhraseKeys} with the keys of exactly those files. Load a file and its keys
34
+ * become valid; don't, and they don't — so a forgotten `load` is a compile error rather than raw key
35
+ * text printed to a player at runtime. SourceMod enforces the same rule, but only when it happens.
36
+ *
37
+ * That file is derived and gitignored. Until it has been written — a brand-new plugin, say — keys
38
+ * fall back to plain `string` and go unchecked, so nothing is blocked on the build having run.
39
+ */
40
+
41
+ /**
42
+ * The set of valid phrase keys, filled in per plugin by its generated declaration.
43
+ *
44
+ * Empty here deliberately. Each plugin's `src/phrases.generated.d.ts` augments this interface, and
45
+ * interface merging turns that into the exact union {@link PhraseKey} resolves to. Never add keys
46
+ * here by hand — this is the shared module, and a key added here would apply to every plugin.
47
+ */
48
+ export interface PhraseKeys {}
49
+
50
+ /**
51
+ * Every phrase key valid in this plugin — the union of the keys in every file it loads.
52
+ *
53
+ * Falls back to `string` when nothing has augmented {@link PhraseKeys}, so a plugin that loads no
54
+ * phrases (or whose generated declaration has not been written yet) compiles unchecked rather than
55
+ * failing to compile at all.
56
+ */
57
+ export type PhraseKey = [keyof PhraseKeys] extends [never] ? string : Extract<keyof PhraseKeys, string>;
package/plugin.d.ts CHANGED
@@ -114,6 +114,26 @@ export interface CtxCommands {
114
114
  handler: (slot: number, argString: string) => HookResultValue | void,
115
115
  ): void;
116
116
  }
117
+ /**
118
+ * The phrase files this plugin uses ({@link PluginContext.translations}).
119
+ *
120
+ * Nothing is loaded automatically — a plugin declares what it needs, the same rule SourceMod's
121
+ * `LoadTranslations` enforces. The build reads this call to work out which keys `cmd.replyT` and
122
+ * `Translations.translate` will accept, so a key from a file you did not load is a compile error.
123
+ */
124
+ export interface CtxTranslations {
125
+ /**
126
+ * Load `translations/<name>.phrases.json` for each name, in the order given.
127
+ *
128
+ * Order is significant: `translate` takes the first hit within each of its two passes (the
129
+ * client's language, then English), so list your own set before any shared one if you want to be
130
+ * able to override a shared phrase.
131
+ *
132
+ * @example ctx.translations.load("basecomm", "common");
133
+ */
134
+ load(...names: string[]): void;
135
+ }
136
+
117
137
  /** Config live-reload subscription on this plugin's load-scope ({@link PluginContext.config}). */
118
138
  export interface CtxConfig {
119
139
  /** Fires when the plugin's config file is re-materialized on disk; re-read values inside. */
@@ -175,6 +195,8 @@ export interface PluginContext {
175
195
  readonly commands: CtxCommands;
176
196
  /** Config live-reload subscription ({@link CtxConfig}). */
177
197
  readonly config: CtxConfig;
198
+ /** Phrase files this plugin uses ({@link CtxTranslations}). */
199
+ readonly translations: CtxTranslations;
178
200
  /** TopMenu (adminmenu) contribution ({@link CtxTopMenu}). */
179
201
  readonly topmenu: CtxTopMenu;
180
202
  /** Publish this plugin's manifest-declared interface. Buffered; goes live at Active. */
package/server.d.ts CHANGED
@@ -15,16 +15,16 @@ export declare const Server: {
15
15
  /** A cvar's current value as a string. `""` if the cvar doesn't exist (or an unsupported type → `"<type>"`). */
16
16
  getCvar(name: string): string;
17
17
  /**
18
- * Set a cvar via the server console (so any type is accepted). SECURITY: this builds and runs a console
19
- * command (`<name> <value>`), which the console splits on `;` — treat `value` like `command()` input and
20
- * sanitize/quote any untrusted value to avoid command injection. Queued: applies next frame.
18
+ * Set a cvar through `ICvar` now (SourceMod `SetConVarString`). Not a console command: `;` in
19
+ * `value` is data, not a second command. `getCvar` and `onCvarChange` see the new value before
20
+ * this returns. `false` if the cvar does not exist or the string cannot become that type.
21
21
  */
22
- setCvar(name: string, value: string): void;
22
+ setCvar(name: string, value: string): boolean;
23
23
  /**
24
24
  * Register a plugin-owned ConVar (CSSharp FakeConVar / SM CreateConVar parity). Idempotent —
25
25
  * re-registering an existing name is a no-op success, and the cvar + its value persist across
26
26
  * plugin reloads (SourceMod parity). The shim adds FCVAR_RELEASE (customer-visible); `flags`
27
- * are additive raw FCVAR bits. Read the value with `getCvar`; set it with `setCvar`/the console.
27
+ * are additive raw FCVAR bits. Read the value with `getCvar`; set it with `setCvar`.
28
28
  * `min`/`max` apply to numeric types only.
29
29
  */
30
30
  registerCvar(name: string, opts: {
package/sound.d.ts CHANGED
@@ -52,4 +52,18 @@ export declare const Sound: {
52
52
  * function / stale source entity / an empty `recipients` array). An all-bot-skipped recipient
53
53
  * set still emits to nobody (a real engine call, may return a nonzero GUID). */
54
54
  emit(name: string, opts?: SoundEmitOptions): number;
55
+ /**
56
+ * Stop a named SoundEvent playing on an entity — the counterpart to {@link Sound.emit}.
57
+ *
58
+ * **`opts.entity` is required**, unlike `emit`. The engine call behind this is an instance method
59
+ * on the entity, so there is no global/2D form to default to the way `emit` falls back to
60
+ * worldspawn. `recipients` and `volume` do not apply: the engine stops the sound for everyone
61
+ * hearing it.
62
+ *
63
+ * Equivalent to `entity.stopSound(name)`; this is the discoverable spelling next to `emit`.
64
+ * `pawn.stopSound(name)` in `@s2script/cs2` is the pawn-shaped one.
65
+ *
66
+ * Returns false when `opts.entity` is absent or stale, or the engine op is unavailable.
67
+ */
68
+ stop(name: string, opts: { entity: EntityRef }): boolean;
55
69
  };
package/translations.d.ts CHANGED
@@ -1,20 +1,50 @@
1
1
  /** @s2script/translations — SourceMod-style i18n (per-client language, phrase files, {1} formatting). */
2
+ import type { PhraseKey } from "./phrases";
2
3
 
3
4
  /** A phrase set: key → template string, where `{1}`/`{2}`/… are positional substitution slots. */
4
5
  export type Phrases = Record<string, string>;
6
+
5
7
  /**
6
- * SourceMod-style translation registry: seed a default phrase set, override it per language from
7
- * `translations/<code>/<name>.phrases.json`, then translate a key for a given client's language.
8
+ * SourceMod-style translation registry. Phrases live in `translations/<name>.phrases.json`, shipped
9
+ * with the addon and edited by operators; `translations/<code>/<name>.phrases.json` overrides per
10
+ * language, added by a translator with no code change.
11
+ *
12
+ * A plugin declares the files it uses with {@link PluginContext.translations} — nothing is loaded
13
+ * for it automatically, the same rule SourceMod's `LoadTranslations` enforces:
14
+ *
8
15
  * @example
9
- * import { Translations } from "@s2script/sdk/translations";
10
- * Translations.load("trdemo", { Greeting: "Hello {1}", Bye: "Goodbye {1}" });
11
- * console.log(Translations.translate(-1, "Greeting", "world")); // "Hello world"
16
+ * export default plugin((ctx) => {
17
+ * ctx.translations.load("basecomm", "common");
18
+ * ctx.commands.registerAdmin("sm_gag", ADMFLAG.CHAT, (cmd) => {
19
+ * cmd.replyT("Usage Gag"); // key-checked
20
+ * Chat.toSlot(p.slot, Translations.translate(p.slot, "Gagged Player", n));
21
+ * });
22
+ * });
23
+ *
24
+ * Keys are checked against the files that plugin loads — see `@s2script/sdk/phrases`.
12
25
  */
13
26
  export declare const Translations: {
14
- /** Register a phrase set: `seed` is the built-in English default; translations/<code>/<name>.phrases.json overrides per language. */
15
- load(name: string, seed: Phrases): void;
16
- /** Translate `key` for `slot`'s language (slot < 0 = the server default), substituting positional {1}/{2} args. */
17
- translate(slot: number, key: string, ...args: (string | number)[]): string;
27
+ /**
28
+ * Register a phrase set by name, populating it from `translations/<name>.phrases.json`.
29
+ *
30
+ * Prefer {@link PluginContext.translations}`.load(...)`, which is what the build reads to work out
31
+ * which keys your plugin may use. This is the lower-level form, for a name computed at runtime or
32
+ * a set with an in-code default — a `seed` here is the starting content, which the file overrides.
33
+ *
34
+ * Registration order is significant: `translate` takes the first hit within each of its two passes
35
+ * (the client's language, then English), so load your own set before any shared one if you want to
36
+ * override a shared phrase.
37
+ */
38
+ load(name: string, seed?: Phrases): void;
39
+ /**
40
+ * Translate `key` for `slot`'s language (slot < 0 = the server default), substituting positional
41
+ * {1}/{2} args.
42
+ *
43
+ * `key` is checked against the phrase files this plugin loads; it widens to `string` in a plugin
44
+ * that loads none, so this is never in the way. A key found in no loaded set returns the key
45
+ * itself — which is what the checking exists to prevent reaching a player.
46
+ */
47
+ translate(slot: number, key: PhraseKey, ...args: (string | number)[]): string;
18
48
  /** Set the server/console default language code (default "" = root/English). */
19
49
  setDefaultLanguage(code: string): void;
20
50
  };
package/unsafe.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * @s2script/sdk/unsafe — plugin-declared engine calls. NO runtime code: the engine injects the
3
- * implementation at load (`__s2pkg_unsafe`).
2
+ * @s2script/sdk/unsafe — plugin-declared engine calls and inbound hooks. NO runtime code: the
3
+ * engine injects the implementation at load (`__s2pkg_unsafe`).
4
4
  *
5
- * UNSAFE by design. A declared call reaches a real engine function. The framework validates every
6
- * descriptor at load and degrades a failure to `null`, but it cannot prove your signature or vtable
7
- * index names the function you meant. Requires manifest `permissions: ["engine:calls"]` AND an
8
- * operator allow-list entry (see docs/superpowers/specs/2026-07-24-plugin-gamedata-design.md §6).
5
+ * UNSAFE by design. A declared call reaches a real engine function; a declared hook patches
6
+ * that function's prologue. The framework validates every descriptor at load and degrades a
7
+ * failure to `null`, but it cannot prove your signature or vtable index names the function you
8
+ * meant. Calls require manifest `permissions: ["engine:calls"]`; hooks require the separate
9
+ * `"engine:hooks"` permission. Both also need an operator allow-list entry
10
+ * (see docs/superpowers/specs/2026-07-24-plugin-gamedata-design.md §6 and ARCHITECTURE.md §2.0.7).
9
11
  */
10
12
 
11
13
  /**
@@ -15,13 +17,21 @@
15
17
  export interface EngineCalls {}
16
18
 
17
19
  /**
18
- * Plugin-declared engine calls, resolved from this plugin's own gamedata.
20
+ * The inbound hooks this plugin declares. The base interface is EMPTY — `s2s build` generates
21
+ * `.s2script/hooks.d.ts`, which augments this from your gamedata's `hooks` section.
22
+ */
23
+ export interface EngineHooks {}
24
+
25
+ /**
26
+ * Plugin-declared engine calls and inbound hooks, resolved from this plugin's own gamedata.
19
27
  *
20
28
  * @example
21
29
  * import { Engine } from "@s2script/sdk/unsafe";
22
30
  * // Resolve ONCE at load — null means the descriptor failed a load-time gate.
23
31
  * const ignite = Engine.call("ignite");
24
32
  * if (!ignite) console.log(`unavailable: ${Engine.status("ignite")}`);
33
+ * const onFoo = Engine.hook("onFoo");
34
+ * if (onFoo) onFoo((view) => { view.reason = 0; });
25
35
  */
26
36
  export declare const Engine: {
27
37
  /**
@@ -30,6 +40,16 @@ export declare const Engine: {
30
40
  * operator-allow-listed). Guard once at load; the returned function is a plain callable.
31
41
  */
32
42
  call<K extends keyof EngineCalls>(name: K): EngineCalls[K] | null;
33
- /** Why a descriptor is unavailable; `"available"` when it resolved. For diagnostics/operator reports. */
43
+ /** Why a call descriptor is unavailable; `"available"` when it resolved. For diagnostics/operator reports. */
34
44
  status(name: string): string;
45
+ /**
46
+ * The declared hook's subscribe function, or `null` when its descriptor failed a load-time
47
+ * gate (same class of reasons as {@link Engine.call}, plus a missing `validate` or an unknown
48
+ * thunk shape). Guard once at load; the returned function records the handler and lazily
49
+ * installs the detour. The owner is always the calling plugin — you cannot subscribe to
50
+ * another plugin's hook through this factory (game-package hooks hang off `ctx.*`).
51
+ */
52
+ hook<K extends keyof EngineHooks>(name: K): EngineHooks[K] | null;
53
+ /** Why a hook descriptor is unavailable; `"available"` when it resolved. */
54
+ hookStatus(name: string): string;
35
55
  };