@s2script/sdk 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  }
@@ -4636,9 +4640,15 @@ var ATOMIC = {
4636
4640
  uint32: { k: "u32", w: true },
4637
4641
  uint64: { k: "u64", w: false },
4638
4642
  int64: { k: "i64", w: false },
4639
- float64: { k: "f64", w: false }
4643
+ float64: { k: "f64", w: false },
4640
4644
  // no narrow 64-bit writers
4645
+ // `Color` is a 4-byte RGBA struct, not a scalar, but it is laid out exactly as a little-endian
4646
+ // uint32 (R in the low byte) and every engine consumer treats it that way. Mapping it here rather
4647
+ // than adding a value class keeps the surface small and matches how packed colours are already
4648
+ // passed elsewhere in the SDK (e.g. the fade user-message).
4649
+ Color: { k: "u32", w: true }
4641
4650
  };
4651
+ var ENUM_WIDTH = { 1: "u8", 2: "u16", 4: "u32", 8: "u64" };
4642
4652
  var VEC = { Vector: "vector", VectorWS: "vector", QAngle: "qangle" };
4643
4653
  var VEC_INFO = {
4644
4654
  vector: { cls: "Vector", count: 3 },
@@ -4651,7 +4661,13 @@ function idiomaticName(raw) {
4651
4661
  const core = m && KNOWN_TAGS.has(m[1]) ? m[2] : s;
4652
4662
  return core.charAt(0).toLowerCase() + core.slice(1);
4653
4663
  }
4654
- function classifyField(type) {
4664
+ function transparentValue(catalog, className) {
4665
+ const e = catalog[className];
4666
+ if (!e || e.parent || e.fields.length !== 1) return null;
4667
+ const f = e.fields[0];
4668
+ return f.name === "m_Value" && f.type.kind === "atomic" ? f : null;
4669
+ }
4670
+ function classifyField(type, embeddable = /* @__PURE__ */ new Set()) {
4655
4671
  if (type.kind === "handle") return { accessorKind: "handle", writable: false };
4656
4672
  if (type.kind === "atomic") {
4657
4673
  const vk = VEC[type.name ?? ""];
@@ -4665,8 +4681,16 @@ function classifyField(type) {
4665
4681
  if (cm) return { accessorKind: "str", writable: false, strLen: parseInt(cm[1], 10) };
4666
4682
  return { skip: `unmapped 'unknown' type '${type.name ?? ""}'` };
4667
4683
  }
4668
- if (type.kind === "enum") return { skip: "enum byte-width absent from catalog (deferred)" };
4669
- if (type.kind === "class") return { skip: `embedded class '${type.name ?? ""}' deferred` };
4684
+ if (type.kind === "enum") {
4685
+ const w = ENUM_WIDTH[type.size ?? 0];
4686
+ if (w) return { accessorKind: w, writable: WRITE[w] !== void 0 };
4687
+ return { skip: `enum '${type.name ?? ""}' byte width not stated by the schema` };
4688
+ }
4689
+ if (type.kind === "class") {
4690
+ const n3 = type.name ?? "";
4691
+ if (embeddable.has(n3)) return { embedded: n3 };
4692
+ return { skip: `embedded class '${n3}' deferred` };
4693
+ }
4670
4694
  if (type.kind === "ptr") return { skip: "raw pointer" };
4671
4695
  return { skip: `unmapped kind '${type.kind}'` };
4672
4696
  }
@@ -4681,7 +4705,11 @@ function flattenedFields(model, className) {
4681
4705
  }
4682
4706
  return chain.flatMap((c2) => c2.ownFields);
4683
4707
  }
4684
- function buildModel(catalog, requested) {
4708
+ function buildModel(catalog, requested, embeddable = []) {
4709
+ for (const n3 of embeddable) if (!catalog[n3]) throw new Error(`gen-schema: embedded class '${n3}' is not in the catalog`);
4710
+ const restrict = embeddable.length > 0 ? new Set(embeddable) : null;
4711
+ const canEmbed = (n3) => !!catalog[n3] && transparentValue(catalog, n3) === null && (restrict === null || restrict.has(n3));
4712
+ const embedSet = new Set(Object.keys(catalog).filter(canEmbed));
4685
4713
  const inClosure = /* @__PURE__ */ new Set();
4686
4714
  for (const start of requested) {
4687
4715
  if (!catalog[start]) throw new Error(`gen-schema: requested class '${start}' is not in the catalog`);
@@ -4700,34 +4728,98 @@ function buildModel(catalog, requested) {
4700
4728
  return d;
4701
4729
  };
4702
4730
  const ordered = [...inClosure].sort((a2, b) => depth(a2) - depth(b) || (a2 < b ? -1 : a2 > b ? 1 : 0));
4703
- const classes = ordered.map((className) => {
4704
- const parent = catalog[className].parent;
4705
- const ownFields = [];
4706
- const skipped = [];
4707
- for (const f of catalog[className].fields) {
4708
- const c2 = classifyField(f.type);
4731
+ const usedEmbeds = /* @__PURE__ */ new Set();
4732
+ const classify = (f) => {
4733
+ if (f.type.kind === "class") {
4734
+ const inner = transparentValue(catalog, f.type.name ?? "");
4735
+ if (inner) {
4736
+ const c2 = classifyField(inner.type);
4737
+ if (!("skip" in c2) && !("embedded" in c2)) return { ...c2, addOffset: inner.offset };
4738
+ }
4739
+ }
4740
+ return classifyField(f.type, embedSet);
4741
+ };
4742
+ const fieldsOf = (owner, out, emb, skipped) => {
4743
+ for (const f of catalog[owner].fields) {
4744
+ const c2 = classify(f);
4709
4745
  if ("skip" in c2) {
4710
- skipped.push({ className, rawName: f.name, reason: c2.skip });
4746
+ skipped.push({ className: owner, rawName: f.name, reason: c2.skip });
4747
+ continue;
4748
+ }
4749
+ if ("embedded" in c2) {
4750
+ usedEmbeds.add(c2.embedded);
4751
+ emb.push({ propName: idiomaticName(f.name), rawName: f.name, declaringClass: owner, embeddedClass: c2.embedded });
4711
4752
  continue;
4712
4753
  }
4713
- ownFields.push({ propName: idiomaticName(f.name), rawName: f.name, declaringClass: className, accessorKind: c2.accessorKind, writable: c2.writable, strLen: c2.strLen });
4754
+ out.push({ propName: idiomaticName(f.name), rawName: f.name, declaringClass: owner, accessorKind: c2.accessorKind, writable: c2.writable, strLen: c2.strLen, addOffset: c2.addOffset });
4714
4755
  }
4715
- return { className, parent: parent && inClosure.has(parent) ? parent : null, ownFields, skipped };
4756
+ };
4757
+ const classes = ordered.map((className) => {
4758
+ const parent = catalog[className].parent;
4759
+ const ownFields = [];
4760
+ const embeddedFields = [];
4761
+ const skipped = [];
4762
+ fieldsOf(className, ownFields, embeddedFields, skipped);
4763
+ return { className, parent: parent && inClosure.has(parent) ? parent : null, ownFields, embeddedFields, skipped };
4716
4764
  });
4765
+ const embedded = [];
4766
+ const done = /* @__PURE__ */ new Set();
4767
+ for (; ; ) {
4768
+ const next = [...usedEmbeds].filter((n3) => !done.has(n3)).sort();
4769
+ if (next.length === 0) break;
4770
+ for (const className of next) {
4771
+ done.add(className);
4772
+ const fields = [];
4773
+ const embeddedFields = [];
4774
+ const skipped = [];
4775
+ const chain = [];
4776
+ for (let cur = className; cur && catalog[cur]; cur = catalog[cur].parent) chain.unshift(cur);
4777
+ for (const owner of chain) fieldsOf(owner, fields, embeddedFields, skipped);
4778
+ embedded.push({ className, fields, embeddedFields, skipped });
4779
+ }
4780
+ }
4781
+ embedded.sort((a2, b) => a2.className < b.className ? -1 : a2.className > b.className ? 1 : 0);
4782
+ const ancestors = (cls) => {
4783
+ const out = /* @__PURE__ */ new Set();
4784
+ for (let cur = catalog[cls]?.parent ?? null; cur && catalog[cur]; cur = catalog[cur].parent) out.add(cur);
4785
+ return out;
4786
+ };
4717
4787
  const byProp = /* @__PURE__ */ new Map();
4718
- for (const c2 of classes) for (const f of c2.ownFields) {
4788
+ for (const c2 of classes) for (const f of [...c2.ownFields, ...c2.embeddedFields]) {
4719
4789
  (byProp.get(f.propName) ?? byProp.set(f.propName, []).get(f.propName)).push(f);
4720
4790
  }
4721
4791
  const collisions = [];
4722
4792
  for (const [prop, fields] of byProp) {
4723
- const distinct = new Set(fields.map((f) => `${f.declaringClass}.${f.rawName}`));
4724
- if (distinct.size >= 2) {
4725
- for (const f of fields) f.propName = f.rawName;
4726
- collisions.push(`${prop} \u2190 ${[...distinct].sort().join(", ")}`);
4793
+ const decls = /* @__PURE__ */ new Map();
4794
+ for (const f of fields) (decls.get(`${f.declaringClass}.${f.rawName}`) ?? decls.set(`${f.declaringClass}.${f.rawName}`, []).get(`${f.declaringClass}.${f.rawName}`)).push(f);
4795
+ if (decls.size < 2) continue;
4796
+ const keys = [...decls.keys()].sort();
4797
+ const clsOf = (k) => decls.get(k)[0].declaringClass;
4798
+ const conflicting = /* @__PURE__ */ new Set();
4799
+ for (let i2 = 0; i2 < keys.length; i2++) {
4800
+ for (let j = i2 + 1; j < keys.length; j++) {
4801
+ const a2 = clsOf(keys[i2]), b = clsOf(keys[j]);
4802
+ if (a2 === b || ancestors(a2).has(b) || ancestors(b).has(a2)) {
4803
+ conflicting.add(keys[i2]);
4804
+ conflicting.add(keys[j]);
4805
+ }
4806
+ }
4727
4807
  }
4808
+ if (conflicting.size === 0) continue;
4809
+ const depthOf = (k) => ancestors(clsOf(k)).size;
4810
+ const inConflict = [...conflicting].sort((x, y) => depthOf(x) - depthOf(y) || (x < y ? -1 : 1));
4811
+ const winnerCls = clsOf(inConflict[0]);
4812
+ const soleWinner = inConflict.filter((k) => clsOf(k) === winnerCls).length === 1;
4813
+ const renamed = [];
4814
+ for (const k of inConflict) {
4815
+ if (soleWinner && k === inConflict[0]) continue;
4816
+ for (const f of decls.get(k)) f.propName = f.rawName;
4817
+ renamed.push(k);
4818
+ }
4819
+ collisions.push(`${prop} <- ${renamed.sort().join(", ")}${soleWinner ? ` (kept by ${winnerCls})` : ""}`);
4728
4820
  }
4729
4821
  collisions.sort();
4730
- return { classes, collisions };
4822
+ return { classes, embedded, collisions };
4731
4823
  }
4732
4824
 
4733
4825
  // src/schemagen/emit-dts.ts
@@ -4740,8 +4832,20 @@ function emitDts(model) {
4740
4832
  const vi = VEC_INFO[f.accessorKind];
4741
4833
  if (vi) vecClasses.add(vi.cls);
4742
4834
  }
4835
+ for (const e of model.embedded) for (const f of e.fields) {
4836
+ const vi = VEC_INFO[f.accessorKind];
4837
+ if (vi) vecClasses.add(vi.cls);
4838
+ }
4743
4839
  if (vecClasses.size) out.push(`import type { ${[...vecClasses].sort().join(", ")} } from "@s2script/sdk/math";`);
4744
4840
  out.push("");
4841
+ for (const e of model.embedded) {
4842
+ out.push(`export interface ${e.className} {`);
4843
+ for (const f of e.fields) {
4844
+ out.push(` ${f.writable ? "" : "readonly "}${f.propName}: ${TSTYPE[f.accessorKind]};`);
4845
+ }
4846
+ for (const f of e.embeddedFields) out.push(` readonly ${f.propName}: ${f.embeddedClass};`);
4847
+ out.push("}", "");
4848
+ }
4745
4849
  for (const c2 of model.classes) {
4746
4850
  const ext = c2.parent && names.has(c2.parent) ? ` extends ${c2.parent}` : "";
4747
4851
  out.push(`export interface ${c2.className}${ext} {`);
@@ -4749,18 +4853,66 @@ function emitDts(model) {
4749
4853
  const ro = f.writable ? "" : "readonly ";
4750
4854
  out.push(` ${ro}${f.propName}: ${TSTYPE[f.accessorKind]};`);
4751
4855
  }
4856
+ for (const f of c2.embeddedFields) out.push(` readonly ${f.propName}: ${f.embeddedClass};`);
4752
4857
  out.push("}", "");
4753
4858
  }
4859
+ out.push("/** Every wrappable entity class, by schema name. */", "export interface SchemaClasses {");
4860
+ for (const c2 of model.classes) out.push(` ${c2.className}: ${c2.className};`);
4861
+ out.push("}", "");
4862
+ out.push(
4863
+ "/**",
4864
+ " * Apply this schema class's field accessors to an entity reference.",
4865
+ " *",
4866
+ " * `createEntity` returns a bare `EntityRef` with no schema fields on it \u2014 this is how you get",
4867
+ " * them. `Player.pawn` is already wrapped; this is for entities you create or resolve yourself.",
4868
+ " *",
4869
+ " * The wrapper is a view, not a copy: it holds the ref and reads through it on every access, so",
4870
+ " * it stays correct as the entity changes and costs nothing to keep. Field reads return null",
4871
+ " * once the ref goes stale, exactly as they do on a pawn.",
4872
+ " *",
4873
+ " * @example",
4874
+ ' * const prop = createEntity("prop_dynamic");',
4875
+ " * prop.spawn();",
4876
+ ' * const fields = wrapEntity("CBaseModelEntity", prop);',
4877
+ " * fields.glow.glowing = true;",
4878
+ " */",
4879
+ "export declare function wrapEntity<K extends keyof SchemaClasses>(className: K, ref: EntityRef): SchemaClasses[K];",
4880
+ ""
4881
+ );
4754
4882
  return out.join("\n");
4755
4883
  }
4756
4884
 
4757
4885
  // src/schemagen/emit-js.ts
4758
4886
  var HEADER2 = "// GENERATED by `s2script gen-schema` from schema-catalog.json \u2014 DO NOT EDIT.";
4759
4887
  var S = JSON.stringify;
4888
+ function resolveExpr(f) {
4889
+ const base = `off(${S(f.declaringClass)},${S(f.rawName)})`;
4890
+ return f.addOffset ? `${base} + ${f.addOffset}` : base;
4891
+ }
4892
+ function accessorEntry(f, resolve13, guard2) {
4893
+ const g = guard2 === "" ? "" : `${guard2} `;
4894
+ const gs = guard2 === "" ? "" : `${guard2.replace("return null;", "return;")} `;
4895
+ let entry;
4896
+ if (f.accessorKind === "str") {
4897
+ entry = `get: function () { ${g}return this.ref.readString(${resolve13}, ${f.strLen}); }`;
4898
+ } else if (f.accessorKind === "u64" || f.accessorKind === "i64") {
4899
+ entry = `get: function () { ${g}var v = this.ref.${READ[f.accessorKind]}(${resolve13}); return v === null ? null : v.toString(); }`;
4900
+ } else if (VEC_INFO[f.accessorKind]) {
4901
+ const vi = VEC_INFO[f.accessorKind];
4902
+ entry = `get: function () { ${g}var a = this.ref.readFloats(${resolve13}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
4903
+ } else {
4904
+ entry = `get: function () { ${g}return this.ref.${READ[f.accessorKind]}(${resolve13}); }`;
4905
+ }
4906
+ if (f.writable) {
4907
+ const wm = WRITE[f.accessorKind];
4908
+ entry += `, set: function (v) { ${gs}var o = ${resolve13}; if (this.ref.${wm}(o, v)) this.ref.notifyStateChanged(o); }`;
4909
+ }
4910
+ return entry;
4911
+ }
4760
4912
  function emitJs(model) {
4761
- const out = [HEADER2, "(function () {", " var off = __s2_schema_offset;", " var A = {};"];
4913
+ const out = [HEADER2, "(function () {", " var off = __s2_schema_offset;", " var A = {};", " var E = {};"];
4762
4914
  const vecClasses = /* @__PURE__ */ new Set();
4763
- for (const c2 of model.classes) for (const f of flattenedFields(model, c2.className)) {
4915
+ for (const c2 of model.classes) for (const f of c2.ownFields) {
4764
4916
  const vi = VEC_INFO[f.accessorKind];
4765
4917
  if (vi) vecClasses.add(vi.cls);
4766
4918
  }
@@ -4769,36 +4921,58 @@ function emitJs(model) {
4769
4921
  }
4770
4922
  for (const c2 of model.classes) {
4771
4923
  out.push(` A[${S(c2.className)}] = {`);
4772
- for (const f of flattenedFields(model, c2.className)) {
4773
- const resolve13 = `off(${S(f.declaringClass)},${S(f.rawName)})`;
4774
- let entry;
4775
- if (f.accessorKind === "str") {
4776
- entry = `get: function () { return this.ref.readString(${resolve13}, ${f.strLen}); }`;
4777
- } else if (f.accessorKind === "u64" || f.accessorKind === "i64") {
4778
- entry = `get: function () { var v = this.ref.${READ[f.accessorKind]}(${resolve13}); return v === null ? null : v.toString(); }`;
4779
- } else if (VEC_INFO[f.accessorKind]) {
4780
- const vi = VEC_INFO[f.accessorKind];
4781
- entry = `get: function () { var a = this.ref.readFloats(${resolve13}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
4782
- } else {
4783
- entry = `get: function () { return this.ref.${READ[f.accessorKind]}(${resolve13}); }`;
4784
- }
4785
- if (f.writable) {
4786
- const wm = WRITE[f.accessorKind];
4787
- entry += `, set: function (v) { var o = ${resolve13}; if (this.ref.${wm}(o, v)) this.ref.notifyStateChanged(o); }`;
4788
- }
4924
+ for (const f of c2.ownFields) {
4925
+ const entry = accessorEntry(f, resolveExpr(f), "");
4789
4926
  out.push(` ${S(f.propName)}: { ${entry} },`);
4790
4927
  }
4928
+ for (const f of c2.embeddedFields) {
4929
+ out.push(` ${S(f.propName)}: { get: function () { return embed(${S(f.embeddedClass)}, this.ref, off(${S(f.declaringClass)},${S(f.rawName)})); } },`);
4930
+ }
4931
+ out.push(" };");
4932
+ }
4933
+ for (const e of model.embedded) {
4934
+ out.push(` E[${S(e.className)}] = {`);
4935
+ for (const f of e.fields) {
4936
+ const sub = `off(${S(f.declaringClass)},${S(f.rawName)})`;
4937
+ const resolve13 = f.addOffset ? `this.base + ${sub} + ${f.addOffset}` : `this.base + ${sub}`;
4938
+ const guard2 = `if (this.base < 0 || ${sub} < 0) return null;`;
4939
+ out.push(` ${S(f.propName)}: { ${accessorEntry(f, resolve13, guard2)} },`);
4940
+ }
4941
+ for (const f of e.embeddedFields) {
4942
+ const sub = `off(${S(f.declaringClass)},${S(f.rawName)})`;
4943
+ out.push(` ${S(f.propName)}: { get: function () { var s = ${sub}; return embed(${S(f.embeddedClass)}, this.ref, (this.base < 0 || s < 0) ? -1 : this.base + s); } },`);
4944
+ }
4791
4945
  out.push(" };");
4792
4946
  }
4947
+ out.push(" var P = {");
4948
+ for (const c2 of model.classes) if (c2.parent) out.push(` ${S(c2.className)}: ${S(c2.parent)},`);
4949
+ out.push(" };");
4793
4950
  out.push(
4951
+ " // Walk the ancestor chain rather than storing every inherited accessor per class. Flattening",
4952
+ " // into each table duplicated the whole chain 3.77x on the CS2 class set and grew as",
4953
+ " // O(classes * depth), so every class added inflated every other one; this is O(total fields).",
4954
+ " // Ancestors are applied FIRST so a derived class's own definition wins if a name repeats.",
4794
4955
  " function applyAccessors(proto, className) {",
4795
- " var defs = A[className]; if (!defs) return;",
4796
- " for (var name in defs) {",
4797
- " Object.defineProperty(proto, name, { get: defs[name].get, set: defs[name].set, enumerable: true, configurable: true });",
4956
+ " var chain = [], c = className;",
4957
+ " for (; c; c = P[c]) chain.unshift(c);",
4958
+ " for (var i = 0; i < chain.length; i++) {",
4959
+ " var defs = A[chain[i]]; if (!defs) continue;",
4960
+ " for (var name in defs) {",
4961
+ " Object.defineProperty(proto, name, { get: defs[name].get, set: defs[name].set, enumerable: true, configurable: true });",
4962
+ " }",
4798
4963
  " }",
4799
4964
  " }",
4800
4965
  " function wrap(className, ref) { var o = { ref: ref }; applyAccessors(o, className); return o; }",
4966
+ " function embed(className, ref, base) {",
4967
+ " var o = { ref: ref, base: base }, defs = E[className];",
4968
+ " if (defs) for (var name in defs) {",
4969
+ " Object.defineProperty(o, name, { get: defs[name].get, set: defs[name].set, enumerable: true, configurable: true });",
4970
+ " }",
4971
+ " return o;",
4972
+ " }",
4801
4973
  " globalThis.__s2pkg_cs2_schema = { applyAccessors: applyAccessors, wrap: wrap };",
4974
+ " // Merge (not overwrite): this prelude runs FIRST, and pawn.js merges onto whatever it finds.",
4975
+ " globalThis.__s2pkg_cs2 = Object.assign({}, globalThis.__s2pkg_cs2, { wrapEntity: wrap });",
4802
4976
  "})();"
4803
4977
  );
4804
4978
  return out.join("\n");
@@ -4807,12 +4981,18 @@ function emitJs(model) {
4807
4981
  // src/schemagen/gen.ts
4808
4982
  var CATALOG_PATH = "games/cs2/gamedata/schema-catalog.json";
4809
4983
  var LIST_PATH = "games/cs2/codegen-classes.json";
4984
+ var EMBED_PATH = "games/cs2/codegen-embedded.json";
4810
4985
  var JS_OUT = "games/cs2/js/schema.generated.js";
4811
4986
  var DTS_OUT = "packages/cs2/schema.generated.d.ts";
4812
4987
  function runGenSchema(repoRoot2, opts) {
4813
4988
  const catalog = JSON.parse(readFileSync13(join13(repoRoot2, CATALOG_PATH), "utf8"));
4814
4989
  const list = JSON.parse(readFileSync13(join13(repoRoot2, LIST_PATH), "utf8"));
4815
- const model = buildModel(catalog, list);
4990
+ let embed = [];
4991
+ try {
4992
+ embed = JSON.parse(readFileSync13(join13(repoRoot2, EMBED_PATH), "utf8"));
4993
+ } catch {
4994
+ }
4995
+ const model = buildModel(catalog, list, embed);
4816
4996
  const dts = emitDts(model);
4817
4997
  const js = emitJs(model);
4818
4998
  const fields = model.classes.reduce((n3, c2) => n3 + c2.ownFields.length, 0);
@@ -4963,27 +5143,44 @@ var SUPPORTED_NAV_KINDS = /* @__PURE__ */ new Set([
4963
5143
  "vector",
4964
5144
  "qangle"
4965
5145
  ]);
5146
+ var WRITABLE_NAV_KINDS = /* @__PURE__ */ new Set(["f32", "bool", "i32"]);
4966
5147
  function buildNavModel(config, catalog) {
4967
5148
  const targetClasses = [...new Set(config.map((e) => e.target))];
4968
5149
  const schemaModel = buildModel(catalog, targetClasses);
4969
5150
  const sorted = [...config].sort((a2, b) => a2.wrapper < b.wrapper ? -1 : a2.wrapper > b.wrapper ? 1 : 0);
4970
5151
  const wrappers = sorted.map((entry) => {
4971
5152
  const all = flattenedFields(schemaModel, entry.target);
5153
+ const wantWritable = new Set(entry.writable ?? []);
4972
5154
  const fields = [];
4973
5155
  const skippedKinds = [];
4974
5156
  for (const f of all) {
4975
5157
  if (SUPPORTED_NAV_KINDS.has(f.accessorKind)) {
4976
- fields.push(f);
5158
+ fields.push({ ...f, navWritable: wantWritable.has(f.rawName) });
4977
5159
  } else {
4978
5160
  skippedKinds.push({ propName: f.propName, accessorKind: f.accessorKind });
4979
5161
  }
4980
5162
  }
5163
+ for (const raw of wantWritable) {
5164
+ const f = fields.find((x) => x.rawName === raw);
5165
+ if (!f) {
5166
+ const known = all.some((x) => x.rawName === raw);
5167
+ throw new Error(
5168
+ `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?).`)
5169
+ );
5170
+ }
5171
+ if (!WRITABLE_NAV_KINDS.has(f.accessorKind)) {
5172
+ throw new Error(
5173
+ `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.`
5174
+ );
5175
+ }
5176
+ }
4981
5177
  return {
4982
5178
  wrapper: entry.wrapper,
4983
5179
  prop: entry.prop,
4984
5180
  source: entry.source,
4985
5181
  target: entry.target,
4986
5182
  path: entry.path,
5183
+ base: entry.base ?? [],
4987
5184
  fields,
4988
5185
  skippedKinds
4989
5186
  };
@@ -5004,6 +5201,11 @@ var READ_VIA = {
5004
5201
  u16: "readUInt16Via",
5005
5202
  u32: "readUInt32Via"
5006
5203
  };
5204
+ var WRITE_VIA = {
5205
+ f32: "writeFloat32Via",
5206
+ bool: "writeBoolVia",
5207
+ i32: "writeInt32Via"
5208
+ };
5007
5209
  function emitNavJs(model) {
5008
5210
  const out = [HEADER4, "(function () {", " var off = __s2_schema_offset;"];
5009
5211
  const vecClasses = /* @__PURE__ */ new Set();
@@ -5017,10 +5219,12 @@ function emitNavJs(model) {
5017
5219
  out.push(` var ${cls} = __s2require("@s2script/sdk/math").${cls};`);
5018
5220
  }
5019
5221
  for (const w of model.wrappers) {
5020
- out.push(` function ${w.wrapper}(root, path) { this.root = root; this.path = path; }`);
5222
+ out.push(` function ${w.wrapper}(root, path, base) { this.root = root; this.path = path; this.base = base; }`);
5021
5223
  out.push(` Object.defineProperties(${w.wrapper}.prototype, {`);
5022
5224
  for (const f of w.fields) {
5023
- const resolve13 = `off(${S2(f.declaringClass)},${S2(f.rawName)})`;
5225
+ const own = `off(${S2(f.declaringClass)},${S2(f.rawName)})`;
5226
+ const withDelta = f.addOffset ? `${own} + ${f.addOffset}` : own;
5227
+ const resolve13 = `${withDelta} + this.base`;
5024
5228
  let getter;
5025
5229
  const kind = f.accessorKind;
5026
5230
  if (kind === "u64") {
@@ -5037,7 +5241,9 @@ function emitNavJs(model) {
5037
5241
  if (!method) continue;
5038
5242
  getter = `get: function () { return this.root.${method}(this.path, ${resolve13}); }`;
5039
5243
  }
5040
- out.push(` ${S2(f.propName)}: { ${getter} },`);
5244
+ const wmethod = f.navWritable ? WRITE_VIA[kind] : void 0;
5245
+ const setter = wmethod ? `, set: function (v) { this.root.${wmethod}(this.path, ${resolve13}, v); }` : "";
5246
+ out.push(` ${S2(f.propName)}: { ${getter}${setter} },`);
5041
5247
  }
5042
5248
  out.push(" });");
5043
5249
  }
@@ -5057,7 +5263,16 @@ function emitNavJs(model) {
5057
5263
  const h2 = w.path[i2];
5058
5264
  out.push(` var o${i2} = off(${S2(h2.cls)},${S2(h2.field)}); if (o${i2} < 0) return null; _p.push(o${i2});`);
5059
5265
  }
5060
- out.push(` return new ${w.wrapper}(this.ref, _p);`);
5266
+ if (w.base.length > 0) {
5267
+ out.push(` var _b = 0;`);
5268
+ for (let i2 = 0; i2 < w.base.length; i2++) {
5269
+ const h2 = w.base[i2];
5270
+ out.push(` var b${i2} = off(${S2(h2.cls)},${S2(h2.field)}); if (b${i2} < 0) return null; _b += b${i2};`);
5271
+ }
5272
+ out.push(` return new ${w.wrapper}(this.ref, _p, _b);`);
5273
+ } else {
5274
+ out.push(` return new ${w.wrapper}(this.ref, _p, 0);`);
5275
+ }
5061
5276
  out.push(` }, enumerable: true, configurable: true,`);
5062
5277
  out.push(` });`);
5063
5278
  }
@@ -5092,9 +5307,20 @@ function emitNavDts(model) {
5092
5307
  if (needsEntityRef) out.push('import type { EntityRef } from "@s2script/sdk/entity";');
5093
5308
  out.push("");
5094
5309
  for (const w of model.wrappers) {
5310
+ if (w.fields.some((f) => f.navWritable)) {
5311
+ out.push(`/**`);
5312
+ out.push(` * Fields NOT marked \`readonly\` are writable.`);
5313
+ out.push(` *`);
5314
+ out.push(` * A write takes effect immediately \u2014 the server reads these during movement \u2014 but it is **not**`);
5315
+ out.push(` * flagged for replication, because this object is reached through a pointer chain and the`);
5316
+ out.push(` * change-notifier addresses the root entity. A client predicting the old value may therefore`);
5317
+ out.push(` * see brief mismatch until the next authoritative correction. SourceMod's \`SetEntPropFloat\``);
5318
+ out.push(` * on the same fields behaves the same way.`);
5319
+ out.push(` */`);
5320
+ }
5095
5321
  out.push(`export interface ${w.wrapper} {`);
5096
5322
  for (const f of w.fields) {
5097
- out.push(` readonly ${f.propName}: ${TSTYPE[f.accessorKind]};`);
5323
+ out.push(` ${f.navWritable ? "" : "readonly "}${f.propName}: ${TSTYPE[f.accessorKind]};`);
5098
5324
  }
5099
5325
  out.push("}", "");
5100
5326
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s2script/sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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
+ };