@jgengine/core 0.25.0 → 0.27.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 +109 -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/game/playableGame.d.ts +19 -2
- package/dist/gameplay.d.ts +9 -0
- package/dist/gameplay.js +9 -0
- package/dist/inventory/carryLoad.d.ts +48 -0
- package/dist/inventory/carryLoad.js +44 -0
- package/dist/inventory/equipment.d.ts +95 -0
- package/dist/inventory/equipment.js +169 -0
- package/dist/inventory/inventoryModel.d.ts +15 -6
- package/dist/inventory/inventoryModel.js +54 -5
- package/dist/inventory/shapedGrid.d.ts +26 -0
- package/dist/inventory/shapedGrid.js +47 -0
- package/dist/loot/lootGen.d.ts +186 -0
- package/dist/loot/lootGen.js +437 -0
- package/dist/loot/lootWeapon.d.ts +35 -0
- package/dist/loot/lootWeapon.js +70 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +92 -1
- package/dist/nav/laneGraph.d.ts +178 -0
- package/dist/nav/laneGraph.js +671 -0
- 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/nav/traffic.d.ts +223 -0
- package/dist/nav/traffic.js +919 -0
- package/dist/physics/kinematicVehicle.d.ts +36 -0
- package/dist/physics/kinematicVehicle.js +15 -1
- package/dist/physics/vehicleDamage.d.ts +136 -0
- package/dist/physics/vehicleDamage.js +214 -0
- package/dist/physics/vehicleParts.d.ts +62 -0
- package/dist/physics/vehicleParts.js +65 -0
- package/dist/progression/gearScore.d.ts +56 -0
- package/dist/progression/gearScore.js +59 -0
- package/dist/progression/skillTree.d.ts +129 -0
- package/dist/progression/skillTree.js +265 -0
- package/dist/progression/xpCurve.d.ts +60 -0
- package/dist/progression/xpCurve.js +141 -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/scene/builtinSceneKinds.js +2 -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/decalKind.d.ts +48 -0
- package/dist/world/decalKind.js +86 -0
- package/dist/world/features.d.ts +11 -1
- package/dist/world/features.js +1 -0
- package/dist/world/grassKind.d.ts +5 -0
- package/dist/world/grassKind.js +3 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world.d.ts +10 -1
- package/dist/world.js +9 -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 +19 -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 +39 -0
- package/skills/jgengine-gameplay/api.md +221 -7
- package/skills/jgengine-gameplay/capabilities.md +64 -3
- package/skills/jgengine-gameplay/reference-systems.md +118 -0
- package/skills/jgengine-world/SKILL.md +7 -1
- package/skills/jgengine-world/api.md +222 -8
- package/skills/jgengine-world/capabilities.md +42 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { NeedSet } from "../sim/needs.js";
|
|
2
|
+
/**
|
|
3
|
+
* How one need's satisfaction maps to an option's appeal: `1` at `from`, `0` at `to`, clamped
|
|
4
|
+
* outside. Read satisfaction `1` as a full meter; pair with {@link UtilityTerm.invert} when a full
|
|
5
|
+
* meter is what makes the option attractive.
|
|
6
|
+
*/
|
|
7
|
+
export type ResponseCurve = {
|
|
8
|
+
readonly kind: "linear";
|
|
9
|
+
readonly from: number;
|
|
10
|
+
readonly to: number;
|
|
11
|
+
}
|
|
12
|
+
/** Same shape as `linear`, raised to `exponent` (default `2`), so appeal stays flat until it matters. */
|
|
13
|
+
| {
|
|
14
|
+
readonly kind: "quadratic";
|
|
15
|
+
readonly from: number;
|
|
16
|
+
readonly to: number;
|
|
17
|
+
readonly exponent?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Smooth S-curve through `0.5` at `midpoint`; `steepness` (default `10`) is how sharp the knee is. */
|
|
20
|
+
| {
|
|
21
|
+
readonly kind: "logistic";
|
|
22
|
+
readonly midpoint: number;
|
|
23
|
+
readonly steepness?: number;
|
|
24
|
+
}
|
|
25
|
+
/** `exp(-rate·x)` normalized to reach exactly 1 at empty and 0 at full; sharper than `linear` near empty. */
|
|
26
|
+
| {
|
|
27
|
+
readonly kind: "exponential";
|
|
28
|
+
readonly rate: number;
|
|
29
|
+
}
|
|
30
|
+
/** Step: `value` (default `1`) while satisfaction is at or below `at`, else `0`. */
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: "threshold";
|
|
33
|
+
readonly at: number;
|
|
34
|
+
readonly value?: number;
|
|
35
|
+
};
|
|
36
|
+
/** Sample a response curve at a `0..1` satisfaction. Deterministic, allocation-free. */
|
|
37
|
+
export declare function sampleResponseCurve(curve: ResponseCurve, satisfaction: number): number;
|
|
38
|
+
/** One need's contribution to an option's appeal. */
|
|
39
|
+
export interface UtilityTerm {
|
|
40
|
+
/** Declared need id on the brain's need set; resolved once at construction. */
|
|
41
|
+
readonly need: string;
|
|
42
|
+
readonly curve: ResponseCurve;
|
|
43
|
+
/** Multiplier on the sampled appeal (negative pushes the option away). Default `1`. */
|
|
44
|
+
readonly weight?: number;
|
|
45
|
+
/** Sample `1 - satisfaction` — an option that wants a *full* meter rather than an empty one. */
|
|
46
|
+
readonly invert?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/** A thing an agent can decide to do, scored against its needs. */
|
|
49
|
+
export interface UtilityOption {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
/** Appeal regardless of needs — the option's standing preference. Default `0`. */
|
|
52
|
+
readonly base?: number;
|
|
53
|
+
readonly terms?: readonly UtilityTerm[];
|
|
54
|
+
}
|
|
55
|
+
/** One reachable instance of an option this tick; `cost` is the caller's own effort number. */
|
|
56
|
+
export interface UtilityCandidate {
|
|
57
|
+
/** Option id. Unknown ids are skipped, so content can be removed at runtime. */
|
|
58
|
+
readonly option: string;
|
|
59
|
+
/**
|
|
60
|
+
* Caller-computed effort — travel distance, queue seconds, price, risk. Scored as
|
|
61
|
+
* `-costWeight · cost`, so the game keeps ownership of what a meter means. Default `0`.
|
|
62
|
+
*/
|
|
63
|
+
readonly cost?: number;
|
|
64
|
+
}
|
|
65
|
+
/** A need level that overrides the sticky pick the moment it is crossed. */
|
|
66
|
+
export interface UtilityInterrupt {
|
|
67
|
+
readonly need: string;
|
|
68
|
+
/** Fraction that fires the interrupt. */
|
|
69
|
+
readonly at: number;
|
|
70
|
+
/** Fire while the need is `below` (default, a starving agent) or `above` (panic, overheat). */
|
|
71
|
+
readonly when?: "below" | "above";
|
|
72
|
+
/** Force this option when it is among the candidates; omit to just re-decide without stickiness. */
|
|
73
|
+
readonly option?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Why the brain's pick changed on the tick it changed. */
|
|
76
|
+
export type UtilitySwitchReason = "initial" | "score" | "interrupt" | "unavailable";
|
|
77
|
+
/** The outcome of one {@link UtilityBrain.decide}. Valid until the next `decide` call. */
|
|
78
|
+
export interface UtilityDecision {
|
|
79
|
+
readonly option: string | null;
|
|
80
|
+
/** Costed score of the chosen candidate. */
|
|
81
|
+
readonly score: number;
|
|
82
|
+
/** The pick changed on this tick. */
|
|
83
|
+
readonly changed: boolean;
|
|
84
|
+
readonly reason: UtilitySwitchReason | null;
|
|
85
|
+
/** An interrupt fired this tick, whether or not it changed the pick. */
|
|
86
|
+
readonly interrupted: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** In-play dials that stay reachable through {@link UtilityBrain.retune}. */
|
|
89
|
+
export interface UtilityTunables {
|
|
90
|
+
/** Score margin a rival must beat the held pick by. Default `0.05`; `0` switches on any lead. */
|
|
91
|
+
readonly switchMargin?: number;
|
|
92
|
+
/** Seconds a pick is held before a non-interrupt rival may replace it. Default `0`. */
|
|
93
|
+
readonly minHoldSeconds?: number;
|
|
94
|
+
/** Score points per candidate cost unit. Default `1`. */
|
|
95
|
+
readonly costWeight?: number;
|
|
96
|
+
/** Symmetric score noise amplitude from the injected `rng`; default `0` (ties keep the held pick). */
|
|
97
|
+
readonly jitter?: number;
|
|
98
|
+
}
|
|
99
|
+
/** Everything a utility brain needs: the shared need set, its scored options, and selection policy. */
|
|
100
|
+
export interface UtilityBrainConfig extends UtilityTunables {
|
|
101
|
+
readonly needs: NeedSet;
|
|
102
|
+
readonly options: readonly UtilityOption[];
|
|
103
|
+
readonly interrupts?: readonly UtilityInterrupt[];
|
|
104
|
+
/** `[0,1)` source for `jitter`; pass `ctx.rng`. Only read when `jitter > 0`. */
|
|
105
|
+
readonly rng?: () => number;
|
|
106
|
+
/** Register a devtools probe under this name so the perf overlay shows the live pick. */
|
|
107
|
+
readonly devtoolsName?: string;
|
|
108
|
+
}
|
|
109
|
+
/** One option's need-driven score, ignoring candidate cost. */
|
|
110
|
+
export interface UtilityScore {
|
|
111
|
+
readonly option: string;
|
|
112
|
+
readonly score: number;
|
|
113
|
+
}
|
|
114
|
+
/** JSON-safe view of a brain for the devtools overlay and bug reports. */
|
|
115
|
+
export interface UtilityDebugSnapshot {
|
|
116
|
+
readonly chosen: string | null;
|
|
117
|
+
readonly score: number;
|
|
118
|
+
readonly reason: UtilitySwitchReason | null;
|
|
119
|
+
readonly switchMargin: number;
|
|
120
|
+
readonly costWeight: number;
|
|
121
|
+
readonly holdSeconds: number;
|
|
122
|
+
readonly needs: readonly {
|
|
123
|
+
id: string;
|
|
124
|
+
value: number;
|
|
125
|
+
fraction: number;
|
|
126
|
+
}[];
|
|
127
|
+
readonly options: readonly UtilityScore[];
|
|
128
|
+
}
|
|
129
|
+
/** Serializable brain state: the held pick plus any in-play retuning. Needs save separately. */
|
|
130
|
+
export interface UtilityBrainSnapshot {
|
|
131
|
+
option: string | null;
|
|
132
|
+
score: number;
|
|
133
|
+
holdSeconds: number;
|
|
134
|
+
switchMargin: number;
|
|
135
|
+
minHoldSeconds: number;
|
|
136
|
+
costWeight: number;
|
|
137
|
+
jitter: number;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Utility-scored action selection over decaying needs: score every option's appeal through response
|
|
141
|
+
* curves, pick the best reachable candidate minus its cost, and hold that pick until a rival clears
|
|
142
|
+
* the hysteresis margin — unless a declared interrupt fires.
|
|
143
|
+
*
|
|
144
|
+
* The tick cost is bounded by declared content: `advance` is O(needs), `decide` is
|
|
145
|
+
* O(options × terms + candidates + interrupts), with no allocation on either path.
|
|
146
|
+
*/
|
|
147
|
+
export interface UtilityBrain {
|
|
148
|
+
/** Decay the injected needs by `dt` seconds and age the hold timer. */
|
|
149
|
+
advance(dt: number): void;
|
|
150
|
+
/**
|
|
151
|
+
* Pick among `candidates` for this tick. The returned object is reused, so read it before the
|
|
152
|
+
* next call. `cost` defaults weight candidates down linearly (travel, queues, price).
|
|
153
|
+
*/
|
|
154
|
+
decide(candidates: readonly UtilityCandidate[]): UtilityDecision;
|
|
155
|
+
chosen(): string | null;
|
|
156
|
+
/** Need-driven score of one option right now, cost excluded. Throws on an unknown id. */
|
|
157
|
+
scoreOf(optionId: string): number;
|
|
158
|
+
/** Every option's need-driven score in declared order. Allocates — debug and tests. */
|
|
159
|
+
scores(): readonly UtilityScore[];
|
|
160
|
+
retune(patch: UtilityTunables): void;
|
|
161
|
+
debugSnapshot(): UtilityDebugSnapshot;
|
|
162
|
+
snapshot(): UtilityBrainSnapshot;
|
|
163
|
+
restore(next: UtilityBrainSnapshot): void;
|
|
164
|
+
/** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
|
|
165
|
+
dispose(): void;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Create a utility brain over an existing {@link NeedSet}. The need set is injected, not owned, so
|
|
169
|
+
* the game shows the same hunger the brain scores and persists one copy.
|
|
170
|
+
*
|
|
171
|
+
* @capability utility-brain pick actions by decaying-need appeal with response curves, hysteresis and interrupts
|
|
172
|
+
*/
|
|
173
|
+
export declare function createUtilityBrain(config: UtilityBrainConfig): UtilityBrain;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { clamp } from "../math/scalar.js";
|
|
3
|
+
import { resolveRng } from "../random/resolveRng.js";
|
|
4
|
+
/** Sample a response curve at a `0..1` satisfaction. Deterministic, allocation-free. */
|
|
5
|
+
export function sampleResponseCurve(curve, satisfaction) {
|
|
6
|
+
const x = clamp(satisfaction, 0, 1);
|
|
7
|
+
switch (curve.kind) {
|
|
8
|
+
case "linear":
|
|
9
|
+
case "quadratic": {
|
|
10
|
+
const span = curve.to - curve.from;
|
|
11
|
+
const t = span === 0 ? (x <= curve.from ? 0 : 1) : clamp((x - curve.from) / span, 0, 1);
|
|
12
|
+
if (curve.kind === "linear")
|
|
13
|
+
return 1 - t;
|
|
14
|
+
const exponent = Math.max(1, curve.exponent ?? 2);
|
|
15
|
+
return Math.pow(1 - t, exponent);
|
|
16
|
+
}
|
|
17
|
+
case "logistic": {
|
|
18
|
+
const steepness = Math.max(0.0001, curve.steepness ?? 10);
|
|
19
|
+
const low = rawLogistic(0, curve.midpoint, steepness);
|
|
20
|
+
const high = rawLogistic(1, curve.midpoint, steepness);
|
|
21
|
+
if (low === high)
|
|
22
|
+
return x <= curve.midpoint ? 1 : 0;
|
|
23
|
+
return clamp((rawLogistic(x, curve.midpoint, steepness) - high) / (low - high), 0, 1);
|
|
24
|
+
}
|
|
25
|
+
case "exponential": {
|
|
26
|
+
const rate = curve.rate;
|
|
27
|
+
if (!(rate > 0))
|
|
28
|
+
return 1 - x;
|
|
29
|
+
const tail = Math.exp(-rate);
|
|
30
|
+
return clamp((Math.exp(-rate * x) - tail) / (1 - tail), 0, 1);
|
|
31
|
+
}
|
|
32
|
+
case "threshold":
|
|
33
|
+
return x <= curve.at ? (curve.value ?? 1) : 0;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function rawLogistic(x, midpoint, steepness) {
|
|
37
|
+
return 1 / (1 + Math.exp(steepness * (x - midpoint)));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create a utility brain over an existing {@link NeedSet}. The need set is injected, not owned, so
|
|
41
|
+
* the game shows the same hunger the brain scores and persists one copy.
|
|
42
|
+
*
|
|
43
|
+
* @capability utility-brain pick actions by decaying-need appeal with response curves, hysteresis and interrupts
|
|
44
|
+
*/
|
|
45
|
+
export function createUtilityBrain(config) {
|
|
46
|
+
const needs = config.needs;
|
|
47
|
+
const optionIndex = new Map();
|
|
48
|
+
const options = config.options.map((option, index) => {
|
|
49
|
+
if (optionIndex.has(option.id))
|
|
50
|
+
throw new Error(`duplicate utility option "${option.id}"`);
|
|
51
|
+
optionIndex.set(option.id, index);
|
|
52
|
+
const terms = (option.terms ?? []).map((term) => {
|
|
53
|
+
const needIndex = needs.indexOf(term.need);
|
|
54
|
+
if (needIndex < 0) {
|
|
55
|
+
throw new Error(`utility option "${option.id}" scores need "${term.need}", which the need set does not declare`);
|
|
56
|
+
}
|
|
57
|
+
return { needIndex, curve: term.curve, weight: term.weight ?? 1, invert: term.invert === true };
|
|
58
|
+
});
|
|
59
|
+
return { id: option.id, base: option.base ?? 0, terms };
|
|
60
|
+
});
|
|
61
|
+
const interrupts = (config.interrupts ?? []).map((interrupt) => {
|
|
62
|
+
const needIndex = needs.indexOf(interrupt.need);
|
|
63
|
+
if (needIndex < 0) {
|
|
64
|
+
throw new Error(`utility interrupt watches need "${interrupt.need}", which the need set does not declare`);
|
|
65
|
+
}
|
|
66
|
+
const forced = interrupt.option === undefined ? -1 : optionIndex.get(interrupt.option) ?? -1;
|
|
67
|
+
if (interrupt.option !== undefined && forced < 0) {
|
|
68
|
+
throw new Error(`utility interrupt forces unknown option "${interrupt.option}"`);
|
|
69
|
+
}
|
|
70
|
+
return { needIndex, at: interrupt.at, when: interrupt.when ?? "below", optionIndex: forced };
|
|
71
|
+
});
|
|
72
|
+
let switchMargin = Math.max(0, config.switchMargin ?? 0.05);
|
|
73
|
+
let minHoldSeconds = Math.max(0, config.minHoldSeconds ?? 0);
|
|
74
|
+
let costWeight = config.costWeight ?? 1;
|
|
75
|
+
let jitter = Math.max(0, config.jitter ?? 0);
|
|
76
|
+
let rng = config.rng;
|
|
77
|
+
let heldIndex = -1;
|
|
78
|
+
let heldScore = 0;
|
|
79
|
+
let holdSeconds = 0;
|
|
80
|
+
const optionScores = new Float64Array(options.length);
|
|
81
|
+
const decision = { option: null, score: 0, changed: false, reason: null, interrupted: false };
|
|
82
|
+
function scoreOption(compiled) {
|
|
83
|
+
let score = compiled.base;
|
|
84
|
+
for (const term of compiled.terms) {
|
|
85
|
+
const fraction = needs.fractionAt(term.needIndex);
|
|
86
|
+
score += term.weight * sampleResponseCurve(term.curve, term.invert ? 1 - fraction : fraction);
|
|
87
|
+
}
|
|
88
|
+
return score;
|
|
89
|
+
}
|
|
90
|
+
function scoreList() {
|
|
91
|
+
return options.map((option) => ({ option: option.id, score: scoreOption(option) }));
|
|
92
|
+
}
|
|
93
|
+
function busyInterrupt() {
|
|
94
|
+
for (let index = 0; index < interrupts.length; index += 1) {
|
|
95
|
+
const interrupt = interrupts[index];
|
|
96
|
+
const fraction = needs.fractionAt(interrupt.needIndex);
|
|
97
|
+
const crossed = interrupt.when === "below" ? fraction <= interrupt.at : fraction >= interrupt.at;
|
|
98
|
+
if (crossed)
|
|
99
|
+
return index;
|
|
100
|
+
}
|
|
101
|
+
return -1;
|
|
102
|
+
}
|
|
103
|
+
function costedScore(index, candidates) {
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
if (optionIndex.get(candidate.option) === index) {
|
|
106
|
+
return optionScores[index] - costWeight * (candidate.cost ?? 0);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return Number.NaN;
|
|
110
|
+
}
|
|
111
|
+
function setHeld(index, score, reason) {
|
|
112
|
+
heldIndex = index;
|
|
113
|
+
heldScore = score;
|
|
114
|
+
holdSeconds = 0;
|
|
115
|
+
decision.option = options[index]?.id ?? null;
|
|
116
|
+
decision.score = score;
|
|
117
|
+
decision.changed = true;
|
|
118
|
+
decision.reason = reason;
|
|
119
|
+
}
|
|
120
|
+
function keepHeld(score) {
|
|
121
|
+
heldScore = score;
|
|
122
|
+
decision.option = options[heldIndex]?.id ?? null;
|
|
123
|
+
decision.score = score;
|
|
124
|
+
}
|
|
125
|
+
function decide(candidates) {
|
|
126
|
+
decision.changed = false;
|
|
127
|
+
decision.reason = null;
|
|
128
|
+
let rngSource = rng ?? null;
|
|
129
|
+
if (jitter > 0 && rngSource === null) {
|
|
130
|
+
rngSource = resolveRng(undefined, "createUtilityBrain");
|
|
131
|
+
rng = rngSource;
|
|
132
|
+
}
|
|
133
|
+
for (let index = 0; index < options.length; index += 1) {
|
|
134
|
+
let score = scoreOption(options[index]);
|
|
135
|
+
if (jitter > 0)
|
|
136
|
+
score += (rngSource() * 2 - 1) * jitter;
|
|
137
|
+
optionScores[index] = score;
|
|
138
|
+
}
|
|
139
|
+
let bestIndex = -1;
|
|
140
|
+
let bestScore = -Infinity;
|
|
141
|
+
let heldCandidateScore = Number.NaN;
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
const index = optionIndex.get(candidate.option);
|
|
144
|
+
if (index === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
const score = optionScores[index] - costWeight * (candidate.cost ?? 0);
|
|
147
|
+
if (index === heldIndex)
|
|
148
|
+
heldCandidateScore = score;
|
|
149
|
+
if (score > bestScore) {
|
|
150
|
+
bestScore = score;
|
|
151
|
+
bestIndex = index;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const interrupt = busyInterrupt();
|
|
155
|
+
decision.interrupted = interrupt >= 0;
|
|
156
|
+
const heldReachable = heldIndex >= 0 && Number.isFinite(heldCandidateScore);
|
|
157
|
+
if (interrupt >= 0) {
|
|
158
|
+
const forced = interrupts[interrupt].optionIndex;
|
|
159
|
+
const forcedScore = forced >= 0 ? costedScore(forced, candidates) : Number.NaN;
|
|
160
|
+
if (forced >= 0 && Number.isFinite(forcedScore)) {
|
|
161
|
+
if (forced === heldIndex)
|
|
162
|
+
keepHeld(forcedScore);
|
|
163
|
+
else
|
|
164
|
+
setHeld(forced, forcedScore, "interrupt");
|
|
165
|
+
}
|
|
166
|
+
else if (bestIndex >= 0 && (!heldReachable || bestScore > heldCandidateScore)) {
|
|
167
|
+
setHeld(bestIndex, bestScore, "interrupt");
|
|
168
|
+
}
|
|
169
|
+
else if (heldReachable) {
|
|
170
|
+
keepHeld(heldCandidateScore);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
keepHeld(heldIndex < 0 ? 0 : heldScore);
|
|
174
|
+
}
|
|
175
|
+
return decision;
|
|
176
|
+
}
|
|
177
|
+
if (bestIndex < 0) {
|
|
178
|
+
keepHeld(heldIndex < 0 ? 0 : heldScore);
|
|
179
|
+
if (heldIndex < 0)
|
|
180
|
+
decision.option = null;
|
|
181
|
+
return decision;
|
|
182
|
+
}
|
|
183
|
+
if (heldIndex < 0) {
|
|
184
|
+
setHeld(bestIndex, bestScore, "initial");
|
|
185
|
+
return decision;
|
|
186
|
+
}
|
|
187
|
+
if (!heldReachable) {
|
|
188
|
+
setHeld(bestIndex, bestScore, "unavailable");
|
|
189
|
+
return decision;
|
|
190
|
+
}
|
|
191
|
+
keepHeld(heldCandidateScore);
|
|
192
|
+
if (bestIndex !== heldIndex && bestScore > heldScore + switchMargin && holdSeconds >= minHoldSeconds) {
|
|
193
|
+
setHeld(bestIndex, bestScore, "score");
|
|
194
|
+
}
|
|
195
|
+
return decision;
|
|
196
|
+
}
|
|
197
|
+
const unregister = config.devtoolsName === undefined
|
|
198
|
+
? null
|
|
199
|
+
: devtools.probes.register(config.devtoolsName, () => debugLine());
|
|
200
|
+
function debugLine() {
|
|
201
|
+
const stick = `margin ${switchMargin.toFixed(2)} hold ${holdSeconds.toFixed(1)}s`;
|
|
202
|
+
const pick = `${decision.option ?? "none"} ${decision.score.toFixed(2)}${decision.reason === null ? "" : ` (${decision.reason})`}`;
|
|
203
|
+
const urgent = needs.mostUrgent();
|
|
204
|
+
const need = urgent === null ? "no needs" : `${urgent.id} ${urgent.fraction.toFixed(2)}`;
|
|
205
|
+
return `${pick} · ${need} · ${stick}`;
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
advance(dt) {
|
|
209
|
+
if (!(dt > 0))
|
|
210
|
+
return;
|
|
211
|
+
needs.tick(dt);
|
|
212
|
+
holdSeconds += dt;
|
|
213
|
+
},
|
|
214
|
+
decide,
|
|
215
|
+
chosen: () => (heldIndex < 0 ? null : options[heldIndex].id),
|
|
216
|
+
scoreOf(optionId) {
|
|
217
|
+
const index = optionIndex.get(optionId);
|
|
218
|
+
if (index === undefined)
|
|
219
|
+
throw new Error(`unknown utility option "${optionId}"`);
|
|
220
|
+
return scoreOption(options[index]);
|
|
221
|
+
},
|
|
222
|
+
scores: () => scoreList(),
|
|
223
|
+
retune(patch) {
|
|
224
|
+
if (patch.switchMargin !== undefined)
|
|
225
|
+
switchMargin = Math.max(0, patch.switchMargin);
|
|
226
|
+
if (patch.minHoldSeconds !== undefined)
|
|
227
|
+
minHoldSeconds = Math.max(0, patch.minHoldSeconds);
|
|
228
|
+
if (patch.costWeight !== undefined)
|
|
229
|
+
costWeight = patch.costWeight;
|
|
230
|
+
if (patch.jitter !== undefined)
|
|
231
|
+
jitter = Math.max(0, patch.jitter);
|
|
232
|
+
},
|
|
233
|
+
debugSnapshot() {
|
|
234
|
+
const readings = [];
|
|
235
|
+
for (const id of needs.ids()) {
|
|
236
|
+
readings.push({ id, value: needs.value(id), fraction: needs.fraction(id) });
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
chosen: decision.option,
|
|
240
|
+
score: decision.score,
|
|
241
|
+
reason: decision.reason,
|
|
242
|
+
switchMargin,
|
|
243
|
+
costWeight,
|
|
244
|
+
holdSeconds,
|
|
245
|
+
needs: readings,
|
|
246
|
+
options: scoreList(),
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
snapshot: () => ({
|
|
250
|
+
option: heldIndex < 0 ? null : options[heldIndex].id,
|
|
251
|
+
score: heldScore,
|
|
252
|
+
holdSeconds,
|
|
253
|
+
switchMargin,
|
|
254
|
+
minHoldSeconds,
|
|
255
|
+
costWeight,
|
|
256
|
+
jitter,
|
|
257
|
+
}),
|
|
258
|
+
restore(next) {
|
|
259
|
+
const index = next.option === null ? undefined : optionIndex.get(next.option);
|
|
260
|
+
heldIndex = index ?? -1;
|
|
261
|
+
heldScore = next.score;
|
|
262
|
+
holdSeconds = Math.max(0, next.holdSeconds);
|
|
263
|
+
switchMargin = Math.max(0, next.switchMargin);
|
|
264
|
+
minHoldSeconds = Math.max(0, next.minHoldSeconds);
|
|
265
|
+
costWeight = next.costWeight;
|
|
266
|
+
jitter = Math.max(0, next.jitter);
|
|
267
|
+
decision.option = heldIndex < 0 ? null : options[heldIndex].id;
|
|
268
|
+
decision.score = heldScore;
|
|
269
|
+
decision.changed = false;
|
|
270
|
+
decision.reason = null;
|
|
271
|
+
decision.interrupted = false;
|
|
272
|
+
},
|
|
273
|
+
dispose() {
|
|
274
|
+
unregister?.();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Vec3 } from "../world/geometry.js";
|
|
2
|
+
/**
|
|
3
|
+
* A box of authored world geometry a body can hide behind. Structurally a `WorldSolid`, so
|
|
4
|
+
* `ctx.world.solids.all()` drops in directly; a game with its own collision can pass any boxes.
|
|
5
|
+
*/
|
|
6
|
+
export interface CoverSolid {
|
|
7
|
+
readonly center: readonly [number, number, number];
|
|
8
|
+
readonly halfExtents: readonly [number, number, number];
|
|
9
|
+
/** Yaw about `center`, radians, in the engine's `rotationY` convention. Default `0`. */
|
|
10
|
+
readonly rotationY?: number;
|
|
11
|
+
}
|
|
12
|
+
/** One stand position beside authored geometry, with the face direction it hides against. */
|
|
13
|
+
export interface CoverPoint {
|
|
14
|
+
/** Stable id from the rounded position and face, so a held point survives a rebuild. */
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly position: Vec3;
|
|
17
|
+
/** Unit XZ direction pointing away from the solid — the way a body leans out to shoot. */
|
|
18
|
+
readonly normal: readonly [number, number];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Bounded source of cover near a position: generated from authored geometry, or the game's own
|
|
22
|
+
* spatial query. Called for one member at a time, never for the whole squad.
|
|
23
|
+
*/
|
|
24
|
+
export interface CoverSource {
|
|
25
|
+
near(position: Vec3, radius: number, limit?: number): readonly CoverPoint[];
|
|
26
|
+
}
|
|
27
|
+
/** How cover points sit relative to the geometry that generates them. */
|
|
28
|
+
export interface CoverPointOptions {
|
|
29
|
+
/** How far a stand point sits off the face, world units. Default `0.7`. */
|
|
30
|
+
standOff?: number;
|
|
31
|
+
/** Spacing of stand points along a face. Default `2`. */
|
|
32
|
+
spacing?: number;
|
|
33
|
+
/** Faces shorter than this get no stand points. Default `1.2`. */
|
|
34
|
+
minFaceLength?: number;
|
|
35
|
+
/** Most stand points per face. Default `3`. */
|
|
36
|
+
maxPerFace?: number;
|
|
37
|
+
/** Ground height at a stand point; omit to use the bottom of the solid. */
|
|
38
|
+
yOf?: (x: number, z: number) => number;
|
|
39
|
+
/** Rejects stand points that are not walkable — wire `nav.findPolygon(p) !== null`. */
|
|
40
|
+
walkable?: (point: Vec3) => boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Stand points on every vertical face of the supplied boxes: one row per face, spaced along it,
|
|
44
|
+
* `standOff` out from the surface. Geometry in, cover out — no authored coordinate list, so moving
|
|
45
|
+
* a wall in the editor moves its cover.
|
|
46
|
+
*
|
|
47
|
+
* @capability cover-points-from-solids derive stand-able cover positions from authored solid boxes
|
|
48
|
+
*/
|
|
49
|
+
export declare function coverPointsFromSolids(solids: readonly CoverSolid[], options?: CoverPointOptions): CoverPoint[];
|
|
50
|
+
/**
|
|
51
|
+
* The `limit` nearest cover points within `radius`, nearest first, ties broken by id. A linear scan
|
|
52
|
+
* of the points the field holds; the field caps how many points exist.
|
|
53
|
+
*
|
|
54
|
+
* @capability cover-points-near bounded nearest-cover query
|
|
55
|
+
*/
|
|
56
|
+
export declare function coverPointsNear(points: readonly CoverPoint[], position: Vec3, radius: number, limit?: number): CoverPoint[];
|
|
57
|
+
/** Serializable cover field state: the boxes and the dials, not the derived points. */
|
|
58
|
+
export interface CoverFieldSnapshot {
|
|
59
|
+
solids: CoverSolid[];
|
|
60
|
+
standOff: number;
|
|
61
|
+
spacing: number;
|
|
62
|
+
minFaceLength: number;
|
|
63
|
+
maxPerFace: number;
|
|
64
|
+
}
|
|
65
|
+
/** Options for {@link createCoverField}: the authored boxes, the dials, and a probe name. */
|
|
66
|
+
export interface CoverFieldOptions extends CoverPointOptions {
|
|
67
|
+
solids: readonly CoverSolid[];
|
|
68
|
+
/** Register a devtools probe under this name so the overlay shows the derived point count. */
|
|
69
|
+
devtoolsName?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Cover derived from authored geometry, rebuilt when the world is edited. */
|
|
72
|
+
export interface CoverField extends CoverSource {
|
|
73
|
+
points(): readonly CoverPoint[];
|
|
74
|
+
count(): number;
|
|
75
|
+
/** Re-derive from a new set of boxes after an editor edit or a procedural world change. */
|
|
76
|
+
rebuild(solids: readonly CoverSolid[]): void;
|
|
77
|
+
retune(patch: CoverPointOptions): void;
|
|
78
|
+
snapshot(): CoverFieldSnapshot;
|
|
79
|
+
restore(next: CoverFieldSnapshot): void;
|
|
80
|
+
/** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
|
|
81
|
+
dispose(): void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Cover over authored boxes: derive the stand points once, re-derive them after an edit, and answer
|
|
85
|
+
* bounded nearest-cover queries. `walkable` is injected, so the seam checks the game's own ground
|
|
86
|
+
* test (`nav.findPolygon(point) !== null`) instead of assuming a flat floor.
|
|
87
|
+
*
|
|
88
|
+
* @capability cover-field authored-geometry cover with a bounded nearest query and rebuild on edit
|
|
89
|
+
*/
|
|
90
|
+
export declare function createCoverField(options: CoverFieldOptions): CoverField;
|
|
91
|
+
/** A remembered threat: where the member last saw it and how strongly it still believes it. */
|
|
92
|
+
export interface CoverThreat {
|
|
93
|
+
readonly id: string;
|
|
94
|
+
readonly position: Vec3;
|
|
95
|
+
/** `0..1` memory confidence; a fading memory steers less. */
|
|
96
|
+
readonly confidence: number;
|
|
97
|
+
}
|
|
98
|
+
/** What each term is worth when choosing cover. A role owns these numbers. */
|
|
99
|
+
export interface CoverWeights {
|
|
100
|
+
/** Hides the stand point from remembered threats. Default `3`. */
|
|
101
|
+
hidden?: number;
|
|
102
|
+
/** Stands at the role's preferred fighting distance from the nearest threat. Default `1`. */
|
|
103
|
+
range?: number;
|
|
104
|
+
/** Stands at the role's flank angle around the threat. Default `1`. */
|
|
105
|
+
flank?: number;
|
|
106
|
+
/** Stays close to where the member is now. Default `0.6`. */
|
|
107
|
+
near?: number;
|
|
108
|
+
/** Avoids a point a squadmate already holds. Default `2`. */
|
|
109
|
+
crowding?: number;
|
|
110
|
+
/** Adds distance from the nearest threat — a fallback. Default `0`. */
|
|
111
|
+
retreat?: number;
|
|
112
|
+
/** Penalizes a point the member cannot walk to in a straight line (a detour around the obstacle). Default `1.2`. */
|
|
113
|
+
path?: number;
|
|
114
|
+
}
|
|
115
|
+
/** Everything one cover decision reads: where the member is, what it believes, and its role's policy. */
|
|
116
|
+
export interface CoverQuery {
|
|
117
|
+
position: Vec3;
|
|
118
|
+
/** Radius `near()` was called with, so `near` and `retreat` can normalize. */
|
|
119
|
+
radius: number;
|
|
120
|
+
threats: readonly CoverThreat[];
|
|
121
|
+
/** Distance from the nearest threat this role would rather fight at. */
|
|
122
|
+
preferredRange: number;
|
|
123
|
+
/** Angle around the threat to attack from, radians off the squad's line. Default `0`. */
|
|
124
|
+
flankAngle?: number;
|
|
125
|
+
/** Point the squad's line runs through, so a flank angle means the same for every member. Default `position`. */
|
|
126
|
+
lineThrough?: Vec3;
|
|
127
|
+
/** Ids squadmates already hold. */
|
|
128
|
+
taken?: ReadonlySet<string>;
|
|
129
|
+
/** True when nothing blocks the segment — the game's collision layer. */
|
|
130
|
+
blocked: (from: Vec3, to: Vec3) => boolean;
|
|
131
|
+
/** Ignore memories weaker than this. Default `0.05`. */
|
|
132
|
+
minConfidence?: number;
|
|
133
|
+
/** Only consider points hidden from at least one remembered threat. Default `false`. */
|
|
134
|
+
hiddenOnly?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Radius of the body a point must hide, world units: a point counts as hidden only when the body
|
|
137
|
+
* standing there is out of sight, not just its centre. `0` tests the point alone. Default `0.4`.
|
|
138
|
+
*/
|
|
139
|
+
bodyRadius?: number;
|
|
140
|
+
weights?: CoverWeights;
|
|
141
|
+
}
|
|
142
|
+
/** A scored cover point, with every term visible so a probe or test can see why it won. */
|
|
143
|
+
export interface CoverChoice {
|
|
144
|
+
readonly point: CoverPoint;
|
|
145
|
+
readonly score: number;
|
|
146
|
+
readonly hidden: number;
|
|
147
|
+
readonly range: number;
|
|
148
|
+
readonly flank: number;
|
|
149
|
+
readonly near: number;
|
|
150
|
+
readonly retreat: number;
|
|
151
|
+
/** `1` when the straight walk to the point is blocked, so reaching it means a detour. */
|
|
152
|
+
readonly path: number;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Score one stand point against the threats the member remembers. `hidden` is the confidence-weighted
|
|
156
|
+
* share of remembered threats that cannot see the point, so a point that hides from the shooter that
|
|
157
|
+
* matters scores over one that hides from a fading one. Returns `null` when nothing it can hide from
|
|
158
|
+
* is worth considering.
|
|
159
|
+
*
|
|
160
|
+
* @capability cover-score threat-driven cover point scoring
|
|
161
|
+
*/
|
|
162
|
+
export declare function scoreCoverPoint(point: CoverPoint, query: CoverQuery): CoverChoice | null;
|
|
163
|
+
/**
|
|
164
|
+
* The best of `points` under one {@link CoverQuery}, ties broken by point id. Pass the candidate set
|
|
165
|
+
* the injected query already bounded; this scores what it is given.
|
|
166
|
+
*
|
|
167
|
+
* @capability cover-select select cover by threat, role range and flank angle
|
|
168
|
+
*/
|
|
169
|
+
export declare function selectCover(points: readonly CoverPoint[], query: CoverQuery): CoverChoice | null;
|