@jgengine/core 0.17.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 +130 -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 +103 -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 +44 -3
  90. package/dist/runtime/commandRunner.js +41 -3
  91. package/dist/runtime/gameContext.js +11 -1
  92. package/dist/runtime/gameContextTypes.d.ts +14 -4
  93. package/dist/runtime/gameRuntime.d.ts +33 -4
  94. package/dist/runtime/gameRuntime.js +26 -5
  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 +38 -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 +98 -22
  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
@@ -1,10 +1,38 @@
1
1
  import type { ChatSync } from "../multiplayer/chatContract.js";
2
2
  import type { PlayerPose } from "../multiplayer/poseSyncGate.js";
3
3
  import type { PresenceTransport } from "../multiplayer/presenceContract.js";
4
+ import type { WorldDiff } from "./worldReplication.js";
5
+ import type { WorldSnapshot } from "./worldSnapshot.js";
6
+ /** A revision-stamped world baseline or compact diff sent to a server-authoritative client. */
7
+ export type WorldSyncFrame = {
8
+ kind: "baseline";
9
+ revision: number;
10
+ snapshot: WorldSnapshot;
11
+ } | {
12
+ kind: "diff";
13
+ revision: number;
14
+ diff: WorldDiff;
15
+ };
16
+ /** A short-lived credential allowing a disconnected client to reclaim one server membership. */
17
+ export type ResumeTicket = {
18
+ userId: string;
19
+ serverId: string;
20
+ token: string;
21
+ };
4
22
  export type JoinServerResult = {
5
23
  serverId: string;
6
24
  isNew: boolean;
25
+ resumeTicket?: ResumeTicket;
26
+ };
27
+ /** Join failures are returned to the caller so the triggering surface can offer retry. */
28
+ export type JoinServerOutcome = (JoinServerResult & {
29
+ ok: true;
30
+ }) | {
31
+ ok: false;
32
+ reason: "full" | "closed" | "unauthorized";
7
33
  };
34
+ /** Connection role used for authoritative world access; spectators are read-only. */
35
+ export type MultiplayerRole = "player" | "spectator";
8
36
  export type RunCommandArgs = {
9
37
  serverId: string;
10
38
  command: string;
@@ -21,7 +49,7 @@ export type GameRuntimeServerView = {
21
49
  gameId: string;
22
50
  revision: number;
23
51
  memberUserIds: string[];
24
- serverState: unknown;
52
+ serverState: unknown | WorldSyncFrame;
25
53
  updatedAt: number;
26
54
  };
27
55
  export type GameRuntimePlayerView = {
@@ -38,7 +66,8 @@ export type GameRuntimeTransport = {
38
66
  joinServer: (args: {
39
67
  gameId: string;
40
68
  serverId?: string;
41
- }) => Promise<JoinServerResult>;
69
+ role?: MultiplayerRole;
70
+ }) => Promise<JoinServerOutcome>;
42
71
  leaveServer: (args: {
43
72
  serverId: string;
44
73
  }) => Promise<void>;
@@ -54,6 +83,7 @@ export type GameRuntimeFeeds = {
54
83
  serverId: string;
55
84
  action: string;
56
85
  }, onChange: (view: GameRuntimeFeedView) => void) => FeedUnsubscribe;
86
+ requestServerBaseline?: (serverId: string) => void;
57
87
  };
58
88
  export type GameBackend<TPresenceRow = unknown, TPresenceLocation = unknown, TGameId extends string = string> = {
59
89
  transport: GameRuntimeTransport;
@@ -62,6 +92,12 @@ export type GameBackend<TPresenceRow = unknown, TPresenceLocation = unknown, TGa
62
92
  };
63
93
  export type PresencePoseRow = {
64
94
  userId: string;
95
+ /** Set when the host tracks presence per session, so one user can hold two rows. */
96
+ sessionId?: string;
97
+ /** Actor class, e.g. `"player"` / `"agent"` — a host may clamp each differently. */
98
+ kind?: string;
99
+ /** Display name carried on the row, so a nameplate needs no second lookup. */
100
+ label?: string;
65
101
  position: {
66
102
  x: number;
67
103
  y: number;
@@ -37,3 +37,5 @@ export declare function setChunk(snapshot: GameRuntimeSnapshot, chunkKey: string
37
37
  export declare function updateChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, update: (chunk: RuntimeChunkRow) => RuntimeChunkRow): GameRuntimeSnapshot;
38
38
  /** Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row. */
39
39
  export declare function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): GameRuntimeSnapshot;
40
+ /** Square chunk neighborhood around a world position, including its own chunk. */
41
+ export declare function chunkKeysAround(position: readonly [number, number, number], rings?: number, size?: number): string[];
@@ -81,3 +81,17 @@ export function deleteChunk(snapshot, chunkKey) {
81
81
  dirty: { ...snapshot.dirty, server: true, chunks: withChunkDirty(snapshot, chunkKey) },
82
82
  };
83
83
  }
