@s2script/sdk 0.9.0 → 0.10.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
@@ -36,6 +36,42 @@ export declare class Client {
36
36
  * reason in the server log), setting is an inert no-op and reads stay false.
37
37
  */
38
38
  voiceMuted: boolean;
39
+ /**
40
+ * Tell this client to run `cmd` in their own console, as if they had typed it
41
+ * (SourceMod `ClientCommand`).
42
+ *
43
+ * Requires a real client: a bot has no console, so this is a no-op on bots — use
44
+ * {@link Client.fakeCommand} for server-side execution. Returns `false` when the command was not
45
+ * dispatched (empty text, bad slot, or the engine interface is unavailable on this build), never
46
+ * a silent no-op.
47
+ *
48
+ * @example
49
+ * client.command("play sounds/ui/beep.vsnd");
50
+ */
51
+ command(cmd: string): boolean;
52
+ /**
53
+ * Have the SERVER process `cmd` as if this client had sent it (SourceMod `FakeClientCommand`).
54
+ *
55
+ * Unlike {@link Client.command} this works on bots, because nothing is sent to a client — the
56
+ * engine dispatches the command itself, attributed to this player's slot. Live-verified:
57
+ * `fakeCommand("say hi")` on slot 0 prints as that player, not as Console.
58
+ *
59
+ * Returns `false` — never a silent no-op — for a bad slot, empty text, an unavailable interface,
60
+ * or a name that is not a registered console **command** (a ConVar such as `mp_friendlyfire` is
61
+ * refused: use `Server.command` for those).
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.
70
+ *
71
+ * @example
72
+ * client.fakeCommand("say hello"); // as though the player typed it
73
+ */
74
+ fakeCommand(cmd: string): boolean;
39
75
  }
