@jgengine/core 0.18.0 → 0.18.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 (174) hide show
  1. package/CHANGELOG.md +114 -2
  2. package/dist/ai/decisionGraph.d.ts +52 -0
  3. package/dist/ai/decisionGraph.js +82 -0
  4. package/dist/ai/mobBrainGraph.d.ts +3 -0
  5. package/dist/ai/mobBrainGraph.js +25 -0
  6. package/dist/ai/tacticalQueries.d.ts +30 -0
  7. package/dist/ai/tacticalQueries.js +45 -0
  8. package/dist/anim/animGraph.d.ts +144 -0
  9. package/dist/anim/animGraph.js +286 -0
  10. package/dist/anim/ikSolver.d.ts +51 -0
  11. package/dist/anim/ikSolver.js +124 -0
  12. package/dist/anim/locomotionGraph.d.ts +26 -0
  13. package/dist/anim/locomotionGraph.js +33 -0
  14. package/dist/assets/spriteAtlas.d.ts +21 -0
  15. package/dist/assets/spriteAtlas.js +103 -0
  16. package/dist/audio/audioFalloff.d.ts +10 -0
  17. package/dist/economy/currency.d.ts +16 -2
  18. package/dist/economy/currency.js +55 -14
  19. package/dist/economy/wallet.d.ts +5 -4
  20. package/dist/economy/wallet.js +8 -4
  21. package/dist/editor/commandApply.js +1 -0
  22. package/dist/editor/commands.d.ts +4 -1
  23. package/dist/editor/document.d.ts +2 -1
  24. package/dist/editor/document.js +89 -0
  25. package/dist/editor/environment.d.ts +3 -0
  26. package/dist/editor/environment.js +16 -0
  27. package/dist/editor/index.d.ts +4 -2
  28. package/dist/editor/index.js +1 -1
  29. package/dist/editor/types.d.ts +16 -0
  30. package/dist/game/cameraConfig.d.ts +8 -0
  31. package/dist/game/chat.d.ts +22 -1
  32. package/dist/game/chat.js +48 -19
  33. package/dist/game/controlGate.d.ts +6 -0
  34. package/dist/game/controlGate.js +30 -1
  35. package/dist/game/defineGame.d.ts +10 -1
  36. package/dist/game/events.d.ts +8 -1
  37. package/dist/game/pixelPerfect.d.ts +25 -0
  38. package/dist/game/pixelPerfect.js +29 -0
  39. package/dist/game/playableGame.d.ts +97 -3
  40. package/dist/game/systemRuntime.js +6 -1
  41. package/dist/input/actionBindings.d.ts +2 -1
  42. package/dist/input/actionBindings.js +10 -8
  43. package/dist/input/actionContexts.d.ts +21 -0
  44. package/dist/input/actionContexts.js +56 -0
  45. package/dist/input/gamepadModel.d.ts +45 -0
  46. package/dist/input/gamepadModel.js +69 -0
  47. package/dist/meta/changelog.d.ts +1 -1
  48. package/dist/meta/changelog.js +85 -1
  49. package/dist/movement/characterController.d.ts +76 -0
  50. package/dist/movement/characterController.js +216 -0
  51. package/dist/movement/freeFlight.d.ts +119 -0
  52. package/dist/movement/freeFlight.js +294 -0
  53. package/dist/movement/playerMovement.d.ts +10 -2
  54. package/dist/movement/playerMovement.js +218 -4
  55. package/dist/movement/serverStep.d.ts +5 -0
  56. package/dist/movement/serverStep.js +6 -0
  57. package/dist/movement/solidObstacles.d.ts +30 -7
  58. package/dist/movement/solidObstacles.js +27 -4
  59. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  60. package/dist/multiplayer/poseSyncGate.js +9 -1
  61. package/dist/multiplayer/presenceContract.d.ts +6 -0
  62. package/dist/multiplayer/presenceModel.d.ts +2 -0
  63. package/dist/multiplayer/presenceModel.js +11 -0
  64. package/dist/nav/navMesh.d.ts +33 -0
  65. package/dist/nav/navMesh.js +164 -0
  66. package/dist/physics/flightDynamics.d.ts +8 -0
  67. package/dist/physics/kinematicVehicle.d.ts +6 -1
  68. package/dist/physics/physicsBackend.d.ts +191 -0
  69. package/dist/physics/physicsBackend.js +49 -0
  70. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  71. package/dist/physics/physicsBackendConformance.js +117 -0
  72. package/dist/physics/physicsWorldBackend.d.ts +44 -0
  73. package/dist/physics/physicsWorldBackend.js +485 -0
  74. package/dist/physics/worldColliders.d.ts +13 -0
  75. package/dist/physics/worldColliders.js +96 -0
  76. package/dist/random/rng.d.ts +12 -1
  77. package/dist/random/rng.js +20 -3
  78. package/dist/render/environment.d.ts +23 -0
  79. package/dist/render/environment.js +1 -0
  80. package/dist/render/lookPreset.d.ts +11 -5
  81. package/dist/render/lookPreset.js +29 -4
  82. package/dist/render/postProcessing.d.ts +10 -1
  83. package/dist/render/sprite2d.d.ts +38 -0
  84. package/dist/render/sprite2d.js +40 -0
  85. package/dist/runtime/adapter.d.ts +20 -1
  86. package/dist/runtime/adapter.js +8 -1
  87. package/dist/runtime/commandInput.d.ts +15 -0
  88. package/dist/runtime/commandInput.js +13 -0
  89. package/dist/runtime/commandRunner.d.ts +12 -7
  90. package/dist/runtime/commandRunner.js +7 -5
  91. package/dist/runtime/gameContext.js +11 -1
  92. package/dist/runtime/gameContextTypes.d.ts +14 -4
  93. package/dist/runtime/gameRuntime.d.ts +20 -4
  94. package/dist/runtime/gameRuntime.js +17 -4
  95. package/dist/runtime/headlessRunner.d.ts +10 -0
  96. package/dist/runtime/headlessRunner.js +26 -8
  97. package/dist/runtime/hostPolicy.d.ts +3 -2
  98. package/dist/runtime/hostPolicy.js +8 -10
  99. package/dist/runtime/hostedGameRunner.js +34 -6
  100. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  101. package/dist/runtime/hostedWorldSession.js +29 -6
  102. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  103. package/dist/runtime/hostedWorldStore.js +1 -0
  104. package/dist/runtime/inputRecorder.d.ts +30 -0
  105. package/dist/runtime/inputRecorder.js +59 -0
  106. package/dist/runtime/inputSnapshot.d.ts +8 -0
  107. package/dist/runtime/inputSnapshot.js +1 -0
  108. package/dist/runtime/poseInterpolation.d.ts +36 -0
  109. package/dist/runtime/poseInterpolation.js +91 -0
  110. package/dist/runtime/prediction.d.ts +45 -0
  111. package/dist/runtime/prediction.js +62 -0
  112. package/dist/runtime/simContext.d.ts +45 -0
  113. package/dist/runtime/simContext.js +65 -0
  114. package/dist/runtime/simLoop.d.ts +52 -0
  115. package/dist/runtime/simLoop.js +72 -0
  116. package/dist/runtime/simSnapshot.d.ts +32 -0
  117. package/dist/runtime/simSnapshot.js +60 -0
  118. package/dist/runtime/snapshot.d.ts +8 -0
  119. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  120. package/dist/runtime/snapshotBuffer.js +58 -0
  121. package/dist/runtime/transport.d.ts +32 -2
  122. package/dist/runtime/worldChunks.d.ts +2 -0
  123. package/dist/runtime/worldChunks.js +14 -0
  124. package/dist/runtime/worldProjection.d.ts +9 -0
  125. package/dist/runtime/worldProjection.js +17 -1
  126. package/dist/runtime/worldSnapshot.d.ts +6 -0
  127. package/dist/runtime/worldSnapshot.js +3 -0
  128. package/dist/scene/behaviorRuntime.d.ts +19 -1
  129. package/dist/scene/behaviorRuntime.js +55 -4
  130. package/dist/scene/behaviors.d.ts +8 -1
  131. package/dist/scene/builtinSceneKinds.js +10 -0
  132. package/dist/sensor/perception.d.ts +60 -0
  133. package/dist/sensor/perception.js +142 -0
  134. package/dist/settings/graphicsProfile.d.ts +19 -0
  135. package/dist/settings/graphicsProfile.js +36 -0
  136. package/dist/time/accrueSince.d.ts +10 -0
  137. package/dist/time/accrueSince.js +9 -0
  138. package/dist/time/rateWindow.d.ts +16 -0
  139. package/dist/time/rateWindow.js +14 -0
  140. package/dist/time/serverTick.d.ts +21 -3
  141. package/dist/time/serverTick.js +14 -13
  142. package/dist/vfx/particleDirector.d.ts +68 -0
  143. package/dist/vfx/particleDirector.js +81 -0
  144. package/dist/world/buildings.d.ts +12 -0
  145. package/dist/world/buildings.js +43 -3
  146. package/dist/world/cityContent.js +103 -25
  147. package/dist/world/cityKind.d.ts +132 -0
  148. package/dist/world/cityKind.js +315 -15
  149. package/dist/world/features.d.ts +27 -1
  150. package/dist/world/features.js +7 -2
  151. package/dist/world/placement.d.ts +7 -0
  152. package/dist/world/placement.js +2 -0
  153. package/dist/world/placementController.d.ts +1 -1
  154. package/dist/world/placementController.js +7 -0
  155. package/dist/world/raceCircuit.d.ts +168 -0
  156. package/dist/world/raceCircuit.js +797 -0
  157. package/dist/world/terrain.d.ts +5 -1
  158. package/dist/world/terrain.js +5 -0
  159. package/dist/world/territory.d.ts +97 -0
  160. package/dist/world/territory.js +182 -0
  161. package/dist/world.d.ts +5 -3
  162. package/dist/world.js +4 -2
  163. package/package.json +1 -1
  164. package/skills/ce-handoff/SKILL.md +1 -1
  165. package/skills/game-design/SKILL.md +11 -1
  166. package/skills/jgengine/SKILL.md +2 -0
  167. package/skills/jgengine/api.md +94 -19
  168. package/skills/jgengine/capabilities.md +28 -0
  169. package/skills/jgengine-gameplay/SKILL.md +10 -0
  170. package/skills/jgengine-gameplay/api.md +40 -18
  171. package/skills/jgengine-gameplay/capabilities.md +12 -0
  172. package/skills/jgengine-world/SKILL.md +6 -0
  173. package/skills/jgengine-world/api.md +312 -12
  174. package/skills/jgengine-world/capabilities.md +102 -0
