@its-not-rocket-science/ananke 0.1.64 → 0.1.65

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/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@ Versioning follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ---
8
8
 
9
+ ## [0.1.65] — 2026-04-01
10
+
11
+ ### Added
12
+
13
+ - **PM-6 — Content-Pack Registry Format (complete):**
14
+ - `PackRegistryMeta` interface (new): optional `registry` block in `AnankePackManifest` with fields:
15
+ - `compatRange` (string): semver range enforced at runtime by `validatePack` — rejects packs incompatible with the running engine version.
16
+ - `stabilityTier` (`"stable"` | `"experimental"` | `"internal"`): controls listing in a public registry.
17
+ - `requiredExports` (string[]): subpath exports the pack's content depends on — informational.
18
+ - `checksum` (string): SHA-256 hex digest of the pack JSON — computed by `npx ananke pack bundle`, verified by the host.
19
+ - `license` (string): SPDX identifier.
20
+ - `provenance` (object[]): dataset / paper references for empirically grounded content.
21
+ - `PackStabilityTier` and `PackProvenanceRef` types (new, exported from `"./content-pack"`).
22
+ - `ANANKE_ENGINE_VERSION = "0.1.65"` constant (new, exported from `"./content-pack"`): current engine version used in `compatRange` evaluation.
23
+ - `semverSatisfies(version, range)` (new, exported): lightweight semver range evaluator — supports `>=`, `>`, `<=`, `<`, `=`, `^` (caret), `~` (tilde), bare version, and compound space-separated ranges. No external dependencies.
24
+ - `validatePack` extended to validate all registry sub-fields and reject incompatible `compatRange`.
25
+ - `tools/pack-cli.ts` `bundle` command: automatically computes SHA-256 checksum and embeds it in `registry.checksum` before writing the bundle.
26
+ - `schema/pack.schema.json`: `registry` block with full JSON Schema definition for all sub-fields.
27
+ - `docs/pack-registry-spec.md` (new): full specification — field reference, checksum algorithm, runtime enforcement table, future online registry design.
28
+ - 24 new tests (5,593 total). Coverage: 97.11%/88.07%/95.83%/97.11%. Build: clean.
29
+
30
+ ---
31
+
9
32
  ## [0.1.64] — 2026-04-01
10
33
 
11
34
  ### Added
@@ -1,4 +1,6 @@
1
1
  import type { WorldState } from "./sim/world.js";
2
+ /** Current Ananke engine version — used to evaluate pack compatRange at runtime. */
3
+ export declare const ANANKE_ENGINE_VERSION = "0.1.65";
2
4
  /** A single actionable validation failure from `validatePack`. */