40
76
  /**
41
77
  * Look up connected clients by slot or enumerate them all.
@@ -0,0 +1,37 @@
1
+ # Standard interface contracts
2
+
3
+ These are **type-only contracts for inter-plugin interfaces** — not SDK capability modules.
4
+
5
+ The framework deliberately ships **no implementation** for anything in here. Each contract describes
6
+ a shape that a *community plugin* implements and publishes, so that consumers can depend on one
7
+ agreed interface instead of a different ad-hoc shape per plugin:
8
+
9
+ ```ts
10
+ // producer
11
+ import type { EconService } from "@s2script/cs2/econ";
12
+ const impl: EconService = { /* … */ };
13
+ ctx.publish("econ", impl); // manifest: s2script.publishes
14
+
15
+ // consumer
16
+ import type { EconService } from "@s2script/cs2/econ";
17
+ const econ = ctx.tryUse<EconService>("econ"); // null while unpublished
18
+ ```
19
+
20
+ **Why contracts and not features.** Skins and workshop integration are Valve-backend and
21
+ game-content concerns, not engine touchpoints. Implementing them in the framework would drag the
22
+ core toward a specific game's economy and Steam's services, against *the core is engine-generic* and
23
+ *dependencies point one way: game → core*. Publishing the contract gets the ecosystem interop
24
+ without the framework taking on that surface.
25
+
26
+ **Rules that apply to every contract here** (they cross the plugin boundary, see
27
+ `docs/ARCHITECTURE.md`):
28
+
29
+ - Arguments and return values cross by **structured copy as JSON**. A `BigInt` throws and silently
30
+ drops the whole payload, so every 64-bit value (Steam IDs, workshop IDs, item IDs) is typed as a
31
+ **decimal string**, never `number` or `bigint`.
32
+ - `EntityRef` is the one live-ish exception: it crosses tagged and is revived serial-gated on the
33
+ other side, so it stays `T | null`-safe.
34
+ - A hard dependency (`ctx.use`) throws `InterfaceUnavailable` if the producer is absent; an optional
35
+ one (`ctx.tryUse`) returns `null`. For anything in here, prefer `tryUse` — these are third-party.
36
+ - Contracts are semver-governed. Adding an optional member is a minor; changing or removing one is a
37
+ major.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `@s2script/sdk/contracts/workshop` — the standard contract for a **workshop / UGC** plugin.
3
+ *
4
+ * TYPE-ONLY. The framework ships no implementation: subscribing to, downloading and mounting UGC is
5
+ * a Steam-services concern, not a Source 2 engine touchpoint. A community plugin implements this and
6
+ * publishes it; consumers depend on the one agreed shape. See `contracts/README.md`.
7
+ *
8
+ * Engine-generic on purpose — every Source 2 title has workshop content, so nothing here is
9
+ * CS2-specific. Game-specific economy lives in `@s2script/cs2/econ`.
10
+ *
11
+ * @example
12
+ * import type { WorkshopService } from "@s2script/sdk/contracts/workshop";
13
+ * const ws = ctx.tryUse<WorkshopService>("workshop");
14
+ * if (ws) {
15
+ * const map = await ws.currentMap();
16
+ * console.log(map ? `${map.title} (${map.id})` : "not a workshop map");
17
+ * }
18
+ */
19
+
20
+ /**
21
+ * A workshop item. `id` is a **decimal string**, never a number: published-file IDs are 64-bit and a
22
+ * `BigInt` cannot cross the plugin boundary (it throws and drops the whole payload).
23
+ */
24
+ export interface WorkshopItem {
25
+ /** Published-file ID as a decimal string, e.g. `"3070288894"`. */
26
+ readonly id: string;
27
+ /** Item title, or `""` when metadata has not been fetched yet. */
28
+ readonly title: string;
29
+ /** Owning author's SteamID64 as a decimal string, or `""` if unknown. */
30
+ readonly authorSteamId: string;
31
+ /** Bytes on disk once installed; `0` when not installed or unknown. */
32
+ readonly sizeBytes: number;
33
+ /** Last-updated Unix seconds; `0` if unknown. */
34
+ readonly updatedAt: number;
35
+ }
36
+
37
+ /** An installed workshop map, as reported by {@link WorkshopService.currentMap}. */
38
+ export interface WorkshopMap extends WorkshopItem {
39
+ /** The BSP name the engine loads (what `Server.mapName` reports once live). */
40
+ readonly mapName: string;
41
+ }
42
+
43
+ /** Progress for an in-flight download. */
44
+ export interface WorkshopDownloadProgress {
45
+ readonly id: string;
46
+ readonly bytesDownloaded: number;
47
+ readonly bytesTotal: number;
48
+ }
49
+
50
+ /**
51
+ * The contract a workshop plugin publishes.
52
+ *
53
+ * Everything that touches Steam is asynchronous — an implementation must not block the game frame.
54
+ * Methods resolve to `null`/`false` rather than throwing when an item is unknown or Steam is
55
+ * unreachable, so a consumer degrades instead of crashing (the framework's standing posture).
56
+ */
57
+ export interface WorkshopService {
58
+ /** The currently-running workshop map, or `null` when the server is on a stock map. */
59
+ currentMap(): Promise<WorkshopMap | null>;
60
+
61
+ /** Metadata for `id`, or `null` if unknown/unreachable. */
62
+ info(id: string): Promise<WorkshopItem | null>;
63
+
64
+ /**
65
+ * Ensure `id` is installed and up to date, resolving `true` once usable.
66
+ * Implementations should be idempotent — calling it for an already-current item is a cheap `true`.
67
+ */
68
+ ensureInstalled(id: string): Promise<boolean>;
69
+
70
+ /** In-flight download progress, or `null` when nothing is downloading for `id`. */
71
+ progress(id: string): Promise<WorkshopDownloadProgress | null>;
72
+
73
+ /**
74
+ * Change level to a workshop map. Resolves `false` when the item is not a map, is not installed
75
+ * and could not be fetched, or the change was refused. On success the server changes level, so a
76
+ * caller should not assume any code after this runs on the same map.
77
+ */
78
+ changeMap(id: string): Promise<boolean>;
79
+
80
+ /** Every installed workshop item known to the implementation. */
81
+ installed(): Promise<readonly WorkshopItem[]>;
82
+ }
package/dist/cli.js CHANGED
@@ -1217,6 +1217,10 @@ function typecheckPlugin(pluginDir, opts) {
1217
1217
  // dirs are deleted). tsc picks the longest matching prefix, so `@s2script/sdk/*` wins for
1218
1218
  // sdk imports.
1219
1219
  "@s2script/sdk/*": ["sdk/*.d.ts"],
1220
+ // Game-package SUBPATHS (e.g. @s2script/cs2/econ -> packages/cs2/econ.d.ts). Needed because
1221
+ // the `@s2script/*` fallback below maps to `<pkg>/index.d.ts`, which cannot express a
1222
+ // subpath. tsc picks the longest matching prefix, so this wins for cs2 subpath imports.
1223
+ "@s2script/cs2/*": ["cs2/*.d.ts"],
1220
1224
  "@s2script/*": ["*/index.d.ts"],
1221
1225
  ...contractPaths
1222
1226
  }
@@ -4963,21 +4967,37 @@ var SUPPORTED_NAV_KINDS = /* @__PURE__ */ new Set([
4963
4967
  "vector",
4964
4968
  "qangle"
4965
4969
  ]);
