@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +109 -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/game/playableGame.d.ts +19 -2
  50. package/dist/gameplay.d.ts +9 -0
  51. package/dist/gameplay.js +9 -0
  52. package/dist/inventory/carryLoad.d.ts +48 -0
  53. package/dist/inventory/carryLoad.js +44 -0
  54. package/dist/inventory/equipment.d.ts +95 -0
  55. package/dist/inventory/equipment.js +169 -0
  56. package/dist/inventory/inventoryModel.d.ts +15 -6
  57. package/dist/inventory/inventoryModel.js +54 -5
  58. package/dist/inventory/shapedGrid.d.ts +26 -0
  59. package/dist/inventory/shapedGrid.js +47 -0
  60. package/dist/loot/lootGen.d.ts +186 -0
  61. package/dist/loot/lootGen.js +437 -0
  62. package/dist/loot/lootWeapon.d.ts +35 -0
  63. package/dist/loot/lootWeapon.js +70 -0
  64. package/dist/meta/changelog.d.ts +1 -1
  65. package/dist/meta/changelog.js +92 -1
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/navCrowd.d.ts +138 -0
  69. package/dist/nav/navCrowd.js +412 -0
  70. package/dist/nav/navRuntime.d.ts +108 -0
  71. package/dist/nav/navRuntime.js +256 -0
  72. package/dist/nav/traffic.d.ts +223 -0
  73. package/dist/nav/traffic.js +919 -0
  74. package/dist/physics/kinematicVehicle.d.ts +36 -0
  75. package/dist/physics/kinematicVehicle.js +15 -1
  76. package/dist/physics/vehicleDamage.d.ts +136 -0
  77. package/dist/physics/vehicleDamage.js +214 -0
  78. package/dist/physics/vehicleParts.d.ts +62 -0
  79. package/dist/physics/vehicleParts.js +65 -0
  80. package/dist/progression/gearScore.d.ts +56 -0
  81. package/dist/progression/gearScore.js +59 -0
  82. package/dist/progression/skillTree.d.ts +129 -0
  83. package/dist/progression/skillTree.js +265 -0
  84. package/dist/progression/xpCurve.d.ts +60 -0
  85. package/dist/progression/xpCurve.js +141 -0
  86. package/dist/render/colorPipeline.d.ts +53 -0
  87. package/dist/render/colorPipeline.js +79 -0
  88. package/dist/render/lightProbes.d.ts +97 -0
  89. package/dist/render/lightProbes.js +221 -0
  90. package/dist/render/lookPreset.js +4 -4
  91. package/dist/render/physicalSky.d.ts +39 -0
  92. package/dist/render/physicalSky.js +89 -0
  93. package/dist/render/postProcessing.d.ts +40 -8
  94. package/dist/render/postProcessing.js +1 -1
  95. package/dist/render/skyOwnedLighting.d.ts +31 -0
  96. package/dist/render/skyOwnedLighting.js +46 -0
  97. package/dist/scene/builtinSceneKinds.js +2 -0
  98. package/dist/sensor/perception.d.ts +66 -5
  99. package/dist/sensor/perception.js +167 -42
  100. package/dist/sim/nearAgents.d.ts +68 -0
  101. package/dist/sim/nearAgents.js +152 -0
  102. package/dist/sim/needs.d.ts +60 -0
  103. package/dist/sim/needs.js +108 -0
  104. package/dist/sim/offScreen.d.ts +181 -0
  105. package/dist/sim/offScreen.js +450 -0
  106. package/dist/sim/routine.d.ts +79 -0
  107. package/dist/sim/routine.js +137 -0
  108. package/dist/visibility/visibilitySystem.js +21 -0
  109. package/dist/world/decalKind.d.ts +48 -0
  110. package/dist/world/decalKind.js +86 -0
  111. package/dist/world/features.d.ts +11 -1
  112. package/dist/world/features.js +1 -0
  113. package/dist/world/grassKind.d.ts +5 -0
  114. package/dist/world/grassKind.js +3 -0
  115. package/dist/world/terraform.d.ts +8 -0
  116. package/dist/world.d.ts +10 -1
  117. package/dist/world.js +9 -0
  118. package/package.json +1 -1
  119. package/skills/jgengine/api.md +59 -6
  120. package/skills/jgengine/capabilities.md +4 -0
  121. package/skills/jgengine-combat/SKILL.md +19 -0
  122. package/skills/jgengine-combat/api.md +246 -0
  123. package/skills/jgengine-combat/capabilities.md +112 -0
  124. package/skills/jgengine-combat/reference.md +32 -0
  125. package/skills/jgengine-gameplay/SKILL.md +39 -0
  126. package/skills/jgengine-gameplay/api.md +221 -7
  127. package/skills/jgengine-gameplay/capabilities.md +64 -3
  128. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  129. package/skills/jgengine-world/SKILL.md +7 -1
  130. package/skills/jgengine-world/api.md +222 -8
  131. package/skills/jgengine-world/capabilities.md +42 -2
  132. package/skills/jgengine-world/recipes/npc-senses.md +8 -2
  133. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
