@jgengine/core 0.7.0 → 0.9.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 (329) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +2 -0
  3. package/dist/ai/flock.d.ts +36 -0
  4. package/dist/ai/flock.js +182 -0
  5. package/dist/ai/groupAssist.d.ts +19 -0
  6. package/dist/ai/groupAssist.js +59 -0
  7. package/dist/ai/laneSelect.d.ts +21 -0
  8. package/dist/ai/laneSelect.js +37 -0
  9. package/dist/ai/mobBrain.d.ts +68 -0
  10. package/dist/ai/mobBrain.js +115 -0
  11. package/dist/ai/spawnDirector.d.ts +5 -0
  12. package/dist/ai/spawnDirector.js +14 -2
  13. package/dist/ai/threat.d.ts +2 -0
  14. package/dist/ai/threat.js +34 -0
  15. package/dist/anim/easing.d.ts +14 -0
  16. package/dist/anim/easing.js +55 -0
  17. package/dist/behaviour/behaviour.d.ts +136 -0
  18. package/dist/behaviour/behaviour.js +388 -0
  19. package/dist/cards/cardPile.d.ts +1 -0
  20. package/dist/cards/cardPile.js +12 -3
  21. package/dist/cartridge/runtime.d.ts +15 -0
  22. package/dist/cartridge/runtime.js +556 -0
  23. package/dist/cartridge/spec.d.ts +188 -0
  24. package/dist/cartridge/spec.js +19 -0
  25. package/dist/cartridge/validate.d.ts +2 -0
  26. package/dist/cartridge/validate.js +78 -0
  27. package/dist/combat/abilityKit.d.ts +22 -2
  28. package/dist/combat/abilityKit.js +72 -13
  29. package/dist/combat/castRunner.d.ts +40 -0
  30. package/dist/combat/castRunner.js +56 -0
  31. package/dist/combat/comboPoints.d.ts +14 -0
  32. package/dist/combat/comboPoints.js +52 -0
  33. package/dist/combat/defensiveWindow.d.ts +1 -1
  34. package/dist/combat/defensiveWindow.js +5 -3
  35. package/dist/combat/effects.d.ts +1 -1
  36. package/dist/combat/effects.js +9 -6
  37. package/dist/combat/projectiles.d.ts +64 -6
  38. package/dist/combat/projectiles.js +232 -74
  39. package/dist/combat/resistance.d.ts +16 -4
  40. package/dist/combat/resistance.js +43 -7
  41. package/dist/combat/resourcePool.d.ts +20 -0
  42. package/dist/combat/resourcePool.js +55 -0
  43. package/dist/combat/shotOrigin.d.ts +40 -0
  44. package/dist/combat/shotOrigin.js +57 -0
  45. package/dist/combat/telegraph.d.ts +30 -1
  46. package/dist/combat/telegraph.js +61 -0
  47. package/dist/commands/commandRegistry.d.ts +1 -1
  48. package/dist/commands/commandRegistry.js +2 -1
  49. package/dist/crafting/recipe.js +4 -1
  50. package/dist/data/dataSource.d.ts +26 -0
  51. package/dist/data/dataSource.js +93 -0
  52. package/dist/data/devProxy.d.ts +11 -0
  53. package/dist/data/devProxy.js +38 -0
  54. package/dist/data/fetchJson.d.ts +20 -0
  55. package/dist/data/fetchJson.js +43 -0
  56. package/dist/data/jsonDataSource.d.ts +4 -0
  57. package/dist/data/jsonDataSource.js +6 -0
  58. package/dist/devtools/devtools.d.ts +205 -0
  59. package/dist/devtools/devtools.js +832 -0
  60. package/dist/devtools/transformTunables.d.ts +14 -0
  61. package/dist/devtools/transformTunables.js +38 -0
  62. package/dist/devtools/tunableSchema.d.ts +140 -0
  63. package/dist/devtools/tunableSchema.js +476 -0
  64. package/dist/faction/factions.d.ts +33 -0
  65. package/dist/faction/factions.js +76 -0
  66. package/dist/faction/reputation.d.ts +32 -0
  67. package/dist/faction/reputation.js +82 -0
  68. package/dist/game/chat.d.ts +68 -0
  69. package/dist/game/chat.js +173 -0
  70. package/dist/game/chatFilter.d.ts +15 -0
  71. package/dist/game/chatFilter.js +55 -0
  72. package/dist/game/controlGate.d.ts +4 -0
  73. package/dist/game/controlGate.js +7 -0
  74. package/dist/game/defineGame.d.ts +10 -2
  75. package/dist/game/defineGame.js +2 -1
  76. package/dist/game/events.d.ts +35 -0
  77. package/dist/game/gamePhase.d.ts +20 -0
  78. package/dist/game/gamePhase.js +21 -0
  79. package/dist/game/levelSequence.d.ts +44 -0
  80. package/dist/game/levelSequence.js +78 -0
  81. package/dist/game/lootTable.d.ts +10 -1
  82. package/dist/game/lootTable.js +27 -7
  83. package/dist/game/playableGame.d.ts +286 -6
  84. package/dist/game/playableGame.js +6 -1
  85. package/dist/game/progression.d.ts +1 -0
  86. package/dist/game/progression.js +23 -2
  87. package/dist/game/quest.d.ts +7 -3
  88. package/dist/game/quest.js +17 -8
  89. package/dist/game/race.d.ts +44 -1
  90. package/dist/game/race.js +116 -11
  91. package/dist/game/recordBook.d.ts +29 -0
  92. package/dist/game/recordBook.js +86 -0
  93. package/dist/game/social.d.ts +42 -0
  94. package/dist/game/social.js +122 -0
  95. package/dist/game/spawnPoints.d.ts +19 -0
  96. package/dist/game/spawnPoints.js +23 -0
  97. package/dist/game/talents.d.ts +47 -0
  98. package/dist/game/talents.js +140 -0
  99. package/dist/game/worldItem.d.ts +3 -0
  100. package/dist/game/worldItem.js +12 -6
  101. package/dist/index.d.ts +1 -0
  102. package/dist/index.js +1 -0
  103. package/dist/input/actionBindings.d.ts +10 -0
  104. package/dist/input/actionBindings.js +14 -0
  105. package/dist/input/axisInput.d.ts +26 -3
  106. package/dist/input/axisInput.js +44 -4
  107. package/dist/input/bindingOverrides.d.ts +25 -0
  108. package/dist/input/bindingOverrides.js +95 -0
  109. package/dist/input/gestureSurface.d.ts +31 -0
  110. package/dist/input/gestureSurface.js +120 -0
  111. package/dist/input/pointer.d.ts +45 -0
  112. package/dist/input/pointer.js +48 -0
  113. package/dist/input/pointerAxis.d.ts +30 -0
  114. package/dist/input/pointerAxis.js +31 -0
  115. package/dist/input/touchScheme.d.ts +89 -0
  116. package/dist/input/touchScheme.js +153 -0
  117. package/dist/item/affix.d.ts +1 -1
  118. package/dist/item/affix.js +1 -19
  119. package/dist/meta/changelog.d.ts +1 -1
  120. package/dist/meta/changelog.js +59 -1
  121. package/dist/movement/dash.d.ts +3 -0
  122. package/dist/movement/dash.js +19 -5
  123. package/dist/movement/glideModel.d.ts +44 -0
  124. package/dist/movement/glideModel.js +54 -0
  125. package/dist/movement/grappleSwing.d.ts +34 -0
  126. package/dist/movement/grappleSwing.js +56 -0
  127. package/dist/movement/leaderTrail.d.ts +29 -0
  128. package/dist/movement/leaderTrail.js +67 -0
  129. package/dist/movement/movementModel.d.ts +36 -3
  130. package/dist/movement/movementModel.js +81 -5
  131. package/dist/movement/steering.d.ts +27 -0
  132. package/dist/movement/steering.js +40 -0
  133. package/dist/movement/voxelController.d.ts +61 -0
  134. package/dist/movement/voxelController.js +229 -0
  135. package/dist/multiplayer/chatContract.d.ts +42 -0
  136. package/dist/multiplayer/chatContract.js +57 -0
  137. package/dist/multiplayer/feedWriteGate.d.ts +10 -0
  138. package/dist/multiplayer/feedWriteGate.js +12 -0
  139. package/dist/multiplayer/identity.d.ts +13 -0
  140. package/dist/multiplayer/identity.js +20 -0
  141. package/dist/multiplayer/poseSyncGate.d.ts +1 -0
  142. package/dist/multiplayer/poseSyncGate.js +13 -1
  143. package/dist/multiplayer/presenceModel.d.ts +17 -2
  144. package/dist/multiplayer/presenceModel.js +30 -4
  145. package/dist/multiplayer/voiceContract.d.ts +44 -0
  146. package/dist/multiplayer/voiceContract.js +108 -0
  147. package/dist/nav/corridors.d.ts +30 -0
  148. package/dist/nav/corridors.js +69 -0
  149. package/dist/nav/navConstrain.d.ts +14 -0
  150. package/dist/nav/navConstrain.js +26 -0
  151. package/dist/nav/navFromEnvironment.d.ts +11 -0
  152. package/dist/nav/navFromEnvironment.js +16 -0
  153. package/dist/nav/navGrid.d.ts +10 -0
  154. package/dist/nav/navGrid.js +48 -5
  155. package/dist/nav/pathFollow.d.ts +4 -2
  156. package/dist/nav/pathFollow.js +5 -3
  157. package/dist/nav/railGraph.d.ts +66 -0
  158. package/dist/nav/railGraph.js +129 -0
  159. package/dist/nav/timetable.d.ts +41 -0
  160. package/dist/nav/timetable.js +94 -0
  161. package/dist/physics/ballisticSweep.d.ts +18 -0
  162. package/dist/physics/ballisticSweep.js +49 -0
  163. package/dist/physics/buoyancy.d.ts +6 -0
  164. package/dist/physics/buoyancy.js +14 -0
  165. package/dist/physics/flowTube.d.ts +32 -0
  166. package/dist/physics/flowTube.js +64 -0
  167. package/dist/physics/forceVolume.d.ts +27 -0
  168. package/dist/physics/forceVolume.js +43 -5
  169. package/dist/physics/kinematicVehicle.d.ts +52 -0
  170. package/dist/physics/kinematicVehicle.js +81 -0
  171. package/dist/physics/physicsWorld.d.ts +54 -8
  172. package/dist/physics/physicsWorld.js +400 -79
  173. package/dist/physics/ragdoll.js +1 -1
  174. package/dist/physics/spatialGrid.d.ts +1 -1
  175. package/dist/physics/spatialGrid.js +5 -0
  176. package/dist/physics/vehicleBody.d.ts +6 -0
  177. package/dist/physics/vehicleBody.js +18 -18
  178. package/dist/puzzle/cellGrid.d.ts +26 -0
  179. package/dist/puzzle/cellGrid.js +110 -0
  180. package/dist/puzzle/fallingPiece.d.ts +24 -0
  181. package/dist/puzzle/fallingPiece.js +51 -0
  182. package/dist/random/nameGen.d.ts +18 -0
  183. package/dist/random/nameGen.js +35 -0
  184. package/dist/random/rng.d.ts +2 -0
  185. package/dist/random/rng.js +23 -0
  186. package/dist/random/seedLink.d.ts +5 -0
  187. package/dist/random/seedLink.js +35 -0
  188. package/dist/runtime/adapter.d.ts +35 -0
  189. package/dist/runtime/adapter.js +37 -1
  190. package/dist/runtime/cameraDirector.d.ts +15 -0
  191. package/dist/runtime/cameraDirector.js +31 -0
  192. package/dist/runtime/commandRunner.d.ts +2 -2
  193. package/dist/runtime/commandRunner.js +2 -2
  194. package/dist/runtime/gameContext.d.ts +65 -2
  195. package/dist/runtime/gameContext.js +280 -9
  196. package/dist/runtime/gameRuntime.d.ts +5 -1
  197. package/dist/runtime/gameRuntime.js +13 -12
  198. package/dist/runtime/hostPersistence.d.ts +30 -1
  199. package/dist/runtime/hostPersistence.js +70 -5
  200. package/dist/runtime/inputSnapshot.d.ts +12 -0
  201. package/dist/runtime/inputSnapshot.js +17 -0
  202. package/dist/runtime/motionIntents.d.ts +33 -0
  203. package/dist/runtime/motionIntents.js +43 -0
  204. package/dist/runtime/snapshot.d.ts +1 -0
  205. package/dist/runtime/snapshot.js +1 -1
  206. package/dist/runtime/transport.d.ts +32 -0
  207. package/dist/runtime/visibility.d.ts +74 -0
  208. package/dist/runtime/visibility.js +127 -0
  209. package/dist/scene/colliders.d.ts +44 -0
  210. package/dist/scene/colliders.js +77 -0
  211. package/dist/scene/entityStore.d.ts +30 -5
  212. package/dist/scene/entityStore.js +83 -13
  213. package/dist/scene/movementSpeed.d.ts +35 -0
  214. package/dist/scene/movementSpeed.js +20 -0
  215. package/dist/scene/objectQuery.d.ts +29 -0
  216. package/dist/scene/objectQuery.js +140 -0
  217. package/dist/scene/objectStore.d.ts +13 -0
  218. package/dist/scene/objectStore.js +130 -6
  219. package/dist/scene/paintLayer.d.ts +15 -0
  220. package/dist/scene/paintLayer.js +53 -0
  221. package/dist/scene/sceneRaycast.d.ts +80 -0
  222. package/dist/scene/sceneRaycast.js +284 -0
  223. package/dist/scene/spatial.d.ts +17 -1
  224. package/dist/scene/spatial.js +140 -17
  225. package/dist/sensor/concealment.d.ts +21 -0
  226. package/dist/sensor/concealment.js +63 -0
  227. package/dist/sensor/freezeMonitor.d.ts +17 -0
  228. package/dist/sensor/freezeMonitor.js +35 -0
  229. package/dist/sensor/recordingBuffer.d.ts +6 -0
  230. package/dist/sensor/recordingBuffer.js +7 -0
  231. package/dist/sensor/replayLoop.d.ts +39 -0
  232. package/dist/sensor/replayLoop.js +64 -0
  233. package/dist/sensor/visionCone.d.ts +31 -0
  234. package/dist/sensor/visionCone.js +72 -0
  235. package/dist/session/extraction.d.ts +2 -2
  236. package/dist/session/extraction.js +13 -2
  237. package/dist/session/roles.d.ts +6 -0
  238. package/dist/session/roles.js +32 -0
  239. package/dist/session/roundState.d.ts +41 -17
  240. package/dist/session/roundState.js +67 -38
  241. package/dist/settings/settingsModel.d.ts +97 -0
  242. package/dist/settings/settingsModel.js +100 -0
  243. package/dist/stats/statModifiers.d.ts +5 -2
  244. package/dist/stats/statModifiers.js +32 -14
  245. package/dist/store/observableKeyedStore.js +8 -2
  246. package/dist/survival/decayMeter.d.ts +1 -1
  247. package/dist/survival/decayMeter.js +0 -1
  248. package/dist/tactics/fallingGrid.d.ts +62 -0
  249. package/dist/tactics/fallingGrid.js +158 -0
  250. package/dist/tactics/tacticalGrid.d.ts +11 -0
  251. package/dist/tactics/tacticalGrid.js +17 -0
  252. package/dist/time/beatClock.d.ts +18 -0
  253. package/dist/time/beatClock.js +24 -0
  254. package/dist/time/idleProgress.d.ts +29 -0
  255. package/dist/time/idleProgress.js +31 -0
  256. package/dist/time/serverTick.d.ts +7 -1
  257. package/dist/time/serverTick.js +27 -4
  258. package/dist/time/simClock.d.ts +25 -1
  259. package/dist/time/simClock.js +28 -3
  260. package/dist/time/stateSchedule.d.ts +57 -0
  261. package/dist/time/stateSchedule.js +121 -0
  262. package/dist/turn/commit.d.ts +2 -2
  263. package/dist/turn/commit.js +2 -2
  264. package/dist/turn/intent.d.ts +14 -0
  265. package/dist/turn/intent.js +29 -0
  266. package/dist/turn/turnLoop.d.ts +2 -0
  267. package/dist/turn/turnLoop.js +8 -1
  268. package/dist/ui/hudLayout.d.ts +58 -0
  269. package/dist/ui/hudLayout.js +207 -0
  270. package/dist/ui/hudScale.d.ts +44 -0
  271. package/dist/ui/hudScale.js +55 -0
  272. package/dist/visibility/assetStreaming.d.ts +66 -0
  273. package/dist/visibility/assetStreaming.js +201 -0
  274. package/dist/visibility/bounds.d.ts +66 -0
  275. package/dist/visibility/bounds.js +113 -0
  276. package/dist/visibility/camera.d.ts +21 -0
  277. package/dist/visibility/camera.js +3 -0
  278. package/dist/visibility/config.d.ts +21 -0
  279. package/dist/visibility/config.js +1 -0
  280. package/dist/visibility/diagnostics.d.ts +26 -0
  281. package/dist/visibility/diagnostics.js +36 -0
  282. package/dist/visibility/distance.d.ts +11 -0
  283. package/dist/visibility/distance.js +30 -0
  284. package/dist/visibility/frustum.d.ts +47 -0
  285. package/dist/visibility/frustum.js +186 -0
  286. package/dist/visibility/occlusion.d.ts +36 -0
  287. package/dist/visibility/occlusion.js +45 -0
  288. package/dist/visibility/settings.d.ts +75 -0
  289. package/dist/visibility/settings.js +49 -0
  290. package/dist/visibility/simulationCulling.d.ts +34 -0
  291. package/dist/visibility/simulationCulling.js +36 -0
  292. package/dist/visibility/spatialIndex.d.ts +45 -0
  293. package/dist/visibility/spatialIndex.js +198 -0
  294. package/dist/visibility/visibilitySystem.d.ts +90 -0
  295. package/dist/visibility/visibilitySystem.js +257 -0
  296. package/dist/world/buildings.d.ts +6 -0
  297. package/dist/world/buildings.js +152 -0
  298. package/dist/world/carve.d.ts +7 -2
  299. package/dist/world/carve.js +17 -7
  300. package/dist/world/cellStates.d.ts +45 -0
  301. package/dist/world/cellStates.js +103 -0
  302. package/dist/world/environmentSummary.d.ts +61 -0
  303. package/dist/world/environmentSummary.js +138 -0
  304. package/dist/world/features.d.ts +138 -9
  305. package/dist/world/features.js +73 -2
  306. package/dist/world/gridInstances.d.ts +12 -0
  307. package/dist/world/gridInstances.js +160 -0
  308. package/dist/world/lod.d.ts +17 -0
  309. package/dist/world/lod.js +66 -0
  310. package/dist/world/mapLayers.d.ts +60 -0
  311. package/dist/world/mapLayers.js +44 -0
  312. package/dist/world/massing.d.ts +45 -0
  313. package/dist/world/massing.js +581 -0
  314. package/dist/world/minimap.d.ts +13 -1
  315. package/dist/world/minimap.js +21 -2
  316. package/dist/world/placementController.d.ts +22 -1
  317. package/dist/world/placementController.js +56 -0
  318. package/dist/world/terrain.d.ts +62 -1
  319. package/dist/world/terrain.js +253 -9
  320. package/dist/world/voxelField.d.ts +54 -0
  321. package/dist/world/voxelField.js +241 -0
  322. package/dist/world/water.d.ts +7 -0
  323. package/dist/world/water.js +19 -4
  324. package/dist/world/weather.d.ts +3 -18
  325. package/dist/world/weather.js +4 -17
  326. package/dist/world/windZones.d.ts +47 -0
  327. package/dist/world/windZones.js +59 -0
  328. package/llms.txt +4983 -0
  329. package/package.json +14 -3
