@its-not-rocket-science/ananke 0.1.69 → 0.5.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.
Files changed (165) hide show
  1. package/CHANGELOG.md +254 -0
  2. package/README.md +150 -432
  3. package/STABLE_API.md +113 -490
  4. package/conformance/README.md +46 -44
  5. package/conformance/lockstep-sequence.json +395 -395
  6. package/conformance/phase-order.json +21 -0
  7. package/conformance/replay-parity.json +65 -65
  8. package/conformance/state-hash-regression.json +39 -0
  9. package/conformance/state-hash.json +23 -22
  10. package/dist/src/conformance.d.ts +8 -2
  11. package/dist/src/content/index.d.ts +3 -0
  12. package/dist/src/content/index.js +2 -0
  13. package/dist/src/content/injector.d.ts +19 -0
  14. package/dist/src/content/injector.js +61 -0
  15. package/dist/src/content/loader.d.ts +3 -0
  16. package/dist/src/content/loader.js +170 -0
  17. package/dist/src/content/types.d.ts +56 -0
  18. package/dist/src/content/types.js +1 -0
  19. package/dist/src/content/validator.d.ts +6 -0
  20. package/dist/src/content/validator.js +14 -0
  21. package/dist/src/content-pack.d.ts +3 -2
  22. package/dist/src/content-pack.js +205 -10
  23. package/dist/src/data-governance.d.ts +73 -0
  24. package/dist/src/data-governance.js +188 -0
  25. package/dist/src/debug.d.ts +6 -0
  26. package/dist/src/debug.js +9 -0
  27. package/dist/src/determinism.d.ts +12 -0
  28. package/dist/src/determinism.js +93 -0
  29. package/dist/src/dialogue.d.ts +41 -0
  30. package/dist/src/dialogue.js +113 -2
  31. package/dist/src/economy.d.ts +97 -3
  32. package/dist/src/economy.js +191 -37
  33. package/dist/src/history/autosave.d.ts +19 -0
  34. package/dist/src/history/autosave.js +98 -0
  35. package/dist/src/history/timetravel.d.ts +28 -0
  36. package/dist/src/history/timetravel.js +61 -0
  37. package/dist/src/index.d.ts +19 -31
  38. package/dist/src/index.js +20 -38
  39. package/dist/src/model3d.js +63 -2
  40. package/dist/src/narrative/combat-logger.d.ts +25 -0
  41. package/dist/src/narrative/combat-logger.js +97 -0
  42. package/dist/src/narrative/plausibility.d.ts +30 -0
  43. package/dist/src/narrative/plausibility.js +70 -0
  44. package/dist/src/narrative-layer.d.ts +2 -0
  45. package/dist/src/narrative-layer.js +2 -0
  46. package/dist/src/navigation/causal-chain.d.ts +26 -0
  47. package/dist/src/navigation/causal-chain.js +74 -0
  48. package/dist/src/netcode.d.ts +1 -1
  49. package/dist/src/netcode.js +1 -1
  50. package/dist/src/performance/adaptive-tick.d.ts +23 -0
  51. package/dist/src/performance/adaptive-tick.js +50 -0
  52. package/dist/src/plugins/loader.d.ts +23 -0
  53. package/dist/src/plugins/loader.js +86 -0
  54. package/dist/src/plugins/registry.d.ts +11 -0
  55. package/dist/src/plugins/registry.js +28 -0
  56. package/dist/src/plugins/types.d.ts +31 -0
  57. package/dist/src/plugins/types.js +7 -0
  58. package/dist/src/replay.js +5 -2
  59. package/dist/src/scenario.js +2 -1
  60. package/dist/src/serialization/binary.d.ts +5 -0
  61. package/dist/src/serialization/binary.js +193 -0
  62. package/dist/src/session.d.ts +115 -0
  63. package/dist/src/session.js +228 -0
  64. package/dist/src/sim/ai/decide.js +1 -1
  65. package/dist/src/sim/ai/personality.js +2 -2
  66. package/dist/src/sim/bodyplan.d.ts +30 -0
  67. package/dist/src/sim/context.d.ts +2 -0
  68. package/dist/src/sim/kernel.js +80 -1294
  69. package/dist/src/sim/normalization.d.ts +12 -0
  70. package/dist/src/sim/normalization.js +92 -0
  71. package/dist/src/sim/resolvers/attack-resolver.d.ts +30 -0
  72. package/dist/src/sim/resolvers/attack-resolver.js +310 -0
  73. package/dist/src/sim/resolvers/capability-resolver.d.ts +15 -0
  74. package/dist/src/sim/resolvers/capability-resolver.js +270 -0
  75. package/dist/src/sim/resolvers/grapple-resolver.d.ts +28 -0
  76. package/dist/src/sim/resolvers/grapple-resolver.js +70 -0
  77. package/dist/src/sim/resolvers/shoot-resolver.d.ts +27 -0
  78. package/dist/src/sim/resolvers/shoot-resolver.js +212 -0
  79. package/dist/src/sim/resolvers/treat-resolver.d.ts +16 -0
  80. package/dist/src/sim/resolvers/treat-resolver.js +77 -0
  81. package/dist/src/sim/step/apply/intents.d.ts +6 -0
  82. package/dist/src/sim/step/apply/intents.js +83 -0
  83. package/dist/src/sim/step/kernel-pipeline.d.ts +9 -0
  84. package/dist/src/sim/step/kernel-pipeline.js +15 -0
  85. package/dist/src/sim/step/phases/capability-phase.d.ts +8 -0
  86. package/dist/src/sim/step/phases/capability-phase.js +65 -0
  87. package/dist/src/sim/step/phases/cooldowns-phase.d.ts +3 -0
  88. package/dist/src/sim/step/phases/cooldowns-phase.js +37 -0
  89. package/dist/src/sim/step/phases/prepare-phase.d.ts +10 -0
  90. package/dist/src/sim/step/phases/prepare-phase.js +33 -0
  91. package/dist/src/sim/step/pipeline.d.ts +2 -0
  92. package/dist/src/sim/step/pipeline.js +15 -0
  93. package/dist/src/sim/step/resolvers/impact-resolver.d.ts +9 -0
  94. package/dist/src/sim/step/resolvers/impact-resolver.js +55 -0
  95. package/dist/src/sim/step/world-phases.d.ts +7 -0
  96. package/dist/src/sim/step/world-phases.js +15 -0
  97. package/dist/src/sim/step/world-step-context.d.ts +23 -0
  98. package/dist/src/sim/step/world-step-context.js +1 -0
  99. package/dist/src/sim/systemic-toxicology.js +1 -1
  100. package/dist/src/sim/team.js +2 -2
  101. package/dist/src/sim/testing.js +4 -3
  102. package/dist/src/sim/toxicology.d.ts +1 -1
  103. package/dist/src/sim/toxicology.js +1 -1
  104. package/dist/src/sim/world.d.ts +17 -24
  105. package/dist/src/snapshot.js +3 -6
  106. package/dist/src/tier2.d.ts +19 -0
  107. package/dist/src/tier2.js +21 -0
  108. package/dist/src/tier3.d.ts +10 -0
  109. package/dist/src/tier3.js +12 -0
  110. package/dist/src/version.d.ts +6 -0
  111. package/dist/src/version.js +8 -0
  112. package/dist/src/wasm/bridge.d.ts +14 -0
  113. package/dist/src/wasm/bridge.js +84 -0
  114. package/dist/src/wasm-kernel.d.ts +2 -0
  115. package/dist/src/wasm-kernel.js +1 -0
  116. package/dist/src/world-evolution-backend/engine.d.ts +4 -0
  117. package/dist/src/world-evolution-backend/engine.js +347 -0
  118. package/dist/src/world-evolution-backend/host-schema.d.ts +145 -0
  119. package/dist/src/world-evolution-backend/host-schema.js +375 -0
  120. package/dist/src/world-evolution-backend/index.d.ts +1 -0
  121. package/dist/src/world-evolution-backend/index.js +1 -0
  122. package/dist/src/world-evolution-backend/open-world-host-adapter.d.ts +96 -0
  123. package/dist/src/world-evolution-backend/open-world-host-adapter.js +208 -0
  124. package/dist/src/world-evolution-backend/profiles.d.ts +4 -0
  125. package/dist/src/world-evolution-backend/profiles.js +136 -0
  126. package/dist/src/world-evolution-backend/public.d.ts +11 -0
  127. package/dist/src/world-evolution-backend/public.js +7 -0
  128. package/dist/src/world-evolution-backend/reproducibility.d.ts +8 -0
  129. package/dist/src/world-evolution-backend/reproducibility.js +35 -0
  130. package/dist/src/world-evolution-backend/timeline.d.ts +6 -0
  131. package/dist/src/world-evolution-backend/timeline.js +337 -0
  132. package/dist/src/world-evolution-backend/types.d.ts +138 -0
  133. package/dist/src/world-evolution-backend/types.js +1 -0
  134. package/dist/src/world-evolution-host-backend.d.ts +51 -0
  135. package/dist/src/world-evolution-host-backend.js +97 -0
  136. package/dist/src/world-evolution.d.ts +201 -0
  137. package/dist/src/world-evolution.js +543 -0
  138. package/dist/src/world-factory.js +2 -1
  139. package/dist/tools/pack-cli.js +55 -2
  140. package/docs/bridge-contract.md +140 -332
  141. package/docs/completion-evidence-dashboard.md +63 -0
  142. package/docs/host-contract.md +86 -310
  143. package/docs/host-world-evolution-schema.md +155 -0
  144. package/docs/integration-primer.md +29 -22
  145. package/docs/migration-monolith-to-modular.md +102 -120
  146. package/docs/package-architecture.md +116 -323
  147. package/docs/plugins/README.md +42 -0
  148. package/docs/plugins/hooks.md +22 -0
  149. package/docs/plugins/publishing.md +29 -0
  150. package/docs/plugins/security-model.md +273 -0
  151. package/docs/project-overview.md +30 -30
  152. package/docs/release-dashboard.md +47 -0
  153. package/docs/session-api.md +127 -0
  154. package/docs/stable-api-manifest.json +52 -0
  155. package/docs/trust-dashboard.md +37 -0
  156. package/docs/versioning.md +256 -245
  157. package/docs/wire-protocol.md +140 -209
  158. package/docs/world-evolution-backend.md +200 -0
  159. package/docs/world-evolution-engine.md +150 -0
  160. package/docs/world-evolution-host-backend.md +70 -0
  161. package/docs/world-evolution-orchestration.md +248 -0
  162. package/package.json +125 -6
  163. package/schema/content-pack.schema.json +85 -0
  164. package/schema/plugin.schema.json +57 -0
  165. package/schema/world-evolution-input.schema.json +196 -0
