@jgengine/core 0.19.0 → 0.21.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 (260) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +2 -0
  41. package/dist/editor/liveSync.d.ts +4 -1
  42. package/dist/editor/liveSync.js +26 -10
  43. package/dist/editor/materialAuthoring.d.ts +14 -0
  44. package/dist/editor/materialAuthoring.js +38 -0
  45. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  46. package/dist/editor/modelNodeVisibility.js +29 -0
  47. package/dist/editor/movementCatalog.d.ts +90 -0
  48. package/dist/editor/movementCatalog.js +144 -0
  49. package/dist/editor/simulation.d.ts +60 -0
  50. package/dist/editor/simulation.js +202 -0
  51. package/dist/editor/staticPrefab.d.ts +17 -0
  52. package/dist/editor/staticPrefab.js +51 -0
  53. package/dist/editor/types.d.ts +11 -0
  54. package/dist/game/cameraConfig.d.ts +3 -1
  55. package/dist/game/clipRoles.d.ts +2 -1
  56. package/dist/game/clipRoles.js +7 -3
  57. package/dist/game/contentValidation.d.ts +69 -0
  58. package/dist/game/contentValidation.js +118 -0
  59. package/dist/game/controlGate.d.ts +8 -0
  60. package/dist/game/controlGate.js +43 -6
  61. package/dist/game/defineGame.d.ts +32 -3
  62. package/dist/game/dialogueGraph.d.ts +20 -0
  63. package/dist/game/dialogueGraph.js +107 -0
  64. package/dist/game/events.d.ts +5 -0
  65. package/dist/game/keyValueStore.d.ts +17 -4
  66. package/dist/game/keyValueStore.js +35 -23
  67. package/dist/game/lootModifiers.d.ts +17 -0
  68. package/dist/game/lootModifiers.js +26 -0
  69. package/dist/game/narrativeAuthoring.d.ts +67 -0
  70. package/dist/game/narrativeAuthoring.js +122 -0
  71. package/dist/game/playableGame.d.ts +52 -20
  72. package/dist/game/quest.d.ts +15 -0
  73. package/dist/game/quest.js +209 -63
  74. package/dist/game/questCatalog.d.ts +50 -0
  75. package/dist/game/questCatalog.js +205 -0
  76. package/dist/game/saveStore.d.ts +6 -5
  77. package/dist/game/saveStore.js +57 -57
  78. package/dist/game/systemRuntime.d.ts +2 -2
  79. package/dist/game/systemRuntime.js +84 -33
  80. package/dist/gameplay.d.ts +3 -0
  81. package/dist/gameplay.js +3 -0
  82. package/dist/interaction/promptRegistry.d.ts +5 -4
  83. package/dist/interaction/promptRegistry.js +8 -2
  84. package/dist/inventory/inventoryModel.js +9 -6
  85. package/dist/inventory/shapedGrid.d.ts +48 -9
  86. package/dist/inventory/shapedGrid.js +69 -10
  87. package/dist/item/durability.js +4 -0
  88. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  89. package/dist/item/itemInstanceRegistry.js +111 -7
  90. package/dist/item/itemgen.d.ts +1 -1
  91. package/dist/item/itemgen.js +1 -1
  92. package/dist/item/modularItem.js +7 -1
  93. package/dist/material/appearanceSignals.d.ts +53 -0
  94. package/dist/material/appearanceSignals.js +47 -0
  95. package/dist/material/hairCards.d.ts +47 -0
  96. package/dist/material/hairCards.js +136 -0
  97. package/dist/material/materialAsset.d.ts +162 -0
  98. package/dist/material/materialAsset.js +382 -0
  99. package/dist/meta/changelog.d.ts +1 -1
  100. package/dist/meta/changelog.js +333 -1
  101. package/dist/movement/characterController.d.ts +5 -2
  102. package/dist/movement/characterController.js +50 -23
  103. package/dist/movement/freeFlight.d.ts +2 -2
  104. package/dist/movement/freeFlight.js +3 -3
  105. package/dist/movement/movementModel.d.ts +24 -1
  106. package/dist/movement/movementModel.js +74 -7
  107. package/dist/movement/playerMovement.d.ts +28 -1
  108. package/dist/movement/playerMovement.js +425 -102
  109. package/dist/movement/poseState.d.ts +2 -0
  110. package/dist/movement/poseState.js +29 -1
  111. package/dist/movement/solidObstacles.d.ts +3 -1
  112. package/dist/movement/solidObstacles.js +8 -5
  113. package/dist/movement/terrainGrade.d.ts +13 -0
  114. package/dist/movement/terrainGrade.js +33 -0
  115. package/dist/movement/voxelController.d.ts +2 -2
  116. package/dist/movement/voxelController.js +3 -3
  117. package/dist/nav/navGrid.d.ts +19 -1
  118. package/dist/nav/navGrid.js +26 -10
  119. package/dist/nav/solidRoute.d.ts +50 -0
  120. package/dist/nav/solidRoute.js +144 -0
  121. package/dist/physics/ballisticSweep.d.ts +10 -3
  122. package/dist/physics/ballisticSweep.js +53 -29
  123. package/dist/physics/forceVolume.d.ts +41 -0
  124. package/dist/physics/forceVolume.js +88 -0
  125. package/dist/physics/kinematicVehicle.d.ts +8 -3
  126. package/dist/physics/kinematicVehicle.js +2 -2
  127. package/dist/physics/physicsWorldBackend.js +19 -3
  128. package/dist/physics/spatialGrid.d.ts +1 -1
  129. package/dist/physics/spatialGrid.js +8 -9
  130. package/dist/physics/vehicleDynamics.d.ts +3 -2
  131. package/dist/physics/vehicleDynamics.js +2 -2
  132. package/dist/physics/vehicleObstacles.d.ts +3 -2
  133. package/dist/physics/vehicleObstacles.js +29 -5
  134. package/dist/runtime/cameraDirector.d.ts +39 -2
  135. package/dist/runtime/cameraDirector.js +162 -0
  136. package/dist/runtime/commandDispatch.d.ts +14 -0
  137. package/dist/runtime/commandDispatch.js +19 -0
  138. package/dist/runtime/context/combat.d.ts +11 -4
  139. package/dist/runtime/context/combat.js +178 -11
  140. package/dist/runtime/context/deathLoot.d.ts +9 -8
  141. package/dist/runtime/context/deathLoot.js +16 -16
  142. package/dist/runtime/context/scene.d.ts +2 -0
  143. package/dist/runtime/context/scene.js +13 -21
  144. package/dist/runtime/descriptors/baseline.js +34 -3
  145. package/dist/runtime/descriptors/features.d.ts +2 -1
  146. package/dist/runtime/descriptors/features.js +18 -0
  147. package/dist/runtime/gameContext.js +79 -13
  148. package/dist/runtime/gameContextTypes.d.ts +14 -0
  149. package/dist/runtime/headlessRunner.d.ts +3 -1
  150. package/dist/runtime/headlessRunner.js +11 -11
  151. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  152. package/dist/runtime/hostedGameRunner.js +156 -10
  153. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  154. package/dist/runtime/hostedWorldSession.js +154 -16
  155. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  156. package/dist/runtime/inputRecorder.d.ts +1 -1
  157. package/dist/runtime/inputRecorder.js +21 -1
  158. package/dist/runtime/inputSnapshot.d.ts +16 -4
  159. package/dist/runtime/inputSnapshot.js +31 -3
  160. package/dist/runtime/runtimeSave.d.ts +10 -6
  161. package/dist/runtime/runtimeSave.js +11 -5
  162. package/dist/runtime/simContext.d.ts +8 -3
  163. package/dist/runtime/simContext.js +7 -3
  164. package/dist/runtime/simSnapshot.js +2 -0
  165. package/dist/runtime/worldReplication.d.ts +2 -0
  166. package/dist/runtime/worldReplication.js +1 -1
  167. package/dist/scene/assetCatalog.d.ts +2 -0
  168. package/dist/scene/behaviorRuntime.d.ts +26 -5
  169. package/dist/scene/behaviorRuntime.js +263 -16
  170. package/dist/scene/behaviors.d.ts +30 -1
  171. package/dist/scene/behaviors.js +29 -0
  172. package/dist/scene/entityStore.d.ts +14 -3
  173. package/dist/scene/entityStore.js +45 -9
  174. package/dist/scene/mount.d.ts +9 -0
  175. package/dist/scene/mount.js +35 -0
  176. package/dist/scene/possession.d.ts +2 -0
  177. package/dist/scene/possession.js +21 -1
  178. package/dist/scene/sceneRaycast.d.ts +7 -0
  179. package/dist/scene/sceneRaycast.js +55 -0
  180. package/dist/scene/spatial.d.ts +20 -1
  181. package/dist/scene/spatial.js +94 -2
  182. package/dist/scene/vehicleSeat.d.ts +36 -2
  183. package/dist/scene/vehicleSeat.js +89 -2
  184. package/dist/stats/accumulatorMeter.d.ts +10 -0
  185. package/dist/stats/accumulatorMeter.js +6 -0
  186. package/dist/stats/eventMeter.d.ts +9 -2
  187. package/dist/stats/eventMeter.js +5 -2
  188. package/dist/store/defineStore.d.ts +3 -1
  189. package/dist/store/defineStore.js +3 -1
  190. package/dist/store/observableKeyedStore.d.ts +1 -1
  191. package/dist/store/observableKeyedStore.js +8 -6
  192. package/dist/time/simClock.d.ts +6 -2
  193. package/dist/time/simClock.js +5 -0
  194. package/dist/vfx/particleDirector.d.ts +58 -6
  195. package/dist/vfx/particleDirector.js +113 -22
  196. package/dist/vfx/particles.d.ts +68 -5
  197. package/dist/vfx/particles.js +284 -32
  198. package/dist/visibility/assetStreaming.d.ts +21 -3
  199. package/dist/visibility/assetStreaming.js +108 -24
  200. package/dist/visibility/settings.d.ts +8 -2
  201. package/dist/visibility/settings.js +3 -0
  202. package/dist/visibility/visibilitySystem.js +1 -0
  203. package/dist/work/expedition.d.ts +110 -0
  204. package/dist/work/expedition.js +195 -0
  205. package/dist/work/staffedStation.d.ts +29 -0
  206. package/dist/work/staffedStation.js +27 -0
  207. package/dist/world/authoredAnimation.d.ts +38 -0
  208. package/dist/world/authoredAnimation.js +99 -0
  209. package/dist/world/authoredObjects.d.ts +24 -4
  210. package/dist/world/authoredObjects.js +43 -16
  211. package/dist/world/authoredSimulation.d.ts +74 -0
  212. package/dist/world/authoredSimulation.js +302 -0
  213. package/dist/world/authoredWeather.d.ts +120 -0
  214. package/dist/world/authoredWeather.js +314 -0
  215. package/dist/world/buildingKit.d.ts +8 -3
  216. package/dist/world/buildingKit.js +21 -8
  217. package/dist/world/environmentForces.d.ts +21 -0
  218. package/dist/world/environmentForces.js +42 -0
  219. package/dist/world/features.d.ts +3 -3
  220. package/dist/world/footprintGrid.d.ts +55 -0
  221. package/dist/world/footprintGrid.js +201 -0
  222. package/dist/world/pathTerrain.d.ts +1 -1
  223. package/dist/world/terraform.d.ts +1 -1
  224. package/dist/world/terraform.js +46 -18
  225. package/dist/world/weather.d.ts +17 -0
  226. package/dist/world/weather.js +116 -20
  227. package/dist/world/worldSolids.d.ts +11 -0
  228. package/dist/world/worldSolids.js +112 -1
  229. package/dist/world.d.ts +5 -5
  230. package/dist/world.js +4 -4
  231. package/package.json +1 -1
  232. package/skills/ce-handoff/SKILL.md +1 -1
  233. package/skills/jgengine/SKILL.md +22 -2
  234. package/skills/jgengine/api.md +28 -17
  235. package/skills/jgengine/recipes/minimal-game.md +7 -2
  236. package/skills/jgengine-combat/SKILL.md +8 -2
  237. package/skills/jgengine-combat/api.md +22 -10
  238. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  239. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  240. package/skills/jgengine-combat/reference.md +24 -2
  241. package/skills/jgengine-gameplay/SKILL.md +63 -0
  242. package/skills/jgengine-gameplay/api.md +131 -37
  243. package/skills/jgengine-gameplay/capabilities.md +68 -0
  244. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  245. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  246. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  247. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  248. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  249. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  250. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  251. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  252. package/skills/jgengine-world/SKILL.md +12 -3
  253. package/skills/jgengine-world/api.md +200 -57
  254. package/skills/jgengine-world/capabilities.md +116 -3
  255. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  256. package/skills/jgengine-world/recipes/controls.md +4 -0
  257. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  258. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  259. package/skills/jgengine-world/reference.md +152 -5
  260. package/skills/jgengine-world/visibility.md +28 -1