@@ -38,8 +38,10 @@ export function createDefensiveWindow(config) {
38
38
  if (openedAt === null)
39
39
  return null;
40
40
  const dt = nowMs - openedAt;
41
- if (dt < 0 || dt >= totalWindowMs(config))
41
+ if (dt < 0 || dt >= totalWindowMs(config)) {
42
+ openedAt = null;
42
43
  return null;
44
+ }
43
45
  return dt;
44
46
  }
45
47
  return {
@@ -49,8 +51,8 @@ export function createDefensiveWindow(config) {
49
51
  close() {
50
52
  openedAt = null;
51
53
  },
52
- isOpen() {
53
- return openedAt !== null;
54
+ isOpen(nowMs) {
55
+ return elapsed(nowMs) !== null;
54
56
  },
55
57
  evaluate(nowMs, attack) {
56
58
  const dt = elapsed(nowMs);
@@ -65,7 +65,7 @@ export interface EffectSystemDeps {
65
65
  onLethal?(instanceId: string, ctx: LethalContext): void;
66
66
  }
67
67
  export interface EffectSystem {
68
- canReceive(instanceId: string, effect: string): string | null;
68
+ canReceive(instanceId: string, effect: string, magnitude?: number): string | null;
69
69
  preview(input: SingleTargetEffectInput): number;
70
70
  applyEffect(input: EffectInput): EffectResult[];
71
71
  }
@@ -40,18 +40,21 @@ export function createEffectSystem(deps) {
40
40
  }
41
41
  return result;
42
42
  }
43
- function canReceive(instanceId, effect) {
43
+ function canReceive(instanceId, effect, magnitude) {
44
44
  const rule = resolveRule(instanceId, effect);
45
45
  if (rule === null)
46
46
  return "not-receivable";
47
47
  const stats = deps.resolveStats(instanceId);
48
48
  if (stats === undefined)
49
49
  return "unknown-instance";
50
- const anyPoolAboveMin = rule.order.some((statId) => {
50
+ const restorative = magnitude !== undefined && magnitude < 0;
51
+ const anyPoolHasHeadroom = rule.order.some((statId) => {
51
52
  const stat = stats[statId];
52
- return stat !== undefined && stat.current > stat.min;
53
+ if (stat === undefined)
54
+ return false;
55
+ return restorative ? stat.current < stat.max : stat.current > stat.min;
53
56
  });
54
- if (!anyPoolAboveMin)
57
+ if (!anyPoolHasHeadroom)
55
58
  return "pools-depleted";
56
59
  return null;
57
60
  }
@@ -80,13 +83,13 @@ export function createEffectSystem(deps) {
80
83
  return { instanceId, effect, applied, lethal };
81
84
  }
82
85
  function applyTo(instanceId, effect, via, from, scale) {
83
- if (canReceive(instanceId, effect) !== null)
84
- return null;
85
86
  const rule = resolveRule(instanceId, effect);
86
87
  const stats = deps.resolveStats(instanceId);
87
88
  if (rule === null || stats === undefined)
88
89
  return null;
89
90
  const drainMagnitude = modifiedDrainMagnitude(baseDrainMagnitude(effect, via) * scale, rule);
91
+ if (canReceive(instanceId, effect, drainMagnitude) !== null)
92
+ return null;
90
93
  const result = drainPools(instanceId, effect, rule, stats, drainMagnitude);
91
94
  if (result.lethal)
92
95
  deps.onLethal?.(instanceId, { from, via, effect });
@@ -1,18 +1,39 @@
1
+ import type { BallisticSweep } from "../physics/ballisticSweep.js";
1
2
  import type { EntityPosition } from "../scene/entityStore.js";
3
+ import type { EntityColliderSet } from "../scene/colliders.js";
4
+ import { type SceneRaycastApi } from "../scene/sceneRaycast.js";
2
5
  import type { Aim } from "../scene/spatial.js";
3
6
  import type { CombatSpatialDeps, EffectResult, EffectSystem, EffectVia } from "./effects.js";
7
+ import { type ShotOriginPolicy } from "./shotOrigin.js";
4
8
  export interface ProjectileShotInput {
5
9
  from: string;
6
10
  via: EffectVia;
7
11
  aim: Aim;
8
12
  effect: string;
13
+ /** Defaults to `{ kind: "legacy" }` — aim.origin or shooter position. */
14
+ originPolicy?: ShotOriginPolicy;
9
15
  }
10
- export interface RaycastHit {
16
+ export interface EntityRaycastHit {
17
+ kind: "entity";
11
18
  instanceId: string;
12
19
  distance: number;
13
20
  at: EntityPosition;
21
+ colliderName?: string;
22
+ damageEligible?: boolean;
23
+ blocks?: boolean;
14
24
  }
15
- export type Raycast = (from: string, aim: Aim, range: number) => RaycastHit[];
25
+ export interface ObjectRaycastHit {
26
+ kind: "object";
27
+ instanceId: string;
28
+ catalogId: string;
29
+ distance: number;
30
+ at: EntityPosition;
31
+ colliderName?: string;
32
+ damageEligible?: boolean;
33
+ blocks?: boolean;
34
+ }
35
+ export type RaycastHit = EntityRaycastHit | ObjectRaycastHit;
36
+ export type Raycast = (from: string, aim: Aim, range: number, originPolicy?: ShotOriginPolicy) => RaycastHit[];
16
37
  export interface ProjectileSettleReport {
17
38
  from: string;
18
39
  origin: EntityPosition;
@@ -20,26 +41,63 @@ export interface ProjectileSettleReport {
20
41
  effect: string;
21
42
  hit: boolean;
22
43
  }
44
+ export interface ProjectileObjectsDeps {
45
+ list(): readonly {
46
+ instanceId: string;
47
+ catalogId: string;
48
+ position: readonly [number, number, number];
49
+ rotationY?: number;
50
+ }[];
51
+ inBox?(min: EntityPosition, max: EntityPosition): readonly {
52
+ instanceId: string;
53
+ catalogId: string;
54
+ position: readonly [number, number, number];
55
+ rotationY?: number;
56
+ }[];
57
+ halfExtents?(catalogId: string): [number, number, number] | null;
58
+ collidersOf?(instanceId: string): EntityColliderSet | null | undefined;
59
+ }
23
60
  export interface ProjectileSystemDeps {
24
61
  effects: EffectSystem;
25
62
  spatial: CombatSpatialDeps;
26
63
  getStat(itemId: string, stat: string): number | null;
27
64
  raycast?: Raycast;
65
+ sceneRaycast?: SceneRaycastApi;
66
+ objects?: ProjectileObjectsDeps;
67
+ entityCollidersOf?(instanceId: string): EntityColliderSet | null | undefined;
68
+ rotationYOf?(instanceId: string): number | undefined;
69
+ sweepBallistic?: BallisticSweep;
70
+ defaultOriginPolicy?: ShotOriginPolicy;
28
71
  now?: () => number;
29
72
  onSettle?(report: ProjectileSettleReport): void;
30
73
  }
74
+ export type ProjectileHit = {
75
+ kind: "entity";
76
+ instanceId: string;
77
+ distance: number;
78
+ colliderName?: string;
79
+ damageEligible?: boolean;
80
+ } | {
81
+ kind: "object";
82
+ instanceId: string;
83
+ catalogId: string;
84
+ distance: number;
85
+ colliderName?: string;
86
+ damageEligible?: boolean;
87
+ };
31
88
  export interface ProjectilePrediction {
32
- hits: {
33
- instanceId: string;
34
- distance: number;
35
- }[];
89
+ hits: ProjectileHit[];
36
90
  blocked?: boolean;
91
+ origin?: EntityPosition;
92
+ direction?: EntityPosition;
93
+ firstImpact?: ProjectileHit | null;
37
94
  }
38
95
  export type SettleResult = {
39
96
  status: "settled";
40
97
  shotId: string;
41
98
  at: [number, number, number];
42
99
  hits: EffectResult[];
100
+ origin?: [number, number, number];
43
101
  } | {
44
102
  status: "rejected";
45
103
  shotId: string;
@@ -1,69 +1,169 @@
1
+ import { createSceneRaycast, firstImpact, hitsUntilBlocked, } from "../scene/sceneRaycast.js";
2
+ import { aimDirection, aimSpreadDeg, resolveShot, } from "./shotOrigin.js";
1
3
  const DEFAULT_RANGE = 100;
2
4
  const DEFAULT_PROJECTILE_SPEED = 15;
3
5
  const GRAVITY = 9.8;
4
6
  const BASE_HIT_RADIUS = 0.5;
5
- function normalize(vector) {
6
- const length = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]);
7
- if (length === 0)
8
- return null;
9
- return [vector[0] / length, vector[1] / length, vector[2] / length];
7
+ function isEntityHit(hit) {
8
+ return hit.kind === "entity";
10
9
  }
11
- function aimDirection(aim) {
12
- if ("origin" in aim)
13
- return normalize(aim.direction);
14
- const cosPitch = Math.cos(aim.pitch);
15
- return [Math.sin(aim.yaw) * cosPitch, Math.sin(aim.pitch), Math.cos(aim.yaw) * cosPitch];
10
+ function isObjectHit(hit) {
11
+ return hit.kind === "object";
16
12
  }
17
- function aimSpreadDeg(aim) {
18
- return "origin" in aim ? 0 : aim.spread ?? 0;
13
+ function sceneHitToRaycast(hit) {
14
+ if (hit.targetKind === "entity") {
15
+ return {
16
+ kind: "entity",
17
+ instanceId: hit.instanceId,
18
+ distance: hit.distance,
19
+ at: hit.point,
20
+ colliderName: hit.colliderName,
21
+ damageEligible: hit.damageEligible,
22
+ blocks: hit.blocks,
23
+ };
24
+ }
25
+ return {
26
+ kind: "object",
27
+ instanceId: hit.instanceId,
28
+ catalogId: hit.catalogId ?? hit.targetKind,
29
+ distance: hit.distance,
30
+ at: hit.point,
31
+ colliderName: hit.colliderName,
32
+ damageEligible: hit.damageEligible,
33
+ blocks: hit.blocks,
34
+ };
35
+ }
36
+ function toProjectileHit(hit) {
37
+ if (isObjectHit(hit)) {
38
+ return {
39
+ kind: "object",
40
+ instanceId: hit.instanceId,
41
+ catalogId: hit.catalogId,
42
+ distance: hit.distance,
43
+ ...(hit.colliderName !== undefined ? { colliderName: hit.colliderName } : {}),
44
+ ...(hit.damageEligible !== undefined ? { damageEligible: hit.damageEligible } : {}),
45
+ };
46
+ }
47
+ return {
48
+ kind: "entity",
49
+ instanceId: hit.instanceId,
50
+ distance: hit.distance,
51
+ ...(hit.colliderName !== undefined ? { colliderName: hit.colliderName } : {}),
52
+ ...(hit.damageEligible !== undefined ? { damageEligible: hit.damageEligible } : {}),
53
+ };
54
+ }
55
+ function asSceneHits(hits) {
56
+ return hits.map((hit) => ({
57
+ targetKind: hit.kind === "entity" ? "entity" : "object",
58
+ instanceId: hit.instanceId,
59
+ catalogId: hit.kind === "object" ? hit.catalogId : undefined,
60
+ colliderName: hit.colliderName ?? "body",
61
+ purpose: hit.damageEligible === false ? "physical" : "damage",
62
+ damageEligible: hit.kind === "entity" ? hit.damageEligible !== false : hit.damageEligible === true,
63
+ blocks: hit.blocks !== false,
64
+ distance: hit.distance,
65
+ point: hit.at,
66
+ normal: [0, 1, 0],
67
+ }));
19
68
  }
20
69
  export function createProjectileSystem(deps) {
21
70
  const shots = new Map();
22
71
  let shotCounter = 0;
23
72
  const now = deps.now ?? (() => Date.now());
73
+ const defaultPolicy = deps.defaultOriginPolicy ?? { kind: "legacy" };
24
74
  function itemStat(via, stat) {
25
75
  return via.item === undefined ? null : deps.getStat(via.item, stat);
26
76
  }
27
- function shotOrigin(from, aim) {
28
- return "origin" in aim ? aim.origin : deps.spatial.positionOf(from);
29
- }
30
77
  function withWeaponSpread(via, aim) {
31
78
  if ("origin" in aim || aim.spread !== undefined)
32
79
  return aim;
33
80
  const spread = itemStat(via, "spread");
34
81
  return spread === null ? aim : { ...aim, spread };
35
82
  }
83
+ function resolveShotFor(from, aim, policy) {
84
+ return resolveShot({
85
+ positionOf: deps.spatial.positionOf,
86
+ rotationYOf: deps.rotationYOf,
87
+ }, from, aim, policy ?? defaultPolicy);
88
+ }
89
+ const internalSceneRaycast = deps.sceneRaycast ??
90
+ createSceneRaycast({
91
+ entities: {
92
+ list: () => {
93
+ const ids = deps.spatial.inRadius([0, 0, 0], 1e9);
94
+ const out = [];
95
+ for (const id of ids) {
96
+ const position = deps.spatial.positionOf(id);
97
+ if (position === undefined)
98
+ continue;
99
+ out.push({ id, position, rotationY: deps.rotationYOf?.(id) ?? 0 });
100
+ }
101
+ return out;
102
+ },
103
+ collidersOf: deps.entityCollidersOf,
104
+ inRadius: (center, radius) => deps.spatial.inRadius(center, radius),
105
+ get: (id) => {
106
+ const position = deps.spatial.positionOf(id);
107
+ if (position === undefined)
108
+ return null;
109
+ return { id, position, rotationY: deps.rotationYOf?.(id) ?? 0 };
110
+ },
111
+ },
112
+ objects: deps.objects === undefined
113
+ ? undefined
114
+ : {
115
+ list: () => deps.objects.list().map((object) => ({
116
+ instanceId: object.instanceId,
117
+ catalogId: object.catalogId,
118
+ position: [object.position[0], object.position[1], object.position[2]],
119
+ rotationY: object.rotationY ?? 0,
120
+ })),
121
+ inBox: deps.objects.inBox
122
+ ? (min, max) => deps.objects.inBox(min, max).map((object) => ({
123
+ instanceId: object.instanceId,
124
+ catalogId: object.catalogId,
125
+ position: [object.position[0], object.position[1], object.position[2]],
126
+ rotationY: object.rotationY ?? 0,
127
+ }))
128
+ : undefined,
129
+ halfExtentsOf: deps.objects.halfExtents,
130
+ collidersOf: deps.objects.collidersOf,
131
+ },
132
+ });
36
133
  const raycast = deps.raycast ??
37
- ((from, aim, range) => {
38
- const origin = shotOrigin(from, aim);
39
- if (origin === undefined)
40
- return [];
41
- const direction = aimDirection(aim);
42
- if (direction === null)
134
+ ((from, aim, range, originPolicy) => {
135
+ const resolved = resolveShotFor(from, aim, originPolicy);
136
+ if (resolved === null)
43
137
  return [];
138
+ const { origin, direction } = resolved;
139
+ const all = internalSceneRaycast.raycastAll({
140
+ origin,
141
+ direction,
142
+ maxDistance: range,
143
+ excludeInstanceIds: [from],
144
+ });
145
+ const until = hitsUntilBlocked(all);
44
146
  const spreadRad = (aimSpreadDeg(aim) * Math.PI) / 180;
45
- const hits = [];
46
- for (const instanceId of deps.spatial.inRadius(origin, range)) {
47
- if (instanceId === from)
48
- continue;
49
- const position = deps.spatial.positionOf(instanceId);
50
- if (position === undefined)
51
- continue;
52
- const dx = position[0] - origin[0];
53
- const dy = position[1] - origin[1];
54
- const dz = position[2] - origin[2];
55
- const along = dx * direction[0] + dy * direction[1] + dz * direction[2];
56
- if (along <= 0 || along > range)
57
- continue;
58
- const px = dx - direction[0] * along;
59
- const py = dy - direction[1] * along;
60
- const pz = dz - direction[2] * along;
61
- const perpendicular = Math.sqrt(px * px + py * py + pz * pz);
62
- if (perpendicular > BASE_HIT_RADIUS + Math.tan(spreadRad) * along)
63
- continue;
64
- hits.push({ instanceId, distance: along, at: position });
147
+ const mapped = [];
148
+ for (const hit of until) {
149
+ if (hit.targetKind === "entity" && spreadRad > 0) {
150
+ const position = deps.spatial.positionOf(hit.instanceId);
151
+ if (position !== undefined) {
152
+ const dx = position[0] - origin[0];
153
+ const dy = position[1] - origin[1];
154
+ const dz = position[2] - origin[2];
155
+ const along = dx * direction[0] + dy * direction[1] + dz * direction[2];
156
+ const px = dx - direction[0] * along;
157
+ const py = dy - direction[1] * along;
158
+ const pz = dz - direction[2] * along;
159
+ const perpendicular = Math.sqrt(px * px + py * py + pz * pz);
160
+ if (perpendicular > 0.5 + Math.tan(spreadRad) * along)
161
+ continue;
162
+ }
163
+ }
164
+ mapped.push(sceneHitToRaycast(hit));
65
165
  }
66
- return hits.sort((a, b) => a.distance - b.distance);
166
+ return mapped;
67
167
  });
68
168
  function resolveRange(via) {
69
169
  return itemStat(via, "range") ?? DEFAULT_RANGE;
@@ -71,9 +171,10 @@ export function createProjectileSystem(deps) {
71
171
  function isBallistic(via) {
72
172
  return itemStat(via, "projectile.fuseTime") !== null || itemStat(via, "explosion.radius") !== null;
73
173
  }
74
- function ballisticSettlePoint(input) {
75
- const origin = shotOrigin(input.from, input.aim) ?? [0, 0, 0];
76
- const direction = aimDirection(input.aim) ?? [0, 0, 1];
174
+ function ballisticArc(input) {
175
+ const resolved = resolveShotFor(input.from, input.aim, input.originPolicy);
176
+ const origin = resolved?.origin ?? [0, 0, 0];
177
+ const direction = resolved?.direction ?? aimDirection(input.aim) ?? [0, 0, 1];
77
178
  const speed = itemStat(input.via, "projectile.speed") ?? DEFAULT_PROJECTILE_SPEED;
78
179
  const gravityScale = itemStat(input.via, "projectile.gravity") ?? 1;
79
180
  const fuseTime = itemStat(input.via, "projectile.fuseTime");
@@ -85,37 +186,66 @@ export function createProjectileSystem(deps) {
85
186
  : flightCap;
86
187
  const flightTime = Math.min(impactTime, flightCap);
87
188
  const settledY = Math.max(0, origin[1] + verticalSpeed * flightTime - 0.5 * gravity * flightTime * flightTime);
88
- return [
89
- origin[0] + direction[0] * speed * flightTime,
90
- settledY,
91
- origin[2] + direction[2] * speed * flightTime,
92
- ];
189
+ return {
190
+ origin,
191
+ velocity: [direction[0] * speed, verticalSpeed, direction[2] * speed],
192
+ gravity,
193
+ flightTime,
194
+ landing: [
195
+ origin[0] + direction[0] * speed * flightTime,
196
+ settledY,
197
+ origin[2] + direction[2] * speed * flightTime,
198
+ ],
199
+ };
200
+ }
201
+ function ballisticSettlePoint(input) {
202
+ const arc = ballisticArc(input);
203
+ const sweep = deps.sweepBallistic;
204
+ if (sweep !== undefined) {
205
+ const impact = sweep(arc.origin, arc.velocity, arc.gravity, arc.flightTime);
206
+ if (impact !== null)
207
+ return impact.point;
208
+ }
209
+ return arc.landing;
93
210
  }
94
211
  function predictHits(input) {
95
212
  const aim = withWeaponSpread(input.via, input.aim);
96
- const rawHits = raycast(input.from, aim, resolveRange(input.via));
97
- const visible = rawHits.filter((hit) => deps.spatial.hasLineOfSight(input.from, hit.instanceId));
98
- return { rawHits, visible };
213
+ const resolved = resolveShotFor(input.from, aim, input.originPolicy);
214
+ const rawHits = raycast(input.from, aim, resolveRange(input.via), input.originPolicy);
215
+ const visible = rawHits.filter((hit) => isObjectHit(hit) || deps.spatial.hasLineOfSight(input.from, hit.instanceId));
216
+ return {
217
+ rawHits,
218
+ visible,
219
+ origin: resolved?.origin,
220
+ direction: resolved?.direction,
221
+ };
99
222
  }
100
- function settledAt(input, hits) {
101
- const first = hits[0];
102
- if (first !== undefined)
103
- return [first.at[0], first.at[1], first.at[2]];
104
- const origin = shotOrigin(input.from, input.aim);
105
- const direction = origin === undefined ? null : aimDirection(input.aim);
106
- if (origin === undefined || direction === null)
223
+ function missPoint(input) {
224
+ const resolved = resolveShotFor(input.from, input.aim, input.originPolicy);
225
+ if (resolved === null)
107
226
  return [0, 0, 0];
108
227
  const range = resolveRange(input.via);
109
- return [origin[0] + direction[0] * range, origin[1] + direction[1] * range, origin[2] + direction[2] * range];
228
+ return [
229
+ resolved.origin[0] + resolved.direction[0] * range,
230
+ resolved.origin[1] + resolved.direction[1] * range,
231
+ resolved.origin[2] + resolved.direction[2] * range,
232
+ ];
110
233
  }
111
234
  return {
112
235
  willHitProjectile(input) {
113
- const { rawHits, visible } = predictHits(input);
236
+ const { rawHits, visible, origin, direction } = predictHits(input);
237
+ const impact = firstImpact(asSceneHits(rawHits));
238
+ const solidBlock = impact !== null && impact.blocks && !impact.damageEligible;
114
239
  const prediction = {
115
- hits: visible.map(({ instanceId, distance }) => ({ instanceId, distance })),
240
+ hits: visible.map(toProjectileHit),
241
+ ...(origin !== undefined ? { origin } : {}),
242
+ ...(direction !== undefined ? { direction } : {}),
243
+ firstImpact: impact === null ? null : toProjectileHit(sceneHitToRaycast(impact)),
116
244
  };
117
245
  if (rawHits.length > 0 && visible.length === 0)
118
246
  prediction.blocked = true;
247
+ else if (solidBlock)
248
+ prediction.blocked = true;
119
249
  return prediction;
120
250
  },
121
251
  fireProjectile(input) {
@@ -132,19 +262,38 @@ export function createProjectileSystem(deps) {
132
262
  return { status: "rejected", shotId, reason: "already-settled" };
133
263
  shot.settled = true;
134
264
  const { input } = shot;
135
- const origin = shotOrigin(input.from, input.aim) ?? [0, 0, 0];
265
+ const resolved = resolveShotFor(input.from, input.aim, input.originPolicy);
266
+ const origin = resolved?.origin ?? [0, 0, 0];
267
+ const originTuple = [origin[0], origin[1], origin[2]];
136
268
  if (isBallistic(input.via)) {
137
269
  const at = ballisticSettlePoint(input);
138
- deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: false });
139
- return { status: "settled", shotId, at, hits: [] };
270
+ const splashRadius = itemStat(input.via, "explosion.radius");
271
+ const radius = splashRadius ?? BASE_HIT_RADIUS;
272
+ const hits = deps.effects.applyEffect({
273
+ from: input.from,
274
+ effect: input.effect,
275
+ via: input.via,
276
+ at,
277
+ radius,
278
+ falloff: splashRadius === null ? "none" : "linear",
279
+ });
280
+ deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0 });
281
+ return { status: "settled", shotId, at, hits, origin: originTuple };
140
282
  }
141
- const { visible } = predictHits(input);
142
- const receivable = visible.filter((hit) => deps.effects.canReceive(hit.instanceId, input.effect) === null);
283
+ const { visible, rawHits } = predictHits(input);
284
+ const ordered = asSceneHits(rawHits);
285
+ const untilBlock = hitsUntilBlocked(ordered);
286
+ const impact = firstImpact(untilBlock);
287
+ const solidBlock = impact !== null && impact.blocks && !impact.damageEligible;
288
+ const damageEntityHits = visible.filter((hit) => isEntityHit(hit) &&
289
+ hit.damageEligible !== false &&
290
+ (!solidBlock || hit.distance <= (impact?.distance ?? Infinity) + 1e-9) &&
291
+ deps.effects.canReceive(hit.instanceId, input.effect) === null);
143
292
  const pellets = Math.max(1, Math.round(itemStat(input.via, "pellets") ?? 1));
144
293
  const hits = [];
145
- if (receivable.length > 0) {
146
- for (let pellet = 0; pellet < pellets; pellet++) {
147
- const target = receivable[pellet % receivable.length];
294
+ if (!solidBlock && damageEntityHits.length > 0) {
295
+ for (let pellet = 0; pellet < pellets; pellet += 1) {
296
+ const target = damageEntityHits[pellet % damageEntityHits.length];
148
297
  hits.push(...deps.effects.applyEffect({
149
298
  from: input.from,
150
299
  to: target.instanceId,
@@ -153,9 +302,18 @@ export function createProjectileSystem(deps) {
153
302
  }));
154
303
  }
155
304
  }
156
- const at = settledAt(input, receivable);
157
- deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: receivable.length > 0 });
158
- return { status: "settled", shotId, at, hits };
305
+ let at;
306
+ if (impact !== null) {
307
+ at = [impact.point[0], impact.point[1], impact.point[2]];
308
+ }
309
+ else if (damageEntityHits[0] !== undefined) {
310
+ at = [damageEntityHits[0].at[0], damageEntityHits[0].at[1], damageEntityHits[0].at[2]];
311
+ }
312
+ else {
313
+ at = missPoint(input);
314
+ }
315
+ deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0 });
316
+ return { status: "settled", shotId, at, hits, origin: originTuple };
159
317
  },
160
318
  };
161
319
  }
@@ -1,13 +1,25 @@
1
1
  export type ResistVerdict = "immune" | "resist" | "normal" | "vulnerable";
2
- export interface ResistanceMatrix {
3
- categories: Record<string, Record<string, ResistVerdict>>;
2
+ export declare class UnknownResistanceCategoryError extends Error {
3
+ readonly category: string;
4
+ constructor(category: string);
5
+ }
6
+ export declare class UnknownResistancePropertyError extends Error {
7
+ readonly category: string;
8
+ readonly property: string;
9
+ constructor(category: string, property: string);
10
+ }
11
+ export interface ResistanceMatrix<TCategory extends string = string, TProperty extends string = string> {
12
+ categories: Partial<Record<TCategory, Partial<Record<TProperty, ResistVerdict>>>>;
13
+ categoryIds?: readonly TCategory[];
14
+ propertyIds?: readonly TProperty[];
4
15
  multipliers?: Partial<Record<ResistVerdict, number>>;
5
16
  default?: ResistVerdict;
17
+ unknownCategory?: ResistVerdict;
6
18
  }
7
19
  export interface ResistanceResult {
8
20
  verdict: ResistVerdict;
9
21
  multiplier: number;
10
22
  immune: boolean;
11
23
  }
12
- export declare function resolveResistance(matrix: ResistanceMatrix, category: string, targetProperties: readonly string[]): ResistanceResult;
13
- export declare function resistanceScale(matrix: ResistanceMatrix, category: string, targetProperties: readonly string[]): number;
24
+ export declare function resolveResistance<TCategory extends string = string, TProperty extends string = string>(matrix: ResistanceMatrix<TCategory, TProperty>, category: TCategory | string, targetProperties: readonly (TProperty | string)[]): ResistanceResult;
25
+ export declare function resistanceScale<TCategory extends string = string, TProperty extends string = string>(matrix: ResistanceMatrix<TCategory, TProperty>, category: TCategory | string, targetProperties: readonly (TProperty | string)[]): number;
@@ -1,3 +1,21 @@
1
+ export class UnknownResistanceCategoryError extends Error {
2
+ category;
3
+ constructor(category) {
4
+ super(`Unknown resistance category: ${category}`);
5
+ this.name = "UnknownResistanceCategoryError";
6
+ this.category = category;
7
+ }
8
+ }
9
+ export class UnknownResistancePropertyError extends Error {
10
+ category;
11
+ property;
12
+ constructor(category, property) {
13
+ super(`Unknown resistance property "${property}" for category "${category}"`);
14
+ this.name = "UnknownResistancePropertyError";
15
+ this.category = category;
16
+ this.property = property;
17
+ }
18
+ }
1
19
  const DEFAULT_MULTIPLIERS = {
2
20
  immune: 0,
3
21
  resist: 0.5,
@@ -17,13 +35,35 @@ function summaryVerdict(matrix, immune, net) {
17
35
  return "resist";
18
36
  return "normal";
19
37
  }
38
+ function fromVerdict(matrix, verdict) {
39
+ if (verdict === "immune")
40
+ return { verdict: "immune", multiplier: 0, immune: true };
41
+ const multiplier = multiplierFor(matrix, verdict);
42
+ return { verdict, multiplier, immune: false };
43
+ }
44
+ function categoryTable(matrix, category) {
45
+ return matrix.categories[category];
46
+ }
20
47
  export function resolveResistance(matrix, category, targetProperties) {
21
- const byProperty = matrix.categories[category] ?? {};
48
+ const byProperty = categoryTable(matrix, category);
49
+ if (byProperty === undefined) {
50
+ if (matrix.unknownCategory !== undefined)
51
+ return fromVerdict(matrix, matrix.unknownCategory);
52
+ const catalog = matrix.categoryIds ?? Object.keys(matrix.categories);
53
+ if (catalog.length === 0 && matrix.default !== undefined) {
54
+ return fromVerdict(matrix, matrix.default);
55
+ }
56
+ throw new UnknownResistanceCategoryError(category);
57
+ }
58
+ const propertyCatalog = matrix.propertyIds !== undefined ? new Set(matrix.propertyIds) : null;
22
59
  const fallback = matrix.default ?? "normal";
23
60
  let net = 1;
24
61
  let matched = false;
25
62
  let immune = false;
26
63
  for (const property of targetProperties) {
64
+ if (propertyCatalog !== null && !propertyCatalog.has(property)) {
65
+ throw new UnknownResistancePropertyError(category, property);
66
+ }
27
67
  const verdict = byProperty[property];
28
68
  if (verdict === undefined)
29
69
  continue;
@@ -32,12 +72,8 @@ export function resolveResistance(matrix, category, targetProperties) {
32
72
  immune = true;
33
73
  net *= multiplierFor(matrix, verdict);
34
74
  }
35
- if (!matched) {
36
- if (fallback === "immune")
37
- return { verdict: "immune", multiplier: 0, immune: true };
38
- const multiplier = multiplierFor(matrix, fallback);
39
- return { verdict: fallback, multiplier, immune: false };
40
- }
75
+ if (!matched)
76
+ return fromVerdict(matrix, fallback);
41
77
  const multiplier = immune ? 0 : net;
42
78
  return { verdict: summaryVerdict(matrix, immune, multiplier), multiplier, immune };
43
79
  }