@@ -1,5 +1,27 @@
1
- /** Renderer tone-mapping curve applied by the post chain's output stage. */
1
+ import type { AutoExposureConfig } from "./colorPipeline.js";
2
+ /**
3
+ * Renderer tone-mapping curve applied by the post chain's output stage. `"agx"` is the pipeline
4
+ * default: it keeps hue in saturated highlights and rolls a sun-lit surface off gradually, where
5
+ * `"aces"` skews bright colours toward white.
6
+ */
2
7
  export type ToneMappingMode = "aces" | "agx" | "reinhard" | "cineon" | "linear" | "none";
8
+ /**
9
+ * A baked display-space LUT cube: `size³` RGB entries with red varying fastest, three bytes per entry
10
+ * in 0..255. Kept as plain data so a scene document can author one and a test can assert on it.
11
+ */
12
+ export interface LutCube {
13
+ /** Edge length of the cube, 2..16. */
14
+ size: number;
15
+ /** `size³ × 3` bytes, red fastest, then green, then blue. */
16
+ data: Uint8Array;
17
+ }
18
+ /** A grade's LUT: a strip PNG (one `size`-wide tile per blue slice) or a baked cube. */
19
+ export type GradeLut = {
20
+ url: string;
21
+ size?: number;
22
+ } | {
23
+ cube: LutCube;
24
+ };
3
25
  /** UnrealBloom stage — soft HDR glow around bright pixels (sun, glints, emissive). */