@@ -55,6 +55,67 @@ function getCanonicalOffset(segId) {
55
55
  return { xFrac: lat * 0.50, yFrac: 0.65 };
56
56
  return { xFrac: 0, yFrac: 0.50 }; // unknown: geometric midpoint
57
57
  }
58
+ const VERTICAL_POSITION_TO_Y_FRAC = {
59
+ crown: 0.94,
60
+ high: 0.80,
61
+ upper: 0.68,
62
+ mid: 0.50,
63
+ lower: 0.28,
64
+ ground: 0.03,
65
+ };
66
+ const RIG_ROLE_TO_Y_FRAC = {
67
+ head: 0.94,
68
+ neck: 0.80,
69
+ torso: 0.63,
70
+ abdomen: 0.52,
71
+ pelvis: 0.43,
72
+ shoulder: 0.72,
73
+ arm_upper: 0.68,
74
+ arm_lower: 0.56,
75
+ hand: 0.44,
76
+ leg_upper: 0.33,
77
+ leg_lower: 0.17,
78
+ foot: 0.03,
79
+ tail: 0.35,
80
+ wing: 0.65,
81
+ appendage: 0.50,
82
+ core: 0.50,
83
+ };
84
+ const RIG_ROLE_TO_X_FRAC_MAGNITUDE = {
85
+ shoulder: 0.20,
86
+ arm_upper: 0.20,
87
+ arm_lower: 0.22,
88
+ hand: 0.25,
89
+ leg_upper: 0.07,
90
+ leg_lower: 0.07,
91
+ foot: 0.07,
92
+ wing: 0.50,
93
+ };
94
+ function sideToSign(side) {
95
+ if (side === "left")
96
+ return -1;
97
+ if (side === "right")
98
+ return 1;
99
+ return 0;
100
+ }
101
+ function getCanonicalOffsetFromMetadata(segId, spatial) {
102
+ if (!spatial)
103
+ return null;
104
+ const legacy = getCanonicalOffset(segId);
105
+ const side = sideToSign(spatial.lateralSide);
106
+ const fromHintX = spatial.centerOfMassHint?.xFrac;
107
+ const fromHintY = spatial.centerOfMassHint?.yFrac;
108
+ const fromVertical = spatial.verticalPosition ? VERTICAL_POSITION_TO_Y_FRAC[spatial.verticalPosition] : undefined;
109
+ const fromRigY = spatial.rigRole ? RIG_ROLE_TO_Y_FRAC[spatial.rigRole] : undefined;
110
+ const xMag = spatial.rigRole ? (RIG_ROLE_TO_X_FRAC_MAGNITUDE[spatial.rigRole] ?? 0) : 0;
111
+ return {
112
+ xFrac: Math.max(-1, Math.min(1, fromHintX ?? (side * xMag))),
113
+ yFrac: Math.max(0, Math.min(1, fromHintY ?? fromVertical ?? fromRigY ?? legacy.yFrac)),
114
+ };
115
+ }
116
+ function resolveCanonicalOffset(seg) {
117
+ return getCanonicalOffsetFromMetadata(seg.id, seg.renderSpatial) ?? getCanonicalOffset(seg.id);
118
+ }
58
119
  /**
59
120
  * Derive mass distribution and centre of gravity from entity body plan.
60
121
  * Falls back to a single "body" segment at the geometric midpoint when no
@@ -69,7 +130,7 @@ export function deriveMassDistribution(entity) {
69
130
  let cogX = 0;
70
131
  let cogY = 0;
71
132
  const segments = segs.map(seg => {
72
- const off = getCanonicalOffset(seg.id);
133
+ const off = resolveCanonicalOffset(seg);
73
134
  const mass_r = seg.mass_kg / SCALE.kg;
74
135
  cogX += mass_r * off.xFrac * stature_m;
75
136
  cogY += mass_r * off.yFrac * stature_m;
@@ -104,7 +165,7 @@ export function deriveInertiaTensor(entity) {
104
165
  if (entity.bodyPlan) {
105
166
  let I_yaw = 0, I_pitch = 0, I_roll = 0;
106
167
  for (const seg of entity.bodyPlan.segments) {
107
- const off = getCanonicalOffset(seg.id);
168
+ const off = resolveCanonicalOffset(seg);
108
169
  const m = seg.mass_kg / SCALE.kg;
109
170
  const x = off.xFrac * stature_m;
110
171
  const y = off.yFrac * stature_m;
@@ -0,0 +1,25 @@
1
+ export type NarrativeVerbosity = "tactical" | "cinematic";
2
+ export interface NarrativeActionContext {
3
+ attackerName?: string;
4
+ targetName?: string;
5
+ terrain?: string;
6
+ weather?: string;
7
+ weaponName?: string;
8
+ distance_m?: number;
9
+ causes?: string[];
10
+ }
11
+ export interface CombatAction {
12
+ kind: "melee" | "ranged" | "morale" | "status";
13
+ hit?: boolean;
14
+ damage?: number;
15
+ blocked?: boolean;
16
+ parried?: boolean;
17
+ shieldBlocked?: boolean;
18
+ region?: string;
19
+ critical?: boolean;
20
+ suppressed?: boolean;
21
+ }
22
+ export interface DescribeActionOptions {
23
+ verbosity?: NarrativeVerbosity;
24
+ }
25
+ export declare function describeAction(action: CombatAction, context?: NarrativeActionContext, options?: DescribeActionOptions): string;
@@ -0,0 +1,97 @@
1
+ import { SCALE } from "../units.js";
2
+ function name(label, fallback) {
3
+ return label?.trim() || fallback;
4
+ }
5
+ function joinCauses(context) {
6
+ const causes = [...(context.causes ?? [])];
7
+ if (context.terrain)
8
+ causes.push(`${context.terrain} terrain`);
9
+ if (context.weather)
10
+ causes.push(context.weather);
11
+ if (causes.length === 0)
12
+ return "";
13
+ if (causes.length === 1)
14
+ return ` because of ${causes[0]}`;
15
+ return ` because of ${causes.slice(0, -1).join(", ")} and ${causes.at(-1)}`;
16
+ }
17
+ function cinematicMelee(action, context) {
18
+ const attacker = name(context.attackerName, "The attacker");
19
+ const target = name(context.targetName, "the defender");
20
+ const weapon = context.weaponName ?? "weapon";
21
+ if (action.hit === false) {
22
+ return `${attacker}'s ${weapon} clangs against ${target}${action.shieldBlocked ? "'s shield" : ""} — a near miss${joinCauses(context)}!`;
23
+ }
24
+ if (action.blocked || action.parried || action.shieldBlocked) {
25
+ return `${target} turns aside ${attacker}'s strike at the last instant${joinCauses(context)}.`;
26
+ }
27
+ const damageText = typeof action.damage === "number"
28
+ ? ` for ${action.damage} damage`
29
+ : "";
30
+ const critText = action.critical ? " It's a decisive blow." : "";
31
+ const regionText = action.region ? ` into ${target}'s ${action.region}` : " into the opening";
32
+ return `${attacker} drives the ${weapon}${regionText}${damageText}${critText}`;
33
+ }
34
+ function tacticalMelee(action, context) {
35
+ const attacker = name(context.attackerName, "attacker");
36
+ const target = name(context.targetName, "target");
37
+ const weapon = context.weaponName ?? "weapon";
38
+ if (action.hit === false) {
39
+ return `${attacker} misses ${target} with ${weapon}${joinCauses(context)}.`;
40
+ }
41
+ if (action.blocked || action.parried || action.shieldBlocked) {
42
+ const defense = action.parried ? "parried" : action.shieldBlocked ? "shield block" : "blocked";
43
+ return `${attacker} attacks ${target}; ${defense}${joinCauses(context)}.`;
44
+ }
45
+ const distance = typeof context.distance_m === "number"
46
+ ? ` at ${(context.distance_m / SCALE.m).toFixed(1)}m`
47
+ : "";
48
+ return `${attacker} hits ${target}${action.region ? ` (${action.region})` : ""} for ${action.damage ?? 0} damage${distance}.`;
49
+ }
50
+ function tacticalRanged(action, context) {
51
+ const shooter = name(context.attackerName, "shooter");
52
+ const target = name(context.targetName, "target");
53
+ const weapon = context.weaponName ?? "projectile";
54
+ const distance = typeof context.distance_m === "number"
55
+ ? ` at ${(context.distance_m / SCALE.m).toFixed(1)}m`
56
+ : "";
57
+ if (action.hit === false) {
58
+ return `${shooter} misses ${target} with ${weapon}${distance}${joinCauses(context)}.`;
59
+ }
60
+ if (action.suppressed) {
61
+ return `${shooter} suppresses ${target} with ${weapon}${distance}.`;
62
+ }
63
+ return `${shooter} lands a shot on ${target}${action.region ? ` (${action.region})` : ""}${distance}.`;
64
+ }
65
+ function cinematicRanged(action, context) {
66
+ const shooter = name(context.attackerName, "The archer");
67
+ const target = name(context.targetName, "the enemy");
68
+ const weapon = context.weaponName ?? "shot";
69
+ if (action.hit === false) {
70
+ return `${shooter}'s ${weapon} whistles past ${target}${joinCauses(context)}.`;
71
+ }
72
+ if (action.suppressed) {
73
+ return `${shooter} pins ${target} down in a storm of fire.`;
74
+ }
75
+ return `${shooter}'s ${weapon} finds ${target}${action.region ? `'s ${action.region}` : ""}, shifting the momentum of the fight.`;
76
+ }
77
+ export function describeAction(action, context = {}, options = {}) {
78
+ const verbosity = options.verbosity ?? "tactical";
79
+ if (action.kind === "morale") {
80
+ return verbosity === "cinematic"
81
+ ? `${name(context.attackerName, "The line")} falters as fear spreads${joinCauses(context)}.`
82
+ : `${name(context.attackerName, "Unit")} suffers a morale break${joinCauses(context)}.`;
83
+ }
84
+ if (action.kind === "status") {
85
+ return verbosity === "cinematic"
86
+ ? `${name(context.attackerName, "The warrior")} staggers but refuses to fall.`
87
+ : `${name(context.attackerName, "Entity")} status changed.`;
88
+ }
89
+ if (action.kind === "ranged") {
90
+ return verbosity === "cinematic"
91
+ ? cinematicRanged(action, context)
92
+ : tacticalRanged(action, context);
93
+ }
94
+ return verbosity === "cinematic"
95
+ ? cinematicMelee(action, context)
96
+ : tacticalMelee(action, context);
97
+ }
@@ -0,0 +1,30 @@
1
+ export interface NarrativeWeight {
2
+ expectedWinnerId?: number;
3
+ expectedWinnerTeamId?: number;
4
+ heroIds?: number[];
5
+ desiredBeat?: "heroic_near_win" | "clean_victory" | "tragic_loss" | "upset";
6
+ dramaticTolerance?: number;
7
+ }
8
+ export interface WorldPlausibilityState {
9
+ winnerId?: number;
10
+ winnerTeamId?: number;
11
+ casualtiesByEntityId?: Record<number, boolean>;
12
+ eliminationOrder?: number[];
13
+ rareEventRolls?: Array<{
14
+ label: string;
15
+ chance: number;
16
+ happened: boolean;
17
+ }>;
18
+ }
19
+ export interface NarrativeViolation {
20
+ code: "hero_fell_to_mook" | "unlikely_chain" | "expected_winner_lost";
21
+ message: string;
22
+ severity: "low" | "medium" | "high";
23
+ }
24
+ export interface PlausibilityReport {
25
+ score: number;
26
+ violations: NarrativeViolation[];
27
+ suggestedSeeds: number[];
28
+ }
29
+ export declare function analyzePlausibility(worldState: WorldPlausibilityState, narrativeWeight: NarrativeWeight): PlausibilityReport;
30
+ export declare function scorePlausibility(worldState: WorldPlausibilityState, narrativeWeight: NarrativeWeight): number;
@@ -0,0 +1,70 @@
1
+ function clampScore(v) {
2
+ return Math.max(0, Math.min(100, Math.round(v)));
3
+ }
4
+ export function analyzePlausibility(worldState, narrativeWeight) {
5
+ const violations = [];
6
+ let score = 100;
7
+ const tolerance = narrativeWeight.dramaticTolerance ?? 0.25;
8
+ const heroIds = narrativeWeight.heroIds ?? [];
9
+ if (narrativeWeight.expectedWinnerTeamId !== undefined && worldState.winnerTeamId !== undefined) {
10
+ if (narrativeWeight.expectedWinnerTeamId !== worldState.winnerTeamId) {
11
+ score -= 28;
12
+ violations.push({
13
+ code: "expected_winner_lost",
14
+ message: `Expected team ${narrativeWeight.expectedWinnerTeamId} to win, but team ${worldState.winnerTeamId} prevailed.`,
15
+ severity: "high",
16
+ });
17
+ }
18
+ }
19
+ if (narrativeWeight.expectedWinnerId !== undefined && worldState.winnerId !== undefined) {
20
+ if (narrativeWeight.expectedWinnerId !== worldState.winnerId) {
21
+ score -= 22;
22
+ violations.push({
23
+ code: "expected_winner_lost",
24
+ message: `Expected entity ${narrativeWeight.expectedWinnerId} to win, but entity ${worldState.winnerId} won.`,
25
+ severity: "medium",
26
+ });
27
+ }
28
+ }
29
+ const deadHeroes = heroIds.filter(id => worldState.casualtiesByEntityId?.[id]);
30
+ if (deadHeroes.length > 0 && (narrativeWeight.desiredBeat === "heroic_near_win" || narrativeWeight.desiredBeat === "clean_victory")) {
31
+ score -= 35;
32
+ violations.push({
33
+ code: "hero_fell_to_mook",
34
+ message: `Named hero(es) ${deadHeroes.join(", ")} died against the target beat.`,
35
+ severity: "high",
36
+ });
37
+ }
38
+ const improbableWins = (worldState.rareEventRolls ?? []).filter(r => r.happened && r.chance <= 0.01);
39
+ if (improbableWins.length > Math.max(0, Math.round(2 * tolerance))) {
40
+ score -= 18;
41
+ violations.push({
42
+ code: "unlikely_chain",
43
+ message: `Outcome relied on ${improbableWins.length} ultra-rare events (${improbableWins.map(r => r.label).join(", ")}).`,
44
+ severity: "medium",
45
+ });
46
+ }
47
+ if (narrativeWeight.desiredBeat === "heroic_near_win" && heroIds.length > 0) {
48
+ const hero = heroIds[0];
49
+ const eliminationOrder = worldState.eliminationOrder ?? [];
50
+ const heroElimIndex = eliminationOrder.indexOf(hero);
51
+ if (heroElimIndex !== -1) {
52
+ score -= 30;
53
+ violations.push({
54
+ code: "hero_fell_to_mook",
55
+ message: `Hero ${hero} was eliminated at position ${heroElimIndex + 1} during a near-win target beat.`,
56
+ severity: "high",
57
+ });
58
+ }
59
+ }
60
+ const suggestedSeeds = buildSuggestedSeeds(score, narrativeWeight.desiredBeat);
61
+ return { score: clampScore(score), violations, suggestedSeeds };
62
+ }
63
+ function buildSuggestedSeeds(score, beat) {
64
+ const base = beat === "heroic_near_win" ? 7300 : beat === "clean_victory" ? 4100 : 9100;
65
+ const adjustment = Math.max(1, Math.trunc((100 - score) / 5));
66
+ return [base + adjustment, base + adjustment + 17, base + adjustment + 49];
67
+ }
68
+ export function scorePlausibility(worldState, narrativeWeight) {
69
+ return analyzePlausibility(worldState, narrativeWeight).score;
70
+ }
@@ -18,3 +18,5 @@ export * from "./narrative.js";
18
18
  export * from "./narrative-stress.js";
19
19
  export * from "./metrics.js";
20
20
  export * from "./arena.js";
21
+ export * from "./narrative/combat-logger.js";
22
+ export * from "./narrative/plausibility.js";
@@ -18,3 +18,5 @@ export * from "./narrative.js";
18
18
  export * from "./narrative-stress.js";
19
19
  export * from "./metrics.js";
20
20
  export * from "./arena.js";
21
+ export * from "./narrative/combat-logger.js";
22
+ export * from "./narrative/plausibility.js";
@@ -0,0 +1,26 @@
1
+ import type { TraceEvent } from "../sim/trace.js";
2
+ export interface TickRange {
3
+ start: number;
4
+ end: number;
5
+ }
6
+ export interface CausalFactor {
7
+ label: string;
8
+ weight: number;
9
+ evidence: string[];
10
+ }
11
+ export interface Explanation {
12
+ entityId: number;
13
+ tickRange: TickRange;
14
+ summary: string;
15
+ factors: CausalFactor[];
16
+ mermaid: string;
17
+ }
18
+ export interface ExplanationContext {
19
+ trace: TraceEvent[];
20
+ windPenaltyByTick?: Record<number, number>;
21
+ fatigueByEntityId?: Record<number, number>;
22
+ }
23
+ export declare function explainOutcome(entityId: number, tickRange: TickRange, context: ExplanationContext): Explanation;
24
+ export declare function createCausalExplainer(context: ExplanationContext): {
25
+ explainOutcome: (entityId: number, tickRange: TickRange) => Explanation;
26
+ };
@@ -0,0 +1,74 @@
1
+ import { TraceKinds } from "../sim/kinds.js";
2
+ function inRange(tick, range) {
3
+ return tick >= range.start && tick <= range.end;
4
+ }
5
+ function toMermaid(entityId, factors) {
6
+ const lines = [
7
+ "flowchart TD",
8
+ ` ROOT[Why entity ${entityId} got this outcome]`,
9
+ ];
10
+ for (let i = 0; i < factors.length; i++) {
11
+ const node = `F${i}`;
12
+ lines.push(` ROOT --> ${node}[${factors[i].label} (${factors[i].weight.toFixed(2)})]`);
13
+ for (let e = 0; e < factors[i].evidence.length; e++) {
14
+ lines.push(` ${node} --> ${node}_${e}[${factors[i].evidence[e]}]`);
15
+ }
16
+ }
17
+ return lines.join("\n");
18
+ }
19
+ export function explainOutcome(entityId, tickRange, context) {
20
+ const relevant = context.trace.filter(ev => inRange(ev.tick, tickRange));
21
+ const missEvents = relevant.filter((ev) => ev.kind === TraceKinds.ProjectileHit && ev.shooterId === entityId && !ev.hit);
22
+ const totalShots = relevant.filter((ev) => ev.kind === TraceKinds.ProjectileHit && ev.shooterId === entityId).length;
23
+ const avgDistance = missEvents.length > 0
24
+ ? missEvents.reduce((sum, ev) => sum + ev.distance_m, 0) / missEvents.length
25
+ : 0;
26
+ const windPenalty = missEvents.reduce((sum, ev) => sum + (context.windPenaltyByTick?.[ev.tick] ?? 0), 0);
27
+ const fatigue = context.fatigueByEntityId?.[entityId] ?? 0;
28
+ const factors = [];
29
+ if (missEvents.length > 0) {
30
+ factors.push({
31
+ label: "Range pressure",
32
+ weight: Math.min(1, avgDistance / 50000),
33
+ evidence: [`${missEvents.length}/${totalShots} shots missed`, `Average miss distance ${(avgDistance / 1000).toFixed(1)}m`],
34
+ });
35
+ }
36
+ if (fatigue > 0) {
37
+ factors.push({
38
+ label: "Fatigue accumulation",
39
+ weight: Math.min(1, fatigue),
40
+ evidence: [`Fatigue score ${fatigue.toFixed(2)} reduced aim stability`],
41
+ });
42
+ }
43
+ if (windPenalty > 0) {
44
+ factors.push({
45
+ label: "Crosswind interference",
46
+ weight: Math.min(1, windPenalty / 3),
47
+ evidence: [`Wind penalty total ${windPenalty.toFixed(2)} across selected ticks`],
48
+ });
49
+ }
50
+ if (factors.length === 0) {
51
+ factors.push({
52
+ label: "No dominant penalty detected",
53
+ weight: 0.2,
54
+ evidence: ["Trace did not include ranged misses or explicit penalties"],
55
+ });
56
+ }
57
+ const summary = missEvents.length >= 3
58
+ ? `Entity ${entityId} missed repeatedly due to stacked penalties in the selected tick window.`
59
+ : `Entity ${entityId} outcome explained from ${factors.length} weighted factor(s).`;
60
+ return {
61
+ entityId,
62
+ tickRange,
63
+ summary,
64
+ factors,
65
+ mermaid: toMermaid(entityId, factors),
66
+ };
67
+ }
68
+ export function createCausalExplainer(context) {
69
+ return {
70
+ explainOutcome(entityId, tickRange) {
71
+ return explainOutcome(entityId, tickRange, context);
72
+ },
73
+ };
74
+ }
@@ -5,7 +5,7 @@ import { type Replay } from "./replay.js";
5
5
  * Compute a deterministic 64-bit hash of the simulation's core state.
6
6
  *
7
7
  * Covers `tick`, `seed`, and all entity data sorted by `id`. Optional
8
- * subsystem fields (`__sensoryEnv`, `__factionRegistry`, etc.) are excluded —
8
+ * subsystem fields (`runtimeState.sensoryEnv`, `runtimeState.factionRegistry`, etc.) are excluded —
9
9
  * they are host concerns and do not affect simulation determinism.
10
10
  *
11
11
  * Use this as a desync checksum in multiplayer loops:
@@ -44,7 +44,7 @@ function stableReplacer(_key, value) {
44
44
  * Compute a deterministic 64-bit hash of the simulation's core state.
45
45
  *
46
46
  * Covers `tick`, `seed`, and all entity data sorted by `id`. Optional
47
- * subsystem fields (`__sensoryEnv`, `__factionRegistry`, etc.) are excluded —
47
+ * subsystem fields (`runtimeState.sensoryEnv`, `runtimeState.factionRegistry`, etc.) are excluded —
48
48
  * they are host concerns and do not affect simulation determinism.
49
49
  *
50
50
  * Use this as a desync checksum in multiplayer loops:
@@ -0,0 +1,23 @@
1
+ export type AdaptiveFallback = "reduceDistanceChecks" | "skipNonVisible" | "batchMoves";
2
+ export interface AdaptiveTickConfig {
3
+ targetHz: number;
4
+ maxEntities: number;
5
+ fallback: AdaptiveFallback;
6
+ overshootToleranceMs?: number;
7
+ }
8
+ export interface AdaptiveTickInput {
9
+ frameTimeMs: number;
10
+ entityCount: number;
11
+ visibleEntityRatio: number;
12
+ }
13
+ export interface AdaptiveTickPlan {
14
+ fidelityScale: number;
15
+ processDistanceChecks: boolean;
16
+ processVisibilityOnly: boolean;
17
+ batchMovement: boolean;
18
+ }
19
+ export declare function createAdaptiveTick(config?: Partial<AdaptiveTickConfig>): {
20
+ config: AdaptiveTickConfig;
21
+ frameBudgetMs: number;
22
+ plan: (input: AdaptiveTickInput) => AdaptiveTickPlan;
23
+ };
@@ -0,0 +1,50 @@
1
+ const DEFAULT_CONFIG = {
2
+ targetHz: 60,
3
+ maxEntities: 2000,
4
+ fallback: "reduceDistanceChecks",
5
+ overshootToleranceMs: 1,
6
+ };
7
+ export function createAdaptiveTick(config = {}) {
8
+ const resolved = { ...DEFAULT_CONFIG, ...config };
9
+ const frameBudgetMs = 1000 / resolved.targetHz;
10
+ function plan(input) {
11
+ const overshoot = input.frameTimeMs - frameBudgetMs;
12
+ const overload = input.entityCount > resolved.maxEntities;
13
+ const shouldFallback = overload || overshoot > (resolved.overshootToleranceMs ?? 1);
14
+ if (!shouldFallback) {
15
+ return {
16
+ fidelityScale: 1,
17
+ processDistanceChecks: true,
18
+ processVisibilityOnly: false,
19
+ batchMovement: false,
20
+ };
21
+ }
22
+ if (resolved.fallback === "skipNonVisible") {
23
+ return {
24
+ fidelityScale: Math.max(0.3, input.visibleEntityRatio),
25
+ processDistanceChecks: true,
26
+ processVisibilityOnly: true,
27
+ batchMovement: false,
28
+ };
29
+ }
30
+ if (resolved.fallback === "batchMoves") {
31
+ return {
32
+ fidelityScale: 0.6,
33
+ processDistanceChecks: true,
34
+ processVisibilityOnly: false,
35
+ batchMovement: true,
36
+ };
37
+ }
38
+ return {
39
+ fidelityScale: 0.5,
40
+ processDistanceChecks: false,
41
+ processVisibilityOnly: false,
42
+ batchMovement: true,
43
+ };
44
+ }
45
+ return {
46
+ config: resolved,
47
+ frameBudgetMs,
48
+ plan,
49
+ };
50
+ }
@@ -0,0 +1,23 @@
1
+ import type { LoadedPlugin } from "./types.js";
2
+ export interface PluginLoaderOptions {
3
+ onTelemetry?: (pluginId: string, metric: string, payload: unknown) => void;
4
+ artifactsRoot?: string;
5
+ /**
6
+ * Optional evaluator override for hardened runtimes.
7
+ *
8
+ * Node hosts can swap this with a vm2/isolated-vm adapter for stronger isolation,
9
+ * while browser hosts can evaluate plugins in a Web Worker.
10
+ */
11
+ evaluator?: (source: string, filename: string) => unknown;
12
+ }
13
+ /**
14
+ * Load plugin manifest + module from disk.
15
+ *
16
+ * Security notes:
17
+ * - Node default uses node:vm with a minimal CommonJS sandbox.
18
+ * - For untrusted third-party code in production, use the `evaluator` option with
19
+ * vm2 or isolated-vm.
20
+ * - Browser hosts should evaluate plugin code inside a dedicated Web Worker and
21
+ * proxy only the PluginRuntimeApi methods.
22
+ */
23
+ export declare function loadPlugin(path: string, options?: PluginLoaderOptions): LoadedPlugin;
@@ -0,0 +1,86 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import vm from "node:vm";
4
+ function deepFreeze(value) {
5
+ if (value && typeof value === "object") {
6
+ Object.freeze(value);
7
+ for (const child of Object.values(value)) {
8
+ deepFreeze(child);
9
+ }
10
+ }
11
+ return value;
12
+ }
13
+ function hasPermission(manifest, permission) {
14
+ return manifest.permissions.includes(permission);
15
+ }
16
+ function evaluateCommonJsModule(source, filename) {
17
+ const module = { exports: {} };
18
+ const sandbox = {
19
+ module,
20
+ exports: module.exports,
21
+ };
22
+ const script = new vm.Script(source, { filename });
23
+ script.runInNewContext(sandbox, {
24
+ timeout: 100,
25
+ microtaskMode: "afterEvaluate",
26
+ });
27
+ return module.exports;
28
+ }
29
+ function createRuntimeApi(manifest, options) {
30
+ return {
31
+ manifest,
32
+ hasPermission: permission => hasPermission(manifest, permission),
33
+ readWorldState: value => deepFreeze(structuredClone(value)),
34
+ mutateWorld: (worldState, mutator) => {
35
+ if (!hasPermission(manifest, "write:worldState")) {
36
+ throw new Error(`Plugin ${manifest.id} attempted world mutation without write:worldState permission`);
37
+ }
38
+ mutator(worldState);
39
+ },
40
+ emitTelemetry: (metric, payload) => {
41
+ if (!hasPermission(manifest, "write:telemetry")) {
42
+ throw new Error(`Plugin ${manifest.id} attempted telemetry write without write:telemetry permission`);
43
+ }
44
+ options.onTelemetry?.(manifest.id, metric, payload);
45
+ },
46
+ writeArtifact: (path, contents) => {
47
+ if (!hasPermission(manifest, "write:artifacts")) {
48
+ throw new Error(`Plugin ${manifest.id} attempted artifact write without write:artifacts permission`);
49
+ }
50
+ const outputPath = resolve(options.artifactsRoot ?? ".ananke-artifacts", manifest.id, path);
51
+ mkdirSync(dirname(outputPath), { recursive: true });
52
+ writeFileSync(outputPath, contents, "utf8");
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * Load plugin manifest + module from disk.
58
+ *
59
+ * Security notes:
60
+ * - Node default uses node:vm with a minimal CommonJS sandbox.
61
+ * - For untrusted third-party code in production, use the `evaluator` option with
62
+ * vm2 or isolated-vm.
63
+ * - Browser hosts should evaluate plugin code inside a dedicated Web Worker and
64
+ * proxy only the PluginRuntimeApi methods.
65
+ */
66
+ export function loadPlugin(path, options = {}) {
67
+ const pluginRoot = resolve(path);
68
+ const manifest = JSON.parse(readFileSync(resolve(pluginRoot, "plugin.json"), "utf8"));
69
+ const source = readFileSync(resolve(pluginRoot, "index.js"), "utf8");
70
+ const rawModule = (options.evaluator ?? evaluateCommonJsModule)(source, `${manifest.id}/index.js`);
71
+ const pluginModule = rawModule;
72
+ if (!pluginModule || typeof pluginModule.setup !== "function") {
73
+ throw new Error(`Plugin ${manifest.id} must export setup(api)`);
74
+ }
75
+ const hooks = pluginModule.setup(createRuntimeApi(manifest, options));
76
+ return {
77
+ manifest,
78
+ hooks,
79
+ async runHook(hook, context) {
80
+ const handler = hooks[hook];
81
+ if (!handler)
82
+ return;
83
+ await handler(context);
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,11 @@
1
+ export interface RegistryIndexEntry {
2
+ name: string;
3
+ manifestUrl: string;
4
+ moduleUrl: string;
5
+ }
6
+ export interface RegistryInstallerOptions {
7
+ registryIndexUrl?: string;
8
+ pluginsDir?: string;
9
+ fetchImpl?: typeof fetch;
10
+ }
11
+ export declare function installPluginFromRegistry(pluginName: string, options?: RegistryInstallerOptions): Promise<string>;
@@ -0,0 +1,28 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ const DEFAULT_REGISTRY_INDEX_URL = "https://raw.githubusercontent.com/its-not-rocket-science/ananke-plugins/main/index.json";
4
+ export async function installPluginFromRegistry(pluginName, options = {}) {
5
+ const fetchImpl = options.fetchImpl ?? fetch;
6
+ const indexUrl = options.registryIndexUrl ?? DEFAULT_REGISTRY_INDEX_URL;
7
+ const pluginsDir = resolve(options.pluginsDir ?? "plugins");
8
+ const indexResp = await fetchImpl(indexUrl);
9
+ if (!indexResp.ok) {
10
+ throw new Error(`Failed to fetch registry index: ${indexResp.status} ${indexResp.statusText}`);
11
+ }
12
+ const entries = (await indexResp.json());
13
+ const entry = entries.find(candidate => candidate.name === pluginName);
14
+ if (!entry) {
15
+ throw new Error(`Plugin ${pluginName} was not found in ${indexUrl}`);
16
+ }
17
+ const [manifestResp, moduleResp] = await Promise.all([
18
+ fetchImpl(entry.manifestUrl),
19
+ fetchImpl(entry.moduleUrl),
20
+ ]);
21
+ if (!manifestResp.ok || !moduleResp.ok) {
22
+ throw new Error(`Failed to fetch ${pluginName} package files from registry`);
23
+ }
24
+ mkdirSync(resolve(pluginsDir, pluginName), { recursive: true });
25
+ writeFileSync(resolve(pluginsDir, pluginName, "plugin.json"), await manifestResp.text(), "utf8");
26
+ writeFileSync(resolve(pluginsDir, pluginName, "index.js"), await moduleResp.text(), "utf8");
27
+ return resolve(pluginsDir, pluginName);
28
+ }