@jgengine/core 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/ai/rvo.d.ts +106 -0
  3. package/dist/ai/rvo.js +581 -0
  4. package/dist/ai/utilityBrain.d.ts +173 -0
  5. package/dist/ai/utilityBrain.js +277 -0
  6. package/dist/combat/cover.d.ts +169 -0
  7. package/dist/combat/cover.js +264 -0
  8. package/dist/combat/damageRuntime.d.ts +150 -0
  9. package/dist/combat/damageRuntime.js +208 -0
  10. package/dist/combat/damageTable.d.ts +116 -0
  11. package/dist/combat/damageTable.js +165 -0
  12. package/dist/combat/hitZones.d.ts +70 -0
  13. package/dist/combat/hitZones.js +77 -0
  14. package/dist/combat/magazine.d.ts +8 -0
  15. package/dist/combat/magazine.js +20 -2
  16. package/dist/combat/squadCombat.d.ts +167 -0
  17. package/dist/combat/squadCombat.js +557 -0
  18. package/dist/combat/squadTactics.d.ts +73 -0
  19. package/dist/combat/squadTactics.js +49 -0
  20. package/dist/combat/statusRuntime.d.ts +142 -0
  21. package/dist/combat/statusRuntime.js +236 -0
  22. package/dist/combat/weaponDefinition.d.ts +106 -0
  23. package/dist/combat/weaponDefinition.js +108 -0
  24. package/dist/combat/weaponSystem.d.ts +189 -0
  25. package/dist/combat/weaponSystem.js +469 -0
  26. package/dist/combat.d.ts +9 -0
  27. package/dist/combat.js +9 -0
  28. package/dist/devtools/devtools.d.ts +4 -2
  29. package/dist/devtools/devtools.js +2 -1
  30. package/dist/devtools/discover.d.ts +2 -1
  31. package/dist/devtools/discover.js +38 -0
  32. package/dist/devtools/frame.d.ts +0 -2
  33. package/dist/devtools/frame.js +1 -2
  34. package/dist/devtools/logBus.d.ts +58 -0
  35. package/dist/devtools/logBus.js +79 -0
  36. package/dist/devtools/logs.d.ts +6 -3
  37. package/dist/devtools/logs.js +17 -13
  38. package/dist/devtools/report.d.ts +120 -0
  39. package/dist/devtools/report.js +49 -0
  40. package/dist/devtools/reportEndpoint.d.ts +17 -0
  41. package/dist/devtools/reportEndpoint.js +7 -0
  42. package/dist/devtools/types.d.ts +48 -11
  43. package/dist/editor/document.js +210 -0
  44. package/dist/editor/environment.d.ts +48 -1
  45. package/dist/editor/environment.js +161 -0
  46. package/dist/editor/index.d.ts +6 -2
  47. package/dist/editor/index.js +1 -1
  48. package/dist/editor/types.d.ts +54 -0
  49. package/dist/gameplay.d.ts +4 -0
  50. package/dist/gameplay.js +4 -0
  51. package/dist/meta/changelog.d.ts +1 -1
  52. package/dist/meta/changelog.js +56 -1
  53. package/dist/nav/navCrowd.d.ts +138 -0
  54. package/dist/nav/navCrowd.js +412 -0
  55. package/dist/nav/navRuntime.d.ts +108 -0
  56. package/dist/nav/navRuntime.js +256 -0
  57. package/dist/render/colorPipeline.d.ts +53 -0
  58. package/dist/render/colorPipeline.js +79 -0
  59. package/dist/render/lightProbes.d.ts +97 -0
  60. package/dist/render/lightProbes.js +221 -0
  61. package/dist/render/lookPreset.js +4 -4
  62. package/dist/render/physicalSky.d.ts +39 -0
  63. package/dist/render/physicalSky.js +89 -0
  64. package/dist/render/postProcessing.d.ts +40 -8
  65. package/dist/render/postProcessing.js +1 -1
  66. package/dist/render/skyOwnedLighting.d.ts +31 -0
  67. package/dist/render/skyOwnedLighting.js +46 -0
  68. package/dist/sensor/perception.d.ts +66 -5
  69. package/dist/sensor/perception.js +167 -42
  70. package/dist/sim/nearAgents.d.ts +68 -0
  71. package/dist/sim/nearAgents.js +152 -0
  72. package/dist/sim/needs.d.ts +60 -0
  73. package/dist/sim/needs.js +108 -0
  74. package/dist/sim/offScreen.d.ts +181 -0
  75. package/dist/sim/offScreen.js +450 -0
  76. package/dist/sim/routine.d.ts +79 -0
  77. package/dist/sim/routine.js +137 -0
  78. package/dist/visibility/visibilitySystem.js +21 -0
  79. package/dist/world/features.d.ts +8 -0
  80. package/dist/world.d.ts +5 -0
  81. package/dist/world.js +5 -0
  82. package/package.json +1 -1
  83. package/skills/jgengine/api.md +59 -6
  84. package/skills/jgengine/capabilities.md +4 -0
  85. package/skills/jgengine-combat/SKILL.md +17 -0
  86. package/skills/jgengine-combat/api.md +246 -0
  87. package/skills/jgengine-combat/capabilities.md +112 -0
  88. package/skills/jgengine-combat/reference.md +32 -0
  89. package/skills/jgengine-gameplay/SKILL.md +8 -0
  90. package/skills/jgengine-gameplay/api.md +71 -1
  91. package/skills/jgengine-gameplay/capabilities.md +16 -0
  92. package/skills/jgengine-world/SKILL.md +3 -1
  93. package/skills/jgengine-world/api.md +107 -3
  94. package/skills/jgengine-world/capabilities.md +22 -2
  95. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