4
26
  export interface BloomConfig {
5
27
  /** Glow intensity. Default 0.32. */
@@ -26,11 +48,12 @@ export interface AoConfig {
26
48
  }
27
49
  /** Final colour-grade stage: lift/gain/gamma, saturation, vignette, film grain — applied in display space after tone mapping. */
28
50
  export interface GradeConfig {
29
- /** Optional 2D strip LUT URL. The strip contains size square tiles, one tile per blue slice. */
30
- lut?: {
31
- url: string;
32
- size?: number;
33
- };
51
+ /**
52
+ * Colour LUT applied after the numeric grade, in display space. Either a strip PNG (one
53
+ * `size`-wide tile per blue slice, trilinearly sampled) or a baked {@link LutCube} such as the one
54
+ * a scene document's authored grading produces.
55
+ */
56
+ lut?: GradeLut;
34
57
  /** Additive shadow tint (RGB, ~0..0.1). Default cool [0.012, 0.010, 0.018]. */
35
58
  lift?: readonly [number, number, number];
36
59
  /** Multiplicative highlight tint (RGB, ~1). Default warm [1.05, 1.02, 0.98]. */
@@ -99,10 +122,19 @@ export type PostAaMode = "smaa" | "msaa" | false;
99
122
  export interface PostProcessingConfig {
100
123
  /** Master switch; a config object with `enabled: false` mounts nothing. Default true. */
101
124
  enabled?: boolean;
102
- /** Tone-mapping curve the output stage applies. Default "aces". */
125
+ /** Tone-mapping curve the output stage applies. Default "agx". */
103
126
  toneMapping?: ToneMappingMode;
104
- /** Exposure multiplier applied before tone mapping. Default 1. */
127
+ /**
128
+ * Exposure multiplier applied before tone mapping. Default 1. With `autoExposure` on this is the
129
+ * baseline the meter corrects around, not a fixed value.
130
+ */
105
131
  exposure?: number;
132
+ /**
133
+ * Clamped auto-exposure: meters the rendered frame and adapts exposure toward
134
+ * {@link import("./colorPipeline.js").MID_GREY}, bounded by `min`/`max`. Omitted uses
135
+ * {@link import("./colorPipeline.js").DEFAULT_AUTO_EXPOSURE} on every tier; `false` pins `exposure`.
136
+ */
137
+ autoExposure?: AutoExposureConfig | false;
106
138
  /**
107
139
  * Antialiasing. Default `"smaa"` (plus a light MSAA resolve on the beauty target).
108
140
  * Set `"msaa"` to skip the SMAA pass, or `false` for neither.
@@ -7,7 +7,7 @@ export const DEFAULT_AO = { radius: 1.3, intensity: 1.3, distanceFalloff: 2.8, b
7
7
  * is left off by default (it needs a per-scene focus distance); set `dof` to enable it.
8
8
  */
9
9
  export const STUDIO_STAGE_POST = {
10
- toneMapping: "aces",
10
+ toneMapping: "agx",
11
11
  exposure: 1.05,
12
12
  bloom: { strength: 0.28, radius: 0.6, threshold: 0.8 },
13
13
  ao: { radius: 1.1, intensity: 1.5, distanceFalloff: 3, blend: 1 },
@@ -0,0 +1,31 @@
1
+ import type { LightingConfig } from "../game/playableGame.js";
2
+ import type { SkyEnvironmentConfig } from "../world/features.js";
3
+ /**
4
+ * Which half of a game's authored `lighting` a time-of-day sky takes over.
5
+ *
6
+ * A time-of-day sky has to move the sun or the whole world stays lit like noon after dark: the
7
+ * dome turns black while the ground keeps its midday key light. So under a day cycle the first
8
+ * directional light is the **sun**, and `ambient`/`hemisphere` are the **noon keyframes** of the
9
+ * ambient fill — the same convention the sky already uses for `horizonColor`/`zenithColor`. Every
10
+ * other light (fill directionals, point and spot lamps) stays exactly as authored.
11
+ */
12
+ export interface SkyOwnedLighting {
13
+ /** Sun + ambient terms the sky now drives from the day cycle; intensities read as noon values. */
14
+ sky: LightingConfig;
15
+ /** Lights the sky does not own; mounted as authored. */
16
+ rest: LightingConfig;
17
+ }
18
+ /**
19
+ * Splits authored lighting into the sun/ambient terms a time-of-day sky drives and the rest.
20
+ * Returns an empty `sky` when the game authored no lighting, so callers can keep engine defaults.
21
+ * @capability day-night-sky hand the sun and ambient fill to the day cycle while authored lamps keep running
22
+ */
23
+ export declare function splitSkyOwnedLighting(lighting: LightingConfig | undefined): SkyOwnedLighting;
24
+ /**
25
+ * The authored `lighting` as the day cycle's **noon keyframes**, filling only the sky fields the
26
+ * game left unset. A game's midday sun, fill and fog therefore become the peak the cycle scales
27
+ * and cross-fades from, so an authored world gets a sunset and a night without re-authoring the
28
+ * same numbers in the sky. Authored sky fields always win over the lighting config.
29
+ * @capability day-night-sky read authored lighting as the day cycle's noon keyframe
30
+ */
31
+ export declare function skyNoonKeyframes<T extends SkyEnvironmentConfig>(sky: T, lighting: LightingConfig | undefined, fogColor?: string): T;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Splits authored lighting into the sun/ambient terms a time-of-day sky drives and the rest.
3
+ * Returns an empty `sky` when the game authored no lighting, so callers can keep engine defaults.
4
+ * @capability day-night-sky hand the sun and ambient fill to the day cycle while authored lamps keep running
5
+ */
6
+ export function splitSkyOwnedLighting(lighting) {
7
+ if (lighting === undefined)
8
+ return { sky: {}, rest: {} };
9
+ const directional = lighting.directional ?? [];
10
+ const [sun, ...fill] = directional;
11
+ const sky = {
12
+ ...(sun === undefined ? {} : { directional: [sun] }),
13
+ ...(lighting.ambient === undefined ? {} : { ambient: lighting.ambient }),
14
+ ...(lighting.hemisphere === undefined ? {} : { hemisphere: lighting.hemisphere }),
15
+ };
16
+ const rest = {
17
+ ...(fill.length === 0 ? {} : { directional: fill }),
18
+ ...(lighting.point === undefined ? {} : { point: lighting.point }),
19
+ ...(lighting.spot === undefined ? {} : { spot: lighting.spot }),
20
+ ...(lighting.maxDynamicLights === undefined ? {} : { maxDynamicLights: lighting.maxDynamicLights }),
21
+ ...(lighting.iblAmbientScale === undefined ? {} : { iblAmbientScale: lighting.iblAmbientScale }),
22
+ ...(lighting.fallbackKey === undefined ? {} : { fallbackKey: lighting.fallbackKey }),
23
+ };
24
+ return { sky, rest };
25
+ }
26
+ /**
27
+ * The authored `lighting` as the day cycle's **noon keyframes**, filling only the sky fields the
28
+ * game left unset. A game's midday sun, fill and fog therefore become the peak the cycle scales
29
+ * and cross-fades from, so an authored world gets a sunset and a night without re-authoring the
30
+ * same numbers in the sky. Authored sky fields always win over the lighting config.
31
+ * @capability day-night-sky read authored lighting as the day cycle's noon keyframe
32
+ */
33
+ export function skyNoonKeyframes(sky, lighting, fogColor) {
34
+ const sun = lighting?.directional?.[0];
35
+ const fill = lighting?.hemisphere?.intensity ?? lighting?.ambient?.intensity;
36
+ const noon = { ...sky };
37
+ if (noon.sunIntensity === undefined && sun?.intensity !== undefined)
38
+ noon.sunIntensity = sun.intensity;
39
+ if (noon.ambientIntensity === undefined && fill !== undefined)
40
+ noon.ambientIntensity = fill;
41
+ if (noon.sunColor === undefined && sun?.color !== undefined)
42
+ noon.sunColor = sun.color;
43
+ if (noon.fogColor === undefined && fogColor !== undefined)
44
+ noon.fogColor = fogColor;
45
+ return noon;
46
+ }
@@ -13,6 +13,7 @@ import { registerWaterKind } from "../world/waterKind.js";
13
13
  import { registerGrassKind } from "../world/grassKind.js";
14
14
  import { registerSoilKind } from "../world/soilKind.js";
15
15
  import { registerPoleLineKind } from "../world/poleLineKind.js";
16
+ import { registerDecalKind } from "../world/decalKind.js";
16
17
  import { registerBuildingGenerator } from "../world/buildingGenerator.js";
17
18
  import { registerCityKind } from "../world/cityKind.js";
18
19
  import { describeScatterCoverage, densityCoverage } from "../world/scatterCoverage.js";
@@ -80,5 +81,6 @@ export function registerBuiltinSceneKinds() {
80
81
  registerSoilKind();
81
82
  registerCityKind();
82
83
  registerPoleLineKind();
84
+ registerDecalKind();
83
85
  registerBuildingGenerator();
84
86
  }
@@ -25,11 +25,17 @@ export interface PerceptionCandidate {
25
25
  /** A remembered target and its linearly decaying confidence. */
26
26
  export interface PerceptionMemory {
27
27
  targetId: string;
28
+ /** When the fact was observed, on the same clock `observe` is given; its age is `now − lastSeenAt`. */
28
29
  lastSeenAt: number;
29
30
  lastKnownPos: PerceptionPosition;
31
+ /** 1 at the moment of the sighting, falling to 0 across `memorySeconds`. */
30
32
  confidence: number;
33
+ /** Whose senses produced the fact: this observer's id for its own sighting, a squadmate's for a shared one. */
34
+ sourceId: string;
35
+ /** Observe pass that recorded the fact, so a shared sighting is attributable to the tick it arrived. */
36
+ sourceTick: number;
31
37
  }
32
- /** Configuration for sight, hearing, memory decay, and optional occlusion. */
38
+ /** Configuration for sight, hearing, memory decay, squad sharing and optional occlusion. */
33
39
  export interface PerceptionConfig {
34
40
  sightRange: number;
35
41
  sightConeDeg: number;
@@ -37,7 +43,43 @@ export interface PerceptionConfig {
37
43
  memorySeconds: number;
38
44
  /** Most stimuli retained at once; the oldest are dropped first. Default 256. */
39
45
  maxStimuli?: number;
40
- occluded?: (from: PerceptionPosition, to: PerceptionPosition) => boolean;
46
+ /**
47
+ * Cheap "nothing blocks the segment from `from` to `to`" test, supplied by the game's collision
48
+ * layer. Only called for pairs already inside range and cone, so the costly test sees the pruned
49
+ * set. Omit for open ground.
50
+ */
51
+ isSegmentClear?: (from: PerceptionPosition, to: PerceptionPosition) => boolean;
52
+ /**
53
+ * The other members of an observer's squad, by id. A sighting one member makes is written into
54
+ * each mate's memory with the seeing member's id and the pass it happened on. Omit for lone
55
+ * observers. Keep the returned set small — the roster is the caller's, so the caller bounds it.
56
+ */
57
+ squadMates?: (observerId: string) => Iterable<string>;
58
+ /** Most sighting shares one pass may write, so a large squad cannot blow the tick budget. Default 64. */
59
+ maxSharesPerTick?: number;
60
+ /** Register a devtools probe under this name so the overlay shows perception work and memory. */
61
+ devtoolsName?: string;
62
+ }
63
+ /** Counters a perf test or a devtools probe reads. Reset with {@link PerceptionService.resetStats}. */
64
+ export interface PerceptionStats {
65
+ /** Observe passes run. Keeps counting across `resetStats`, since it is also the recorded tick. */
66
+ passes: number;
67
+ /** `(observer, candidate)` pairs examined — one check per pair per pass. */
68
+ pairChecks: number;
69
+ /** Pairs rejected by distance before any cone or occlusion work. */
70
+ rangeCulled: number;
71
+ /** Pairs inside sight range, so they paid the cone test. */
72
+ coneTests: number;
73
+ /** Calls into the injected `isSegmentClear`. */
74
+ occlusionChecks: number;
75
+ /** Pairs that produced the observer's own sighting. */
76
+ sightings: number;
77
+ /** Sound and damage stimuli an observer resolved inside its hearing range. */
78
+ heard: number;
79
+ /** Sightings written into a squadmate's memory. */
80
+ shares: number;
81
+ /** Sightings the per-pass share cap kept from being broadcast. */
82
+ shareDropped: number;
41
83
  }
42
84
  /** Serializable state for a perception service. */
43
85
  export interface PerceptionSnapshot {
@@ -52,19 +94,38 @@ export interface PerceptionSnapshot {
52
94
  /** Per observer, the last stimulus sequence number it has already judged. Absent in older saves. */
53
95
  heard?: Record<string, number>;
54
96
  nextSeq?: number;
97
+ /** Observe passes recorded, which is also the tick a memory carries. Absent in older saves. */
98
+ passes?: number;
55
99
  }
56
100
  /** Stateful perception API with retuning and save/restore support. */
57
101
  export interface PerceptionService {
58
102
  pushStimulus(stimulus: PerceptionStimulus): void;
59
103
  observe(observer: PerceptionObserver, candidates: readonly PerceptionCandidate[], nowMs: number): void;
60
104
  memory(observerId: string): PerceptionMemory[];
105
+ /** One remembered target with its confidence now, or `null` when never seen or already faded. */
106
+ memoryOf(observerId: string, targetId: string): PerceptionMemory | null;
61
107
  /** Drop one remembered target, or everything an observer remembers (despawn, reset, disguise). */
62
108
  forget(observerId: string, targetId?: string): void;
63
- retune(patch: Partial<PerceptionConfig>): void;
109
+ stats(): PerceptionStats;
110
+ /** Zero the work counters, keeping the pass count that memories are stamped with. */
111
+ resetStats(): void;
112
+ retune(patch: Partial<Omit<PerceptionConfig, "devtoolsName">>): void;
64
113
  snapshot(): PerceptionSnapshot;
65
114
  restore(snapshot: PerceptionSnapshot): void;
115
+ /** Drop the devtools probe registered by `devtoolsName`. Safe to call twice. */
116
+ dispose(): void;
66
117
  }
67
- /** Creates a deterministic sight, sound, and damage perception service with expiring target memory.
68
- * @capability perception-service retain sight, sound, and damage observations with range, cone, occlusion, and decay policy
118
+ /**
119
+ * Creates a deterministic sight, sound, and damage perception service with expiring target memory.
120
+ * Signals are judged in the order the caller supplies them, never from the wall clock.
121
+ *
122
+ * A sighting needs range, cone and a clear line; a sound carries `hearingRange × loudness` and
123
+ * fades with the distance it crossed, so a distant footstep is inaudible while a near gunshot is
124
+ * not. Memory is the only record of "seen recently": each fact fades linearly over `memorySeconds`
125
+ * and is then gone. With `squadMates` injected, a sighting one member makes lands in every mate's
126
+ * memory stamped with the seeing member and the pass, which is what lets a squad react to one pair
127
+ * of eyes without each member re-seeing anything.
128
+ *
129
+ * @capability perception-service retain sightings, sounds and damage with cone, range, occlusion, decaying memory and shared squad knowledge
69
130
  */
70
131
  export declare function createPerception(initial: PerceptionConfig): PerceptionService;
@@ -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;