4970
+ var WRITABLE_NAV_KINDS = /* @__PURE__ */ new Set(["f32", "bool", "i32"]);
4966
4971
  function buildNavModel(config, catalog) {
4967
4972
  const targetClasses = [...new Set(config.map((e) => e.target))];
4968
4973
  const schemaModel = buildModel(catalog, targetClasses);
4969
4974
  const sorted = [...config].sort((a2, b) => a2.wrapper < b.wrapper ? -1 : a2.wrapper > b.wrapper ? 1 : 0);
4970
4975
  const wrappers = sorted.map((entry) => {
4971
4976
  const all = flattenedFields(schemaModel, entry.target);
4977
+ const wantWritable = new Set(entry.writable ?? []);
4972
4978
  const fields = [];
4973
4979
  const skippedKinds = [];
4974
4980
  for (const f of all) {
4975
4981
  if (SUPPORTED_NAV_KINDS.has(f.accessorKind)) {
4976
- fields.push(f);
4982
+ fields.push({ ...f, navWritable: wantWritable.has(f.rawName) });
4977
4983
  } else {
4978
4984
  skippedKinds.push({ propName: f.propName, accessorKind: f.accessorKind });
4979
4985
  }
4980
4986
  }
4987
+ for (const raw of wantWritable) {
4988
+ const f = fields.find((x) => x.rawName === raw);
4989
+ if (!f) {
4990
+ const known = all.some((x) => x.rawName === raw);
4991
+ throw new Error(
4992
+ `nav-targets: ${entry.wrapper}.writable lists ${JSON.stringify(raw)}, which ` + (known ? `exists on ${entry.target} but has an unsupported accessor kind, so it has no nav accessor at all.` : `does not exist on ${entry.target} or any of its ancestors (renamed or removed?).`)
4993
+ );
4994
+ }
4995
+ if (!WRITABLE_NAV_KINDS.has(f.accessorKind)) {
4996
+ throw new Error(
4997
+ `nav-targets: ${entry.wrapper}.writable lists ${JSON.stringify(raw)} (kind '${f.accessorKind}'), but there is no EntityRef.write*Via for that kind. Add the native and extend WRITABLE_NAV_KINDS, or drop the field from the allowlist.`
4998
+ );
4999
+ }
5000
+ }
4981
5001
  return {
4982
5002
  wrapper: entry.wrapper,
4983
5003
  prop: entry.prop,
@@ -5004,6 +5024,11 @@ var READ_VIA = {
5004
5024
  u16: "readUInt16Via",
5005
5025
  u32: "readUInt32Via"
5006
5026
  };
5027
+ var WRITE_VIA = {
5028
+ f32: "writeFloat32Via",
5029
+ bool: "writeBoolVia",
5030
+ i32: "writeInt32Via"
5031
+ };
5007
5032
  function emitNavJs(model) {
5008
5033
  const out = [HEADER4, "(function () {", " var off = __s2_schema_offset;"];
5009
5034
  const vecClasses = /* @__PURE__ */ new Set();
@@ -5037,7 +5062,9 @@ function emitNavJs(model) {
5037
5062
  if (!method) continue;
5038
5063
  getter = `get: function () { return this.root.${method}(this.path, ${resolve13}); }`;
5039
5064
  }
5040
- out.push(` ${S2(f.propName)}: { ${getter} },`);
5065
+ const wmethod = f.navWritable ? WRITE_VIA[kind] : void 0;
5066
+ const setter = wmethod ? `, set: function (v) { this.root.${wmethod}(this.path, ${resolve13}, v); }` : "";
5067
+ out.push(` ${S2(f.propName)}: { ${getter}${setter} },`);
5041
5068
  }
5042
5069
  out.push(" });");
5043
5070
  }
@@ -5092,9 +5119,20 @@ function emitNavDts(model) {
5092
5119
  if (needsEntityRef) out.push('import type { EntityRef } from "@s2script/sdk/entity";');
5093
5120
  out.push("");
5094
5121
  for (const w of model.wrappers) {
5122
+ if (w.fields.some((f) => f.navWritable)) {
5123
+ out.push(`/**`);
5124
+ out.push(` * Fields NOT marked \`readonly\` are writable.`);
5125
+ out.push(` *`);
5126
+ out.push(` * A write takes effect immediately \u2014 the server reads these during movement \u2014 but it is **not**`);
5127
+ out.push(` * flagged for replication, because this object is reached through a pointer chain and the`);
5128
+ out.push(` * change-notifier addresses the root entity. A client predicting the old value may therefore`);
5129
+ out.push(` * see brief mismatch until the next authoritative correction. SourceMod's \`SetEntPropFloat\``);
5130
+ out.push(` * on the same fields behaves the same way.`);
5131
+ out.push(` */`);
5132
+ }
5095
5133
  out.push(`export interface ${w.wrapper} {`);
5096
5134
  for (const f of w.fields) {
5097
- out.push(` readonly ${f.propName}: ${TSTYPE[f.accessorKind]};`);
5135
+ out.push(` ${f.navWritable ? "" : "readonly "}${f.propName}: ${TSTYPE[f.accessorKind]};`);
5098
5136
  }
5099
5137
  out.push("}", "");
5100
5138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s2script/sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -30,7 +30,8 @@
30
30
  "*.d.ts",
31
31
  "README.md",
32
32
  "LICENSE-MIT",
33
- "LICENSE-APACHE"
33
+ "LICENSE-APACHE",
34
+ "contracts"
34
35
  ],
35
36
  "exports": {
36
37
  "./admin": {
@@ -120,13 +121,19 @@
120
121
  "./usermessages": {
121
122
  "types": "./usermessages.d.ts"
122
123
  },
124
+ "./voice": {
125
+ "types": "./voice.d.ts"
126
+ },
123
127
  "./votes": {
124
128
  "types": "./votes.d.ts"
125
129
  },
126
130
  "./ws": {
127
131
  "types": "./ws.d.ts"
128
132
  },
129
- "./package.json": "./package.json"
133
+ "./package.json": "./package.json",
134
+ "./contracts/workshop": {
135
+ "types": "./contracts/workshop.d.ts"
136
+ }
130
137
  },
131
138
  "repository": {
132
139
  "type": "git",
package/server.d.ts CHANGED
@@ -35,6 +35,28 @@ export declare const Server: {
35
35
  min?: number;
36
36
  max?: number;
37
37
  }): boolean;
38
+ /**
39
+ * Watch a cvar for changes (SourceMod `HookConVarChange`, ModSharp's cvar change hook).
40
+ *
41
+ * `name` is a cvar name, or `"*"` for every cvar. The handler receives the cvar's name and its
42
+ * new and old values as strings — for `"*"` the name tells you which one moved.
43
+ *
44
+ * NOTIFY-only: the engine's global change callback runs **after** the value has been applied, so
45
+ * a handler cannot veto a change; returning anything is ignored. A handler that throws is logged
46
+ * and contained, and the remaining handlers still run.
47
+ *
48
+ * Subscriptions are ledgered — unload removes them whether or not `dispose()` is called.
49
+ *
50
+ * @example
51
+ * import { Server } from "@s2script/sdk/server";
52
+ * Server.onCvarChange("mp_friendlyfire", (name, next, prev) => {
53
+ * console.log(`${name}: ${prev} -> ${next}`);
54
+ * });
55
+ */
56
+ onCvarChange(
57
+ name: string,
58
+ handler: (name: string, newValue: string, oldValue: string) => void,
59
+ ): { dispose(): void };
38
60
  /** The server's configured max client count (`GetMaxClients()`). `0` if unavailable. */
39
61
  readonly maxPlayers: number;
40
62
  /** The current map name (`GetMapName()`, the BSP). `""` if unavailable. */
package/timers.d.ts CHANGED
@@ -20,3 +20,42 @@ export declare function nextFrame(): Promise<void>;
20
20
  * Only valid inside a threadSleep-capable fiber context.
21
21
  */
22
22
  export declare function threadSleep(ms: number): void;
23
+
24
+ /**
25
+ * A live callback timer, returned by {@link after} and {@link every}. Ledgered against the creating
26
+ * plugin: unload kills it whether or not `kill()` was called, so a repeating timer can never outlive
27
+ * its plugin and fire into a dead context.
28
+ */
29
+ export interface Timer {
30
+ /** False once the timer has fired (one-shot), been killed, or had its plugin unloaded. */
31
+ readonly alive: boolean;
32
+ /** Cancel it. Idempotent — returns false if it was already dead. Safe to call from inside the callback. */
33
+ kill(): boolean;
34
+ }
35
+
36
+ /**
37
+ * Run `fn` once after `ms` milliseconds (SourceMod `CreateTimer` without `TIMER_REPEAT`).
38
+ *
39
+ * Prefer {@link delay} when you can `await`; use this when you need something cancellable.
40
+ * A throwing callback is reported and contained — it never kills the frame or the timer system.
41
+ *
42
+ * @example
43
+ * import { after } from "@s2script/sdk/timers";
44
+ * const t = after(5000, () => console.log("5s later"));
45
+ * t.kill(); // ...unless cancelled first
46
+ */
47
+ export declare function after(ms: number, fn: () => void): Timer;
48
+
49
+ /**
50
+ * Run `fn` every `ms` milliseconds until killed (SourceMod `CreateTimer` with `TIMER_REPEAT`).
51
+ *
52
+ * The next firing is scheduled *after* the callback returns, so a slow callback cannot pile up.
53
+ * `ms` must be greater than 0 — a zero-interval repeat would re-arm every drain and starve the
54
+ * frame, so it throws rather than degrading.
55
+ *
56
+ * @example
57
+ * import { every } from "@s2script/sdk/timers";
58
+ * const tick = every(1000, () => console.log("tick"));
59
+ * // later: tick.kill();
60
+ */
61
+ export declare function every(ms: number, fn: () => void): Timer;
package/unsafe.d.ts CHANGED
@@ -14,6 +14,15 @@
14
14
  */
15
15
  export interface EngineCalls {}
16
16
 
17
+ /**
18
+ * Plugin-declared engine calls, resolved from this plugin's own gamedata.
19
+ *
20
+ * @example
21
+ * import { Engine } from "@s2script/sdk/unsafe";
22
+ * // Resolve ONCE at load — null means the descriptor failed a load-time gate.
23
+ * const ignite = Engine.call("ignite");
24
+ * if (!ignite) console.log(`unavailable: ${Engine.status("ignite")}`);
25
+ */
17
26
  export declare const Engine: {
18
27
  /**
19
28
  * The declared call, or `null` when its descriptor failed a load-time gate (signature miss,
package/voice.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @s2script/sdk/voice — who can hear whom. NO runtime code: the engine injects the implementation
3
+ * at load (`__s2pkg_voice`).
4
+ *
5
+ * Layered UNDER `Client.voiceMuted`: a muted sender is inaudible to everyone regardless of any rule
6
+ * here, so admin moderation always beats a gameplay rule.
7
+ *
8
+ * Rules are declarative state, not a callback. The engine's listen matrix is re-asserted
9
+ * continuously and the underlying hook fires per (receiver, sender) pair, so a per-pair JS callback
10
+ * would run up to 64x64 times per refresh. Set a rule; the shim enforces it.
11
+ */
12
+
13
+ /** Hot-path measurement counters returned by {@link Voice.stats}. */
14
+ export interface VoiceStats {
15
+ /** Listen-matrix decisions the engine has asked about. */
16
+ calls: number;
17
+ /** Senders currently carrying a merged rule. */
18
+ entries: number;
19
+ /** Times a rule actually silenced a pair. THE effect counter — a rule that never rewrites is not working. */
20
+ rewrites: number;
21
+ }
22
+
23
+ /**
24
+ * Per-(receiver, sender) voice hearability.
25
+ *
26
+ * @example
27
+ * import { Voice } from "@s2script/sdk/voice";
28
+ * // A dead player is heard only by other dead players.
29
+ * Voice.setAudibleTo(deadSlot, otherDeadSlots);
30
+ * // ...and on respawn, hand the slot back to the engine.
31
+ * Voice.reset(deadSlot);
32
+ */
33
+ export declare const Voice: {
34
+ /**
35
+ * This sender is audible ONLY to `receivers` (0-based slots). An empty array means audible to
36
+ * nobody — distinct from {@link Voice.reset}, which removes the rule and lets the engine decide.
37
+ * Rules from multiple plugins AND-merge, so another plugin can only ever narrow yours, never widen it.
38
+ *
39
+ * A rule is dropped automatically when the sender disconnects: slots are recycled, and a rule is
40
+ * about the player who occupied one, not the seat.
41
+ *
42
+ * Returns `false` — never throws — when the rule was NOT applied: voice control is degraded or its
43
+ * engine hook never installed on this build, `sender` is outside `[0, 64)`, or any element of
44
+ * `receivers` is. Note this differs from {@link import("./transmit").Transmit.setVisibleTo}, which
45
+ * throws `RangeError` on a bad slot; check the return value here rather than relying on a throw.
46
+ *
47
+ * @throws `TypeError` if `receivers` is not an array.
48
+ */
49
+ setAudibleTo(sender: number, receivers: readonly number[]): boolean;
50
+ /**
51
+ * Drop THIS plugin's rule for `sender`; other plugins' rules survive and are re-merged.
52
+ * `false` when you had no rule for that sender.
53
+ */
54
+ reset(sender: number): boolean;
55
+ /** Drop every rule this plugin owns. Unload does this automatically. */
56
+ resetAll(): void;
57
+ /** Hot-path counters, or null when the running shim predates this capability. */
58
+ stats(): VoiceStats | null;
59
+ };