@@ -9,7 +9,7 @@ export function normalizeOnDeath(spec) {
9
9
  return normalized;
10
10
  }
11
11
  function matchesReason(when, reason) {
12
- return when === undefined || when.reason === reason.kind;
12
+ return when === undefined || when.reason === "any" || when.reason === reason.kind;
13
13
  }
14
14
  /**
15
15
  * Resolve entity death and the on-death consequences — drops, respawn eligibility, kill credit.
@@ -37,7 +37,7 @@ export function createDeathSystem(deps) {
37
37
  if (identity.displayName !== undefined)
38
38
  event.displayName = identity.displayName;
39
39
  deps.events.emit("entity.died", event);
40
- const onDeath = normalizeOnDeath(deps.resolveOnDeath(instanceId));
40
+ const onDeath = normalizeOnDeath(deps.resolveOnDeath(instanceId, reason));
41
41
  const drops = [];
42
42
  for (const rule of onDeath.drops) {
43
43
  if (matchesReason(rule.when, reason))
@@ -47,7 +47,7 @@ export function createDeathSystem(deps) {
47
47
  if (onDeath.command !== null &&
48
48
  matchesReason(onDeath.command.when, reason) &&
49
49
  deps.runCommand !== undefined) {
50
- deps.runCommand(onDeath.command.name, onDeath.command.args);
50
+ deps.runCommand(onDeath.command.name, onDeath.command.args, reason);
51
51
  ranCommand = onDeath.command.name;
52
52
  }
53
53
  deps.despawn(instanceId);
@@ -17,6 +17,13 @@ export interface MagazineConfig {
17
17
  */