@@ -1,24 +1,10 @@
1
+ import { devtools } from "../devtools/devtools.js";
1
2
  function distance(a, b) {
2
3
  return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
3
4
  }
4
5
  function confidenceAt(memory, nowMs, memoryMs) {
5
6
  return memory.confidence * Math.max(0, 1 - Math.max(0, nowMs - memory.lastSeenAt) / memoryMs);
6
7
  }
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
8
  function validate(config) {
23
9
  if (!Number.isFinite(config.sightRange) || config.sightRange < 0)
24
10
  throw new Error("sightRange must be non-negative.");
@@ -30,20 +16,48 @@ function validate(config) {
30
16
  throw new Error("memorySeconds must be positive.");
31
17
  if (config.maxStimuli !== undefined && (!Number.isInteger(config.maxStimuli) || config.maxStimuli < 1))
32
18
  throw new Error("maxStimuli must be a positive integer.");
19
+ if (config.maxSharesPerTick !== undefined && (!Number.isInteger(config.maxSharesPerTick) || config.maxSharesPerTick < 0)) {
20
+ throw new Error("maxSharesPerTick must be a non-negative integer.");
21
+ }
33
22
  }
34
23
  const DEFAULT_MAX_STIMULI = 256;
35
- /** Creates a deterministic sight, sound, and damage perception service with expiring target memory.
36
- * @capability perception-service retain sight, sound, and damage observations with range, cone, occlusion, and decay policy
24
+ const DEFAULT_MAX_SHARES = 64;
25
+ /**
26
+ * Creates a deterministic sight, sound, and damage perception service with expiring target memory.
27
+ * Signals are judged in the order the caller supplies them, never from the wall clock.
28
+ *
29
+ * A sighting needs range, cone and a clear line; a sound carries `hearingRange × loudness` and
30
+ * fades with the distance it crossed, so a distant footstep is inaudible while a near gunshot is
31
+ * not. Memory is the only record of "seen recently": each fact fades linearly over `memorySeconds`
32
+ * and is then gone. With `squadMates` injected, a sighting one member makes lands in every mate's
33
+ * memory stamped with the seeing member and the pass, which is what lets a squad react to one pair
34
+ * of eyes without each member re-seeing anything.
35
+ *
36
+ * @capability perception-service retain sightings, sounds and damage with cone, range, occlusion, decaying memory and shared squad knowledge
37
37
  */
38
38
  export function createPerception(initial) {
39
39
  validate(initial);
40
40
  let config = { ...initial };
41
+ let maxShares = config.maxSharesPerTick ?? DEFAULT_MAX_SHARES;
41
42
  let nowMs = 0;
43
+ let passes = 0;
42
44
  const stimuli = [];
43
45
  const heard = new Map();
44
46
  let nextSeq = 1;
45
47
  const memories = new Map();
46
- function remember(observerId, targetId, position, at, confidence) {
48
+ const stats = {
49
+ passes: 0,
50
+ pairChecks: 0,
51
+ rangeCulled: 0,
52
+ coneTests: 0,
53
+ occlusionChecks: 0,
54
+ sightings: 0,
55
+ heard: 0,
56
+ shares: 0,
57
+ shareDropped: 0,
58
+ };
59
+ const fresh = [];
60
+ function remember(observerId, targetId, position, at, confidence, sourceId, sourceTick) {
47
61
  if (confidence <= 0)
48
62
  return;
49
63
  let observerMemories = memories.get(observerId);
@@ -53,26 +67,61 @@ export function createPerception(initial) {
53
67
  }
54
68
  const previous = observerMemories.get(targetId);
55
69
  if (previous === undefined || at >= previous.lastSeenAt) {
56
- observerMemories.set(targetId, { targetId, lastSeenAt: at, lastKnownPos: [...position], confidence });
70
+ observerMemories.set(targetId, {
71
+ targetId,
72
+ lastSeenAt: at,
73
+ lastKnownPos: [...position],
74
+ confidence,
75
+ sourceId,
76
+ sourceTick,
77
+ });
57
78
  }
58
79
  }
59
- function prune() {
80
+ function pruneObserver(observerId) {
81
+ const observerMemories = memories.get(observerId);
82
+ if (observerMemories === undefined)
83
+ return;
60
84
  const memoryMs = config.memorySeconds * 1000;
61
- for (const [observerId, observerMemories] of memories) {
62
- for (const [targetId, memory] of observerMemories) {
63
- if (confidenceAt(memory, nowMs, memoryMs) <= 0)
64
- observerMemories.delete(targetId);
65
- }
66
- if (observerMemories.size === 0)
67
- memories.delete(observerId);
85
+ for (const [targetId, memory] of observerMemories) {
86
+ if (confidenceAt(memory, nowMs, memoryMs) <= 0)
87
+ observerMemories.delete(targetId);
68
88
  }
69
- const cutoff = nowMs - memoryMs;
89
+ if (observerMemories.size === 0)
90
+ memories.delete(observerId);
91
+ }
92
+ function pruneStimuli() {
93
+ const cutoff = nowMs - config.memorySeconds * 1000;
70
94
  let drop = Math.max(0, stimuli.length - (config.maxStimuli ?? DEFAULT_MAX_STIMULI));
71
95
  while (drop < stimuli.length && stimuli[drop].at < cutoff)
72
96
  drop += 1;
73
97
  if (drop > 0)
74
98
  stimuli.splice(0, drop);
75
99
  }
100
+ /** Every observer's memory, not just the one that just observed — saving, retuning and restoring only. */
101
+ function pruneAll() {
102
+ for (const observerId of memories.keys())
103
+ pruneObserver(observerId);
104
+ pruneStimuli();
105
+ }
106
+ function share(mateId, sighting, seerId, tick) {
107
+ const existing = memories.get(mateId)?.get(sighting.id);
108
+ // The mate's own sighting at or after this instant is better evidence than a hand-off.
109
+ if (existing !== undefined && existing.lastSeenAt >= nowMs)
110
+ return;
111
+ remember(mateId, sighting.id, sighting.position, nowMs, 1, seerId, tick);
112
+ }
113
+ function rememberedCount() {
114
+ let total = 0;
115
+ for (const observerMemories of memories.values())
116
+ total += observerMemories.size;
117
+ return total;
118
+ }
119
+ function debugLine() {
120
+ const work = `${stats.pairChecks} pairs ${stats.rangeCulled} culled ${stats.occlusionChecks} los ${stats.sightings} sights ${stats.heard} heard`;
121
+ const squad = stats.shareDropped > 0 ? `${stats.shares} shared ${stats.shareDropped} dropped` : `${stats.shares} shared`;
122
+ return `${memories.size} observers ${rememberedCount()} remembered · ${work} · ${squad}`;
123
+ }
124
+ const unregister = config.devtoolsName === undefined ? null : devtools.probes.register(config.devtoolsName, () => debugLine());
76
125
  return {
77
126
  pushStimulus(stimulus) {
78
127
  if (!Number.isFinite(stimulus.at))
@@ -85,21 +134,65 @@ export function createPerception(initial) {
85
134
  index -= 1;
86
135
  stimuli.splice(index, 0, { ...stimulus, position: [...stimulus.position], seq: nextSeq++ });
87
136
  nowMs = Math.max(nowMs, stimulus.at);
88
- prune();
137
+ pruneStimuli();
89
138
  },
90
139
  observe(observer, candidates, observedAt) {
91
140
  if (!Number.isFinite(observedAt))
92
141
  throw new Error("nowMs must be finite.");
93
142
  nowMs = observedAt;
143
+ passes += 1;
144
+ stats.passes = passes;
145
+ const tick = passes;
146
+ const halfAngle = (config.sightConeDeg * Math.PI) / 360;
147
+ const isClear = config.isSegmentClear;
148
+ fresh.length = 0;
94
149
  for (const candidate of candidates) {
95
- if (!inSight(observer, candidate.position, config))
150
+ stats.pairChecks += 1;
151
+ const dx = candidate.position[0] - observer.position[0];
152
+ const dz = candidate.position[2] - observer.position[2];
153
+ const range = Math.sqrt(dx * dx + dz * dz);
154
+ if (range > config.sightRange) {
155
+ stats.rangeCulled += 1;
96
156
  continue;
97
- if (config.occluded?.(observer.position, candidate.position))
98
- continue;
99
- remember(observer.id, candidate.id, candidate.position, nowMs, 1);
157
+ }
158
+ stats.coneTests += 1;
159
+ if (range >= 1e-9) {
160
+ let delta = Math.atan2(dx, dz) - observer.yaw;
161
+ while (delta > Math.PI)
162
+ delta -= Math.PI * 2;
163
+ while (delta < -Math.PI)
164
+ delta += Math.PI * 2;
165
+ if (Math.abs(delta) > halfAngle)
166
+ continue;
167
+ }
168
+ if (isClear !== undefined) {
169
+ stats.occlusionChecks += 1;
170
+ if (!isClear(observer.position, candidate.position))
171
+ continue;
172
+ }
173
+ stats.sightings += 1;
174
+ remember(observer.id, candidate.id, candidate.position, nowMs, 1, observer.id, tick);
175
+ fresh.push(candidate);
100
176
  }
101
- // Each observer judges a stimulus once, from where it stood when it first observed after the stimulus;
102
- // re-judging every tick would make an old noise louder as the observer walks toward it.
177
+ const mates = config.squadMates;
178
+ if (mates !== undefined && fresh.length > 0) {
179
+ let budget = maxShares;
180
+ for (const sighting of fresh) {
181
+ for (const mateId of mates(observer.id)) {
182
+ if (mateId === observer.id)
183
+ continue;
184
+ if (budget <= 0) {
185
+ stats.shareDropped += 1;
186
+ continue;
187
+ }
188
+ budget -= 1;
189
+ stats.shares += 1;
190
+ share(mateId, sighting, observer.id, tick);
191
+ }
192
+ }
193
+ }
194
+ // Each observer judges a stimulus once, from where it stood when it first observed after the
195
+ // stimulus; re-judging every tick would make an old noise louder as the observer walks toward it.
103
196
  const judgedThrough = heard.get(observer.id) ?? 0;
104
197
  let judged = judgedThrough;
105
198
  let firstFuture = Number.POSITIVE_INFINITY;
@@ -118,14 +211,16 @@ export function createPerception(initial) {
118
211
  const audible = config.hearingRange * (stimulus.loudness ?? 1);
119
212
  if (range >= audible)
120
213
  continue;
121
- remember(observer.id, stimulus.sourceId, stimulus.position, stimulus.at, 1 - range / audible);
214
+ stats.heard += 1;
215
+ remember(observer.id, stimulus.sourceId, stimulus.position, stimulus.at, 1 - range / audible, observer.id, tick);
122
216
  }
123
217
  else if (stimulus.kind === "damage") {
124
- remember(observer.id, stimulus.sourceId, stimulus.position, stimulus.at, 1);
218
+ stats.heard += 1;
219
+ remember(observer.id, stimulus.sourceId, stimulus.position, stimulus.at, 1, observer.id, tick);
125
220
  }
126
221
  }
127
222
  heard.set(observer.id, Math.min(judged, firstFuture - 1));
128
- prune();
223
+ pruneObserver(observer.id);
129
224
  },
130
225
  memory(observerId) {
131
226
  const observerMemories = memories.get(observerId);
@@ -140,6 +235,15 @@ export function createPerception(initial) {
140
235
  }
141
236
  return result;
142
237
  },
238
+ memoryOf(observerId, targetId) {
239
+ const memory = memories.get(observerId)?.get(targetId);
240
+ if (memory === undefined)
241
+ return null;
242
+ const confidence = confidenceAt(memory, nowMs, config.memorySeconds * 1000);
243
+ if (confidence <= 0)
244
+ return null;
245
+ return { ...memory, lastKnownPos: [...memory.lastKnownPos], confidence };
246
+ },
143
247
  forget(observerId, targetId) {
144
248
  if (targetId === undefined) {
145
249
  memories.delete(observerId);
@@ -150,18 +254,32 @@ export function createPerception(initial) {
150
254
  if (observerMemories?.size === 0)
151
255
  memories.delete(observerId);
152
256
  },
257
+ stats: () => ({ ...stats }),
258
+ resetStats() {
259
+ stats.pairChecks = 0;
260
+ stats.rangeCulled = 0;
261
+ stats.coneTests = 0;
262
+ stats.occlusionChecks = 0;
263
+ stats.sightings = 0;
264
+ stats.heard = 0;
265
+ stats.shares = 0;
266
+ stats.shareDropped = 0;
267
+ },
153
268
  retune(patch) {
154
269
  config = { ...config, ...patch };
155
270
  validate(config);
156
- prune();
271
+ maxShares = config.maxSharesPerTick ?? DEFAULT_MAX_SHARES;
272
+ pruneAll();
157
273
  },
158
274
  snapshot() {
275
+ pruneAll();
159
276
  const result = {
160
277
  memories: [],
161
278
  stimuli: stimuli.map((stimulus) => ({ ...stimulus, position: [...stimulus.position] })),
162
279
  nowMs,
163
280
  heard: Object.fromEntries(heard),
164
281
  nextSeq,
282
+ passes,
165
283
  };
166
284
  for (const [observerId, observerMemories] of memories)
167
285
  for (const memory of observerMemories.values())
@@ -172,6 +290,8 @@ export function createPerception(initial) {
172
290
  memories.clear();
173
291
  stimuli.length = 0;
174
292
  nowMs = snapshot.nowMs;
293
+ passes = snapshot.passes ?? 0;
294
+ stats.passes = passes;
175
295
  heard.clear();
176
296
  for (const [observerId, seq] of Object.entries(snapshot.heard ?? {}))
177
297
  heard.set(observerId, seq);
@@ -181,9 +301,14 @@ export function createPerception(initial) {
181
301
  stimuli.push({ ...stimulus, position: [...stimulus.position], seq });
182
302
  }
183
303
  nextSeq = Math.max(snapshot.nextSeq ?? 0, seq + 1);
184
- for (const entry of snapshot.memories)
185
- remember(entry.observerId, entry.memory.targetId, entry.memory.lastKnownPos, entry.memory.lastSeenAt, entry.memory.confidence);
186
- prune();
304
+ for (const entry of snapshot.memories) {
305
+ const saved = entry.memory;
306
+ remember(entry.observerId, saved.targetId, saved.lastKnownPos, saved.lastSeenAt, saved.confidence, saved.sourceId ?? entry.observerId, saved.sourceTick ?? 0);
307
+ }
308
+ pruneAll();
309
+ },
310
+ dispose() {
311
+ unregister?.();
187
312
  },
188
313
  };
189
314
  }
@@ -0,0 +1,68 @@
1
+ import type { Vec3 } from "../world/geometry.js";
2
+ import { type NavCrowd, type NavCrowdOptions } from "../nav/navCrowd.js";
3
+ import type { NavRuntime } from "../nav/navRuntime.js";
4
+ import { type UtilityBrainSnapshot, type UtilityCandidate, type UtilityInterrupt, type UtilityOption } from "../ai/utilityBrain.js";
5
+ import { type NeedDefinition, type NeedSnapshot } from "./needs.js";
6
+ import type { NearAgents } from "./offScreen.js";
7
+ /** Serializable state of one near agent: where it walks, what it needs, what it last chose. */
8
+ export interface NearAgentSnapshot {
9
+ id: string;
10
+ position: Vec3;
11
+ goal: Vec3;
12
+ place: string | null;
13
+ activity: string | null;
14
+ travelling: boolean;
15
+ needs: NeedSnapshot;
16
+ brain: UtilityBrainSnapshot;
17
+ }
18
+ /** Everything a near agent is doing, for a HUD, a probe or a test. Allocates. */
19
+ export interface NearAgentView {
20
+ id: string;
21
+ position: Vec3;
22
+ goal: Vec3;
23
+ place: string | null;
24
+ activity: string | null;
25
+ travelling: boolean;
26
+ /** The utility pick for this tick; `null` before the first decision or with no candidates. */
27
+ pick: string | null;
28
+ needs: NeedSnapshot;
29
+ }
30
+ /** What the near side needs from the game: a nav mesh, the shared need definitions, and what agents can do. */
31
+ export interface NearAgentsOptions {
32
+ nav: NavRuntime;
33
+ /** Need definitions shared with the off-screen world, so a far decay and a near tick agree. */
34
+ needs: readonly NeedDefinition[];
35
+ /** Utility options each brain scores; omit for agents that only carry needs and walk. */
36
+ options?: readonly UtilityOption[];
37
+ interrupts?: readonly UtilityInterrupt[];
38
+ /** Candidates the agent may pick this tick (where it stands, or anything the game offers). */
39
+ candidates?: (agent: NearAgentView) => readonly UtilityCandidate[];
40
+ crowd?: NavCrowdOptions;
41
+ /** Register a devtools probe under this name so the overlay shows near-agent decisions. */
42
+ devtoolsName?: string;
43
+ }
44
+ /**
45
+ * The near half of the off-screen world: one nav crowd for movement, one need set and utility brain
46
+ * per agent for what it wants. Resident identity survives the handoff because the off-screen world
47
+ * spawns and despawns through this seam and keeps the saved state.
48
+ */
49
+ export interface NearCrowdAgents extends NearAgents {
50
+ /** Utility decisions taken since creation — near agents run brains, far ones do not exist here. */
51
+ decisions(): number;
52
+ view(id: string): NearAgentView | null;
53
+ crowd(): NavCrowd;
54
+ snapshot(): {
55
+ agents: NearAgentSnapshot[];
56
+ };
57
+ restore(next: {
58
+ agents: readonly NearAgentSnapshot[];
59
+ }): void;
60
+ }
61
+ /**
62
+ * Create the near-side simulation: agents on a nav mesh with decaying needs and a utility brain each.
63
+ * The off-screen world decides which agents exist here, so a resident beyond its radius costs this
64
+ * seam nothing at all — no path, no need tick, no decision.
65
+ *
66
+ * @capability near-agents nav crowd plus per-agent needs and utility brains for on-screen residents
67
+ */
68
+ export declare function createNearAgents(options: NearAgentsOptions): NearCrowdAgents;
@@ -0,0 +1,152 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ import { createNavCrowd } from "../nav/navCrowd.js";
3
+ import { createUtilityBrain, } from "../ai/utilityBrain.js";
4
+ import { createNeedSet } from "./needs.js";
5
+ /**
6
+ * Create the near-side simulation: agents on a nav mesh with decaying needs and a utility brain each.
7
+ * The off-screen world decides which agents exist here, so a resident beyond its radius costs this
8
+ * seam nothing at all — no path, no need tick, no decision.
9
+ *
10
+ * @capability near-agents nav crowd plus per-agent needs and utility brains for on-screen residents
11
+ */
12
+ export function createNearAgents(options) {
13
+ const crowd = createNavCrowd({ nav: options.nav, ...options.crowd });
14
+ const defs = options.needs.map((definition) => ({ ...definition }));
15
+ const agents = new Map();
16
+ let decisions = 0;
17
+ function probeLine() {
18
+ const title = agents.size === 1 ? "1 agent" : `${agents.size} agents`;
19
+ const stats = crowd.stats();
20
+ return `${title} ${decisions} decisions ${stats.plans} plans ${stats.arrived} arrived`;
21
+ }
22
+ const unregister = options.devtoolsName === undefined ? null : devtools.probes.register(options.devtoolsName, () => probeLine());
23
+ function viewOf(agent) {
24
+ const body = crowd.agent(agent.id);
25
+ return {
26
+ id: agent.id,
27
+ position: body?.position ?? [0, 0, 0],
28
+ goal: agent.goal,
29
+ place: agent.place,
30
+ activity: agent.activity,
31
+ travelling: agent.travelling,
32
+ pick: agent.brain.chosen(),
33
+ needs: agent.needs.snapshot(),
34
+ };
35
+ }
36
+ function mount(next, saved) {
37
+ if (agents.has(next.id))
38
+ return;
39
+ const needs = createNeedSet(defs);
40
+ needs.restore(saved === undefined ? { values: { ...next.needs }, rates: {}, modifiers: {} } : saved.needs);
41
+ const brain = createUtilityBrain({
42
+ needs,
43
+ options: options.options ?? [],
44
+ ...(options.interrupts === undefined ? {} : { interrupts: options.interrupts }),
45
+ });
46
+ if (saved !== undefined)
47
+ brain.restore(saved.brain);
48
+ const goal = saved?.goal ?? next.goal;
49
+ const agent = {
50
+ id: next.id,
51
+ needs,
52
+ brain,
53
+ goal,
54
+ place: next.place,
55
+ activity: next.activity,
56
+ travelling: next.travelling,
57
+ spawned: next,
58
+ };
59
+ agents.set(next.id, agent);
60
+ crowd.add(next.id, { position: saved?.position ?? next.position, goal });
61
+ }
62
+ return {
63
+ spawn(next) {
64
+ mount(next);
65
+ },
66
+ despawn(id) {
67
+ const agent = agents.get(id);
68
+ if (agent === undefined)
69
+ return null;
70
+ const body = crowd.agent(id);
71
+ const handoff = {
72
+ position: body?.position ?? agent.spawned.position,
73
+ needs: agent.needs.snapshot().values,
74
+ };
75
+ crowd.remove(id);
76
+ agent.brain.dispose();
77
+ agents.delete(id);
78
+ return handoff;
79
+ },
80
+ position(id) {
81
+ return crowd.agent(id)?.position ?? null;
82
+ },
83
+ needs(id) {
84
+ return agents.get(id)?.needs.snapshot().values ?? null;
85
+ },
86
+ setGoal(id, goal) {
87
+ const agent = agents.get(id);
88
+ if (agent === undefined)
89
+ return;
90
+ agent.goal = goal;
91
+ crowd.setGoal(id, goal);
92
+ },
93
+ tick(dt, _now) {
94
+ for (const agent of agents.values()) {
95
+ agent.brain.advance(dt);
96
+ const candidates = options.candidates === undefined ? [] : options.candidates(viewOf(agent));
97
+ agent.brain.decide(candidates);
98
+ decisions += 1;
99
+ }
100
+ crowd.tick(dt);
101
+ },
102
+ decisions: () => decisions,
103
+ view(id) {
104
+ const agent = agents.get(id);
105
+ return agent === undefined ? null : viewOf(agent);
106
+ },
107
+ crowd: () => crowd,
108
+ snapshot() {
109
+ const out = [];
110
+ for (const agent of agents.values()) {
111
+ const body = crowd.agent(agent.id);
112
+ out.push({
113
+ id: agent.id,
114
+ position: body?.position ?? agent.spawned.position,
115
+ goal: agent.goal,
116
+ place: agent.place,
117
+ activity: agent.activity,
118
+ travelling: agent.travelling,
119
+ needs: agent.needs.snapshot(),
120
+ brain: agent.brain.snapshot(),
121
+ });
122
+ }
123
+ return { agents: out };
124
+ },
125
+ restore(next) {
126
+ for (const id of [...agents.keys()]) {
127
+ crowd.remove(id);
128
+ agents.get(id)?.brain.dispose();
129
+ agents.delete(id);
130
+ }
131
+ for (const saved of next.agents) {
132
+ mount({
133
+ id: saved.id,
134
+ position: saved.position,
135
+ place: saved.place,
136
+ activity: saved.activity,
137
+ needs: saved.needs.values,
138
+ goal: saved.goal,
139
+ stop: 0,
140
+ travelling: saved.travelling,
141
+ }, saved);
142
+ }
143
+ },
144
+ dispose() {
145
+ unregister?.();
146
+ for (const agent of agents.values())
147
+ agent.brain.dispose();
148
+ agents.clear();
149
+ crowd.dispose();
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,60 @@
1
+ import { type DecayMeterConfig } from "../survival/decayMeter.js";
2
+ /**
3
+ * A decaying need is a survival decay meter (`max`/`min`/`start`/`rate`, optional moodle
4
+ * thresholds) whose satisfaction an agent brain reads to score actions. The meter owns the
5
+ * numbers; per-need appeal lives in the game's utility options.
6
+ */
7
+ export type NeedDefinition = DecayMeterConfig;
8
+ /** One need's current satisfaction, worst-first ready: `fraction` is 0 (empty) … 1 (full). */
9
+ export interface NeedReading {
10
+ id: string;
11
+ value: number;
12
+ fraction: number;
13
+ }
14
+ /** Serializable need state: values plus any in-play retuning, so a save keeps the dials. */
15
+ export interface NeedSnapshot {
16
+ /** Satisfaction per need id; undeclared ids are ignored on restore. */
17
+ values: Record<string, number>;
18
+ /** Base rates after `setRate`; absent entries keep the definition's rate. */
19
+ rates: Record<string, number>;
20
+ /** Active per-need rate multipliers from `setRateModifier`. */
21
+ modifiers: Record<string, number>;
22
+ }
23
+ /**
24
+ * Named needs that drain or recover on game time — hunger, energy, hygiene, bladder, fun.
25
+ * Reads are index-addressed (`indexOf` once, `fractionAt` per tick) so a thousand-agent loop
26
+ * never re-resolves ids; the whole set round-trips through {@link NeedSnapshot}.
27
+ */
28
+ export interface NeedSet {
29
+ /** Drain every need by `rate * modifier * dt` in place. Call once per game tick. */
30
+ tick(dt: number): void;
31
+ /** Declared ids in definition order. */
32
+ ids(): readonly string[];
33
+ /** Stable index for a declared need, or `-1`. Resolve once, then use the `*At` readers. */
34
+ indexOf(id: string): number;
35
+ /** Raw value at `indexOf(id)` — no allocation, no lookup. */
36
+ valueAt(index: number): number;
37
+ /** 0..1 fill at `indexOf(id)` — what response curves sample. */
38
+ fractionAt(index: number): number;
39
+ value(id: string): number;
40
+ fraction(id: string): number;
41
+ /** Consumable/action refill; negative drains. Throws on an unknown id. */
42
+ refill(id: string, amount: number): void;
43
+ /** Overwrite one need's base rate (a harsher game mode). Throws on an unknown id. */
44
+ setRate(id: string, rate: number): void;
45
+ /** Multiply one need's drain until cleared with `1`; `null` sets every need at once. */
46
+ setRateModifier(id: string | null, multiplier: number): void;
47
+ /** Least-satisfied need by fraction, declared order breaking ties; `null` when none declared. */
48
+ mostUrgent(): NeedReading | null;
49
+ snapshot(): NeedSnapshot;
50
+ restore(next: NeedSnapshot): void;
51
+ }
52
+ /**
53
+ * Create the needs an agent or a park guest carries. Decay reuses the survival meter formulas
54
+ * (`survival/decayMeter`), so moodle thresholds, clamping and rate modifiers behave exactly like
55
+ * every other meter; this set adds index-addressed reads and single-need retuning for hot AI loops.
56
+ * Score these needs into an action with `createUtilityBrain` (`@jgengine/core/world`).
57
+ *
58
+ * @capability needs decaying needs an agent reads by index to decide what it wants next
59
+ */
60
+ export declare function createNeedSet(definitions: readonly NeedDefinition[]): NeedSet;