@@ -13,6 +13,9 @@ export function composeWorldSnapshot(modules, viewer) {
13
13
  const snapshot = {};
14
14
  for (const module of modules) {
15
15
  const value = raw[module.key];
16
+ const interval = viewer?.tick === undefined ? 1 : Math.max(1, Math.floor(module.priority?.(value, viewer) ?? 1));
17
+ if (viewer?.tick !== undefined && interval > 1 && viewer.tick % interval !== 0)
18
+ continue;
16
19
  snapshot[module.key] =
17
20
  module.project !== undefined ? module.project(value, viewer, raw) : value;
18
21
  }
@@ -1,8 +1,22 @@
1
+ import type { DecisionAction, DecisionGraphRuntime } from "../ai/decisionGraph.js";
2
+ import { type InterestGateState } from "../ai/interestScheduler.js";
1
3
  import { type PathFollowState, type PathProgress, type Waypoint } from "../nav/pathFollow.js";
2
4
  import type { GameContext } from "../runtime/gameContext.js";
3
5
  /** Whether a behavior instance advances and writes pose (`active`), is temporarily suspended retaining
4
6
  * state (`paused`), or is held off until re-enabled (`disabled`). */
5
7
  export type BehaviorStatus = "active" | "paused" | "disabled";
8
+ /** Context supplied to a registered decision graph action. */
9
+ export interface BehaviorActionContext {
10
+ ctx: GameContext;
11
+ entityId: string;
12
+ dt: number;
13
+ }
14
+ /** Callback used by a registered decision graph behavior action. */
15
+ export type BehaviorAction = DecisionAction<BehaviorActionContext>;
16
+ /** Register the named actions used by decision graph behavior descriptors.
17
+ * @capability behavior-actions register named callbacks for decision graph behavior descriptors
18
+ */
19
+ export declare function registerBehaviorActions(id: string, actions: Record<string, BehaviorAction>): () => void;
6
20
  /**
7
21
  * Catch-up policy applied when a paused instance resumes. `freeze` (default, deterministic) discards
8
22
  * the time spent paused; `advance` silently fast-forwards the instance by the paused duration without
@@ -18,11 +32,15 @@ export type BehaviorSnapshot = {
18
32
  readonly kind: "wander";
19
33
  readonly origin: Waypoint;
20
34
  readonly target: Waypoint | null;
35
+ } | {
36
+ readonly kind: "decisionGraph";
37
+ readonly graph: ReturnType<DecisionGraphRuntime["snapshot"]>;
38
+ readonly gate: InterestGateState;
21
39
  };
22
40
  /** Inspection readout for editor/debug tooling, from {@link BehaviorControl.inspect}/{@link BehaviorControl.list}. */
23
41
  export interface BehaviorInspection {
24
42
  id: string;
25
- kind: "patrol" | "wander";
43
+ kind: "patrol" | "wander" | "decisionGraph";
26
44
  status: BehaviorStatus;
27
45
  reason: string | null;
28
46
  }
@@ -1,4 +1,5 @@
1
- import { seededRng } from "../random/rng.js";
1
+ import { createDecisionGraphRuntime } from "../ai/decisionGraph.js";
2
+ import { advanceInterestGate, createInterestGateState } from "../ai/interestScheduler.js";
2
3
  import { advancePathFollow, createPathFollow, pathFollowSeek, } from "../nav/pathFollow.js";
3
4
  import { resolveWalkerStep } from "../movement/solidObstacles.js";
4
5
  import { perContext } from "../runtime/perContext.js";
@@ -6,12 +7,27 @@ const DEFAULT_WANDER_SPEED = 1.5;
6
7
  const WANDER_ARRIVAL = 0.6;
7
8
  /** Below this share of its intended step a wanderer counts as blocked and re-rolls its target. */
8
9
  const WANDER_BLOCKED_FRACTION = 0.25;
10
+ const behaviorActions = new Map();
11
+ /** Register the named actions used by decision graph behavior descriptors.
12
+ * @capability behavior-actions register named callbacks for decision graph behavior descriptors
13
+ */
14
+ export function registerBehaviorActions(id, actions) {
15
+ behaviorActions.set(id, actions);
16
+ return () => {
17
+ if (behaviorActions.get(id) === actions)
18
+ behaviorActions.delete(id);
19
+ };
20
+ }
21
+ const DECISION_GRAPH_CADENCE = { wakeRadius: Number.POSITIVE_INFINITY, activeInterval: 0 };
9
22
  function patrolOf(entity) {
10
23
  return entity.behaviors.find((b) => b.kind === "patrol") ?? null;
11
24
  }
12
25
  function wanderOf(entity) {
13
26
  return entity.behaviors.find((b) => b.kind === "wander") ?? null;
14
27
  }
28
+ function decisionGraphOf(entity) {
29
+ return entity.behaviors.find((b) => b.kind === "decisionGraph") ?? null;
30
+ }
15
31
  const runtimeOf = perContext((ctx) => {
16
32
  const nav = new Map();
17
33
  const refresh = () => {
@@ -41,7 +57,23 @@ const runtimeOf = perContext((ctx) => {
41
57
  radius: wander.radius,
42
58
  origin: entity.position,
43
59
  target: null,
44
- roll: seededRng(`wander:${entity.id}`),
60
+ roll: () => ctx.rng(),
61
+ status: "active",
62
+ reason: null,
63
+ pausedElapsed: 0,
64
+ });
65
+ continue;
66
+ }
67
+ const decisionGraph = decisionGraphOf(entity);
68
+ if (decisionGraph !== null) {
69
+ const actions = behaviorActions.get(decisionGraph.actions);
70
+ if (actions === undefined)
71
+ continue;
72
+ nav.set(entity.id, {
73
+ kind: "decisionGraph",
74
+ behavior: decisionGraph,
75
+ runtime: createDecisionGraphRuntime(decisionGraph.graph, actions),
76
+ gate: createInterestGateState(DECISION_GRAPH_CADENCE, ctx.rng()),
45
77
  status: "active",
46
78
  reason: null,
47
79
  pausedElapsed: 0,
@@ -135,9 +167,21 @@ export function advanceBehaviors(ctx, dt) {
135
167
  entry.state = advancePathFollow(entry.config, entry.state, dt);
136
168
  posePatrol(ctx, id, entry, dt);
137
169
  }
138
- else {
170
+ else if (entry.kind === "wander") {
139
171
  stepWander(ctx, id, entry, dt);
140
172
  }
173
+ else {
174
+ const entity = ctx.scene.entity.get(id);
175
+ if (entity === null)
176
+ continue;
177
+ const step = advanceInterestGate(entry.gate, DECISION_GRAPH_CADENCE, dt, { proximity: 0 });
178
+ if (!step.active)
179
+ continue;
180
+ const blackboard = {};
181
+ const status = entry.runtime.tick({ ctx, entityId: id, dt }, blackboard, dt);
182
+ if (status === "done" || status === "failed")
183
+ entry.runtime.restore({ runningPath: null });
184
+ }
141
185
  }
142
186
  }
143
187
  /**
@@ -206,7 +250,9 @@ export function behaviorControl(ctx) {
206
250
  return null;
207
251
  if (entry.kind === "patrol")
208
252
  return { kind: "patrol", state: { ...entry.state } };
209
- return { kind: "wander", origin: [...entry.origin], target: entry.target === null ? null : [...entry.target] };
253
+ if (entry.kind === "wander")
254
+ return { kind: "wander", origin: [...entry.origin], target: entry.target === null ? null : [...entry.target] };
255
+ return { kind: "decisionGraph", graph: entry.runtime.snapshot(), gate: { ...entry.gate } };
210
256
  },
211
257
  restore: (id, snapshot) => {
212
258
  const entry = nav.get(id);
@@ -216,6 +262,11 @@ export function behaviorControl(ctx) {
216
262
  entry.state = { ...snapshot.state };
217
263
  return true;
218
264
  }
265
+ if (entry.kind === "decisionGraph" && snapshot.kind === "decisionGraph") {
266
+ entry.runtime.restore(snapshot.graph);
267
+ entry.gate = { ...snapshot.gate };
268
+ return true;
269
+ }
219
270
  if (entry.kind === "wander" && snapshot.kind === "wander") {
220
271
  entry.origin = [...snapshot.origin];
221
272
  entry.target = snapshot.target === null ? null : [...snapshot.target];
@@ -1,5 +1,6 @@
1
1
  import { type ProximityPrompt } from "../interaction/proximityPrompt.js";
2
2
  import type { PathProgress, Waypoint } from "../nav/pathFollow.js";
3
+ import type { DecisionGraph } from "../ai/decisionGraph.js";
3
4
  export interface WanderBehavior {
4
5
  kind: "wander";
5
6
  radius: number;
@@ -27,7 +28,13 @@ export interface PromptableBehavior {
27
28
  export interface PlayerBehavior {
28
29
  kind: "player";
29
30
  }
30
- export type BehaviorDescriptor = WanderBehavior | PatrolBehavior | PromptableBehavior | PlayerBehavior;
31
+ /** Decision graph behavior descriptor using a named registered action set. */
32
+ export interface DecisionGraphBehavior {
33
+ kind: "decisionGraph";
34
+ graph: DecisionGraph;
35
+ actions: string;
36
+ }
37
+ export type BehaviorDescriptor = WanderBehavior | PatrolBehavior | PromptableBehavior | PlayerBehavior | DecisionGraphBehavior;
31
38
  export declare function wander({ radius }: {
32
39
  radius: number;
33
40
  }): WanderBehavior;
@@ -37,6 +37,15 @@ export const SCATTER_SCHEMA = {
37
37
  { type: "seed", key: "seed", label: "seed", default: "" },
38
38
  ],
39
39
  };
40
+ const LIGHT_SCHEMA = {
41
+ fields: [
42
+ { type: "select", key: "type", label: "type", options: [{ value: "point", label: "Point" }, { value: "spot", label: "Spot" }], default: "point" },
43
+ { type: "color", key: "color", label: "color", default: "#ffd6a0" },
44
+ { type: "number", key: "intensity", label: "intensity", min: 0, step: 0.1, default: 2 },
45
+ { type: "number", key: "range", label: "range", min: 0, step: 0.5, default: 12 },
46
+ { type: "bool", key: "castShadow", label: "cast shadow", default: false },
47
+ ],
48
+ };
40
49
  let registered = false;
41
50
  /** Idempotently registers the engine's built-in environment scene kinds + generators. @internal */
42
51
  export function registerBuiltinSceneKinds() {
@@ -65,6 +74,7 @@ export function registerBuiltinSceneKinds() {
65
74
  return describeScatterCoverage(densityCoverage("scatter", area, density));
66
75
  },
67
76
  });
77
+ registerSceneKind({ kind: "light", target: "marker", label: "Light", addCategory: "Environment", accent: "#fbbf24", schema: LIGHT_SCHEMA });
68
78
  registerWaterKind();
69
79
  registerGrassKind();
70
80
  registerSoilKind();
@@ -0,0 +1,60 @@
1
+ /** A world position used by perception, in `[x, y, z]` order. */
2
+ export type PerceptionPosition = readonly [number, number, number];
3
+ /** The kinds of stimulus the perception service can retain. */
4
+ export type PerceptionStimulusKind = "sight" | "sound" | "damage";
5
+ /** An event that can seed an observer's memory. */
6
+ export interface PerceptionStimulus {
7
+ kind: PerceptionStimulusKind;
8
+ sourceId: string;
9
+ position: PerceptionPosition;
10
+ loudness?: number;
11
+ at: number;
12
+ }
13
+ /** The observer pose used for a sight check. */
14
+ export interface PerceptionObserver {
15
+ id: string;
16
+ position: PerceptionPosition;
17
+ yaw: number;
18
+ }
19
+ /** A candidate target considered by a sight check. */
20
+ export interface PerceptionCandidate {
21
+ id: string;
22
+ position: PerceptionPosition;
23
+ }
24
+ /** A remembered target and its linearly decaying confidence. */
25
+ export interface PerceptionMemory {
26
+ targetId: string;
27
+ lastSeenAt: number;
28
+ lastKnownPos: PerceptionPosition;
29
+ confidence: number;
30
+ }
31
+ /** Configuration for sight, hearing, memory decay, and optional occlusion. */
32
+ export interface PerceptionConfig {
33
+ sightRange: number;
34
+ sightConeDeg: number;
35
+ hearingRange: number;
36
+ memorySeconds: number;
37
+ occluded?: (from: PerceptionPosition, to: PerceptionPosition) => boolean;
38
+ }
39
+ /** Serializable state for a perception service. */
40
+ export interface PerceptionSnapshot {
41
+ memories: Array<{
42
+ observerId: string;
43
+ memory: PerceptionMemory;
44
+ }>;
45
+ stimuli: PerceptionStimulus[];
46
+ nowMs: number;
47
+ }
48
+ /** Stateful perception API with retuning and save/restore support. */
49
+ export interface PerceptionService {
50
+ pushStimulus(stimulus: PerceptionStimulus): void;
51
+ observe(observer: PerceptionObserver, candidates: readonly PerceptionCandidate[], nowMs: number): void;
52
+ memory(observerId: string): PerceptionMemory[];
53
+ retune(patch: Partial<PerceptionConfig>): void;
54
+ snapshot(): PerceptionSnapshot;
55
+ restore(snapshot: PerceptionSnapshot): void;
56
+ }
57
+ /** Creates a deterministic sight, sound, and damage perception service with expiring target memory.
58
+ * @capability perception-service retain sight, sound, and damage observations with range, cone, occlusion, and decay policy
59
+ */
60
+ export declare function createPerception(initial: PerceptionConfig): PerceptionService;
@@ -0,0 +1,142 @@
1
+ function distance(a, b) {
2
+ return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
3
+ }
4
+ function confidenceAt(memory, nowMs, memoryMs) {
5
+ return memory.confidence * Math.max(0, 1 - Math.max(0, nowMs - memory.lastSeenAt) / memoryMs);
6
+ }
7
+ function inSight(observer, target, config) {
8
+ const dx = target[0] - observer.position[0];
9
+ const dz = target[2] - observer.position[2];
10
+ const range = Math.hypot(dx, dz);
11
+ if (range > config.sightRange)
12
+ return false;
13
+ if (range < 1e-9)
14
+ return true;
15
+ let delta = Math.atan2(dx, dz) - observer.yaw;
16
+ while (delta > Math.PI)
17
+ delta -= Math.PI * 2;
18
+ while (delta < -Math.PI)
19
+ delta += Math.PI * 2;
20
+ return Math.abs(delta) <= (config.sightConeDeg * Math.PI) / 360;
21
+ }
22
+ function validate(config) {
23
+ if (!Number.isFinite(config.sightRange) || config.sightRange < 0)
24
+ throw new Error("sightRange must be non-negative.");
25
+ if (!Number.isFinite(config.sightConeDeg) || config.sightConeDeg < 0 || config.sightConeDeg > 360)
26
+ throw new Error("sightConeDeg must be between 0 and 360.");
27
+ if (!Number.isFinite(config.hearingRange) || config.hearingRange < 0)
28
+ throw new Error("hearingRange must be non-negative.");
29
+ if (!Number.isFinite(config.memorySeconds) || config.memorySeconds <= 0)
30
+ throw new Error("memorySeconds must be positive.");
31
+ }
32
+ /** Creates a deterministic sight, sound, and damage perception service with expiring target memory.
33
+ * @capability perception-service retain sight, sound, and damage observations with range, cone, occlusion, and decay policy
34
+ */
35
+ export function createPerception(initial) {
36
+ validate(initial);
37
+ let config = { ...initial };
38
+ let nowMs = 0;
39
+ const stimuli = [];
40
+ const memories = new Map();
41
+ function remember(observerId, targetId, position, at, confidence) {
42
+ if (confidence <= 0)
43
+ return;
44
+ let observerMemories = memories.get(observerId);
45
+ if (observerMemories === undefined) {
46
+ observerMemories = new Map();
47
+ memories.set(observerId, observerMemories);
48
+ }
49
+ const previous = observerMemories.get(targetId);
50
+ if (previous === undefined || at >= previous.lastSeenAt) {
51
+ observerMemories.set(targetId, { targetId, lastSeenAt: at, lastKnownPos: [...position], confidence });
52
+ }
53
+ }
54
+ function prune() {
55
+ const memoryMs = config.memorySeconds * 1000;
56
+ for (const [observerId, observerMemories] of memories) {
57
+ for (const [targetId, memory] of observerMemories) {
58
+ if (confidenceAt(memory, nowMs, memoryMs) <= 0)
59
+ observerMemories.delete(targetId);
60
+ }
61
+ if (observerMemories.size === 0)
62
+ memories.delete(observerId);
63
+ }
64
+ const cutoff = nowMs - memoryMs;
65
+ while (stimuli.length > 0 && stimuli[0].at < cutoff)
66
+ stimuli.shift();
67
+ }
68
+ return {
69
+ pushStimulus(stimulus) {
70
+ if (!Number.isFinite(stimulus.at))
71
+ throw new Error("stimulus.at must be finite.");
72
+ if (stimulus.kind === "sound" && stimulus.loudness !== undefined && (!Number.isFinite(stimulus.loudness) || stimulus.loudness < 0)) {
73
+ throw new Error("stimulus.loudness must be non-negative.");
74
+ }
75
+ stimuli.push({ ...stimulus, position: [...stimulus.position] });
76
+ stimuli.sort((a, b) => a.at - b.at);
77
+ nowMs = Math.max(nowMs, stimulus.at);
78
+ prune();
79
+ },
80
+ observe(observer, candidates, observedAt) {
81
+ if (!Number.isFinite(observedAt))
82
+ throw new Error("nowMs must be finite.");
83
+ nowMs = observedAt;
84
+ for (const candidate of candidates) {
85
+ if (!inSight(observer, candidate.position, config))
86
+ continue;
87
+ if (config.occluded?.(observer.position, candidate.position))
88
+ continue;
89
+ remember(observer.id, candidate.id, candidate.position, nowMs, 1);
90
+ }
91
+ for (const stimulus of stimuli) {
92
+ if (stimulus.at > nowMs || stimulus.sourceId === observer.id)
93
+ continue;
94
+ const range = distance(observer.position, stimulus.position);
95
+ if (stimulus.kind === "sound") {
96
+ if (range > config.hearingRange)
97
+ continue;
98
+ const falloff = 1 - range / Math.max(config.hearingRange, 1e-9);
99
+ remember(observer.id, stimulus.sourceId, stimulus.position, stimulus.at, falloff * (stimulus.loudness ?? 1));
100
+ }
101
+ else if (stimulus.kind === "damage") {
102
+ remember(observer.id, stimulus.sourceId, stimulus.position, stimulus.at, 1);
103
+ }
104
+ }
105
+ prune();
106
+ },
107
+ memory(observerId) {
108
+ const observerMemories = memories.get(observerId);
109
+ if (observerMemories === undefined)
110
+ return [];
111
+ const memoryMs = config.memorySeconds * 1000;
112
+ const result = [];
113
+ for (const memory of observerMemories.values()) {
114
+ const confidence = confidenceAt(memory, nowMs, memoryMs);
115
+ if (confidence > 0)
116
+ result.push({ ...memory, lastKnownPos: [...memory.lastKnownPos], confidence });
117
+ }
118
+ return result;
119
+ },
120
+ retune(patch) {
121
+ config = { ...config, ...patch };
122
+ validate(config);
123
+ prune();
124
+ },
125
+ snapshot() {
126
+ const result = { memories: [], stimuli: stimuli.map((stimulus) => ({ ...stimulus, position: [...stimulus.position] })), nowMs };
127
+ for (const [observerId, observerMemories] of memories)
128
+ for (const memory of observerMemories.values())
129
+ result.memories.push({ observerId, memory: { ...memory, lastKnownPos: [...memory.lastKnownPos] } });
130
+ return result;
131
+ },
132
+ restore(snapshot) {
133
+ memories.clear();
134
+ stimuli.length = 0;
135
+ nowMs = snapshot.nowMs;
136
+ stimuli.push(...snapshot.stimuli.map((stimulus) => ({ ...stimulus, position: [...stimulus.position] })));
137
+ for (const entry of snapshot.memories)
138
+ remember(entry.observerId, entry.memory.targetId, entry.memory.lastKnownPos, entry.memory.lastSeenAt, entry.memory.confidence);
139
+ prune();
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,19 @@
1
+ import type { GraphicsQuality } from "./settingsModel.js";
2
+ /** Serializable renderer budget selected by the player's graphics tier. */
3
+ export interface GraphicsProfile {
4
+ renderScale: number;
5
+ shadowMapSize: 512 | 1024 | 2048 | 4096;
6
+ cascades: 1 | 2 | 3 | 4;
7
+ drawDistance: number;
8
+ particleCap: number;
9
+ postStages: {
10
+ ao: boolean;
11
+ bloom: boolean;
12
+ dof: boolean;
13
+ smaa: boolean;
14
+ };
15
+ }
16
+ /** Conservative defaults that preserve the existing high-quality shell behavior. */
17
+ export declare const DEFAULT_GRAPHICS_PROFILES: Record<GraphicsQuality, GraphicsProfile>;
18
+ /** Resolve one tier and apply game-authored field and post-stage overrides. */
19
+ export declare function resolveGraphicsProfile(quality: GraphicsQuality, overrides?: Partial<GraphicsProfile>): GraphicsProfile;
@@ -0,0 +1,36 @@
1
+ /** Conservative defaults that preserve the existing high-quality shell behavior. */
2
+ export const DEFAULT_GRAPHICS_PROFILES = {
3
+ low: {
4
+ renderScale: 1,
5
+ shadowMapSize: 512,
6
+ cascades: 1,
7
+ drawDistance: 120,
8
+ particleCap: 128,
9
+ postStages: { ao: false, bloom: true, dof: false, smaa: true },
10
+ },
11
+ medium: {
12
+ renderScale: 1.5,
13
+ shadowMapSize: 1024,
14
+ cascades: 2,
15
+ drawDistance: 240,
16
+ particleCap: 256,
17
+ postStages: { ao: true, bloom: true, dof: true, smaa: true },
18
+ },
19
+ high: {
20
+ renderScale: 2,
21
+ shadowMapSize: 2048,
22
+ cascades: 4,
23
+ drawDistance: 400,
24
+ particleCap: 512,
25
+ postStages: { ao: true, bloom: true, dof: true, smaa: true },
26
+ },
27
+ };
28
+ /** Resolve one tier and apply game-authored field and post-stage overrides. */
29
+ export function resolveGraphicsProfile(quality, overrides) {
30
+ const base = DEFAULT_GRAPHICS_PROFILES[quality];
31
+ return {
32
+ ...base,
33
+ ...overrides,
34
+ postStages: { ...base.postStages, ...overrides?.postStages },
35
+ };
36
+ }
@@ -0,0 +1,10 @@
1
+ /** Elapsed duration and the next anchor to persist with accrued effects. */
2
+ export interface Accrual {
3
+ elapsedMs: number;
4
+ elapsedSeconds: number;
5
+ anchorMs: number;
6
+ }
7
+ /** Elapsed time since a persisted anchor, capped once; persist anchorMs with the accrued result. */
8
+ export declare function accrueSince(anchorMs: number, nowMs: number, options?: {
9
+ capMs?: number;
10
+ }): Accrual;
@@ -0,0 +1,9 @@
1
+ /** Elapsed time since a persisted anchor, capped once; persist anchorMs with the accrued result. */
2
+ export function accrueSince(anchorMs, nowMs, options = {}) {
3
+ const cap = options.capMs ?? Infinity;
4
+ if (!Number.isFinite(anchorMs) || !Number.isFinite(nowMs) || Number.isNaN(cap) || cap < 0) {
5
+ throw new RangeError("Accrual requires finite anchors and a nonnegative cap");
6
+ }
7
+ const elapsedMs = Math.min(cap, Math.max(0, nowMs - anchorMs));
8
+ return { elapsedMs, elapsedSeconds: elapsedMs / 1000, anchorMs: Math.max(anchorMs, nowMs) };
9
+ }
@@ -0,0 +1,16 @@
1
+ /** Maximum accepted operations in a sliding duration. */
2
+ export interface RateWindowPolicy {
3
+ windowMs: number;
4
+ max: number;
5
+ }
6
+ /** Admission result, next stored timestamps, and wait before retrying. */
7
+ export interface RateWindowDecision {
8
+ allowed: boolean;
9
+ timestamps: number[];
10
+ retryAfterMs: number;
11
+ }
12
+ /**
13
+ * A sliding rate window; persist timestamps only alongside the accepted operation.
14
+ * @capability rate-window-policy Evaluate deterministic request windows with retry timing.
15
+ */
16
+ export declare function decideRateWindow(timestamps: readonly number[], nowMs: number, policy: RateWindowPolicy): RateWindowDecision;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A sliding rate window; persist timestamps only alongside the accepted operation.
3
+ * @capability rate-window-policy Evaluate deterministic request windows with retry timing.
4
+ */
5
+ export function decideRateWindow(timestamps, nowMs, policy) {
6
+ if (!Number.isFinite(nowMs) || !Number.isFinite(policy.windowMs) || policy.windowMs <= 0 ||
7
+ !Number.isSafeInteger(policy.max) || policy.max < 1)
8
+ throw new RangeError("Invalid rate window policy");
9
+ const active = timestamps.filter((at) => Number.isFinite(at) && at > nowMs - policy.windowMs).sort((a, b) => a - b);
10
+ if (active.length >= policy.max) {
11
+ return { allowed: false, timestamps: active, retryAfterMs: Math.max(0, active[active.length - policy.max] + policy.windowMs - nowMs) };
12
+ }
13
+ return { allowed: true, timestamps: [...active, nowMs], retryAfterMs: 0 };
14
+ }
@@ -2,11 +2,26 @@ export interface TickSystemDefinition<TSystemId extends string = string> {
2
2
  id: TSystemId;
3
3
  /** Minimum real time between runs; the heartbeat fires the system on the first tick at or past it. */
4
4
  intervalMs: number;
5
+ scope?: "onlinePlayers" | "allServers";
6
+ batchSize?: number;
5
7
  }
6
8
  /** Last-run timestamp per system id. */
7
9
  export type TickAnchors = Record<string, number>;
10
+ /** One due system and how many times this heartbeat owes it. */
11
+ export interface TickSystemRun<TSystemId extends string = string> {
12
+ id: TSystemId;
13
+ /** Times to run the system now; above 1 when wall time stalled past several intervals. */
14
+ runs: number;
15
+ scope?: "onlinePlayers" | "allServers";
16
+ batchSize?: number;
17
+ }
8
18
  export interface ServerTickPlan<TSystemId extends string = string> {
9
- due: TSystemId[];
19
+ /**
20
+ * Due systems, at most one entry per id. `runs` carries catch-up multiplicity — the anchors were
21
+ * advanced by that many intervals, so a host that runs the handler once per entry silently drops
22
+ * the rest.
23
+ */
24
+ due: TickSystemRun<TSystemId>[];
10
25
  anchors: TickAnchors;
11
26
  }
12
27
  export type PlanServerTickOptions = {
@@ -15,8 +30,11 @@ export type PlanServerTickOptions = {
15
30
  };
16
31
  /**
17
32
  * Decides which systems a heartbeat should run. A system with no anchor runs immediately.
18
- * When wall time stalls past multiple intervals, the system id is repeated (bounded by
19
- * maxCatchUp). Excess lag past the bound resyncs the anchor to `now`.
33
+ * When wall time stalls past multiple intervals the system's `runs` climbs (bounded by
34
+ * maxCatchUp), and the returned anchor moves by that many intervals — so a host that ignores
35
+ * `runs` loses the catch-up work. Excess lag past the bound resyncs the anchor to `now`.
20
36
  * Returned anchors carry only systems present in the pipeline, so removed systems clean up.
21
37
  */
22
38
  export declare function planServerTick<TSystemId extends string>(systems: readonly TickSystemDefinition<TSystemId>[], anchors: TickAnchors, now: number, options?: PlanServerTickOptions): ServerTickPlan<TSystemId>;
39
+ /** Times this plan owes `id`, or 0 when it is not due. Saves a host a lookup over {@link ServerTickPlan.due}. */
40
+ export declare function tickRunCount<TSystemId extends string>(plan: ServerTickPlan<TSystemId>, id: TSystemId): number;
@@ -1,8 +1,9 @@
1
1
  const DEFAULT_MAX_CATCH_UP = 3;
2
2
  /**
3
3
  * Decides which systems a heartbeat should run. A system with no anchor runs immediately.
4
- * When wall time stalls past multiple intervals, the system id is repeated (bounded by
5
- * maxCatchUp). Excess lag past the bound resyncs the anchor to `now`.
4
+ * When wall time stalls past multiple intervals the system's `runs` climbs (bounded by
5
+ * maxCatchUp), and the returned anchor moves by that many intervals — so a host that ignores
6
+ * `runs` loses the catch-up work. Excess lag past the bound resyncs the anchor to `now`.
6
7
  * Returned anchors carry only systems present in the pipeline, so removed systems clean up.
7
8
  */
8
9
  export function planServerTick(systems, anchors, now, options) {
@@ -10,14 +11,12 @@ export function planServerTick(systems, anchors, now, options) {
10
11
  const due = [];
11
12
  const nextAnchors = {};
12
13
  for (const system of systems) {
14
+ if (system.batchSize !== undefined && (!Number.isSafeInteger(system.batchSize) || system.batchSize < 1 || system.batchSize > 1000))
15
+ throw new RangeError("batchSize must be an integer from 1 to 1000");
16
+ const dispatch = { ...(system.scope === undefined ? {} : { scope: system.scope }), ...(system.batchSize === undefined ? {} : { batchSize: system.batchSize }) };
13
17
  const lastRunAt = anchors[system.id];
14
- if (lastRunAt === undefined) {
15
- due.push(system.id);
16
- nextAnchors[system.id] = now;
17
- continue;
18
- }
19
- if (system.intervalMs <= 0) {
20
- due.push(system.id);
18
+ if (lastRunAt === undefined || system.intervalMs <= 0) {
19
+ due.push({ id: system.id, runs: 1, ...dispatch });
21
20
  nextAnchors[system.id] = now;
22
21
  continue;
23
22
  }
@@ -28,14 +27,16 @@ export function planServerTick(systems, anchors, now, options) {
28
27
  }
29
28
  const missed = Math.floor(elapsed / system.intervalMs);
30
29
  if (missed > maxCatchUp) {
31
- for (let i = 0; i < maxCatchUp; i += 1)
32
- due.push(system.id);
30
+ due.push({ id: system.id, runs: maxCatchUp, ...dispatch });
33
31
  nextAnchors[system.id] = now;
34
32
  continue;
35
33
  }
36
- for (let i = 0; i < missed; i += 1)
37
- due.push(system.id);
34
+ due.push({ id: system.id, runs: missed, ...dispatch });
38
35
  nextAnchors[system.id] = lastRunAt + missed * system.intervalMs;
39
36
  }
40
37
  return { due, anchors: nextAnchors };
41
38
  }
39
+ /** Times this plan owes `id`, or 0 when it is not due. Saves a host a lookup over {@link ServerTickPlan.due}. */
40
+ export function tickRunCount(plan, id) {
41
+ return plan.due.find((entry) => entry.id === id)?.runs ?? 0;
42
+ }