@s2script/sdk 0.8.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/dist/cli.js CHANGED
@@ -1074,8 +1074,8 @@ function unzipSync(data, opts) {
1074
1074
  }
1075
1075
 
1076
1076
  // src/build.ts
1077
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync } from "node:fs";
1078
- import { resolve as resolve5, join as join6 } from "node:path";
1077
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync, rmSync as rmSync2 } from "node:fs";
1078
+ import { resolve as resolve5, join as join6, sep, basename } from "node:path";
1079
1079
 
1080
1080
  // src/typecheck/typecheck.ts
1081
1081
  import ts from "typescript";
@@ -1168,6 +1168,10 @@ function localDeclarationFiles(pluginDir) {
1168
1168
  if (!existsSync3(srcDir)) return [];
1169
1169
  return readdirSync(srcDir).filter((f) => f.endsWith(".d.ts")).map((f) => join3(srcDir, f));
1170
1170
  }
1171
+ function generatedDeclarationFiles(pluginDir) {
1172
+ const gd = join3(pluginDir, ".s2script", "gamedata.d.ts");
1173
+ return existsSync3(gd) ? [gd] : [];
1174
+ }
1171
1175
  function declaredModules(dtsFiles) {
1172
1176
  const out = /* @__PURE__ */ new Set();
1173
1177
  for (const f of dtsFiles) {
@@ -1213,11 +1217,20 @@ function typecheckPlugin(pluginDir, opts) {
1213
1217
  // dirs are deleted). tsc picks the longest matching prefix, so `@s2script/sdk/*` wins for
1214
1218
  // sdk imports.
1215
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"],
1216
1224
  "@s2script/*": ["*/index.d.ts"],
1217
1225
  ...contractPaths
1218
1226
  }
1219
1227
  };
1220
- const rootNames = [entry, join3(packagesDir, "sdk", "globals.d.ts"), ...localDts];
1228
+ const rootNames = [
1229
+ entry,
1230
+ join3(packagesDir, "sdk", "globals.d.ts"),
1231
+ ...localDts,
1232
+ ...generatedDeclarationFiles(absDir)
1233
+ ];
1221
1234
  const tmp = mkdtempSync(join3(tmpdir(), "s2tc-"));
1222
1235
  try {
1223
1236
  if (deps.length) {
@@ -1521,6 +1534,253 @@ async function lintPlugin(pluginDir, program) {
1521
1534
  return { ok: errorCount === 0, output, errorCount };
1522
1535
  }
1523
1536
 
1537
+ // src/gamedata/types.ts
1538
+ var PLATFORM = "linuxsteamrt64";
1539
+ var ARG_KINDS = ["bool", "int", "float", "string", "vector", "entity"];
1540
+ var RET_KINDS = ["void", "bool", "int", "float", "entity"];
1541
+ var INT_CLASS_ARGS = /* @__PURE__ */ new Set(["bool", "int", "string", "vector", "entity"]);
1542
+ var MAX_INT_ARGS = 5;
1543
+ var MAX_FLOAT_ARGS = 8;
1544
+
1545
+ // src/gamedata/validate.ts
1546
+ var ALLOWED_SECTIONS = /* @__PURE__ */ new Set(["signatures", "calls"]);
1547
+ var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1548
+ var RESERVED_CALL_NAMES = /* @__PURE__ */ new Set(["constructor", "prototype", "__proto__"]);
1549
+ var RESOLVE_KINDS = ["direct", "ctor-body-xref", "lea-disp"];
1550
+ var KNOWN_PERMISSIONS = ["engine:calls"];
1551
+ function validatePluginGamedata(gd, opts) {
1552
+ const errs = [];
1553
+ if (gd == null || typeof gd !== "object" || Array.isArray(gd)) return ["gamedata must be an object"];
1554
+ const g = gd;
1555
+ for (const key of Object.keys(g)) {
1556
+ if (!ALLOWED_SECTIONS.has(key)) {
1557
+ errs.push(`gamedata section '${key}' is not supported in v1 (allowed: signatures, calls)`);
1558
+ }
1559
+ }
1560
+ const sigs = g.signatures ?? {};
1561
+ if (typeof sigs !== "object" || Array.isArray(sigs)) errs.push("signatures must be an object");
1562
+ for (const [name, platforms] of Object.entries(sigs)) {
1563
+ const p = platforms?.[PLATFORM];
1564
+ if (!p) {
1565
+ errs.push(`signature '${name}': missing platform '${PLATFORM}'`);
1566
+ continue;
1567
+ }
1568
+ for (const f of ["module", "pattern", "resolve"]) {
1569
+ if (typeof p[f] !== "string" || !p[f].length) {
1570
+ errs.push(`signature '${name}': '${f}' must be a non-empty string`);
1571
+ }
1572
+ }
1573
+ if (typeof p.resolve === "string" && p.resolve.length && !RESOLVE_KINDS.includes(p.resolve)) {
1574
+ errs.push(
1575
+ `signature '${name}': unknown resolve step ${JSON.stringify(p.resolve)} (allowed: ${RESOLVE_KINDS.join(", ")})`
1576
+ );
1577
+ }
1578
+ }
1579
+ for (const perm of opts.permissions) {
1580
+ if (!KNOWN_PERMISSIONS.includes(perm)) {
1581
+ errs.push(`unknown permission ${JSON.stringify(perm)} (allowed: ${KNOWN_PERMISSIONS.join(", ")})`);
1582
+ }
1583
+ }
1584
+ const calls = g.calls ?? {};
1585
+ const callNames = Object.keys(calls);
1586
+ if (callNames.length && !opts.permissions.includes("engine:calls")) {
1587
+ errs.push('gamedata declares a `calls` section but the manifest does not declare permission "engine:calls"');
1588
+ }
1589
+ for (const [name, rawDecl] of Object.entries(calls)) {
1590
+ const where = `call '${name}'`;
1591
+ if (!IDENTIFIER.test(name)) {
1592
+ errs.push(
1593
+ `${where}: call name must be a plain identifier matching ${IDENTIFIER.source} \u2014 it is emitted verbatim as a TypeScript interface member in the generated .s2script/gamedata.d.ts`
1594
+ );
1595
+ continue;
1596
+ }
1597
+ if (RESERVED_CALL_NAMES.has(name)) {
1598
+ errs.push(`${where}: call name '${name}' is reserved`);
1599
+ continue;
1600
+ }
1601
+ if (rawDecl == null || typeof rawDecl !== "object") {
1602
+ errs.push(`${where}: must be an object`);
1603
+ continue;
1604
+ }
1605
+ const decl = rawDecl;
1606
+ const recv = decl.receiver;
1607
+ if (!recv || recv.kind !== "entity") {
1608
+ errs.push(`${where}: receiver.kind must be "entity" in v1`);
1609
+ } else if (recv.via !== void 0) {
1610
+ const via = recv.via;
1611
+ if (typeof via?.class !== "string" || typeof via?.field !== "string") {
1612
+ errs.push(`${where}: receiver.via requires both 'class' and 'field' strings`);
1613
+ }
1614
+ }
1615
+ const target = decl.target;
1616
+ if (!target) {
1617
+ errs.push(`${where}: missing 'target'`);
1618
+ } else if (target.kind === "signature") {
1619
+ if (typeof target.name !== "string") errs.push(`${where}: target.name must be a string`);
1620
+ else if (!(target.name in sigs)) errs.push(`${where}: target.name '${target.name}' has no entry in 'signatures'`);
1621
+ } else if (target.kind === "vtable") {
1622
+ if (typeof target.class !== "string") errs.push(`${where}: vtable target requires 'class'`);
1623
+ const plat = target[PLATFORM];
1624
+ if (!plat) {
1625
+ errs.push(`${where}: vtable target missing platform '${PLATFORM}'`);
1626
+ } else {
1627
+ if (!Number.isInteger(plat.index) || plat.index < 0) {
1628
+ errs.push(`${where}: vtable index must be a non-negative integer`);
1629
+ }
1630
+ const prologue = plat.validate?.prologue;
1631
+ if (typeof prologue !== "string" || !prologue.length) {
1632
+ errs.push(`${where}: a vtable target REQUIRES validate.prologue (a bare borrowed index is never trusted)`);
1633
+ }
1634
+ }
1635
+ } else {
1636
+ errs.push(`${where}: target.kind must be "signature" or "vtable"`);
1637
+ }
1638
+ const args = decl.args;
1639
+ if (!Array.isArray(args)) {
1640
+ errs.push(`${where}: 'args' must be an array`);
1641
+ } else {
1642
+ let ints = 0, floats = 0;
1643
+ for (const a2 of args) {
1644
+ if (typeof a2 !== "string" || !ARG_KINDS.includes(a2)) {
1645
+ errs.push(`${where}: unknown arg kind ${JSON.stringify(a2)} (allowed: ${ARG_KINDS.join(", ")})`);
1646
+ continue;
1647
+ }
1648
+ if (INT_CLASS_ARGS.has(a2)) ints++;
1649
+ else floats++;
1650
+ }
1651
+ if (ints > MAX_INT_ARGS) {
1652
+ errs.push(`${where}: ${ints} integer-class args exceeds the max of ${MAX_INT_ARGS} (\`this\` occupies rdi)`);
1653
+ }
1654
+ if (floats > MAX_FLOAT_ARGS) {
1655
+ errs.push(`${where}: ${floats} float args exceeds the max of ${MAX_FLOAT_ARGS}`);
1656
+ }
1657
+ if (decl.argNames !== void 0) {
1658
+ const names = decl.argNames;
1659
+ if (!Array.isArray(names)) {
1660
+ errs.push(`${where}: 'argNames' must be an array of parameter names`);
1661
+ } else if (names.length !== args.length) {
1662
+ errs.push(
1663
+ `${where}: 'argNames' has ${names.length} entr${names.length === 1 ? "y" : "ies"} but 'args' has ${args.length} \u2014 they are positionally matched`
1664
+ );
1665
+ } else {
1666
+ const seen = /* @__PURE__ */ new Set();
1667
+ for (const nm of names) {
1668
+ if (typeof nm !== "string" || !IDENTIFIER.test(nm)) {
1669
+ errs.push(
1670
+ `${where}: argName ${JSON.stringify(nm)} must be a plain identifier matching ${IDENTIFIER.source} \u2014 it is emitted as a parameter name in the generated .d.ts`
1671
+ );
1672
+ continue;
1673
+ }
1674
+ if (nm === "self") {
1675
+ errs.push(`${where}: argName 'self' is reserved (it names the receiver parameter)`);
1676
+ continue;
1677
+ }
1678
+ if (seen.has(nm)) errs.push(`${where}: duplicate argName ${JSON.stringify(nm)}`);
1679
+ seen.add(nm);
1680
+ }
1681
+ }
1682
+ }
1683
+ }
1684
+ if (typeof decl.returns !== "string" || !RET_KINDS.includes(decl.returns)) {
1685
+ errs.push(`${where}: 'returns' must be one of ${RET_KINDS.join(", ")}`);
1686
+ }
1687
+ }
1688
+ return errs;
1689
+ }
1690
+
1691
+ // src/gamedata/gen-types.ts
1692
+ var VECTOR_TS = "{ readonly x: number; readonly y: number; readonly z: number }";
1693
+ var ARG_TS = {
1694
+ bool: "boolean",
1695
+ int: "number",
1696
+ float: "number",
1697
+ string: "string",
1698
+ vector: VECTOR_TS,
1699
+ entity: "EntityRef | null"
1700
+ };
1701
+ var RET_TS = {
1702
+ void: "void",
1703
+ bool: "boolean | null",
1704
+ int: "number | null",
1705
+ float: "number | null",
1706
+ entity: "EntityRef | null"
1707
+ };
1708
+ function generateGamedataTypes(gd) {
1709
+ const calls = gd.calls ?? {};
1710
+ const lines = Object.keys(calls).sort().map((name) => {
1711
+ const decl = calls[name];
1712
+ const names = decl.argNames;
1713
+ const params = [
1714
+ "self: EntityRef",
1715
+ ...decl.args.map((a2, i2) => `${names?.[i2] ?? `a${i2}`}: ${ARG_TS[a2]}`)
1716
+ ];
1717
+ return ` ${name}: (${params.join(", ")}) => ${RET_TS[decl.returns]};`;
1718
+ });
1719
+ const body = lines.length ? `
1720
+ ${lines.join("\n")}
1721
+ ` : "\n ";
1722
+ return [
1723
+ "// GENERATED by `s2s build` from the plugin's gamedata \u2014 DO NOT EDIT.",
1724
+ "// The EntityRef import is REQUIRED: a `declare module` augmentation resolves type names in its",
1725
+ "// own file scope, so an un-imported EntityRef degrades to an error skipLibCheck can swallow.",
1726
+ 'import type { EntityRef } from "@s2script/sdk/entity";',
1727
+ "",
1728
+ 'declare module "@s2script/sdk/unsafe" {',
1729
+ ` interface EngineCalls {${body}}`,
1730
+ "}",
1731
+ ""
1732
+ ].join("\n");
1733
+ }
1734
+
1735
+ // src/gamedata/jsonc.ts
1736
+ function stripJsonComments(src) {
1737
+ let out = "";
1738
+ let i2 = 0;
1739
+ const n3 = src.length;
1740
+ while (i2 < n3) {
1741
+ const c2 = src[i2];
1742
+ if (c2 === '"') {
1743
+ out += c2;
1744
+ i2++;
1745
+ while (i2 < n3) {
1746
+ const s = src[i2];
1747
+ if (s === "\\") {
1748
+ out += src.slice(i2, i2 + 2);
1749
+ i2 += 2;
1750
+ continue;
1751
+ }
1752
+ out += s;
1753
+ i2++;
1754
+ if (s === '"') break;
1755
+ }
1756
+ continue;
1757
+ }
1758
+ if (c2 === "/" && src[i2 + 1] === "/") {
1759
+ while (i2 < n3 && src[i2] !== "\n") {
1760
+ out += " ";
1761
+ i2++;
1762
+ }
1763
+ continue;
1764
+ }
1765
+ if (c2 === "/" && src[i2 + 1] === "*") {
1766
+ out += " ";
1767
+ i2 += 2;
1768
+ while (i2 < n3 && !(src[i2] === "*" && src[i2 + 1] === "/")) {
1769
+ out += src[i2] === "\n" ? "\n" : " ";
1770
+ i2++;
1771
+ }
1772
+ if (i2 < n3) {
1773
+ out += " ";
1774
+ i2 += 2;
1775
+ }
1776
+ continue;
1777
+ }
1778
+ out += c2;
1779
+ i2++;
1780
+ }
1781
+ return out;
1782
+ }
1783
+
1524
1784
  // src/build.ts
1525
1785
  async function buildPlugin(dir, packagesDir) {
1526
1786
  const absDir = resolve5(dir);
@@ -1533,6 +1793,39 @@ async function buildPlugin(dir, packagesDir) {
1533
1793
  if (cfgErrs.length) throw new Error(`invalid s2script.config:
1534
1794
  ${cfgErrs.join("\n ")}`);
1535
1795
  }
1796
+ const permissions = Array.isArray(s2.permissions) ? s2.permissions : [];
1797
+ let gamedata;
1798
+ if (typeof s2.gamedata === "string") {
1799
+ const gdPath = resolve5(absDir, s2.gamedata);
1800
+ const dirPrefix = absDir.endsWith(sep) ? absDir : absDir + sep;
1801
+ if (gdPath !== absDir && !gdPath.startsWith(dirPrefix)) {
1802
+ throw new Error(
1803
+ `s2script.gamedata escapes the plugin directory: ${JSON.stringify(s2.gamedata)} resolves to ${gdPath}`
1804
+ );
1805
+ }
1806
+ const unscoped = pkg.name.includes("/") ? pkg.name.slice(pkg.name.lastIndexOf("/") + 1) : pkg.name;
1807
+ const actualBase = basename(gdPath);
1808
+ const allowedBases = [`${unscoped}.gamedata.jsonc`, `${unscoped}.gamedata.json`];
1809
+ if (!allowedBases.includes(actualBase)) {
1810
+ throw new Error(
1811
+ `s2script.gamedata must be named '${unscoped}.gamedata.jsonc' (after the plugin name without its scope, like the framework's own core.gamedata.jsonc), but is '${actualBase}'`
1812
+ );
1813
+ }
1814
+ const raw = stripJsonComments(readFileSync4(gdPath, "utf8"));
1815
+ try {
1816
+ gamedata = JSON.parse(raw);
1817
+ } catch (e) {
1818
+ throw new Error(`invalid gamedata ${gdPath}: ${e.message}`);
1819
+ }
1820
+ const gdErrs = validatePluginGamedata(gamedata, { permissions });
1821
+ if (gdErrs.length) throw new Error(`invalid gamedata:
1822
+ ${gdErrs.join("\n ")}`);
1823
+ const genDir = join6(absDir, ".s2script");
1824
+ mkdirSync(genDir, { recursive: true });
1825
+ writeFileSync2(join6(genDir, "gamedata.d.ts"), generateGamedataTypes(gamedata));
1826
+ } else {
1827
+ rmSync2(join6(absDir, ".s2script", "gamedata.d.ts"), { force: true });
1828
+ }
1536
1829
  const tc = typecheckPlugin(absDir, packagesDir !== void 0 ? { packagesDir } : void 0);
1537
1830
  if (!tc.ok) {
1538
1831
  throw new Error(`typecheck failed (${tc.diagnostics.length} error(s)):
@@ -1642,6 +1935,7 @@ ${lint.output}`);
1642
1935
  manifest.publishes = derivedPublishes;
1643
1936
  }
1644
1937
  if (config !== void 0) manifest.config = config;
1938
+ if (permissions.length > 0) manifest.permissions = permissions;
1645
1939
  const compiledAgainst = {};
1646
1940
  for (const dep of [
1647
1941
  ...Object.keys(pluginDependencies),
@@ -1655,6 +1949,7 @@ ${lint.output}`);
1655
1949
  "manifest.json": Buffer.from(JSON.stringify(manifest, null, 2)),
1656
1950
  "plugin.js": Buffer.from(pluginJs)
1657
1951
  };
1952
+ if (gamedata) zipFiles["gamedata.json"] = Buffer.from(JSON.stringify(gamedata, null, 2));
1658
1953
  if (gate.typesPath !== null && Object.keys(derivedPublishes).length > 0) {
1659
1954
  const contract = readFileSync4(gate.typesPath);
1660
1955
  for (const iface of Object.keys(derivedPublishes)) {
@@ -3134,7 +3429,9 @@ var TIMEOUTS_MS = {
3134
3429
  // up to ~40 MiB on a slow uplink
3135
3430
  resolve: 3e4,
3136
3431
  meta: 3e4,
3137
- "types download": 12e4
3432
+ plan: 3e4,
3433
+ "types download": 12e4,
3434
+ "s2sp download": 12e4
3138
3435
  };
3139
3436
  function rootCause(err2) {
3140
3437
  let cur = err2;
@@ -3261,6 +3558,20 @@ var RegistryClient = class {
3261
3558
  const res = await this.request("meta", u4);
3262
3559
  return this.json(res, "meta");
3263
3560
  }
3561
+ async plan(name, range = "*") {
3562
+ const u4 = new URL(`${this.baseUrl}/api/v1/plan`);
3563
+ u4.searchParams.set("name", name);
3564
+ u4.searchParams.set("range", range);
3565
+ const res = await this.request("plan", u4);
3566
+ return this.json(res, "plan");
3567
+ }
3568
+ async downloadS2sp(name, version) {
3569
+ const u4 = new URL(`${this.baseUrl}/api/v1/download/s2sp`);
3570
+ u4.searchParams.set("name", name);
3571
+ u4.searchParams.set("version", version);
3572
+ const res = await this.request("s2sp download", u4);
3573
+ return Buffer.from(await res.arrayBuffer());
3574
+ }
3264
3575
  };
3265
3576
 
3266
3577
  // src/registry/deploy.ts
@@ -3493,30 +3804,217 @@ async function run3(argv2) {
3493
3804
  }
3494
3805
  }
3495
3806
 
3807
+ // src/commands/install.ts
3808
+ import { resolve as resolve11 } from "node:path";
3809
+ import { existsSync as existsSync10 } from "node:fs";
3810
+
3811
+ // src/registry/install.ts
3812
+ import { createHash as createHash2 } from "node:crypto";
3813
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync10, writeFileSync as writeFileSync6, renameSync, existsSync as existsSync9 } from "node:fs";
3814
+ import { basename as basename2, join as join10, resolve as resolve10, sep as sep2 } from "node:path";
3815
+ function parseManifest(input) {
3816
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
3817
+ throw new Error("manifest must be a JSON object");
3818
+ }
3819
+ const plugins = input.plugins;
3820
+ if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) {
3821
+ throw new Error('manifest must have a "plugins" object (name -> version range)');
3822
+ }
3823
+ const out = {};
3824
+ for (const [name, range] of Object.entries(plugins)) {
3825
+ if (typeof range !== "string") throw new Error(`range for ${name} must be a string`);
3826
+ out[name] = range;
3827
+ }
3828
+ return { plugins: out };
3829
+ }
3830
+ function parseSpec2(spec) {
3831
+ const at = spec.lastIndexOf("@");
3832
+ if (at > 0) return { name: spec.slice(0, at), range: spec.slice(at + 1) || "*" };
3833
+ return { name: spec, range: "*" };
3834
+ }
3835
+ function mergeSpecs(manifest, args) {
3836
+ const merged = { ...manifest.plugins };
3837
+ for (const a2 of args) {
3838
+ const { name, range } = parseSpec2(a2);
3839
+ merged[name] = range;
3840
+ }
3841
+ return merged;
3842
+ }
3843
+ function sha256Hex(bytes) {
3844
+ return createHash2("sha256").update(bytes).digest("hex");
3845
+ }
3846
+ var SAFE_FILENAME_RE = /^[A-Za-z0-9._-]+$/;
3847
+ function safeFilename(e) {
3848
+ const raw = e.filename;
3849
+ const base = basename2(raw);
3850
+ if (!base || base === "." || base === ".." || base !== raw || base.includes("/") || base.includes("\\") || base.includes("..") || !SAFE_FILENAME_RE.test(base)) {
3851
+ throw new Error(`refusing ${e.name}@${e.version}: unsafe filename "${raw}"`);
3852
+ }
3853
+ return base;
3854
+ }
3855
+ async function resolveMerged(client, specs) {
3856
+ const chosen = /* @__PURE__ */ new Map();
3857
+ const warnings = [];
3858
+ const errors = [];
3859
+ for (const [name, range] of Object.entries(specs)) {
3860
+ const plan = await client.plan(name, range);
3861
+ if (plan.errors.length) {
3862
+ errors.push(...plan.errors);
3863
+ continue;
3864
+ }
3865
+ warnings.push(...plan.warnings);
3866
+ for (const entry of plan.install) {
3867
+ const prior = chosen.get(entry.name);
3868
+ if (prior && prior.version !== entry.version) {
3869
+ errors.push(
3870
+ `version conflict for ${entry.name}: ${prior.version} vs ${entry.version} across requested plugins`
3871
+ );
3872
+ continue;
3873
+ }
3874
+ chosen.set(entry.name, entry);
3875
+ }
3876
+ }
3877
+ return {
3878
+ install: [...chosen.values()].sort((a2, b) => a2.name.localeCompare(b.name)),
3879
+ warnings: [...new Set(warnings)],
3880
+ errors: [...new Set(errors)]
3881
+ };
3882
+ }
3883
+ async function installPlan(opts) {
3884
+ const log3 = opts.log ?? (() => {
3885
+ });
3886
+ const { install, warnings, errors } = await resolveMerged(opts.client, opts.specs);
3887
+ if (errors.length) throw new Error(`cannot resolve plugins:
3888
+ - ${errors.join("\n - ")}`);
3889
+ if (opts.reviewedOnly) {
3890
+ const unreviewed = install.filter((e) => e.reviewState !== "reviewed");
3891
+ if (unreviewed.length) {
3892
+ throw new Error(
3893
+ `--reviewed-only: refusing unreviewed plugins: ${unreviewed.map((e) => `${e.name}@${e.version}`).join(", ")}`
3894
+ );
3895
+ }
3896
+ }
3897
+ const dirResolved = resolve10(opts.dir);
3898
+ const targets = install.map((e) => {
3899
+ if (!e.sha256) {
3900
+ throw new Error(`refusing ${e.name}@${e.version}: registry did not provide a sha256 to verify against`);
3901
+ }
3902
+ const filename = safeFilename(e);
3903
+ const dest = resolve10(join10(opts.dir, filename));
3904
+ if (dest !== dirResolved && !dest.startsWith(dirResolved + sep2)) {
3905
+ throw new Error(`refusing ${e.name}@${e.version}: destination "${dest}" escapes plugins dir "${dirResolved}"`);
3906
+ }
3907
+ return { entry: e, filename, dest };
3908
+ });
3909
+ for (const w of warnings) log3(`warning: ${w}`);
3910
+ if (opts.dryRun) {
3911
+ for (const e of install) log3(`would install ${e.name}@${e.version} -> ${e.filename}`);
3912
+ return { written: [], skipped: [], warnings };
3913
+ }
3914
+ mkdirSync4(opts.dir, { recursive: true });
3915
+ const written = [];
3916
+ const skipped = [];
3917
+ for (const { entry: e, filename, dest } of targets) {
3918
+ if (existsSync9(dest)) {
3919
+ const have = sha256Hex(readFileSync10(dest));
3920
+ if (have === e.sha256) {
3921
+ skipped.push(filename);
3922
+ log3(`up to date ${filename}`);
3923
+ continue;
3924
+ }
3925
+ }
3926
+ const bytes = await opts.client.downloadS2sp(e.name, e.version);
3927
+ const got = sha256Hex(bytes);
3928
+ if (got !== e.sha256) {
3929
+ throw new Error(`sha256 mismatch for ${e.name}@${e.version}: expected ${e.sha256}, got ${got}`);
3930
+ }
3931
+ const tmp = `${dest}.${process.pid}.tmp`;
3932
+ writeFileSync6(tmp, bytes);
3933
+ renameSync(tmp, dest);
3934
+ written.push(filename);
3935
+ log3(`installed ${e.name}@${e.version} -> ${filename}`);
3936
+ }
3937
+ return { written, skipped, warnings };
3938
+ }
3939
+ function loadManifestFile(path) {
3940
+ if (!existsSync9(path)) return { plugins: {} };
3941
+ return parseManifest(JSON.parse(readFileSync10(path, "utf8")));
3942
+ }
3943
+
3944
+ // src/commands/install.ts
3945
+ var AUTODETECT = "addons/s2script/plugins";
3946
+ async function run4(argv2) {
3947
+ const args = positionals(argv2, ["--dir", "--file", "--registry"]);
3948
+ const file = parseFlag(argv2, "--file") ?? "s2script-plugins.json";
3949
+ const registry = parseFlag(argv2, "--registry") || loadCredentials()?.registryUrl || defaultRegistryUrl();
3950
+ const dryRun = hasFlag(argv2, "--dry-run");
3951
+ const reviewedOnly = hasFlag(argv2, "--reviewed-only");
3952
+ let dir = parseFlag(argv2, "--dir");
3953
+ if (!dir) {
3954
+ if (existsSync10(resolve11(AUTODETECT))) dir = resolve11(AUTODETECT);
3955
+ else {
3956
+ console.error(
3957
+ `Usage: s2s install [name[@range]...] --dir <plugins dir>
3958
+ No --dir given and ./${AUTODETECT} not found. Point --dir at your addons/s2script/plugins directory.`
3959
+ );
3960
+ process.exit(1);
3961
+ }
3962
+ }
3963
+ const client = new RegistryClient({ baseUrl: registry });
3964
+ try {
3965
+ const manifest = loadManifestFile(resolve11(file));
3966
+ const specs = mergeSpecs(manifest, args);
3967
+ if (Object.keys(specs).length === 0) {
3968
+ console.error(
3969
+ `Nothing to install. Add plugins to ${file} or pass names: s2s install rtv@^1.0.0`
3970
+ );
3971
+ process.exit(1);
3972
+ }
3973
+ const res = await installPlan({
3974
+ client,
3975
+ specs,
3976
+ dir,
3977
+ dryRun,
3978
+ reviewedOnly,
3979
+ log: (m) => console.log(m)
3980
+ });
3981
+ if (dryRun) {
3982
+ console.log("dry run \u2014 nothing written");
3983
+ } else {
3984
+ console.log(
3985
+ `done: ${res.written.length} installed, ${res.skipped.length} up to date, in ${dir}`
3986
+ );
3987
+ }
3988
+ } catch (e) {
3989
+ console.error(String(e instanceof Error ? e.message : e));
3990
+ process.exit(1);
3991
+ }
3992
+ }
3993
+
3496
3994
  // src/create/create.ts
3497
3995
  import {
3498
- mkdirSync as mkdirSync4,
3499
- writeFileSync as writeFileSync6,
3500
- existsSync as existsSync9,
3996
+ mkdirSync as mkdirSync5,
3997
+ writeFileSync as writeFileSync7,
3998
+ existsSync as existsSync11,
3501
3999
  readdirSync as readdirSync2,
3502
- readFileSync as readFileSync10,
4000
+ readFileSync as readFileSync11,
3503
4001
  symlinkSync
3504
4002
  } from "node:fs";
3505
- import { dirname as dirname2, join as join10, resolve as resolve10, basename } from "node:path";
4003
+ import { dirname as dirname2, join as join11, resolve as resolve12, basename as basename3 } from "node:path";
3506
4004
  import { spawnSync } from "node:child_process";
3507
4005
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3508
4006
  function readCliVersion() {
3509
4007
  const here = dirname2(fileURLToPath2(import.meta.url));
3510
4008
  const candidates = [
3511
- join10(here, "..", "package.json"),
4009
+ join11(here, "..", "package.json"),
3512
4010
  // dist/cli.js → packages/sdk/package.json
3513
- join10(here, "..", "..", "package.json")
4011
+ join11(here, "..", "..", "package.json")
3514
4012
  // src/create → packages/sdk/package.json
3515
4013
  ];
3516
4014
  for (const p of candidates) {
3517
- if (!existsSync9(p)) continue;
4015
+ if (!existsSync11(p)) continue;
3518
4016
  try {
3519
- const pkg = JSON.parse(readFileSync10(p, "utf8"));
4017
+ const pkg = JSON.parse(readFileSync11(p, "utf8"));
3520
4018
  if (pkg.name === "@s2script/sdk" && pkg.version) return pkg.version;
3521
4019
  } catch {
3522
4020
  }
@@ -3526,18 +4024,18 @@ function readCliVersion() {
3526
4024
  function findLocalPackagesDir() {
3527
4025
  const here = dirname2(fileURLToPath2(import.meta.url));
3528
4026
  const candidates = [
3529
- join10(here, "..", ".."),
4027
+ join11(here, "..", ".."),
3530
4028
  // dist/cli.js → packages
3531
- join10(here, "..", "..", "..")
4029
+ join11(here, "..", "..", "..")
3532
4030
  // src/create → packages
3533
4031
  ];
3534
4032
  for (const c2 of candidates) {
3535
- if (isPackagesDir(c2)) return resolve10(c2);
4033
+ if (isPackagesDir(c2)) return resolve12(c2);
3536
4034
  }
3537
4035
  return void 0;
3538
4036
  }
3539
4037
  function defaultNameFromPath(dir) {
3540
- const base = basename(resolve10(dir));
4038
+ const base = basename3(resolve12(dir));
3541
4039
  const slug = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "plugin";
3542
4040
  return `@plugin/${slug}`;
3543
4041
  }
@@ -3565,18 +4063,18 @@ function versionSpecFrom(status, stdout2) {
3565
4063
  const v = (stdout2 ?? "").trim();
3566
4064
  return status === 0 && /^\d+\.\d+\.\d+/.test(v) ? `^${v}` : "latest";
3567
4065
  }
3568
- function registryDevDeps(game, sdkVersion, resolve11 = resolvePublishedVersion) {
4066
+ function registryDevDeps(game, sdkVersion, resolve13 = resolvePublishedVersion) {
3569
4067
  const deps = {};
3570
4068
  for (const n3 of createPackageNames(game)) {
3571
- deps[`@s2script/${n3}`] = n3 === "sdk" ? `^${sdkVersion}` : resolve11(`@s2script/${n3}`);
4069
+ deps[`@s2script/${n3}`] = n3 === "sdk" ? `^${sdkVersion}` : resolve13(`@s2script/${n3}`);
3572
4070
  }
3573
4071
  return deps;
3574
4072
  }
3575
4073
  function fileDevDeps(packagesDir, game) {
3576
4074
  const deps = {};
3577
4075
  for (const n3 of createPackageNames(game)) {
3578
- const abs = join10(packagesDir, n3);
3579
- if (!existsSync9(join10(abs, "package.json"))) return void 0;
4076
+ const abs = join11(packagesDir, n3);
4077
+ if (!existsSync11(join11(abs, "package.json"))) return void 0;
3580
4078
  deps[`@s2script/${n3}`] = `file:${abs}`;
3581
4079
  }
3582
4080
  return deps;
@@ -3621,7 +4119,12 @@ function tsconfigJson() {
3621
4119
  return JSON.stringify(
3622
4120
  {
3623
4121
  compilerOptions: sharedCompilerOptionsJson,
3624
- include: ["src", "node_modules/@s2script/sdk/globals.d.ts"]
4122
+ // `.s2script/gamedata.d.ts` is generated by `s2s build` from a plugin's own gamedata and
4123
+ // augments EngineCalls. It must be in `include` or the EDITOR sees `keyof EngineCalls` as
4124
+ // `never` — every Engine.call() shows as an error while `s2s build` passes, because the build
4125
+ // adds the file to its own program (typecheck.ts) but tsconfig never did. Harmless when the
4126
+ // plugin ships no gamedata: the path simply matches nothing.
4127
+ include: ["src", ".s2script/gamedata.d.ts", "node_modules/@s2script/sdk/globals.d.ts"]
3625
4128
  },
3626
4129
  null,
3627
4130
  2
@@ -3656,10 +4159,10 @@ dist/
3656
4159
  `;
3657
4160
  }
3658
4161
  function linkLocalPackagesForNoInstall(targetPath, localPackagesDir) {
3659
- const nm = join10(targetPath, "node_modules");
3660
- mkdirSync4(nm, { recursive: true });
3661
- const dest = join10(nm, "@s2script");
3662
- if (!existsSync9(dest)) symlinkSync(localPackagesDir, dest);
4162
+ const nm = join11(targetPath, "node_modules");
4163
+ mkdirSync5(nm, { recursive: true });
4164
+ const dest = join11(nm, "@s2script");
4165
+ if (!existsSync11(dest)) symlinkSync(localPackagesDir, dest);
3663
4166
  }
3664
4167
  function runInstall(dir, pm) {
3665
4168
  if (pm === "none") return;
@@ -3672,7 +4175,7 @@ function runInstall(dir, pm) {
3672
4175
  if (r2.status !== 0) throw new Error(`${pm} install failed (exit ${r2.status})`);
3673
4176
  }
3674
4177
  async function createPlugin(opts = {}) {
3675
- const targetPath = resolve10(opts.path ?? ".");
4178
+ const targetPath = resolve12(opts.path ?? ".");
3676
4179
  const interactive = isInteractive({ yes: opts.yes });
3677
4180
  let game = assertGame(opts.game);
3678
4181
  let name = opts.name;
@@ -3681,7 +4184,7 @@ async function createPlugin(opts = {}) {
3681
4184
  if (template !== "minimal") {
3682
4185
  throw new Error(`unknown template ${JSON.stringify(template)} (v1 supports: minimal)`);
3683
4186
  }
3684
- if (existsSync9(targetPath)) {
4187
+ if (existsSync11(targetPath)) {
3685
4188
  const kids = readdirSync2(targetPath);
3686
4189
  const meaningful = kids.filter((k) => k !== ".git");
3687
4190
  if (meaningful.length) {
@@ -3734,15 +4237,15 @@ async function createPlugin(opts = {}) {
3734
4237
  install = install ?? (opts.noInstall ? "none" : "npm");
3735
4238
  const version = readCliVersion();
3736
4239
  const localPackagesDir = findLocalPackagesDir();
3737
- mkdirSync4(join10(targetPath, "src"), { recursive: true });
3738
- writeFileSync6(
3739
- join10(targetPath, "package.json"),
4240
+ mkdirSync5(join11(targetPath, "src"), { recursive: true });
4241
+ writeFileSync7(
4242
+ join11(targetPath, "package.json"),
3740
4243
  packageJsonContent(name, game, version, localPackagesDir)
3741
4244
  );
3742
- writeFileSync6(join10(targetPath, "tsconfig.json"), tsconfigJson());
3743
- writeFileSync6(join10(targetPath, "eslint.config.mjs"), eslintConfig());
3744
- writeFileSync6(join10(targetPath, "src", "plugin.ts"), pluginSource(game));
3745
- writeFileSync6(join10(targetPath, ".gitignore"), gitignore());
4245
+ writeFileSync7(join11(targetPath, "tsconfig.json"), tsconfigJson());
4246
+ writeFileSync7(join11(targetPath, "eslint.config.mjs"), eslintConfig());
4247
+ writeFileSync7(join11(targetPath, "src", "plugin.ts"), pluginSource(game));
4248
+ writeFileSync7(join11(targetPath, ".gitignore"), gitignore());
3746
4249
  let installed = false;
3747
4250
  if (install !== "none") {
3748
4251
  const pm = install;
@@ -3765,7 +4268,7 @@ async function createPlugin(opts = {}) {
3765
4268
  }
3766
4269
 
3767
4270
  // src/commands/create.ts
3768
- async function run4(argv2) {
4271
+ async function run5(argv2) {
3769
4272
  const yes = hasFlag(argv2, "--yes") || hasFlag(argv2, "-y");
3770
4273
  const interactive = isInteractive({ yes });
3771
4274
  const pathArg = argv2.find((a2) => !a2.startsWith("-"));
@@ -3955,7 +4458,7 @@ async function loginInteractive(opts) {
3955
4458
  }
3956
4459
 
3957
4460
  // src/commands/login.ts
3958
- async function run5(argv2) {
4461
+ async function run6(argv2) {
3959
4462
  const interactive = isInteractive({ ci: hasFlag(argv2, "--ci") });
3960
4463
  try {
3961
4464
  const creds = await loginInteractive({
@@ -3974,8 +4477,8 @@ async function run5(argv2) {
3974
4477
  }
3975
4478
 
3976
4479
  // src/config/gen.ts
3977
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, mkdirSync as mkdirSync5 } from "node:fs";
3978
- import { join as join11 } from "node:path";
4480
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, mkdirSync as mkdirSync6 } from "node:fs";
4481
+ import { join as join12 } from "node:path";
3979
4482
 
3980
4483
  // src/config/config-path.ts
3981
4484
  function configFileName(id) {
@@ -4021,13 +4524,13 @@ function genConfigFromManifest(manifest, outDir) {
4021
4524
  }
4022
4525
  const id = typeof manifest.id === "string" ? manifest.id : "";
4023
4526
  if (!id) throw new Error("config gen: manifest declares config but has no id");
4024
- mkdirSync5(outDir, { recursive: true });
4025
- const path = join11(outDir, configFileName(id));
4026
- writeFileSync7(path, generateDefaultJsonc(config));
4527
+ mkdirSync6(outDir, { recursive: true });
4528
+ const path = join12(outDir, configFileName(id));
4529
+ writeFileSync8(path, generateDefaultJsonc(config));
4027
4530
  return path;
4028
4531
  }
4029
4532
  function genConfigForS2sp(s2spPath, outDir) {
4030
- const entry = unzipSync(readFileSync11(s2spPath))["manifest.json"];
4533
+ const entry = unzipSync(readFileSync12(s2spPath))["manifest.json"];
4031
4534
  if (!entry) throw new Error(`config gen: ${s2spPath} has no manifest.json`);
4032
4535
  const manifest = JSON.parse(Buffer.from(entry).toString("utf8"));
4033
4536
  return genConfigFromManifest(manifest, outDir);
@@ -4044,7 +4547,7 @@ function runConfigGen(s2sps, outDir) {
4044
4547
  }
4045
4548
 
4046
4549
  // src/commands/config.ts
4047
- async function run6(argv2) {
4550
+ async function run7(argv2) {
4048
4551
  const rest = argv2[0] === "gen" ? argv2.slice(1) : argv2;
4049
4552
  const interactive = isInteractive();
4050
4553
  const outDir = parseFlag(rest, "--out") ?? process.cwd();
@@ -4074,12 +4577,12 @@ async function run6(argv2) {
4074
4577
  }
4075
4578
 
4076
4579
  // src/commands/codegen.ts
4077
- import { dirname as dirname3, join as join15 } from "node:path";
4580
+ import { dirname as dirname3, join as join16 } from "node:path";
4078
4581
  import { fileURLToPath as fileURLToPath3 } from "node:url";
4079
4582
 
4080
4583
  // src/schemagen/gen.ts
4081
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "node:fs";
4082
- import { join as join12 } from "node:path";
4584
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "node:fs";
4585
+ import { join as join13 } from "node:path";
4083
4586
 
4084
4587
  // src/schemagen/model.ts
4085
4588
  var READ = {
@@ -4271,21 +4774,21 @@ function emitJs(model) {
4271
4774
  for (const c2 of model.classes) {
4272
4775
  out.push(` A[${S(c2.className)}] = {`);
4273
4776
  for (const f of flattenedFields(model, c2.className)) {
4274
- const resolve11 = `off(${S(f.declaringClass)},${S(f.rawName)})`;
4777
+ const resolve13 = `off(${S(f.declaringClass)},${S(f.rawName)})`;
4275
4778
  let entry;
4276
4779
  if (f.accessorKind === "str") {
4277
- entry = `get: function () { return this.ref.readString(${resolve11}, ${f.strLen}); }`;
4780
+ entry = `get: function () { return this.ref.readString(${resolve13}, ${f.strLen}); }`;
4278
4781
  } else if (f.accessorKind === "u64" || f.accessorKind === "i64") {
4279
- entry = `get: function () { var v = this.ref.${READ[f.accessorKind]}(${resolve11}); return v === null ? null : v.toString(); }`;
4782
+ entry = `get: function () { var v = this.ref.${READ[f.accessorKind]}(${resolve13}); return v === null ? null : v.toString(); }`;
4280
4783
  } else if (VEC_INFO[f.accessorKind]) {
4281
4784
  const vi = VEC_INFO[f.accessorKind];
4282
- entry = `get: function () { var a = this.ref.readFloats(${resolve11}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
4785
+ entry = `get: function () { var a = this.ref.readFloats(${resolve13}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
4283
4786
  } else {
4284
- entry = `get: function () { return this.ref.${READ[f.accessorKind]}(${resolve11}); }`;
4787
+ entry = `get: function () { return this.ref.${READ[f.accessorKind]}(${resolve13}); }`;
4285
4788
  }
4286
4789
  if (f.writable) {
4287
4790
  const wm = WRITE[f.accessorKind];
4288
- entry += `, set: function (v) { var o = ${resolve11}; if (this.ref.${wm}(o, v)) this.ref.notifyStateChanged(o); }`;
4791
+ entry += `, set: function (v) { var o = ${resolve13}; if (this.ref.${wm}(o, v)) this.ref.notifyStateChanged(o); }`;
4289
4792
  }
4290
4793
  out.push(` ${S(f.propName)}: { ${entry} },`);
4291
4794
  }
@@ -4311,8 +4814,8 @@ var LIST_PATH = "games/cs2/codegen-classes.json";
4311
4814
  var JS_OUT = "games/cs2/js/schema.generated.js";
4312
4815
  var DTS_OUT = "packages/cs2/schema.generated.d.ts";
4313
4816
  function runGenSchema(repoRoot2, opts) {
4314
- const catalog = JSON.parse(readFileSync12(join12(repoRoot2, CATALOG_PATH), "utf8"));
4315
- const list = JSON.parse(readFileSync12(join12(repoRoot2, LIST_PATH), "utf8"));
4817
+ const catalog = JSON.parse(readFileSync13(join13(repoRoot2, CATALOG_PATH), "utf8"));
4818
+ const list = JSON.parse(readFileSync13(join13(repoRoot2, LIST_PATH), "utf8"));
4316
4819
  const model = buildModel(catalog, list);
4317
4820
  const dts = emitDts(model);
4318
4821
  const js = emitJs(model);
@@ -4321,16 +4824,16 @@ function runGenSchema(repoRoot2, opts) {
4321
4824
  const drift = [];
4322
4825
  const files = [[JS_OUT, js], [DTS_OUT, dts]];
4323
4826
  for (const [rel, content] of files) {
4324
- const abs = join12(repoRoot2, rel);
4827
+ const abs = join13(repoRoot2, rel);
4325
4828
  if (opts.check) {
4326
4829
  let cur = "";
4327
4830
  try {
4328
- cur = readFileSync12(abs, "utf8");
4831
+ cur = readFileSync13(abs, "utf8");
4329
4832
  } catch {
4330
4833
  }
4331
4834
  if (cur !== content) drift.push(rel);
4332
4835
  } else {
4333
- writeFileSync8(abs, content);
4836
+ writeFileSync9(abs, content);
4334
4837
  }
4335
4838
  }
4336
4839
  if (model.collisions.length) console.error(`gen-schema: ${model.collisions.length} name collision(s) \u2192 raw fallback:
@@ -4340,8 +4843,8 @@ function runGenSchema(repoRoot2, opts) {
4340
4843
  }
4341
4844
 
4342
4845
  // src/eventgen/gen.ts
4343
- import { readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "node:fs";
4344
- import { join as join13 } from "node:path";
4846
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "node:fs";
4847
+ import { join as join14 } from "node:path";
4345
4848
 
4346
4849
  // src/eventgen/model.ts
4347
4850
  var TYPE_TO_GETTER = {
@@ -4426,27 +4929,27 @@ function emitEventDts(model) {
4426
4929
  var CATALOG_PATH2 = "games/cs2/gamedata/event-catalog.json";
4427
4930
  var DTS_OUT2 = "packages/cs2/events.generated.d.ts";
4428
4931
  function runGenEvents(repoRoot2, opts) {
4429
- const catalog = JSON.parse(readFileSync13(join13(repoRoot2, CATALOG_PATH2), "utf8"));
4932
+ const catalog = JSON.parse(readFileSync14(join14(repoRoot2, CATALOG_PATH2), "utf8"));
4430
4933
  const model = buildEventModel(catalog);
4431
4934
  const dts = emitEventDts(model);
4432
4935
  const drift = [];
4433
- const abs = join13(repoRoot2, DTS_OUT2);
4936
+ const abs = join14(repoRoot2, DTS_OUT2);
4434
4937
  if (opts.check) {
4435
4938
  let cur = "";
4436
4939
  try {
4437
- cur = readFileSync13(abs, "utf8");
4940
+ cur = readFileSync14(abs, "utf8");
4438
4941
  } catch {
4439
4942
  }
4440
4943
  if (cur !== dts) drift.push(DTS_OUT2);
4441
4944
  } else {
4442
- writeFileSync9(abs, dts);
4945
+ writeFileSync10(abs, dts);
4443
4946
  }
4444
4947
  return { events: model.length, drift };
4445
4948
  }
4446
4949
 
4447
4950
  // src/navgen/gen.ts
4448
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "node:fs";
4449
- import { join as join14 } from "node:path";
4951
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync11 } from "node:fs";
4952
+ import { join as join15 } from "node:path";
4450
4953
 
4451
4954
  // src/navgen/model.ts
4452
4955
  var SUPPORTED_NAV_KINDS = /* @__PURE__ */ new Set([
@@ -4464,21 +4967,37 @@ var SUPPORTED_NAV_KINDS = /* @__PURE__ */ new Set([
4464
4967
  "vector",
4465
4968
  "qangle"
4466
4969
  ]);
4970
+ var WRITABLE_NAV_KINDS = /* @__PURE__ */ new Set(["f32", "bool", "i32"]);
4467
4971
  function buildNavModel(config, catalog) {
4468
4972
  const targetClasses = [...new Set(config.map((e) => e.target))];
4469
4973
  const schemaModel = buildModel(catalog, targetClasses);
4470
4974
  const sorted = [...config].sort((a2, b) => a2.wrapper < b.wrapper ? -1 : a2.wrapper > b.wrapper ? 1 : 0);
4471
4975
  const wrappers = sorted.map((entry) => {
4472
4976
  const all = flattenedFields(schemaModel, entry.target);
4977
+ const wantWritable = new Set(entry.writable ?? []);
4473
4978
  const fields = [];
4474
4979
  const skippedKinds = [];
4475
4980
  for (const f of all) {
4476
4981
  if (SUPPORTED_NAV_KINDS.has(f.accessorKind)) {
4477
- fields.push(f);
4982
+ fields.push({ ...f, navWritable: wantWritable.has(f.rawName) });
4478
4983
  } else {
4479
4984
  skippedKinds.push({ propName: f.propName, accessorKind: f.accessorKind });
4480
4985
  }
4481
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
+ }
4482
5001
  return {
4483
5002
  wrapper: entry.wrapper,
4484
5003
  prop: entry.prop,
@@ -4505,6 +5024,11 @@ var READ_VIA = {
4505
5024
  u16: "readUInt16Via",
4506
5025
  u32: "readUInt32Via"
4507
5026
  };
5027
+ var WRITE_VIA = {
5028
+ f32: "writeFloat32Via",
5029
+ bool: "writeBoolVia",
5030
+ i32: "writeInt32Via"
5031
+ };
4508
5032
  function emitNavJs(model) {
4509
5033
  const out = [HEADER4, "(function () {", " var off = __s2_schema_offset;"];
4510
5034
  const vecClasses = /* @__PURE__ */ new Set();
@@ -4521,24 +5045,26 @@ function emitNavJs(model) {
4521
5045
  out.push(` function ${w.wrapper}(root, path) { this.root = root; this.path = path; }`);
4522
5046
  out.push(` Object.defineProperties(${w.wrapper}.prototype, {`);
4523
5047
  for (const f of w.fields) {
4524
- const resolve11 = `off(${S2(f.declaringClass)},${S2(f.rawName)})`;
5048
+ const resolve13 = `off(${S2(f.declaringClass)},${S2(f.rawName)})`;
4525
5049
  let getter;
4526
5050
  const kind = f.accessorKind;
4527
5051
  if (kind === "u64") {
4528
- getter = `get: function () { var v = this.root.readUInt64Via(this.path, ${resolve11}); return v === null ? null : v.toString(); }`;
5052
+ getter = `get: function () { var v = this.root.readUInt64Via(this.path, ${resolve13}); return v === null ? null : v.toString(); }`;
4529
5053
  } else if (kind === "i64") {
4530
- getter = `get: function () { var v = this.root.readInt64Via(this.path, ${resolve11}); return v === null ? null : v.toString(); }`;
5054
+ getter = `get: function () { var v = this.root.readInt64Via(this.path, ${resolve13}); return v === null ? null : v.toString(); }`;
4531
5055
  } else if (VEC_INFO[kind]) {
4532
5056
  const vi = VEC_INFO[kind];
4533
- getter = `get: function () { var a = this.root.readFloatsChain(this.path, ${resolve11}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
5057
+ getter = `get: function () { var a = this.root.readFloatsChain(this.path, ${resolve13}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
4534
5058
  } else if (kind === "handle") {
4535
- getter = `get: function () { return this.root.readHandleVia(this.path, ${resolve11}); }`;
5059
+ getter = `get: function () { return this.root.readHandleVia(this.path, ${resolve13}); }`;
4536
5060
  } else {
4537
5061
  const method = READ_VIA[kind];
4538
5062
  if (!method) continue;
4539
- getter = `get: function () { return this.root.${method}(this.path, ${resolve11}); }`;
5063
+ getter = `get: function () { return this.root.${method}(this.path, ${resolve13}); }`;
4540
5064
  }
4541
- 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} },`);
4542
5068
  }
4543
5069
  out.push(" });");
4544
5070
  }
@@ -4593,9 +5119,20 @@ function emitNavDts(model) {
4593
5119
  if (needsEntityRef) out.push('import type { EntityRef } from "@s2script/sdk/entity";');
4594
5120
  out.push("");
4595
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
+ }
4596
5133
  out.push(`export interface ${w.wrapper} {`);
4597
5134
  for (const f of w.fields) {
4598
- out.push(` readonly ${f.propName}: ${TSTYPE[f.accessorKind]};`);
5135
+ out.push(` ${f.navWritable ? "" : "readonly "}${f.propName}: ${TSTYPE[f.accessorKind]};`);
4599
5136
  }
4600
5137
  out.push("}", "");
4601
5138
  }
@@ -4608,8 +5145,8 @@ var CATALOG_PATH3 = "games/cs2/gamedata/schema-catalog.json";
4608
5145
  var JS_OUT2 = "games/cs2/js/nav.generated.js";
4609
5146
  var DTS_OUT3 = "packages/cs2/nav.generated.d.ts";
4610
5147
  function runGenNav(repoRoot2, opts) {
4611
- const config = JSON.parse(readFileSync14(join14(repoRoot2, TARGETS_PATH), "utf8"));
4612
- const catalog = JSON.parse(readFileSync14(join14(repoRoot2, CATALOG_PATH3), "utf8"));
5148
+ const config = JSON.parse(readFileSync15(join15(repoRoot2, TARGETS_PATH), "utf8"));
5149
+ const catalog = JSON.parse(readFileSync15(join15(repoRoot2, CATALOG_PATH3), "utf8"));
4613
5150
  const model = buildNavModel(config, catalog);
4614
5151
  const js = emitNavJs(model);
4615
5152
  const dts = emitNavDts(model);
@@ -4617,16 +5154,16 @@ function runGenNav(repoRoot2, opts) {
4617
5154
  const drift = [];
4618
5155
  const files = [[JS_OUT2, js], [DTS_OUT3, dts]];
4619
5156
  for (const [rel, content] of files) {
4620
- const abs = join14(repoRoot2, rel);
5157
+ const abs = join15(repoRoot2, rel);
4621
5158
  if (opts.check) {
4622
5159
  let cur = "";
4623
5160
  try {
4624
- cur = readFileSync14(abs, "utf8");
5161
+ cur = readFileSync15(abs, "utf8");
4625
5162
  } catch {
4626
5163
  }
4627
5164
  if (cur !== content) drift.push(rel);
4628
5165
  } else {
4629
- writeFileSync10(abs, content);
5166
+ writeFileSync11(abs, content);
4630
5167
  }
4631
5168
  }
4632
5169
  return { wrappers: model.wrappers.length, fields, drift };
@@ -4634,9 +5171,9 @@ function runGenNav(repoRoot2, opts) {
4634
5171
 
4635
5172
  // src/commands/codegen.ts
4636
5173
  function repoRoot() {
4637
- return join15(dirname3(fileURLToPath3(import.meta.url)), "..", "..", "..");
5174
+ return join16(dirname3(fileURLToPath3(import.meta.url)), "..", "..", "..");
4638
5175
  }
4639
- async function run7(kind, argv2) {
5176
+ async function run8(kind, argv2) {
4640
5177
  const root = repoRoot();
4641
5178
  const check = argv2[0] === "--check";
4642
5179
  if (kind === "schema") {
@@ -4680,15 +5217,16 @@ async function run7(kind, argv2) {
4680
5217
 
4681
5218
  // src/commands/index.ts
4682
5219
  var COMMANDS = [
4683
- { name: "create", summary: "Scaffold a new plugin", run: run4 },
5220
+ { name: "create", summary: "Scaffold a new plugin", run: run5 },
4684
5221
  { name: "build", summary: "Build a plugin to a .s2sp", run },
4685
5222
  { name: "deploy", summary: "Publish a plugin to the registry", run: run2 },
4686
5223
  { name: "add", summary: "Add a registry package's types", run: run3 },
4687
- { name: "login", summary: "Save a registry deploy token", run: run5 },
4688
- { name: "config", summary: "Emit a plugin's default config file(s)", run: run6 },
4689
- { name: "gen-schema", summary: "Regenerate schema accessors", run: (a2) => run7("schema", a2) },
4690
- { name: "gen-events", summary: "Regenerate the event catalog", run: (a2) => run7("events", a2) },
4691
- { name: "gen-nav", summary: "Regenerate nav accessors", run: (a2) => run7("nav", a2) }
5224
+ { name: "install", summary: "Download plugins + their deps into a server", run: run4 },
5225
+ { name: "login", summary: "Save a registry deploy token", run: run6 },
5226
+ { name: "config", summary: "Emit a plugin's default config file(s)", run: run7 },
5227
+ { name: "gen-schema", summary: "Regenerate schema accessors", run: (a2) => run8("schema", a2) },
5228
+ { name: "gen-events", summary: "Regenerate the event catalog", run: (a2) => run8("events", a2) },
5229
+ { name: "gen-nav", summary: "Regenerate nav accessors", run: (a2) => run8("nav", a2) }
4692
5230
  ];
4693
5231
  function find(name) {
4694
5232
  if (name === "publish") return COMMANDS.find((c2) => c2.name === "deploy");
@@ -4696,7 +5234,7 @@ function find(name) {
4696
5234
  }
4697
5235
 
4698
5236
  // src/commands/menu.ts
4699
- async function run8() {
5237
+ async function run9() {
4700
5238
  intro2("s2script");
4701
5239
  const name = await select2({
4702
5240
  message: "What would you like to do?",
@@ -4711,12 +5249,12 @@ var argv = process.argv.slice(2);
4711
5249
  var command = argv[0];
4712
5250
  function usage() {
4713
5251
  console.error(
4714
- "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 login [--token s2s_\u2026] [--registry <url>]\n s2s deploy [dir] [--ci] [--registry <url>] [--packages-dir <path>]\n s2s add <pkg>[@range] [--dir <plugin>] [--registry <url>]\n s2s config gen <plugin.s2sp...> --out <dir>\n s2s gen-schema [--check]\n s2s gen-events [--check]\n s2s gen-nav [--check]\n\nEnv: S2SCRIPT_REGISTRY_URL S2SCRIPT_TOKEN (CI deploy)"
5252
+ "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 login [--token s2s_\u2026] [--registry <url>]\n s2s deploy [dir] [--ci] [--registry <url>] [--packages-dir <path>]\n s2s add <pkg>[@range] [--dir <plugin>] [--registry <url>]\n s2s install [name[@range]...] [--file <path>] [--dir <plugins dir>] [--registry <url>]\n s2s config gen <plugin.s2sp...> --out <dir>\n s2s gen-schema [--check]\n s2s gen-events [--check]\n s2s gen-nav [--check]\n\nEnv: S2SCRIPT_REGISTRY_URL S2SCRIPT_TOKEN (CI deploy)"
4715
5253
  );
4716
5254
  }
4717
5255
  if (!command) {
4718
5256
  if (isInteractive()) {
4719
- await run8();
5257
+ await run9();
4720
5258
  } else {
4721
5259
  usage();
4722
5260
  process.exit(1);