18
18
  reserve?: number | MagazineReserve;
19
19
  }
20
+ /** Live magazine tuning; shrinking below loaded rounds rejects unless overflow is explicit. */
21
+ export interface MagazineTuning {
22
+ capacity?: number;
23
+ reloadMs?: number;
24
+ /** Defaults to `reject`; returning rounds requires a finite reserve with `gain`. */
25
+ overflow?: "reject" | "discard" | "return-to-reserve";
26
+ }
20
27
  /** Complete serializable state for a {@link Magazine}; `reserve: null` means infinite reserve. */
21
28
  export interface MagazineSnapshot {
22
29
  loaded: number;
@@ -31,6 +38,8 @@ export interface MagazineSnapshot {
31
38
  export interface Magazine {
32
39
  loaded(): number;
33
40
  capacity(): number;
41
+ /** Retune without granting ammo or resetting reload elapsed time; false leaves all state unchanged. */
42
+ retune(tuning: MagazineTuning): boolean;
34
43
  /** Reserve rounds available to reload from, or `null` when the reserve is infinite. */
35
44
  reserve(): number | null;
36
45
  isReloading(): boolean;
@@ -26,8 +26,8 @@ const INFINITE_RESERVE = {
26
26
  * @capability magazine a weapon magazine with capacity, timed reload, and reserve-pool interaction
27
27
  */
28
28
  export function createMagazine(config) {
29
- const capacity = Math.max(0, config.capacity);
30
- const reloadMs = Math.max(0, config.reloadMs);
29
+ let capacity = Math.max(0, config.capacity);
30
+ let reloadMs = Math.max(0, config.reloadMs);
31
31
  const isInfiniteReserve = config.reserve === undefined;
32
32
  const reserve = isInfiniteReserve
33
33
  ? INFINITE_RESERVE
@@ -42,6 +42,28 @@ export function createMagazine(config) {
42
42
  return {
43
43
  loaded: () => loaded,
44
44
  capacity: () => capacity,
45
+ retune(tuning) {
46
+ if ((tuning.capacity !== undefined && !Number.isFinite(tuning.capacity)) ||
47
+ (tuning.reloadMs !== undefined && !Number.isFinite(tuning.reloadMs)))
48
+ return false;
49
+ const nextCapacity = Math.max(0, tuning.capacity ?? capacity);
50
+ const nextReloadMs = Math.max(0, tuning.reloadMs ?? reloadMs);
51
+ const overflow = Math.max(0, loaded - nextCapacity);
52
+ if (overflow > 0) {
53
+ const policy = tuning.overflow ?? "reject";
54
+ if (policy === "reject")
55
+ return false;
56
+ if (policy === "return-to-reserve") {
57
+ if (isInfiniteReserve || reserve.gain === undefined)
58
+ return false;
59
+ reserve.gain(overflow);
60
+ }
61
+ }
62
+ capacity = nextCapacity;
63
+ reloadMs = nextReloadMs;
64
+ loaded = clampLoaded(loaded);
65
+ return true;
66
+ },
45
67
  reserve: () => (isInfiniteReserve ? null : reserve.current()),
46
68
  isReloading: () => reloadElapsedMs !== null,
47
69
  reloadFraction: () => (reloadElapsedMs === null || reloadMs <= 0 ? 0 : Math.min(1, reloadElapsedMs / reloadMs)),
@@ -1,6 +1,6 @@
1
1
  import type { BallisticSweep } from "../physics/ballisticSweep.js";
2
2
  import type { EntityPosition } from "../scene/entityStore.js";
3
- import { type EntityColliderSet } from "../scene/colliders.js";
3
+ import type { EntityColliderSet } from "../scene/colliders.js";
4
4
  import { type SceneRaycastApi } from "../scene/sceneRaycast.js";
5
5
  import type { Aim } from "../scene/spatial.js";
6
6
  import type { CombatSpatialDeps, EffectResult, EffectSystem, EffectVia } from "./effects.js";
@@ -10,8 +10,84 @@ export interface ProjectileShotInput {
10
10
  via: EffectVia;
11
11
  aim: Aim;
12
12
  effect: string;
13
- /** Defaults to `{ kind: "eye" }` — aim.origin when given, else shooter position at eye height. */
13
+ /** Defaults to `{ kind: "converge" }` — explicit rays pass through; angular aim converges from the muzzle to the sightline. */
14
14
  originPolicy?: ShotOriginPolicy;
15
+ /** Opt into simulation-owned travel. Values are captured at launch; time is in game-seconds. */
16
+ travel?: ProjectileTravelConfig;
17
+ }
18
+ /** Captured launch settings for authoritative live travel. */
19
+ export interface ProjectileTravelConfig {
20
+ speed: number;
21
+ lifetime: number;
22
+ /** Forwarded to the injected sweep; the default scene query sweeps a centerline. */
23
+ radius?: number;
24
+ gravity?: EntityPosition;
25
+ /** Game-owned wind coupling in 1/seconds, from 0 to 1e12; omission leaves wind response off. */
26
+ windResponse?: number;
27
+ /** Target bits read by the injected environmental force sampler. */
28
+ forceMask?: number;
29
+ /** Cap injected acceleration in m/s², from 0 to 1e12, independently of gravity. */
30
+ maxAcceleration?: number;
31
+ }
32
+ /** Nearest contact returned by a live segment sweep. */
33
+ export interface ProjectileTravelImpact {
34
+ /** Earliest contact fraction along this step, including moving targets. */
35
+ fraction: number;
36
+ at: EntityPosition;
37
+ target?: ProjectileHit;
38
+ }
39
+ /** Authoritative clock, collision and environmental acceleration providers. */
40
+ export interface ProjectileTravelDeps {
41
+ /** Authoritative game-seconds; no wall-clock fallback is used for live travel. */
42
+ now(): number;
43
+ sweep?(from: EntityPosition, to: EntityPosition, step: {
44
+ shotId: string;
45
+ input: ProjectileShotInput;
46
+ radius: number;
47
+ fromTime: number;
48
+ toTime: number;
49
+ }): ProjectileTravelImpact | null;
50
+ acceleration?(position: EntityPosition, velocity: EntityPosition, time: number, input: ProjectileShotInput): EntityPosition;
51
+ maxActive?: number;
52
+ maxRetained?: number;
53
+ }
54
+ /** Detached pose of one active projectile pellet for presentation. */
55
+ export interface LiveProjectile {
56
+ shotId: string;
57
+ pellet: number;
58
+ from: string;
59
+ effect: string;
60
+ via: EffectVia;
61
+ position: EntityPosition;
62
+ velocity: EntityPosition;
63
+ age: number;
64
+ radius: number;
65
+ }
66
+ /** Serializable motion state of one projectile pellet. */
67
+ export interface ProjectileFlightState {
68
+ position: EntityPosition;
69
+ velocity: EntityPosition;
70
+ age: number;
71
+ distance: number;
72
+ active: boolean;
73
+ }
74
+ /** Serializable captured launch and settlement state. */
75
+ export interface ProjectileShotState {
76
+ shotId: string;
77
+ input: ProjectileShotInput;
78
+ coneSamples: readonly (readonly [number, number])[];
79
+ pellets: number;
80
+ firedAt: number;
81
+ settled: boolean;
82
+ origin?: EntityPosition;
83
+ flights?: ProjectileFlightState[];
84
+ hits: EffectResult[];
85
+ at?: EntityPosition;
86
+ }
87
+ /** Detached projectile owner state for replay and restore. */
88
+ export interface ProjectileSystemState {
89
+ shotCounter: number;
90
+ shots: ProjectileShotState[];
15
91
  }
16
92
  export interface EntityRaycastHit {
17
93
  kind: "entity";
@@ -34,7 +110,11 @@ export interface ObjectRaycastHit {
34
110
  }
35
111
  export type RaycastHit = EntityRaycastHit | ObjectRaycastHit;
36
112
  export type Raycast = (from: string, aim: Aim, range: number, originPolicy?: ShotOriginPolicy) => RaycastHit[];
113
+ /** Exactly-once settlement with launch identity and actual applied effects. */
37
114
  export interface ProjectileSettleReport {
115
+ shotId: string;
116
+ /** Actual applied effects; empty for misses and cover impacts. */
117
+ hits: readonly EffectResult[];
38
118
  from: string;
39
119
  origin: EntityPosition;
40
120
  at: EntityPosition;
@@ -75,7 +155,12 @@ export interface ProjectileSystemDeps {
75
155
  sweepBallistic?: BallisticSweep;
76
156
  defaultOriginPolicy?: ShotOriginPolicy;
77
157
  now?: () => number;
158
+ /** Cone samples use two draws per nonzero-spread pellet at fire time; defaults to Math.random. */
159
+ rng?: () => number;
160
+ /** Per-shot ray limit, clamped to [1, 256]; defaults to 64. */
161
+ maxPellets?: number;
78
162
  onSettle?(report: ProjectileSettleReport): void;
163
+ travel?: ProjectileTravelDeps;
79
164
  }
80
165
  export type ProjectileHit = {
81
166
  kind: "entity";
@@ -110,9 +195,16 @@ export type SettleResult = {
110
195
  reason: string;
111
196
  };
112
197
  export interface ProjectileSystem {
198
+ /** Predict the center ray without consuming random samples or applying effects. */
113
199
  willHitProjectile(input: ProjectileShotInput): ProjectilePrediction;
200
+ /** Capture aim, origin-policy vectors and cone samples independently of subsequent caller mutation. */
114
201
  fireProjectile(input: ProjectileShotInput): string;
115
202
  settleProjectile(shotId: string): SettleResult;
203
+ /** Advance live shots once per authoritative simulation step; settled shots emit no further effects. */
204
+ advanceProjectiles(dt: number, time: number): SettleResult[];
205
+ activeProjectiles(): LiveProjectile[];
206
+ snapshot(): ProjectileSystemState;
207
+ restore(state: ProjectileSystemState): void;
116
208
  }
117
209
  /**
118
210
  * Spawn and advance projectiles each frame, resolving travel, lifetime, and hits.
@@ -1,12 +1,33 @@
1
- import { defaultEntityColliders, resolveColliders } from "../scene/colliders.js";
2
1
  import { createSceneRaycast, firstImpact, hitsUntilBlocked, } from "../scene/sceneRaycast.js";
3
2
  import { aimDirection, aimSpreadDeg, convergeShot, resolveShot, } from "./shotOrigin.js";
4
3
  const DEFAULT_RANGE = 100;
5
4
  const DEFAULT_PROJECTILE_SPEED = 15;
6
5
  const GRAVITY = 9.8;
7
6
  const BASE_HIT_RADIUS = 0.5;
8
- function isEntityHit(hit) {
9
- return hit.kind === "entity";
7
+ function copyPosition(position) {
8
+ return [position[0], position[1], position[2]];
9
+ }
10
+ function copyAim(aim) {
11
+ return "origin" in aim
12
+ ? { ...aim, origin: copyPosition(aim.origin), direction: copyPosition(aim.direction) }
13
+ : { ...aim };
14
+ }
15
+ function copyOriginPolicy(policy) {
16
+ switch (policy.kind) {
17
+ case "camera":
18
+ case "world":
19
+ return { ...policy, origin: copyPosition(policy.origin), ...(policy.direction !== undefined ? { direction: copyPosition(policy.direction) } : {}) };
20
+ case "entityOffset":
21
+ return { ...policy, offset: copyPosition(policy.offset) };
22
+ case "muzzle":
23
+ return { ...policy, ...(policy.offset !== undefined ? { offset: copyPosition(policy.offset) } : {}) };
24
+ case "converge":
25
+ return { ...policy, ...(policy.muzzle !== undefined ? { muzzle: copyPosition(policy.muzzle) } : {}) };
26
+ case "eye":
27
+ case "legacy":
28
+ case "entity":
29
+ return { ...policy };
30
+ }
10
31
  }
11
32
  function isObjectHit(hit) {
12
33
  return hit.kind === "object";
@@ -67,6 +88,41 @@ function asSceneHits(hits) {
67
88
  normal: [0, 1, 0],
68
89
  }));
69
90
  }
91
+ /** Validate captured travel settings before a launch or restore can mutate the owner. */
92
+ function validateProjectileTravel(travel) {
93
+ if (!(travel.speed > 0) || !Number.isFinite(travel.speed) || !(travel.lifetime > 0) || !Number.isFinite(travel.lifetime) ||
94
+ !(travel.radius === undefined || (Number.isFinite(travel.radius) && travel.radius >= 0)) ||
95
+ !(travel.gravity === undefined || travel.gravity.every(Number.isFinite))) {
96
+ throw new RangeError("Projectile travel requires finite positive speed/lifetime and nonnegative radius");
97
+ }
98
+ if (!(travel.windResponse === undefined || (Number.isFinite(travel.windResponse) && travel.windResponse >= 0 && travel.windResponse <= 1e12)) ||
99
+ !(travel.maxAcceleration === undefined || (Number.isFinite(travel.maxAcceleration) && travel.maxAcceleration >= 0 && travel.maxAcceleration <= 1e12))) {
100
+ throw new RangeError("Projectile wind response and acceleration cap must be finite and between 0 and 1e12");
101
+ }
102
+ }
103
+ function sampleCone(forward, spreadDeg, sample) {
104
+ const cone = Math.min(Math.PI / 2, spreadDeg * Math.PI / 180);
105
+ const unit = (value) => Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
106
+ const cosTheta = 1 - unit(sample[0]) * (1 - Math.cos(cone));
107
+ const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta));
108
+ const phi = unit(sample[1]) * Math.PI * 2;
109
+ const horizontal = Math.hypot(forward[0], forward[2]);
110
+ const right = horizontal > 1e-9
111
+ ? [forward[2] / horizontal, 0, -forward[0] / horizontal]
112
+ : [1, 0, 0];
113
+ const up = [
114
+ forward[1] * right[2] - forward[2] * right[1],
115
+ forward[2] * right[0] - forward[0] * right[2],
116
+ forward[0] * right[1] - forward[1] * right[0],
117
+ ];
118
+ const x = sinTheta * Math.cos(phi);
119
+ const y = sinTheta * Math.sin(phi);
120
+ return [
121
+ forward[0] * cosTheta + right[0] * x + up[0] * y,
122
+ forward[1] * cosTheta + right[1] * x + up[1] * y,
123
+ forward[2] * cosTheta + right[2] * x + up[2] * y,
124
+ ];
125
+ }
70
126
  /**
71
127
  * Spawn and advance projectiles each frame, resolving travel, lifetime, and hits.
72
128
  *
@@ -75,8 +131,43 @@ function asSceneHits(hits) {
75
131
  export function createProjectileSystem(deps) {
76
132
  const shots = new Map();
77
133
  let shotCounter = 0;
134
+ let liveCount = 0;
78
135
  const now = deps.now ?? (() => Date.now());
136
+ const rng = deps.rng ?? Math.random;
137
+ const maxPellets = Number.isFinite(deps.maxPellets)
138
+ ? Math.max(1, Math.min(256, Math.floor(deps.maxPellets)))
139
+ : 64;
79
140
  const defaultPolicy = deps.defaultOriginPolicy ?? { kind: "converge" };
141
+ const maxActive = Math.max(1, Math.floor(deps.travel?.maxActive ?? 256));
142
+ const maxRetained = Math.max(0, Math.floor(deps.travel?.maxRetained ?? 256));
143
+ if (!Number.isFinite(maxActive) || !Number.isFinite(maxRetained))
144
+ throw new RangeError("Projectile budgets must be finite");
145
+ function pruneSettled() {
146
+ let retained = 0;
147
+ for (const shot of shots.values())
148
+ if (shot.settled)
149
+ retained += 1;
150
+ for (const [id, shot] of shots) {
151
+ if (retained <= maxRetained)
152
+ break;
153
+ if (shot.settled) {
154
+ shots.delete(id);
155
+ retained -= 1;
156
+ }
157
+ }
158
+ }
159
+ function copyShot(shot) {
160
+ return structuredClone(shot);
161
+ }
162
+ function finishFlight(shot) {
163
+ shot.settled = true;
164
+ liveCount -= 1;
165
+ const origin = shot.origin;
166
+ const at = shot.at ?? shot.flights[0].position;
167
+ deps.onSettle?.({ shotId: shot.shotId, hits: structuredClone(shot.hits), from: shot.input.from, origin: copyPosition(origin), at: copyPosition(at), effect: shot.input.effect, hit: shot.hits.length > 0, ballistic: true });
168
+ pruneSettled();
169
+ return { status: "settled", shotId: shot.shotId, at: [at[0], at[1], at[2]], origin: [origin[0], origin[1], origin[2]], hits: shot.hits };
170
+ }
80
171
  function itemStat(via, stat) {
81
172
  return via.item === undefined ? null : deps.getStat(via.item, stat);
82
173
  }
@@ -163,38 +254,7 @@ export function createProjectileSystem(deps) {
163
254
  excludeInstanceIds: [from],
164
255
  });
165
256
  const until = hitsUntilBlocked(all);
166
- const spreadRad = (aimSpreadDeg(aim) * Math.PI) / 180;
167
- const mapped = [];
168
- for (const hit of until) {
169
- if (hit.targetKind === "entity" && spreadRad > 0) {
170
- const position = deps.spatial.positionOf(hit.instanceId);
171
- if (position !== undefined) {
172
- const colliders = resolveColliders(deps.entityCollidersOf?.(hit.instanceId) ?? defaultEntityColliders());
173
- const collider = colliders.find((candidate) => candidate.name === hit.colliderName) ?? colliders[0];
174
- const localOffset = collider?.shape.offset ?? [0, 0, 0];
175
- const slack = collider === undefined
176
- ? BASE_HIT_RADIUS
177
- : collider.shape.kind === "sphere"
178
- ? collider.shape.radius
179
- : Math.hypot(...collider.shape.halfExtents);
180
- const rotationY = deps.rotationYOf?.(hit.instanceId) ?? 0;
181
- const cos = Math.cos(rotationY);
182
- const sin = Math.sin(rotationY);
183
- const dx = position[0] + localOffset[0] * cos + localOffset[2] * sin - origin[0];
184
- const dy = position[1] + localOffset[1] - origin[1];
185
- const dz = position[2] - localOffset[0] * sin + localOffset[2] * cos - origin[2];
186
- const along = dx * direction[0] + dy * direction[1] + dz * direction[2];
187
- const px = dx - direction[0] * along;
188
- const py = dy - direction[1] * along;
189
- const pz = dz - direction[2] * along;
190
- const perpendicular = Math.sqrt(px * px + py * py + pz * pz);
191
- if (perpendicular > slack + Math.tan(spreadRad) * along)
192
- continue;
193
- }
194
- }
195
- mapped.push(sceneHitToRaycast(hit));
196
- }
197
- return mapped;
257
+ return until.map(sceneHitToRaycast);
198
258
  });
199
259
  function resolveRange(via) {
200
260
  return itemStat(via, "range") ?? DEFAULT_RANGE;
@@ -280,18 +340,179 @@ export function createProjectileSystem(deps) {
280
340
  return prediction;
281
341
  },
282
342
  fireProjectile(input) {
343
+ if (input.travel !== undefined) {
344
+ if (deps.travel === undefined)
345
+ throw new Error("Live projectiles require an authoritative travel clock");
346
+ const travel = input.travel;
347
+ validateProjectileTravel(travel);
348
+ if ((travel.radius ?? 0) > 0 && deps.travel.sweep === undefined)
349
+ throw new RangeError("Projectile radius requires an injected radius-aware sweep");
350
+ if (liveCount >= maxActive)
351
+ throw new RangeError("Active projectile budget exhausted");
352
+ }
283
353
  shotCounter += 1;
284
354
  const shotId = `shot_${shotCounter}`;
285
- shots.set(shotId, { input, firedAt: now(), settled: false });
355
+ const captured = {
356
+ ...input,
357
+ aim: copyAim(input.aim),
358
+ via: { ...input.via },
359
+ originPolicy: copyOriginPolicy(input.originPolicy ?? defaultPolicy),
360
+ ...(input.travel === undefined ? {} : { travel: { ...input.travel, ...(input.travel.gravity === undefined ? {} : { gravity: copyPosition(input.travel.gravity) }) } }),
361
+ };
362
+ captured.aim = withWeaponSpread(captured.via, captured.aim);
363
+ const count = itemStat(captured.via, "pellets") ?? 1;
364
+ const pellets = Number.isFinite(count) ? Math.max(1, Math.min(maxPellets, Math.round(count))) : 1;
365
+ const coneSamples = [];
366
+ if ((captured.travel !== undefined || !isBallistic(captured.via)) && aimSpreadDeg(captured.aim) > 0) {
367
+ for (let pellet = 0; pellet < pellets; pellet += 1) {
368
+ coneSamples.push([rng(), rng()]);
369
+ }
370
+ }
371
+ const shot = { shotId, input: captured, coneSamples, pellets, firedAt: captured.travel === undefined ? now() : deps.travel.now(), settled: false, hits: [] };
372
+ if (captured.travel !== undefined) {
373
+ if (!Number.isFinite(shot.firedAt))
374
+ throw new RangeError("Projectile authority time must be finite");
375
+ const resolved = resolveShotFor(captured.from, captured.aim, captured.originPolicy, resolveRange(captured.via));
376
+ if (resolved === null)
377
+ throw new Error("Live projectile requires a resolvable launch origin and direction");
378
+ shot.origin = copyPosition(resolved.origin);
379
+ shot.flights = [];
380
+ for (let pellet = 0; pellet < pellets; pellet += 1) {
381
+ const sample = coneSamples[pellet];
382
+ const direction = sample === undefined ? resolved.direction : sampleCone(resolved.direction, aimSpreadDeg(captured.aim), sample);
383
+ shot.flights.push({ position: copyPosition(resolved.origin), velocity: [direction[0] * captured.travel.speed, direction[1] * captured.travel.speed, direction[2] * captured.travel.speed], age: 0, distance: 0, active: true });
384
+ }
385
+ }
386
+ shots.set(shotId, shot);
387
+ if (shot.flights !== undefined)
388
+ liveCount += 1;
286
389
  return shotId;
287
390
  },
391
+ advanceProjectiles(dt, time) {
392
+ if (!Number.isFinite(dt) || dt < 0 || !Number.isFinite(time))
393
+ throw new RangeError("Projectile steps require finite nonnegative dt and authoritative time");
394
+ const results = [];
395
+ if (dt === 0 || liveCount === 0)
396
+ return results;
397
+ for (const shot of Array.from(shots.values())) {
398
+ if (shot.settled || shot.flights === undefined)
399
+ continue;
400
+ const travel = shot.input.travel;
401
+ const gravity = travel.gravity ?? [0, 0, 0];
402
+ const range = resolveRange(shot.input.via);
403
+ for (const flight of shot.flights) {
404
+ if (!flight.active)
405
+ continue;
406
+ const step = Math.min(dt, Math.max(0, time - shot.firedAt - flight.age), Math.max(0, travel.lifetime - flight.age));
407
+ if (step <= 0)
408
+ continue;
409
+ const fromTime = shot.firedAt + flight.age;
410
+ const external = deps.travel?.acceleration?.(copyPosition(flight.position), copyPosition(flight.velocity), fromTime, shot.input) ?? [0, 0, 0];
411
+ if (!external.every(Number.isFinite))
412
+ throw new RangeError("Projectile acceleration must be finite");
413
+ const magnitude = Math.hypot(...external);
414
+ const cap = travel.maxAcceleration ?? Number.POSITIVE_INFINITY;
415
+ const forceScale = magnitude > cap ? cap / magnitude : 1;
416
+ const acceleration = [gravity[0] + external[0] * forceScale, gravity[1] + external[1] * forceScale, gravity[2] + external[2] * forceScale];
417
+ const from = flight.position;
418
+ const to = [from[0] + flight.velocity[0] * step + acceleration[0] * step * step / 2, from[1] + flight.velocity[1] * step + acceleration[1] * step * step / 2, from[2] + flight.velocity[2] * step + acceleration[2] * step * step / 2];
419
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2]);
420
+ const rangeFraction = length > 0 ? Math.max(0, Math.min(1, (range - flight.distance) / length)) : 1;
421
+ const end = [from[0] + (to[0] - from[0]) * rangeFraction, from[1] + (to[1] - from[1]) * rangeFraction, from[2] + (to[2] - from[2]) * rangeFraction];
422
+ let impact;
423
+ if (deps.travel?.sweep !== undefined) {
424
+ impact = deps.travel.sweep(copyPosition(from), copyPosition(end), { shotId: shot.shotId, input: shot.input, radius: travel.radius ?? 0, fromTime, toTime: fromTime + step * rangeFraction });
425
+ }
426
+ else {
427
+ const distance = length * rangeFraction;
428
+ const hits = distance > 0 ? internalSceneRaycast.raycastAll({ origin: from, direction: [end[0] - from[0], end[1] - from[1], end[2] - from[2]], maxDistance: distance, excludeInstanceIds: [shot.input.from] }) : [];
429
+ const hit = hits.find(hit => hit.blocks || (hit.targetKind === "entity" && hit.damageEligible)) ?? null;
430
+ impact = hit === null ? null : { fraction: hit.distance / distance, at: hit.point, target: toProjectileHit(sceneHitToRaycast(hit)) };
431
+ }
432
+ if (impact !== null && (!Number.isFinite(impact.fraction) || impact.fraction < 0 || impact.fraction > 1 || !impact.at.every(Number.isFinite)))
433
+ throw new RangeError("Projectile sweep must return a finite contact in the segment");
434
+ const fraction = rangeFraction * (impact?.fraction ?? 1);
435
+ flight.position = impact === null ? end : copyPosition(impact.at);
436
+ flight.velocity = [flight.velocity[0] + acceleration[0] * step * fraction, flight.velocity[1] + acceleration[1] * step * fraction, flight.velocity[2] + acceleration[2] * step * fraction];
437
+ flight.age += step * fraction;
438
+ flight.distance += length * fraction;
439
+ if (impact !== null || rangeFraction < 1 || flight.age >= travel.lifetime || flight.distance >= range) {
440
+ flight.active = false;
441
+ shot.at ??= copyPosition(flight.position);
442
+ const target = impact?.target;
443
+ const splash = itemStat(shot.input.via, "explosion.radius");
444
+ if (impact !== null && splash !== null) {
445
+ shot.hits.push(...deps.effects.applyEffect({ from: shot.input.from, effect: shot.input.effect, via: shot.input.via, at: flight.position, radius: splash, falloff: "linear" }));
446
+ }
447
+ else if (target?.kind === "entity" && target.damageEligible !== false && deps.effects.canReceive(target.instanceId, shot.input.effect) === null) {
448
+ shot.hits.push(...deps.effects.applyEffect({ from: shot.input.from, to: target.instanceId, effect: shot.input.effect, via: shot.input.via }));
449
+ }
450
+ }
451
+ }
452
+ if (shot.flights.every(flight => !flight.active))
453
+ results.push(finishFlight(shot));
454
+ }
455
+ return results;
456
+ },
457
+ activeProjectiles() {
458
+ const active = [];
459
+ if (liveCount === 0)
460
+ return active;
461
+ for (const shot of shots.values()) {
462
+ if (shot.settled || shot.flights === undefined)
463
+ continue;
464
+ shot.flights.forEach((flight, pellet) => {
465
+ if (flight.active)
466
+ active.push({ shotId: shot.shotId, pellet, from: shot.input.from, effect: shot.input.effect, via: { ...shot.input.via }, position: copyPosition(flight.position), velocity: copyPosition(flight.velocity), age: flight.age, radius: shot.input.travel?.radius ?? 0 });
467
+ });
468
+ }
469
+ return active;
470
+ },
471
+ snapshot: () => ({ shotCounter, shots: Array.from(shots.values(), copyShot) }),
472
+ restore(state) {
473
+ const next = new Map();
474
+ let active = 0;
475
+ for (const shot of state.shots) {
476
+ if (!Number.isInteger(shot.pellets) || shot.pellets < 1 || shot.pellets > maxPellets ||
477
+ !Array.isArray(shot.coneSamples) || (shot.coneSamples.length !== 0 && shot.coneSamples.length !== shot.pellets)) {
478
+ throw new RangeError("Restored projectile pellet budget or sample count is invalid");
479
+ }
480
+ if (shot.input.travel !== undefined) {
481
+ validateProjectileTravel(shot.input.travel);
482
+ if ((shot.input.travel.radius ?? 0) > 0 && deps.travel?.sweep === undefined)
483
+ throw new RangeError("Projectile radius requires an injected radius-aware sweep");
484
+ }
485
+ if ((shot.flights !== undefined) !== (shot.input.travel !== undefined) ||
486
+ (shot.flights !== undefined && (!Array.isArray(shot.flights) || shot.flights.length !== shot.pellets))) {
487
+ throw new RangeError("Restored projectile flight count must match captured travel pellets");
488
+ }
489
+ if (shot.flights !== undefined && deps.travel === undefined)
490
+ throw new Error("Restoring live projectiles requires an authoritative travel clock");
491
+ if (shot.flights !== undefined && !shot.settled)
492
+ active += 1;
493
+ if (next.has(shot.shotId))
494
+ throw new Error("Restoring duplicate projectile ids");
495
+ next.set(shot.shotId, copyShot(shot));
496
+ }
497
+ if (active > maxActive)
498
+ throw new RangeError("Restored projectile budget exhausted");
499
+ shots.clear();
500
+ shotCounter = state.shotCounter;
501
+ liveCount = active;
502
+ for (const [id, shot] of next)
503
+ shots.set(id, shot);
504
+ pruneSettled();
505
+ },
288
506
  settleProjectile(shotId) {
289
507
  const shot = shots.get(shotId);
290
508
  if (shot === undefined)
291
509
  return { status: "rejected", shotId, reason: "unknown-shot" };
292
510
  if (shot.settled)
293
511
  return { status: "rejected", shotId, reason: "already-settled" };
512
+ if (shot.flights !== undefined)
513
+ return { status: "rejected", shotId, reason: "in-flight" };
294
514
  shot.settled = true;
515
+ pruneSettled();
295
516
  const { input } = shot;
296
517
  const resolved = resolveShotFor(input.from, input.aim, input.originPolicy, resolveRange(input.via));
297
518
  const origin = resolved?.origin ?? [0, 0, 0];
@@ -308,23 +529,28 @@ export function createProjectileSystem(deps) {
308
529
  radius,
309
530
  falloff: splashRadius === null ? "none" : "linear",
310
531
  });
311
- deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: true });
532
+ deps.onSettle?.({ shotId, hits: structuredClone(hits), from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: true });
312
533
  return { status: "settled", shotId, at, hits, origin: originTuple };
313
534
  }
314
- const { visible, rawHits } = predictHits(input);
315
- const ordered = asSceneHits(rawHits);
316
- const untilBlock = hitsUntilBlocked(ordered);
317
- const impact = firstImpact(untilBlock);
318
- const solidBlock = impact !== null && impact.blocks && !impact.damageEligible;
319
- const damageEntityHits = visible.filter((hit) => isEntityHit(hit) &&
320
- hit.damageEligible !== false &&
321
- (!solidBlock || hit.distance <= (impact?.distance ?? Infinity) + 1e-9) &&
322
- deps.effects.canReceive(hit.instanceId, input.effect) === null);
323
- const pellets = Math.max(1, Math.round(itemStat(input.via, "pellets") ?? 1));
535
+ const pellets = shot.pellets;
324
536
  const hits = [];
325
- if (!solidBlock && damageEntityHits.length > 0) {
326
- for (let pellet = 0; pellet < pellets; pellet += 1) {
327
- const target = damageEntityHits[pellet % damageEntityHits.length];
537
+ let at;
538
+ for (let pellet = 0; pellet < pellets; pellet += 1) {
539
+ const sample = shot.coneSamples[pellet];
540
+ const pelletInput = sample === undefined || resolved === null
541
+ ? input
542
+ : {
543
+ ...input,
544
+ aim: { origin, direction: sampleCone(resolved.direction, aimSpreadDeg(input.aim), sample) },
545
+ originPolicy: { kind: "eye" },
546
+ };
547
+ const { rawHits } = predictHits(pelletInput);
548
+ const untilBlock = hitsUntilBlocked(asSceneHits(rawHits));
549
+ const impact = firstImpact(untilBlock);
550
+ const target = untilBlock.find((hit) => hit.targetKind === "entity" && hit.damageEligible &&
551
+ deps.spatial.hasLineOfSight(input.from, hit.instanceId) &&
552
+ deps.effects.canReceive(hit.instanceId, input.effect) === null);
553
+ if (target !== undefined) {
328
554
  hits.push(...deps.effects.applyEffect({
329
555
  from: input.from,
330
556
  to: target.instanceId,
@@ -332,18 +558,13 @@ export function createProjectileSystem(deps) {
332
558
  via: input.via,
333
559
  }));
334
560
  }
561
+ if (at === undefined) {
562
+ const point = impact?.point ?? target?.point ?? missPoint(pelletInput);
563
+ at = [point[0], point[1], point[2]];
564
+ }
335
565
  }
336
- let at;
337
- if (impact !== null) {
338
- at = [impact.point[0], impact.point[1], impact.point[2]];
339
- }
340
- else if (damageEntityHits[0] !== undefined) {
341
- at = [damageEntityHits[0].at[0], damageEntityHits[0].at[1], damageEntityHits[0].at[2]];
342
- }
343
- else {
344
- at = missPoint(input);
345
- }
346
- deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: false });
566
+ at ??= missPoint(input);
567
+ deps.onSettle?.({ shotId, hits: structuredClone(hits), from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: false });
347
568
  return { status: "settled", shotId, at, hits, origin: originTuple };
348
569
  },
349
570
  };