3
5
  export interface PackValidationError {
4
6
  /** JSONPath-style location, e.g. `"$.weapons[2].mass_kg"`. */
@@ -6,6 +8,61 @@ export interface PackValidationError {
6
8
  /** Human-readable explanation of what is wrong. */
7
9
  message: string;
8
10
  }
11
+ /** Stability tier for a content pack — controls how it is listed in a registry. */
12
+ export type PackStabilityTier = "stable" | "experimental" | "internal";
13
+ /** Dataset or paper reference for empirically grounded pack content. */
14
+ export interface PackProvenanceRef {
15
+ /** Short description of the source. */
16
+ title: string;
17
+ /** URL of the source, if available. */
18
+ url?: string;
19
+ /** DOI of the source, if applicable. */
20
+ doi?: string;
21
+ /** Free-text notes about what this source grounds. */
22
+ notes?: string;
23
+ }
24
+ /**
25
+ * Registry metadata block — optional top-level section of a pack manifest.
26
+ *
27
+ * Including a `registry` block enables:
28
+ * - Runtime compatibility checking via `compatRange`
29
+ * - Deterministic integrity verification via `checksum` (SHA-256)
30
+ * - Licensing and provenance attestation for empirical content
31
+ *
32
+ * Generate the checksum with:
33
+ * `npx ananke pack bundle <dir>` (embeds it automatically)
34
+ *
35
+ * or manually with `computePackChecksum(manifest)` from `@ananke/content-pack`.
36
+ */
37
+ export interface PackRegistryMeta {
38
+ /**
39
+ * Semver range of Ananke engine versions this pack targets.
40
+ * Examples: `">=0.1.50"`, `">=0.1 <0.2"`, `"^0.1.60"`.
41
+ * `validatePack` rejects packs whose `compatRange` excludes the running version.
42
+ */
43
+ compatRange?: string;
44
+ /** Stability guarantee — governs how the pack appears in a public registry. */
45
+ stabilityTier?: PackStabilityTier;
46
+ /**
47
+ * Subpath exports from `@its-not-rocket-science/ananke` that this pack's
48
+ * content references, e.g. `["./combat", "./catalog"]`.
49
+ * Informational only — not enforced at runtime.
50
+ */
51
+ requiredExports?: string[];
52
+ /**
53
+ * SHA-256 hex digest of the pack JSON (with `registry.checksum` set to `""`
54
+ * before hashing, so the field is present but blank).
55
+ * Compute with `npx ananke pack bundle` or `computePackChecksum`.
56
+ */
57
+ checksum?: string;
58
+ /** SPDX license identifier, e.g. `"MIT"`, `"CC-BY-4.0"`. */
59
+ license?: string;
60
+ /**
61
+ * Dataset or paper references for empirically grounded pack content.
62
+ * Include when your pack derives parameters from research data.
63
+ */
64
+ provenance?: PackProvenanceRef[];
65
+ }
9
66
  /**
10
67
  * The `.ananke-pack` manifest schema.
11
68
  *
@@ -24,9 +81,14 @@ export interface AnankePackManifest {
24
81
  description?: string;
25
82
  /**
26
83
  * Minimum Ananke version required, as a semver range string.
27
- * Used for documentation onlynot enforced at runtime in v0.1.
84
+ * @deprecated Use `registry.compatRange` insteadthis field is informational only.
28
85
  */
29
86
  anankeVersion?: string;
87
+ /**
88
+ * Registry metadata — compatibility, checksum, license, and provenance.
89
+ * `registry.compatRange` is enforced at runtime by `validatePack`.
90
+ */
91
+ registry?: PackRegistryMeta;
30
92
  /** Weapon definitions — each passed to `registerWeapon`. */
31
93
  weapons?: unknown[];
32
94
  /** Armour definitions — each passed to `registerArmour`. */
@@ -59,6 +121,11 @@ export interface LoadPackResult {
59
121
  /** Validation and registration errors. Empty on full success. */
60
122
  errors: PackValidationError[];
61
123
  }
124
+ /**
125
+ * Test whether `version` satisfies `range`.
126
+ * Returns `false` if the range string is unparseable.
127
+ */
128
+ export declare function semverSatisfies(version: string, range: string): boolean;
62
129
  /**
63
130
  * Validate a pack manifest for structural conformance without loading it.
64
131
  *
@@ -13,6 +13,95 @@ import { registerWeapon, registerArmour, registerArchetype } from "./catalog.js"
13
13
  import { validateScenario, loadScenario } from "./scenario.js";
14
14
  import { hashMod } from "./modding.js";
15
15
  import { registerWorldArchetype, registerWorldItem } from "./world-factory.js";
16
+ // ── Version constant ──────────────────────────────────────────────────────────
17
+ // Must be kept in sync with package.json "version" field.
18
+ /** Current Ananke engine version — used to evaluate pack compatRange at runtime. */
19
+ export const ANANKE_ENGINE_VERSION = "0.1.65";
20
+ // ── Semver utilities ──────────────────────────────────────────────────────────
21
+ // Lightweight range evaluator — no external dependencies.
22
+ // Supports: >=X.Y.Z >X.Y.Z <=X.Y.Z <X.Y.Z =X.Y.Z ^X.Y.Z ~X.Y.Z
23
+ // Short forms X.Y and X treated as X.Y.0 and X.0.0 respectively.
24
+ // Compound ranges (space-separated) require all constraints to match.
25
+ function parseSemverTuple(v) {
26
+ const parts = v.replace(/^v/, "").split(".").map(Number);
27
+ if (parts.some(isNaN))
28
+ return null;
29
+ const [major = 0, minor = 0, patch = 0] = parts;
30
+ return [major, minor, patch];
31
+ }
32
+ function cmpSemver(a, b) {
33
+ if (a[0] !== b[0])
34
+ return a[0] - b[0];
35
+ if (a[1] !== b[1])
36
+ return a[1] - b[1];
37
+ return a[2] - b[2];
38
+ }
39
+ /**
40
+ * Test whether `version` satisfies `range`.
41
+ * Returns `false` if the range string is unparseable.
42
+ */
43
+ export function semverSatisfies(version, range) {
44
+ const ver = parseSemverTuple(version);
45
+ if (!ver)
46
+ return false;
47
+ const constraints = range.trim().split(/\s+/);
48
+ for (const constraint of constraints) {
49
+ if (!evalConstraint(ver, constraint.trim()))
50
+ return false;
51
+ }
52
+ return true;
53
+ }
54
+ function evalConstraint(ver, c) {
55
+ // Caret: ^X.Y.Z — compatible within the leftmost non-zero component.
56
+ // ^1.2.3 → >=1.2.3 <2.0.0 (major locked when major > 0)
57
+ // ^0.2.3 → >=0.2.3 <0.3.0 (minor locked when major == 0, minor > 0)
58
+ // ^0.0.3 → >=0.0.3 <0.0.4 (patch locked when both major and minor == 0)
59
+ if (c.startsWith("^")) {
60
+ const lo = parseSemverTuple(c.slice(1));
61
+ if (!lo)
62
+ return false;
63
+ let hi;
64
+ if (lo[0] > 0)
65
+ hi = [lo[0] + 1, 0, 0];
66
+ else if (lo[1] > 0)
67
+ hi = [0, lo[1] + 1, 0];
68
+ else
69
+ hi = [0, 0, lo[2] + 1];
70
+ return cmpSemver(ver, lo) >= 0 && cmpSemver(ver, hi) < 0;
71
+ }
72
+ // Tilde: ~X.Y.Z → >=X.Y.Z <X.(Y+1).0
73
+ if (c.startsWith("~")) {
74
+ const lo = parseSemverTuple(c.slice(1));
75
+ if (!lo)
76
+ return false;
77
+ const hi = [lo[0], lo[1] + 1, 0];
78
+ return cmpSemver(ver, lo) >= 0 && cmpSemver(ver, hi) < 0;
79
+ }
80
+ // Comparators
81
+ if (c.startsWith(">=")) {
82
+ const t = parseSemverTuple(c.slice(2));
83
+ return t !== null && cmpSemver(ver, t) >= 0;
84
+ }
85
+ if (c.startsWith(">")) {
86
+ const t = parseSemverTuple(c.slice(1));
87
+ return t !== null && cmpSemver(ver, t) > 0;
88
+ }
89
+ if (c.startsWith("<=")) {
90
+ const t = parseSemverTuple(c.slice(2));
91
+ return t !== null && cmpSemver(ver, t) <= 0;
92
+ }
93
+ if (c.startsWith("<")) {
94
+ const t = parseSemverTuple(c.slice(1));
95
+ return t !== null && cmpSemver(ver, t) < 0;
96
+ }
97
+ if (c.startsWith("=")) {
98
+ const t = parseSemverTuple(c.slice(1));
99
+ return t !== null && cmpSemver(ver, t) === 0;
100
+ }
101
+ // Bare version: exact match
102
+ const t = parseSemverTuple(c);
103
+ return t !== null && cmpSemver(ver, t) === 0;
104
+ }
16
105
  const _packs = new Map();
17
106
  // ── Validation ────────────────────────────────────────────────────────────────
18
107
  /**
@@ -39,6 +128,75 @@ export function validatePack(manifest) {
39
128
  !/^\d+\.\d+(\.\d+)?$/.test(m["version"])) {
40
129
  errors.push({ path: "$.version", message: 'must be a semver string like "1.0.0" or "1.0"' });
41
130
  }
131
+ // Optional: registry block
132
+ if (m["registry"] !== undefined) {
133
+ if (typeof m["registry"] !== "object" || m["registry"] === null || Array.isArray(m["registry"])) {
134
+ errors.push({ path: "$.registry", message: "must be a plain object if present" });
135
+ }
136
+ else {
137
+ const reg = m["registry"];
138
+ // compatRange — semver range string; must include the running engine version
139
+ if (reg["compatRange"] !== undefined) {
140
+ if (typeof reg["compatRange"] !== "string") {
141
+ errors.push({ path: "$.registry.compatRange", message: "must be a string" });
142
+ }
143
+ else if (!semverSatisfies(ANANKE_ENGINE_VERSION, reg["compatRange"])) {
144
+ errors.push({
145
+ path: "$.registry.compatRange",
146
+ message: `engine version ${ANANKE_ENGINE_VERSION} does not satisfy range "${reg["compatRange"]}"`,
147
+ });
148
+ }
149
+ }
150
+ // stabilityTier — must be one of the known tiers
151
+ const TIERS = ["stable", "experimental", "internal"];
152
+ if (reg["stabilityTier"] !== undefined && !TIERS.includes(reg["stabilityTier"])) {
153
+ errors.push({
154
+ path: "$.registry.stabilityTier",
155
+ message: `must be one of: ${TIERS.join(", ")}`,
156
+ });
157
+ }
158
+ // requiredExports — must be array of strings
159
+ if (reg["requiredExports"] !== undefined) {
160
+ if (!Array.isArray(reg["requiredExports"])) {
161
+ errors.push({ path: "$.registry.requiredExports", message: "must be an array" });
162
+ }
163
+ else {
164
+ for (let i = 0; i < reg["requiredExports"].length; i++) {
165
+ if (typeof reg["requiredExports"][i] !== "string") {
166
+ errors.push({ path: `$.registry.requiredExports[${i}]`, message: "must be a string" });
167
+ }
168
+ }
169
+ }
170
+ }
171
+ // checksum — must be a 64-char hex string (SHA-256) if present
172
+ if (reg["checksum"] !== undefined) {
173
+ if (typeof reg["checksum"] !== "string" || !/^[0-9a-f]{64}$/.test(reg["checksum"])) {
174
+ errors.push({ path: "$.registry.checksum", message: "must be a 64-character lowercase hex string (SHA-256)" });
175
+ }
176
+ }
177
+ // license — must be a non-empty string
178
+ if (reg["license"] !== undefined && (typeof reg["license"] !== "string" || reg["license"].trim() === "")) {
179
+ errors.push({ path: "$.registry.license", message: "must be a non-empty SPDX identifier string" });
180
+ }
181
+ // provenance — must be array of objects with at least a title
182
+ if (reg["provenance"] !== undefined) {
183
+ if (!Array.isArray(reg["provenance"])) {
184
+ errors.push({ path: "$.registry.provenance", message: "must be an array" });
185
+ }
186
+ else {
187
+ for (let i = 0; i < reg["provenance"].length; i++) {
188
+ const ref = reg["provenance"][i];
189
+ if (typeof ref !== "object" || ref === null) {
190
+ errors.push({ path: `$.registry.provenance[${i}]`, message: "must be an object" });
191
+ }
192
+ else if (typeof ref["title"] !== "string") {
193
+ errors.push({ path: `$.registry.provenance[${i}].title`, message: "must be a string" });
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
42
200
  // Optional arrays — must be arrays if present
43
201
  for (const key of ["weapons", "armour", "archetypes", "scenarios"]) {
44
202
  if (m[key] !== undefined && !Array.isArray(m[key])) {
@@ -12,6 +12,7 @@
12
12
  // npx ananke replay diff <a.json> <b.json>
13
13
  import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
14
14
  import { join, resolve, extname, basename } from "node:path";
15
+ import { createHash } from "node:crypto";
15
16
  import { validatePack, loadPack } from "../src/content-pack.js";
16
17
  import { diffReplayJson } from "../src/netcode.js";
17
18
  import { q } from "../src/units.js";
@@ -97,6 +98,13 @@ function cmdBundle(args) {
97
98
  if (!bundle.name && typeof partial.name === "string")
98
99
  bundle.name = partial.name;
99
100
  }
101
+ // Compute SHA-256 checksum: serialise with checksum="" (placeholder), then hash.
102
+ // Store in registry block so consumers can verify integrity.
103
+ if (!bundle.registry)
104
+ bundle.registry = {};
105
+ bundle.registry.checksum = ""; // placeholder — field present but blank for hashing
106
+ const checksumInput = JSON.stringify(bundle, null, 2);
107
+ bundle.registry.checksum = createHash("sha256").update(checksumInput).digest("hex");
100
108
  // Pre-validate before writing
101
109
  const errors = validatePack(bundle);
102
110
  if (errors.length > 0) {
@@ -107,6 +115,7 @@ function cmdBundle(args) {
107
115
  writeFileSync(outFile, json, "utf8");
108
116
  console.log(`✓ Bundle written to ${outFile}`);
109
117
  console.log(` weapons: ${bundle.weapons.length}, armour: ${bundle.armour.length}, archetypes: ${bundle.archetypes.length}, scenarios: ${bundle.scenarios.length}`);
118
+ console.log(` checksum: ${bundle.registry.checksum}`);
110
119
  }
111
120
  function cmdLoad(args) {
112
121
  const filePath = args[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@its-not-rocket-science/ananke",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "type": "module",
5
5
  "description": "Deterministic lockstep-friendly SI-units RPG/physics core (fixed-point TS)",
6
6
  "license": "MIT",
@@ -26,7 +26,51 @@
26
26
  },
27
27
  "anankeVersion": {
28
28
  "type": "string",
29
- "description": "Minimum Ananke version required (semver range), e.g. \">=0.1\"."
29
+ "description": "Deprecated: use registry.compatRange instead. Minimum Ananke version required (semver range)."
30
+ },
31
+ "registry": {
32
+ "type": "object",
33
+ "description": "Registry metadata — compatibility, checksum, license, and provenance. registry.compatRange is enforced at runtime by validatePack.",
34
+ "properties": {
35
+ "compatRange": {
36
+ "type": "string",
37
+ "description": "Semver range of Ananke engine versions this pack targets, e.g. \">=0.1.50\", \"^0.1.60\". Validated at runtime against the running engine version."
38
+ },
39
+ "stabilityTier": {
40
+ "type": "string",
41
+ "enum": ["stable", "experimental", "internal"],
42
+ "description": "Stability guarantee — governs how the pack appears in a public registry."
43
+ },
44
+ "requiredExports": {
45
+ "type": "array",
46
+ "items": { "type": "string" },
47
+ "description": "Subpath exports from @its-not-rocket-science/ananke this pack's content depends on. Informational only."
48
+ },
49
+ "checksum": {
50
+ "type": "string",
51
+ "pattern": "^([0-9a-f]{64}|)$",
52
+ "description": "SHA-256 hex digest of the pack JSON (with registry.checksum set to \"\" before hashing). Computed by `npx ananke pack bundle`."
53
+ },
54
+ "license": {
55
+ "type": "string",
56
+ "description": "SPDX license identifier, e.g. \"MIT\", \"CC-BY-4.0\"."
57
+ },
58
+ "provenance": {
59
+ "type": "array",
60
+ "items": {
61
+ "type": "object",
62
+ "required": ["title"],
63
+ "properties": {
64
+ "title": { "type": "string" },
65
+ "url": { "type": "string" },
66
+ "doi": { "type": "string" },
67
+ "notes": { "type": "string" }
68
+ }
69
+ },
70
+ "description": "Dataset or paper references for empirically grounded pack content."
71
+ }
72
+ },
73
+ "additionalProperties": false
30
74
  },
31
75
  "weapons": {
32
76
  "type": "array",