84
+ /** Square chunk neighborhood around a world position, including its own chunk. */
85
+ export function chunkKeysAround(position, rings = 1, size = DEFAULT_CHUNK_SIZE) {
86
+ if (!Number.isSafeInteger(rings) || rings < 0 || rings > 128)
87
+ throw new RangeError("rings must be an integer from 0 to 128");
88
+ if (!Number.isFinite(size) || size <= 0 || !position.every(Number.isFinite))
89
+ throw new RangeError("Invalid chunk position or size");
90
+ const center = chunkCoordAt(position, size);
91
+ const keys = [];
92
+ for (let cx = center.cx - rings; cx <= center.cx + rings; cx++) {
93
+ for (let cz = center.cz - rings; cz <= center.cz + rings; cz++)
94
+ keys.push(chunkKeyOf({ cx, cz }));
95
+ }
96
+ return keys;
97
+ }
@@ -8,6 +8,11 @@ import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
8
8
  * growing a per-feature branch.
9
9
  */
10
10
  export interface ReplicationPolicy {
11
+ /** Decimal places used for replicated positions and angles. */
12
+ quantize?: {
13
+ position?: number;
14
+ angle?: number;
15
+ };
11
16
  /**
12
17
  * Replicate each player's private per-user state (inventory) only to that player — other viewers
13
18
  * receive none of it. Off by default so existing games keep broadcasting every player's state.
@@ -24,6 +29,10 @@ export interface ReplicationPolicy {
24
29
  * @internal host projection plumbing — games set ReplicationPolicy; the runtime applies it.
25
30
  */
26
31
  export declare function policyProjectsViewers(policy: ReplicationPolicy | undefined): boolean;
32
+ /** Quantize replicated entity poses according to a host projection policy.
33
+ * @internal host projection plumbing
34
+ */
35
+ export declare function quantizeWorldForReplication(world: WorldSnapshot, policy: ReplicationPolicy | undefined): WorldSnapshot;
27
36
  /**
28
37
  * Cull an entity list to a viewer's area of interest: keep the viewer's own entity plus every entity
29
38
  * within `radius` of it. When the viewer has no locatable entity the full list is returned (fail-open —
@@ -4,7 +4,23 @@
4
4
  export function policyProjectsViewers(policy) {
5
5
  if (policy === undefined)
6
6
  return false;
7
- return policy.privatePerUser === true || policy.aoiRadius !== undefined;
7
+ return policy.privatePerUser === true || policy.aoiRadius !== undefined || policy.quantize !== undefined;
8
+ }
9
+ /** Quantize replicated entity poses according to a host projection policy.
10
+ * @internal host projection plumbing
11
+ */
12
+ export function quantizeWorldForReplication(world, policy) {
13
+ if (policy?.quantize?.position === undefined && policy?.quantize?.angle === undefined)
14
+ return world;
15
+ const digits = (value, places) => places === undefined ? value : Number(value.toFixed(Math.max(0, places)));
16
+ const entities = world["entities"]?.map((entity) => ({
17
+ ...entity,
18
+ position: entity.position.map((value) => digits(value, policy.quantize?.position)),
19
+ rotationY: digits(entity.rotationY, policy.quantize?.angle),
20
+ rotationX: digits(entity.rotationX, policy.quantize?.angle),
21
+ rotationZ: digits(entity.rotationZ, policy.quantize?.angle),
22
+ }));
23
+ return entities === undefined ? world : { ...world, entities };
8
24
  }
9
25
  function distanceSquared(a, b) {
10
26
  const dx = a[0] - b[0];
@@ -1,6 +1,10 @@
1
1
  /** Who a host→client snapshot is being projected for — the identity a {@link SnapshotModule.project} filters against. */
2
2
  export interface SnapshotViewer {
3
3
  readonly userId: string;
4
+ /** A read-only connection role; spectators receive snapshots but cannot mutate the world. */
5
+ readonly role?: "player" | "spectator";
6
+ /** Host tick used by {@link SnapshotModule.priority} to throttle low-priority modules. */
7
+ readonly tick?: number;
4
8
  }
5
9
  /**
6
10
  * The replication seam for host-authoritative shared worlds: the opt-in feature manifest *is* the
@@ -37,6 +41,8 @@ export interface SnapshotModule<T = unknown> {
37
41
  project?(data: T, viewer: SnapshotViewer, world: WorldSnapshot): T;
38
42
  /** Monotone change counter; unchanged between host commits means this module didn't mutate and need not re-serialize. */
39
43
  version?(): number;
44
+ /** Return the host tick interval for this module; values greater than one omit it on intervening ticks. */
45
+ priority?(data: T, viewer: SnapshotViewer): number;
40
46
  }
41
47
  /** Full world baseline keyed by {@link SnapshotModule.key} — one entry per opted-in subsystem. */
42
48
  export type WorldSnapshot = Record<string, unknown>;
@@ -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
+ }