@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.
- package/CHANGELOG.md +65 -0
- package/dist/ai/rvo.d.ts +106 -0
- package/dist/ai/rvo.js +581 -0
- package/dist/ai/utilityBrain.d.ts +173 -0
- package/dist/ai/utilityBrain.js +277 -0
- package/dist/combat/cover.d.ts +169 -0
- package/dist/combat/cover.js +264 -0
- package/dist/combat/damageRuntime.d.ts +150 -0
- package/dist/combat/damageRuntime.js +208 -0
- package/dist/combat/damageTable.d.ts +116 -0
- package/dist/combat/damageTable.js +165 -0
- package/dist/combat/hitZones.d.ts +70 -0
- package/dist/combat/hitZones.js +77 -0
- package/dist/combat/magazine.d.ts +8 -0
- package/dist/combat/magazine.js +20 -2
- package/dist/combat/squadCombat.d.ts +167 -0
- package/dist/combat/squadCombat.js +557 -0
- package/dist/combat/squadTactics.d.ts +73 -0
- package/dist/combat/squadTactics.js +49 -0
- package/dist/combat/statusRuntime.d.ts +142 -0
- package/dist/combat/statusRuntime.js +236 -0
- package/dist/combat/weaponDefinition.d.ts +106 -0
- package/dist/combat/weaponDefinition.js +108 -0
- package/dist/combat/weaponSystem.d.ts +189 -0
- package/dist/combat/weaponSystem.js +469 -0
- package/dist/combat.d.ts +9 -0
- package/dist/combat.js +9 -0
- package/dist/devtools/devtools.d.ts +4 -2
- package/dist/devtools/devtools.js +2 -1
- package/dist/devtools/discover.d.ts +2 -1
- package/dist/devtools/discover.js +38 -0
- package/dist/devtools/frame.d.ts +0 -2
- package/dist/devtools/frame.js +1 -2
- package/dist/devtools/logBus.d.ts +58 -0
- package/dist/devtools/logBus.js +79 -0
- package/dist/devtools/logs.d.ts +6 -3
- package/dist/devtools/logs.js +17 -13
- package/dist/devtools/report.d.ts +120 -0
- package/dist/devtools/report.js +49 -0
- package/dist/devtools/reportEndpoint.d.ts +17 -0
- package/dist/devtools/reportEndpoint.js +7 -0
- package/dist/devtools/types.d.ts +48 -11
- package/dist/editor/document.js +210 -0
- package/dist/editor/environment.d.ts +48 -1
- package/dist/editor/environment.js +161 -0
- package/dist/editor/index.d.ts +6 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +54 -0
- package/dist/gameplay.d.ts +4 -0
- package/dist/gameplay.js +4 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +56 -1
- package/dist/nav/navCrowd.d.ts +138 -0
- package/dist/nav/navCrowd.js +412 -0
- package/dist/nav/navRuntime.d.ts +108 -0
- package/dist/nav/navRuntime.js +256 -0
- package/dist/render/colorPipeline.d.ts +53 -0
- package/dist/render/colorPipeline.js +79 -0
- package/dist/render/lightProbes.d.ts +97 -0
- package/dist/render/lightProbes.js +221 -0
- package/dist/render/lookPreset.js +4 -4
- package/dist/render/physicalSky.d.ts +39 -0
- package/dist/render/physicalSky.js +89 -0
- package/dist/render/postProcessing.d.ts +40 -8
- package/dist/render/postProcessing.js +1 -1
- package/dist/render/skyOwnedLighting.d.ts +31 -0
- package/dist/render/skyOwnedLighting.js +46 -0
- package/dist/sensor/perception.d.ts +66 -5
- package/dist/sensor/perception.js +167 -42
- package/dist/sim/nearAgents.d.ts +68 -0
- package/dist/sim/nearAgents.js +152 -0
- package/dist/sim/needs.d.ts +60 -0
- package/dist/sim/needs.js +108 -0
- package/dist/sim/offScreen.d.ts +181 -0
- package/dist/sim/offScreen.js +450 -0
- package/dist/sim/routine.d.ts +79 -0
- package/dist/sim/routine.js +137 -0
- package/dist/visibility/visibilitySystem.js +21 -0
- package/dist/world/features.d.ts +8 -0
- package/dist/world.d.ts +5 -0
- package/dist/world.js +5 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +59 -6
- package/skills/jgengine/capabilities.md +4 -0
- package/skills/jgengine-combat/SKILL.md +17 -0
- package/skills/jgengine-combat/api.md +246 -0
- package/skills/jgengine-combat/capabilities.md +112 -0
- package/skills/jgengine-combat/reference.md +32 -0
- package/skills/jgengine-gameplay/SKILL.md +8 -0
- package/skills/jgengine-gameplay/api.md +71 -1
- package/skills/jgengine-gameplay/capabilities.md +16 -0
- package/skills/jgengine-world/SKILL.md +3 -1
- package/skills/jgengine-world/api.md +107 -3
- package/skills/jgengine-world/capabilities.md +22 -2
- 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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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 [
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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;
|