@s2script/sdk 0.2.0 → 0.4.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
@@ -27,6 +27,13 @@ export declare class Client {
27
27
  readonly ip: string;
28
28
  /** Show `reason` (chat + console) once the client is in-game, then kick after `delaySeconds` (default 5). Intended to be called from a Clients.onConnect handler. */
29
29
  kickWithReason(reason: string, delaySeconds?: number): void;
30
+ /**
31
+ * Server-side voice mute: while true, this client's OUTGOING voice is silenced for every receiver.
32
+ * Framework state (not an engine field): cleared automatically on disconnect, persists across map
33
+ * changes while connected. If the voice descriptor is degraded (hook/validation failure — named
34
+ * reason in the server log), setting is an inert no-op and reads stay false.
35
+ */
36
+ voiceMuted: boolean;
30
37
  }
31
38
  export declare const Clients: {
32
39
  /** Fires when a client connects (all clients incl. bots; carries name/xuid). May be async. */
@@ -41,6 +48,12 @@ export declare const Clients: {
41
48
  onDisconnect(handler: (client: Client) => void): void;
42
49
  /** Fires when a client's settings (name/cvars) change. */
43
50
  onSettingsChanged(handler: (client: Client) => void): void;
51
+ /**
52
+ * Fires while a client transmits voice. Throttled to at most one dispatch per client per second;
53
+ * the FIRST packet of a transmission always fires (a lazy mute-on-talk lands immediately).
54
+ * Handlers should be idempotent. Never fires for bots.
55
+ */
56
+ onVoice(handler: (client: Client) => void): void;
44
57
  /** The client in `slot`, or null if the slot is empty. */
45
58
  fromSlot(slot: number): Client | null;
46
59
  /** Every currently-connected client. */
package/config.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** @s2script/config — typed access to the plugin's materialized config. NO runtime code. */
2
- export type Config = Record<string, string | number | boolean>;
2
+ /** A materialized config value: a scalar, or a nested section object of further values. */
3
+ export type ConfigValue = string | number | boolean | { [k: string]: ConfigValue };
4
+ export type Config = Record<string, ConfigValue>;
3
5
  export declare const config: {
4
6
  getString(key: string): string;
5
7
  getInt(key: string): number;
package/dist/cli.js CHANGED
@@ -89,6 +89,9 @@ function typecheckPlugin(pluginDir, opts) {
89
89
  const options = {
90
90
  strict: true,
91
91
  noEmit: true,
92
+ // Accept explicit `.ts` import extensions (node type-stripping requires them for source-to-source
93
+ // imports; esbuild strips them at bundle time). Backward-compatible — extensionless imports still resolve.
94
+ allowImportingTsExtensions: true,
92
95
  moduleResolution: ts.ModuleResolutionKind.Bundler,
93
96
  module: ts.ModuleKind.ESNext,
94
97
  target: ts.ScriptTarget.ES2020,
@@ -139,25 +142,100 @@ function formatDiagnostics(diags) {
139
142
  }
140
143
 
141
144
  // src/config-validate.ts
145
+ function isDeclType(t) {
146
+ return t === "string" || t === "int" || t === "float" || t === "bool";
147
+ }
148
+ function matchesType(v, t) {
149
+ switch (t) {
150
+ case "string":
151
+ return typeof v === "string";
152
+ case "int":
153
+ return typeof v === "number" && Number.isInteger(v);
154
+ case "float":
155
+ return typeof v === "number";
156
+ case "bool":
157
+ return typeof v === "boolean";
158
+ }
159
+ }
142
160
  function validateConfigBlock(config) {
143
161
  const errs = [];
144
162
  if (config == null) return errs;
145
- if (typeof config !== "object" || Array.isArray(config)) return ["s2script.config must be an object"];
146
- for (const [key, raw] of Object.entries(config)) {
147
- if (typeof raw !== "object" || raw === null) {
148
- errs.push(`config '${key}': must be { type, default }`);
163
+ validateEntries(config, "", errs);
164
+ return errs;
165
+ }
166
+ function validateEntries(node, prefix, errs) {
167
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
168
+ errs.push(`${prefix ? `config '${prefix}'` : "s2script.config"} must be an object`);
169
+ return;
170
+ }
171
+ for (const [key, raw] of Object.entries(node)) {
172
+ const full = prefix ? `${prefix}.${key}` : key;
173
+ if (key.includes(".")) {
174
+ errs.push(`config '${full}': key must not contain '.' (dotted names are reserved for section access)`);
149
175
  continue;
150
176
  }
151
- const decl = raw;
152
- const t = decl.type, d = decl.default;
153
- if (t !== "string" && t !== "int" && t !== "float" && t !== "bool") {
154
- errs.push(`config '${key}': unknown type ${JSON.stringify(t)} (want string|int|float|bool)`);
177
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
178
+ errs.push(`config '${full}': must be a declaration { type, default } or a section object`);
155
179
  continue;
156
180
  }
157
- const ok = t === "string" && typeof d === "string" || t === "int" && typeof d === "number" && Number.isInteger(d) || t === "float" && typeof d === "number" || t === "bool" && typeof d === "boolean";
158
- if (!ok) errs.push(`config '${key}': default ${JSON.stringify(d)} does not match type '${t}'`);
181
+ const obj = raw;
182
+ if (typeof obj.type === "string") {
183
+ validateDecl(full, obj, obj.type, errs);
184
+ } else {
185
+ validateEntries(obj, full, errs);
186
+ }
159
187
  }
160
- return errs;
188
+ }
189
+ function validateDecl(full, decl, typeStr, errs) {
190
+ if (!isDeclType(typeStr)) {
191
+ errs.push(`config '${full}': unknown type ${JSON.stringify(typeStr)} (want string|int|float|bool)`);
192
+ return;
193
+ }
194
+ const t = typeStr;
195
+ if (!("default" in decl)) {
196
+ errs.push(`config '${full}': missing 'default'`);
197
+ } else if (!matchesType(decl.default, t)) {
198
+ errs.push(`config '${full}': default ${JSON.stringify(decl.default)} does not match type '${t}'`);
199
+ }
200
+ const hasMin = decl.min !== void 0;
201
+ const hasMax = decl.max !== void 0;
202
+ const hasEnum = decl.enum !== void 0;
203
+ if (hasEnum && (hasMin || hasMax)) {
204
+ errs.push(`config '${full}': 'enum' is mutually exclusive with 'min'/'max'`);
205
+ }
206
+ if (hasMin || hasMax) {
207
+ if (t !== "int" && t !== "float") {
208
+ errs.push(`config '${full}': 'min'/'max' apply to int|float only (type '${t}')`);
209
+ }
210
+ if (hasMin && typeof decl.min !== "number") errs.push(`config '${full}': 'min' must be a number`);
211
+ if (hasMax && typeof decl.max !== "number") errs.push(`config '${full}': 'max' must be a number`);
212
+ if (typeof decl.min === "number" && typeof decl.max === "number" && decl.min > decl.max) {
213
+ errs.push(`config '${full}': 'min' (${decl.min}) exceeds 'max' (${decl.max})`);
214
+ }
215
+ if (typeof decl.default === "number") {
216
+ if (typeof decl.min === "number" && decl.default < decl.min) errs.push(`config '${full}': default ${decl.default} is below min ${decl.min}`);
217
+ if (typeof decl.max === "number" && decl.default > decl.max) errs.push(`config '${full}': default ${decl.default} is above max ${decl.max}`);
218
+ }
219
+ }
220
+ if (hasEnum) {
221
+ if (t !== "string" && t !== "int") {
222
+ errs.push(`config '${full}': 'enum' applies to string|int only (type '${t}')`);
223
+ }
224
+ if (!Array.isArray(decl.enum) || decl.enum.length === 0) {
225
+ errs.push(`config '${full}': 'enum' must be a non-empty array`);
226
+ } else {
227
+ for (const e of decl.enum) {
228
+ if (!matchesType(e, t)) errs.push(`config '${full}': enum value ${JSON.stringify(e)} does not match type '${t}'`);
229
+ }
230
+ if ("default" in decl && matchesType(decl.default, t) && !decl.enum.some((e) => e === decl.default)) {
231
+ errs.push(`config '${full}': default ${JSON.stringify(decl.default)} is not one of enum`);
232
+ }
233
+ }
234
+ }
235
+ if (decl.sensitive !== void 0 && typeof decl.sensitive !== "boolean") errs.push(`config '${full}': 'sensitive' must be a boolean`);
236
+ if (decl.group !== void 0 && typeof decl.group !== "string") errs.push(`config '${full}': 'group' must be a string`);
237
+ if (decl.label !== void 0 && typeof decl.label !== "string") errs.push(`config '${full}': 'label' must be a string`);
238
+ if (decl.description !== void 0 && typeof decl.description !== "string") errs.push(`config '${full}': 'description' must be a string`);
161
239
  }
162
240
 
163
241
  // src/publish-gate.ts
@@ -1176,13 +1254,85 @@ async function createPlugin(opts = {}) {
1176
1254
  };
1177
1255
  }
1178
1256
 
1257
+ // src/config/gen.ts
1258
+ import AdmZip2 from "adm-zip";
1259
+ import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync3 } from "node:fs";
1260
+ import { join as join9 } from "node:path";
1261
+
1262
+ // src/config/config-path.ts
1263
+ function configFileName(id) {
1264
+ return id.replace(/[^A-Za-z0-9._-]/g, "_") + ".json";
1265
+ }
1266
+
1267
+ // src/config/gen.ts
1268
+ function isDecl(v) {
1269
+ return typeof v.type === "string";
1270
+ }
1271
+ function generateDefaultJsonc(config) {
1272
+ return "{\n" + genEntries(config, 1) + "}\n";
1273
+ }
1274
+ function genEntries(entries, indent) {
1275
+ const pad = " ".repeat(indent);
1276
+ const keys = Object.keys(entries).filter((k) => !k.includes(".")).sort();
1277
+ let out = "";
1278
+ keys.forEach((key, i) => {
1279
+ const comma = i + 1 < keys.length ? "," : "";
1280
+ const raw = entries[key];
1281
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return;
1282
+ const obj = raw;
1283
+ if (isDecl(obj)) {
1284
+ const desc = typeof obj.description === "string" ? obj.description : "";
1285
+ out += `${pad}// ${String(obj.type)}${desc ? ` \u2014 ${desc}` : ""}
1286
+ `;
1287
+ out += `${pad}${JSON.stringify(key)}: ${JSON.stringify(obj.default)}${comma}
1288
+ `;
1289
+ } else {
1290
+ out += `${pad}${JSON.stringify(key)}: {
1291
+ `;
1292
+ out += genEntries(obj, indent + 1);
1293
+ out += `${pad}}${comma}
1294
+ `;
1295
+ }
1296
+ });
1297
+ return out;
1298
+ }
1299
+ function genConfigFromManifest(manifest, outDir) {
1300
+ const config = manifest.config;
1301
+ if (config == null || typeof config !== "object" || Array.isArray(config) || Object.keys(config).length === 0) {
1302
+ return null;
1303
+ }
1304
+ const id = typeof manifest.id === "string" ? manifest.id : "";
1305
+ if (!id) throw new Error("config gen: manifest declares config but has no id");
1306
+ mkdirSync3(outDir, { recursive: true });
1307
+ const path = join9(outDir, configFileName(id));
1308
+ writeFileSync6(path, generateDefaultJsonc(config));
1309
+ return path;
1310
+ }
1311
+ function genConfigForS2sp(s2spPath, outDir) {
1312
+ const zip = new AdmZip2(s2spPath);
1313
+ const entry = zip.getEntry("manifest.json");
1314
+ if (!entry) throw new Error(`config gen: ${s2spPath} has no manifest.json`);
1315
+ const manifest = JSON.parse(entry.getData().toString("utf8"));
1316
+ return genConfigFromManifest(manifest, outDir);
1317
+ }
1318
+ function runConfigGen(s2sps, outDir) {
1319
+ const written = [];
1320
+ const skipped = [];
1321
+ for (const p of s2sps) {
1322
+ const r = genConfigForS2sp(p, outDir);
1323
+ if (r) written.push(r);
1324
+ else skipped.push(p);
1325
+ }
1326
+ return { written, skipped };
1327
+ }
1328
+
1179
1329
  // src/cli.ts
1180
1330
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1181
- import { dirname as dirname3, join as join9, resolve as resolve6 } from "node:path";
1331
+ import { dirname as dirname3, join as join10, resolve as resolve6 } from "node:path";
1182
1332
  var argv = process.argv.slice(2);
1183
1333
  var command = argv[0];
1184
1334
  function repoRootFromCli() {
1185
- return join9(dirname3(fileURLToPath3(import.meta.url)), "..", "..", "..");
1335
+ return join10(dirname3(fileURLToPath3(import.meta.url)), "..", "..", "..");
1186
1336
  }
1187
1337
  function parseFlag(args, name) {
1188
1338
  const eq = args.find((a) => a.startsWith(`${name}=`));
@@ -1194,6 +1344,20 @@ function parseFlag(args, name) {
1194
1344
  function hasFlag(args, name) {
1195
1345
  return args.includes(name);
1196
1346
  }
1347
+ function positionals(args, flagsWithValue) {
1348
+ const out = [];
1349
+ for (let i = 0; i < args.length; i++) {
1350
+ const a = args[i];
1351
+ if (a.startsWith("--")) {
1352
+ const name = a.split("=")[0];
1353
+ if (flagsWithValue.includes(name) && !a.includes("=")) i++;
1354
+ continue;
1355
+ }
1356
+ if (a.startsWith("-")) continue;
1357
+ out.push(a);
1358
+ }
1359
+ return out;
1360
+ }
1197
1361
  if (command === "gen-schema") {
1198
1362
  const repoRoot = repoRootFromCli();
1199
1363
  const check = argv[1] === "--check";
@@ -1257,6 +1421,22 @@ if (command === "gen-schema") {
1257
1421
  console.error(String(e instanceof Error ? e.message : e));
1258
1422
  process.exit(1);
1259
1423
  }
1424
+ } else if (command === "config" && argv[1] === "gen") {
1425
+ const args = argv.slice(2);
1426
+ const outDir = parseFlag(args, "--out") ?? process.cwd();
1427
+ const s2sps = positionals(args, ["--out"]);
1428
+ if (s2sps.length === 0) {
1429
+ console.error("Usage: s2s config gen <plugin.s2sp...> --out <dir>");
1430
+ process.exit(1);
1431
+ }
1432
+ try {
1433
+ const { written, skipped } = runConfigGen(s2sps, outDir);
1434
+ for (const w of written) console.log(`config gen: wrote ${w}`);
1435
+ for (const s of skipped) console.log(`config gen: ${s} declares no config \u2014 skipped`);
1436
+ } catch (e) {
1437
+ console.error(String(e instanceof Error ? e.message : e));
1438
+ process.exit(1);
1439
+ }
1260
1440
  } else if (command === "build" && argv[1]) {
1261
1441
  const args = argv.slice(1);
1262
1442
  const dir = args.find((a) => !a.startsWith("-"));
@@ -1274,7 +1454,7 @@ if (command === "gen-schema") {
1274
1454
  }
1275
1455
  } else {
1276
1456
  console.error(
1277
- "Usage:\n s2s create [path] [--game cs2|none] [--name <pkg>] [--template minimal]\n [--install npm|pnpm|yarn|bun|none] [--no-install] [-y]\n s2s build <dir> [--packages-dir <path>]\n s2s gen-schema [--check]\n s2s gen-events [--check]\n s2s gen-nav [--check]"
1457
+ "Usage:\n s2s create [path] [--game cs2|none] [--name <pkg>] [--template minimal]\n [--install npm|pnpm|yarn|bun|none] [--no-install] [-y]\n s2s build <dir> [--packages-dir <path>]\n s2s config gen <plugin.s2sp...> --out <dir>\n s2s gen-schema [--check]\n s2s gen-events [--check]\n s2s gen-nav [--check]"
1278
1458
  );
1279
1459
  process.exit(1);
1280
1460
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s2script/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "s2script SDK — the builtin capability types (imported as @s2script/sdk/entity, …) plus the `s2s` CLI for building Source 2 / CS2 plugins.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,6 +103,9 @@
103
103
  "./translations": {
104
104
  "types": "./translations.d.ts"
105
105
  },
106
+ "./transmit": {
107
+ "types": "./transmit.d.ts"
108
+ },
106
109
  "./usercmd": {
107
110
  "types": "./usercmd.d.ts"
108
111
  },
package/transmit.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /** @s2script/transmit — per-client entity visibility filtering (Source2 CheckTransmit).
2
+ * Declarative rules: a plugin says "this entity is visible only to these viewer slots" and the
3
+ * engine-side hook enforces it every snapshot — zero JS runs in the per-client hot path. */
4
+ import type { EntityRef } from "./entity";
5
+
6
+ export interface TransmitStats {
7
+ /** CheckTransmit invocations observed since load/reset. */
8
+ snapshots: number;
9
+ /** Live rule entries in the native table. */
10
+ entries: number;
11
+ /** Total transmit bits cleared since load/reset. */
12
+ bitsCleared: number;
13
+ /** Nanoseconds spent in the post-hook, last invocation. */
14
+ nsLast: number;
15
+ /** Worst single invocation (ns) since load/reset. */
16
+ nsMax: number;
17
+ }
18
+
19
+ export declare const Transmit: {
20
+ /** Replace this plugin's visibility rule for `entity`: it is transmitted ONLY to the given viewer
21
+ * slots (an empty array = hidden from everyone). Multiple plugins AND-merge — an entity reaches a
22
+ * viewer only if every plugin holding a rule on it allows that viewer. Returns false if the ref is
23
+ * stale or the capability is unavailable/disabled. Throws RangeError on a slot outside [0, 64). */
24
+ setVisibleTo(entity: EntityRef, viewers: readonly number[]): boolean;
25
+ /** Remove this plugin's rule for `entity` (visible to all again, as far as this plugin is
26
+ * concerned). Returns false if the ref is stale or the capability is unavailable. */
27
+ reset(entity: EntityRef): boolean;
28
+ /** Remove all of this plugin's rules. */
29
+ resetAll(): void;
30
+ /** Hot-path counters for measurement/debugging. */
31
+ stats(): TransmitStats;
32
+ };
package/usermessages.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { HookResultValue } from "./events";
2
+
1
3
  /** A general protobuf user-message builder. Build then send in one synchronous burst. */
2
4
  export class UserMessage {
3
5
  constructor(name: string);
@@ -12,3 +14,36 @@ export class UserMessage {
12
14
  /** Broadcast to all connected non-bot clients. */
13
15
  sendAll(): boolean;
14
16
  }
17
+
18
+ // --- UserMessage interception (usermsg-hook slice) ---
19
+
20
+ /** A BLOCK-SCOPED view of an intercepted outbound user message — valid only during a
21
+ * UserMessages.onPre handler; across an await (or stashed) all reads return null/[]/"". */
22
+ export interface UserMessageView {
23
+ /** Canonical unscoped message name (e.g. "CMsgTEFireBullets"). */
24
+ readonly name: string;
25
+ /** Numeric network-message id (e.g. 452). */
26
+ readonly id: number;
27
+ /** Recipient slots (0-based) this post targets; a broadcast decodes to all live slots. Read-only in v1. */
28
+ readonly recipients: number[];
29
+ /** protobuf TextFormat dump — the documented FALLBACK for unmapped messages only; prefer typed reads. */
30
+ readonly debugString: string;
31
+ hasField(path: string): boolean;
32
+ /** Scalar int read (int32/uint32/fixed32/enum; bool as 0/1). Dotted nested paths supported
33
+ * ("origin.x" walks sub-messages). null = no such field / repeated / no current message. */
34
+ readInt(path: string): number | null;
35
+ readFloat(path: string): number | null;
36
+ readBool(path: string): boolean | null;
37
+ readString(path: string): string | null;
38
+ }
39
+
40
+ export declare const UserMessages: {
41
+ /** Pre-hook an outbound user message by unscoped name (partial match, SayText2-style; the view
42
+ * carries the canonical name). Runs SYNCHRONOUSLY before delivery. Return >= HookResult.Handled
43
+ * to SUPPRESS the send for every recipient; Continue/undefined passes it through. THROWS at
44
+ * subscribe time on an unresolvable name or a degraded intercept descriptor. */
45
+ onPre(name: string, handler: (msg: UserMessageView) => HookResultValue | void): void;
46
+ /** Removes ALL of the calling plugin's handlers for this name (mux off semantics — handler
47
+ * identity not compared, matching Events.off). */
48
+ off(name: string, handler?: (msg: UserMessageView) => HookResultValue | void): void;
49
+ };