@jgengine/core 0.5.0 → 0.7.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 (244) hide show
  1. package/CHANGELOG.md +191 -2
  2. package/dist/ai/crowd.d.ts +36 -0
  3. package/dist/ai/crowd.js +213 -0
  4. package/dist/ai/jobBoard.d.ts +49 -0
  5. package/dist/ai/jobBoard.js +155 -0
  6. package/dist/ai/spawnDirector.d.ts +56 -0
  7. package/dist/ai/spawnDirector.js +150 -0
  8. package/dist/ai/threat.d.ts +25 -0
  9. package/dist/ai/threat.js +67 -0
  10. package/dist/audio/audioFalloff.d.ts +38 -0
  11. package/dist/audio/audioFalloff.js +32 -0
  12. package/dist/board/laneBoard.d.ts +63 -0
  13. package/dist/board/laneBoard.js +114 -0
  14. package/dist/board/timelineBoard.d.ts +36 -0
  15. package/dist/board/timelineBoard.js +70 -0
  16. package/dist/cards/cardPile.d.ts +63 -0
  17. package/dist/cards/cardPile.js +166 -0
  18. package/dist/cards/modifierPipeline.d.ts +33 -0
  19. package/dist/cards/modifierPipeline.js +48 -0
  20. package/dist/combat/abilityKit.d.ts +42 -0
  21. package/dist/combat/abilityKit.js +129 -0
  22. package/dist/combat/animationState.d.ts +47 -0
  23. package/dist/combat/animationState.js +101 -0
  24. package/dist/combat/attackTags.d.ts +14 -0
  25. package/dist/combat/attackTags.js +26 -0
  26. package/dist/combat/breakMeters.d.ts +32 -0
  27. package/dist/combat/breakMeters.js +51 -0
  28. package/dist/combat/comboString.d.ts +35 -0
  29. package/dist/combat/comboString.js +59 -0
  30. package/dist/combat/death.d.ts +8 -0
  31. package/dist/combat/death.js +5 -2
  32. package/dist/combat/defensiveWindow.d.ts +34 -0
  33. package/dist/combat/defensiveWindow.js +66 -0
  34. package/dist/combat/downed.d.ts +35 -0
  35. package/dist/combat/downed.js +104 -0
  36. package/dist/combat/effects.d.ts +11 -0
  37. package/dist/combat/effects.js +22 -13
  38. package/dist/combat/hitReaction.d.ts +23 -0
  39. package/dist/combat/hitReaction.js +20 -0
  40. package/dist/combat/resistance.d.ts +13 -0
  41. package/dist/combat/resistance.js +46 -0
  42. package/dist/combat/telegraph.d.ts +27 -0
  43. package/dist/combat/telegraph.js +37 -0
  44. package/dist/crafting/crop.d.ts +59 -0
  45. package/dist/crafting/crop.js +181 -0
  46. package/dist/crafting/production.d.ts +64 -0
  47. package/dist/crafting/production.js +130 -0
  48. package/dist/crafting/recipe.d.ts +62 -0
  49. package/dist/crafting/recipe.js +101 -0
  50. package/dist/economy/sharedWallet.d.ts +28 -0
  51. package/dist/economy/sharedWallet.js +62 -0
  52. package/dist/economy/techTree.d.ts +40 -0
  53. package/dist/economy/techTree.js +66 -0
  54. package/dist/game/cosmetics.d.ts +25 -0
  55. package/dist/game/cosmetics.js +51 -0
  56. package/dist/game/defineGame.d.ts +5 -0
  57. package/dist/game/events.d.ts +62 -0
  58. package/dist/game/lootFilter.d.ts +33 -0
  59. package/dist/game/lootFilter.js +48 -0
  60. package/dist/game/ping.d.ts +67 -0
  61. package/dist/game/ping.js +86 -0
  62. package/dist/game/playableGame.d.ts +291 -5
  63. package/dist/game/race.d.ts +95 -0
  64. package/dist/game/race.js +216 -0
  65. package/dist/game/runDraft.d.ts +34 -0
  66. package/dist/game/runDraft.js +97 -0
  67. package/dist/game/social.d.ts +27 -0
  68. package/dist/game/social.js +16 -0
  69. package/dist/game/worldItem.d.ts +92 -0
  70. package/dist/game/worldItem.js +132 -0
  71. package/dist/input/axisInput.d.ts +43 -0
  72. package/dist/input/axisInput.js +70 -0
  73. package/dist/input/pointer.d.ts +24 -0
  74. package/dist/input/pointer.js +17 -0
  75. package/dist/interaction/contextMenu.d.ts +30 -0
  76. package/dist/interaction/contextMenu.js +22 -0
  77. package/dist/interaction/qte.d.ts +20 -0
  78. package/dist/interaction/qte.js +24 -0
  79. package/dist/interaction/skillCheck.d.ts +20 -0
  80. package/dist/interaction/skillCheck.js +26 -0
  81. package/dist/inventory/shapedGrid.d.ts +56 -0
  82. package/dist/inventory/shapedGrid.js +176 -0
  83. package/dist/inventory/storageTier.d.ts +52 -0
  84. package/dist/inventory/storageTier.js +77 -0
  85. package/dist/item/affix.d.ts +54 -0
  86. package/dist/item/affix.js +133 -0
  87. package/dist/item/durability.d.ts +51 -0
  88. package/dist/item/durability.js +78 -0
  89. package/dist/item/modularItem.d.ts +46 -0
  90. package/dist/item/modularItem.js +75 -0
  91. package/dist/meta/changelog.d.ts +1 -1
  92. package/dist/meta/changelog.js +53 -1
  93. package/dist/movement/dash.d.ts +37 -0
  94. package/dist/movement/dash.js +65 -0
  95. package/dist/multiplayer/combatSnapshot.d.ts +45 -0
  96. package/dist/multiplayer/combatSnapshot.js +89 -0
  97. package/dist/multiplayer/lagCompensation.d.ts +43 -0
  98. package/dist/multiplayer/lagCompensation.js +154 -0
  99. package/dist/multiplayer/matchmaking.d.ts +33 -0
  100. package/dist/multiplayer/matchmaking.js +60 -0
  101. package/dist/multiplayer/simultaneousCommit.d.ts +34 -0
  102. package/dist/multiplayer/simultaneousCommit.js +53 -0
  103. package/dist/nav/navGrid.d.ts +46 -0
  104. package/dist/nav/navGrid.js +248 -0
  105. package/dist/nav/pathFollow.d.ts +29 -0
  106. package/dist/nav/pathFollow.js +67 -0
  107. package/dist/physics/buoyancy.d.ts +49 -0
  108. package/dist/physics/buoyancy.js +112 -0
  109. package/dist/physics/carryable.d.ts +46 -0
  110. package/dist/physics/carryable.js +110 -0
  111. package/dist/physics/damageZones.d.ts +56 -0
  112. package/dist/physics/damageZones.js +107 -0
  113. package/dist/physics/forceVolume.d.ts +48 -0
  114. package/dist/physics/forceVolume.js +119 -0
  115. package/dist/physics/physicsWorld.d.ts +219 -0
  116. package/dist/physics/physicsWorld.js +749 -0
  117. package/dist/physics/ragdoll.d.ts +49 -0
  118. package/dist/physics/ragdoll.js +103 -0
  119. package/dist/physics/spatialGrid.d.ts +41 -0
  120. package/dist/physics/spatialGrid.js +138 -0
  121. package/dist/physics/structure.d.ts +83 -0
  122. package/dist/physics/structure.js +0 -0
  123. package/dist/physics/traversal.d.ts +75 -0
  124. package/dist/physics/traversal.js +138 -0
  125. package/dist/physics/vehicleBody.d.ts +93 -0
  126. package/dist/physics/vehicleBody.js +234 -0
  127. package/dist/runtime/gameContext.d.ts +66 -2
  128. package/dist/runtime/gameContext.js +157 -4
  129. package/dist/runtime/hostPersistence.d.ts +18 -0
  130. package/dist/runtime/hostPersistence.js +4 -0
  131. package/dist/runtime/persistenceScope.d.ts +32 -0
  132. package/dist/runtime/persistenceScope.js +64 -0
  133. package/dist/scene/assetCatalog.d.ts +13 -0
  134. package/dist/scene/autoTarget.d.ts +15 -0
  135. package/dist/scene/autoTarget.js +54 -0
  136. package/dist/scene/behaviors.d.ts +13 -1
  137. package/dist/scene/behaviors.js +3 -0
  138. package/dist/scene/captureCheck.d.ts +7 -0
  139. package/dist/scene/captureCheck.js +11 -0
  140. package/dist/scene/entityStore.d.ts +7 -1
  141. package/dist/scene/entityStore.js +15 -1
  142. package/dist/scene/form.d.ts +40 -0
  143. package/dist/scene/form.js +56 -0
  144. package/dist/scene/mount.d.ts +62 -0
  145. package/dist/scene/mount.js +107 -0
  146. package/dist/scene/possession.d.ts +30 -0
  147. package/dist/scene/possession.js +51 -0
  148. package/dist/scene/roster.d.ts +25 -0
  149. package/dist/scene/roster.js +57 -0
  150. package/dist/scene/selection.d.ts +30 -0
  151. package/dist/scene/selection.js +38 -0
  152. package/dist/scene/stationClaim.d.ts +43 -0
  153. package/dist/scene/stationClaim.js +109 -0
  154. package/dist/sensor/frustumSensor.d.ts +60 -0
  155. package/dist/sensor/frustumSensor.js +104 -0
  156. package/dist/sensor/hiddenStateProbe.d.ts +31 -0
  157. package/dist/sensor/hiddenStateProbe.js +40 -0
  158. package/dist/sensor/recordingBuffer.d.ts +29 -0
  159. package/dist/sensor/recordingBuffer.js +61 -0
  160. package/dist/sensor/revealQuery.d.ts +22 -0
  161. package/dist/sensor/revealQuery.js +36 -0
  162. package/dist/session/contestedChannel.d.ts +36 -0
  163. package/dist/session/contestedChannel.js +100 -0
  164. package/dist/session/extraction.d.ts +54 -0
  165. package/dist/session/extraction.js +108 -0
  166. package/dist/session/ring.d.ts +36 -0
  167. package/dist/session/ring.js +88 -0
  168. package/dist/session/roundState.d.ts +55 -0
  169. package/dist/session/roundState.js +129 -0
  170. package/dist/stats/accumulatorMeter.d.ts +31 -0
  171. package/dist/stats/accumulatorMeter.js +81 -0
  172. package/dist/stats/eventMeter.d.ts +34 -0
  173. package/dist/stats/eventMeter.js +76 -0
  174. package/dist/stats/rollCheck.d.ts +15 -0
  175. package/dist/stats/rollCheck.js +15 -0
  176. package/dist/survival/decayMeter.d.ts +59 -0
  177. package/dist/survival/decayMeter.js +95 -0
  178. package/dist/survival/moodle.d.ts +48 -0
  179. package/dist/survival/moodle.js +91 -0
  180. package/dist/survival/regionHealth.d.ts +72 -0
  181. package/dist/survival/regionHealth.js +129 -0
  182. package/dist/tactics/predictiveQuery.d.ts +34 -0
  183. package/dist/tactics/predictiveQuery.js +44 -0
  184. package/dist/tactics/snapshot.d.ts +18 -0
  185. package/dist/tactics/snapshot.js +65 -0
  186. package/dist/tactics/surface.d.ts +52 -0
  187. package/dist/tactics/surface.js +135 -0
  188. package/dist/tactics/tacticalGrid.d.ts +57 -0
  189. package/dist/tactics/tacticalGrid.js +244 -0
  190. package/dist/time/beatClock.d.ts +40 -0
  191. package/dist/time/beatClock.js +73 -0
  192. package/dist/time/simClock.d.ts +62 -0
  193. package/dist/time/simClock.js +132 -0
  194. package/dist/turn/commit.d.ts +27 -0
  195. package/dist/turn/commit.js +72 -0
  196. package/dist/turn/turnLoop.d.ts +58 -0
  197. package/dist/turn/turnLoop.js +216 -0
  198. package/dist/world/buildPermissions.d.ts +47 -0
  199. package/dist/world/buildPermissions.js +134 -0
  200. package/dist/world/buildingIndex.d.ts +16 -0
  201. package/dist/world/buildingIndex.js +63 -0
  202. package/dist/world/buildings.d.ts +98 -0
  203. package/dist/world/buildings.js +320 -0
  204. package/dist/world/carve.d.ts +105 -0
  205. package/dist/world/carve.js +175 -0
  206. package/dist/world/connectors.d.ts +40 -0
  207. package/dist/world/connectors.js +67 -0
  208. package/dist/world/envField.d.ts +71 -0
  209. package/dist/world/envField.js +96 -0
  210. package/dist/world/features.d.ts +105 -1
  211. package/dist/world/features.js +95 -0
  212. package/dist/world/fog.d.ts +40 -0
  213. package/dist/world/fog.js +137 -0
  214. package/dist/world/markers.d.ts +52 -0
  215. package/dist/world/markers.js +96 -0
  216. package/dist/world/minimap.d.ts +42 -0
  217. package/dist/world/minimap.js +72 -0
  218. package/dist/world/placedStructureStore.d.ts +39 -0
  219. package/dist/world/placedStructureStore.js +95 -0
  220. package/dist/world/placementController.d.ts +48 -0
  221. package/dist/world/placementController.js +97 -0
  222. package/dist/world/realm.d.ts +61 -0
  223. package/dist/world/realm.js +66 -0
  224. package/dist/world/regions.d.ts +66 -0
  225. package/dist/world/regions.js +158 -0
  226. package/dist/world/scatter.d.ts +24 -0
  227. package/dist/world/scatter.js +126 -0
  228. package/dist/world/scatterItems.d.ts +38 -0
  229. package/dist/world/scatterItems.js +85 -0
  230. package/dist/world/support.d.ts +27 -0
  231. package/dist/world/support.js +75 -0
  232. package/dist/world/terraform.d.ts +54 -0
  233. package/dist/world/terraform.js +186 -0
  234. package/dist/world/terrain.d.ts +54 -0
  235. package/dist/world/terrain.js +181 -0
  236. package/dist/world/walls.d.ts +61 -0
  237. package/dist/world/walls.js +221 -0
  238. package/dist/world/water.d.ts +27 -0
  239. package/dist/world/water.js +125 -0
  240. package/dist/world/weather.d.ts +92 -0
  241. package/dist/world/weather.js +169 -0
  242. package/dist/world/wind.d.ts +17 -0
  243. package/dist/world/wind.js +40 -0
  244. package/package.json +1 -1
@@ -0,0 +1,216 @@
1
+ /**
2
+ * A race track is an ordered ring of checkpoint trigger volumes plus a lap count. The final checkpoint
3
+ * is the lap/finish line: a racer completes a lap by passing all checkpoints in order and hitting the
4
+ * last one.
5
+ */
6
+ export function raceTrack(config) {
7
+ return { checkpoints: config.checkpoints, laps: Math.max(1, Math.floor(config.laps ?? 1)) };
8
+ }
9
+ function rankIds(standings) {
10
+ return standings.map((s) => s.racerId);
11
+ }
12
+ /** Race ends when `count` racers have crossed the finish; ranking is the current standings order. */
13
+ export function firstPastPost(count = 1) {
14
+ return (standings) => {
15
+ const finished = standings.filter((s) => s.finished).length;
16
+ return finished >= count ? rankIds(standings) : null;
17
+ };
18
+ }
19
+ /** Round-cut (Fall Guys): the first `k` finishers qualify; race resolves the moment `k` cross. */
20
+ export function topK(k) {
21
+ return firstPastPost(k);
22
+ }
23
+ /** Every non-eliminated racer must finish. */
24
+ export function everyoneFinishes() {
25
+ return (standings) => {
26
+ const active = standings.filter((s) => !s.eliminated);
27
+ return active.length > 0 && active.every((s) => s.finished) ? rankIds(standings) : null;
28
+ };
29
+ }
30
+ /** Destruction-derby last-man-standing: ends when at most one racer is left un-eliminated. */
31
+ export function lastStanding() {
32
+ return (standings) => {
33
+ const alive = standings.filter((s) => !s.eliminated);
34
+ return alive.length <= 1 && standings.length > 1 ? rankIds(standings) : null;
35
+ };
36
+ }
37
+ function insideAabb(point, cp) {
38
+ return (Math.abs(point[0] - cp.center[0]) <= cp.half[0] &&
39
+ Math.abs(point[1] - cp.center[1]) <= cp.half[1] &&
40
+ Math.abs(point[2] - cp.center[2]) <= cp.half[2]);
41
+ }
42
+ function distanceToNext(point, cp) {
43
+ const dx = point[0] - cp.center[0];
44
+ const dz = point[2] - cp.center[2];
45
+ return Math.sqrt(dx * dx + dz * dz);
46
+ }
47
+ /**
48
+ * Race state machine (issue #87). Drive it each tick with `update(now, positions)` — `now` is game time
49
+ * (`ctx.time`), `positions` maps each racer to a world point tested against the ordered checkpoint
50
+ * volumes. It emits `checkpoint.hit` / `lap.completed` / `position.changed` / `race.finished`, keeps
51
+ * cumulative split times for PB deltas, resolves a pluggable win condition (first-past-post, round-cut,
52
+ * derby last-standing), and `resetToCheckpoint` hands back a respawn pose at the racer's last checkpoint.
53
+ */
54
+ export class RaceState {
55
+ track;
56
+ win;
57
+ racers = new Map();
58
+ order = [];
59
+ raceFinished = false;
60
+ finalRanking = [];
61
+ constructor(config) {
62
+ this.track = config.track;
63
+ this.win = config.win ?? firstPastPost(1);
64
+ }
65
+ addRacer(racerId, startTime = 0) {
66
+ if (this.racers.has(racerId))
67
+ return;
68
+ this.racers.set(racerId, {
69
+ racerId,
70
+ lap: 1,
71
+ nextCheckpoint: 0,
72
+ lastCheckpoint: -1,
73
+ progress: 0,
74
+ position: this.order.length + 1,
75
+ finished: false,
76
+ finishTime: null,
77
+ eliminated: false,
78
+ startTime,
79
+ splits: [],
80
+ });
81
+ this.order.push(racerId);
82
+ }
83
+ eliminate(racerId) {
84
+ const r = this.racers.get(racerId);
85
+ if (r !== undefined)
86
+ r.eliminated = true;
87
+ }
88
+ get finished() {
89
+ return this.raceFinished;
90
+ }
91
+ get ranking() {
92
+ return this.finalRanking;
93
+ }
94
+ progressOf(racerId) {
95
+ const r = this.racers.get(racerId);
96
+ return r === null || r === undefined ? null : this.snapshot(r);
97
+ }
98
+ snapshot(r) {
99
+ return {
100
+ racerId: r.racerId,
101
+ lap: r.lap,
102
+ nextCheckpoint: r.nextCheckpoint,
103
+ lastCheckpoint: r.lastCheckpoint,
104
+ progress: r.progress,
105
+ position: r.position,
106
+ finished: r.finished,
107
+ finishTime: r.finishTime,
108
+ eliminated: r.eliminated,
109
+ splits: [...r.splits],
110
+ };
111
+ }
112
+ standings() {
113
+ return this.order
114
+ .map((id) => this.racers.get(id))
115
+ .slice()
116
+ .sort((a, b) => this.compare(a, b, null))
117
+ .map((r) => this.snapshot(r));
118
+ }
119
+ resetToCheckpoint(racerId) {
120
+ const r = this.racers.get(racerId);
121
+ if (r === undefined)
122
+ return null;
123
+ const cps = this.track.checkpoints;
124
+ const idx = r.lastCheckpoint >= 0 ? r.lastCheckpoint : 0;
125
+ const cp = cps[idx];
126
+ if (cp === undefined)
127
+ return null;
128
+ const next = cps[(idx + 1) % cps.length];
129
+ const heading = Math.atan2(next.center[0] - cp.center[0], next.center[2] - cp.center[2]);
130
+ return { position: [cp.center[0], cp.center[1], cp.center[2]], heading };
131
+ }
132
+ compare(a, b, positions) {
133
+ if (a.finished !== b.finished)
134
+ return a.finished ? -1 : 1;
135
+ if (a.finished && b.finished)
136
+ return (a.finishTime ?? 0) - (b.finishTime ?? 0);
137
+ if (a.eliminated !== b.eliminated)
138
+ return a.eliminated ? 1 : -1;
139
+ if (a.progress !== b.progress)
140
+ return b.progress - a.progress;
141
+ if (positions !== null) {
142
+ const pa = positions.get(a.racerId);
143
+ const pb = positions.get(b.racerId);
144
+ const cps = this.track.checkpoints;
145
+ if (pa !== undefined && pb !== undefined && cps.length > 0) {
146
+ const da = distanceToNext(pa, cps[a.nextCheckpoint]);
147
+ const db = distanceToNext(pb, cps[b.nextCheckpoint]);
148
+ if (da !== db)
149
+ return da - db;
150
+ }
151
+ }
152
+ return this.order.indexOf(a.racerId) - this.order.indexOf(b.racerId);
153
+ }
154
+ update(now, positions) {
155
+ const posMap = positions instanceof Map ? positions : new Map(Object.entries(positions));
156
+ const events = [];
157
+ const cps = this.track.checkpoints;
158
+ const count = cps.length;
159
+ if (count > 0) {
160
+ for (const id of this.order) {
161
+ const r = this.racers.get(id);
162
+ if (r.finished || r.eliminated)
163
+ continue;
164
+ const pos = posMap.get(id);
165
+ if (pos === undefined)
166
+ continue;
167
+ let guard = 0;
168
+ while (guard < count && insideAabb(pos, cps[r.nextCheckpoint])) {
169
+ guard += 1;
170
+ const hit = r.nextCheckpoint;
171
+ r.lastCheckpoint = hit;
172
+ r.progress += 1;
173
+ r.splits.push(now - r.startTime);
174
+ events.push({ type: "checkpoint.hit", racerId: id, checkpoint: hit, lap: r.lap, time: now });
175
+ if (hit === count - 1) {
176
+ events.push({ type: "lap.completed", racerId: id, lap: r.lap, time: now });
177
+ if (r.lap >= this.track.laps) {
178
+ r.finished = true;
179
+ r.finishTime = now;
180
+ break;
181
+ }
182
+ r.lap += 1;
183
+ r.nextCheckpoint = 0;
184
+ }
185
+ else {
186
+ r.nextCheckpoint = hit + 1;
187
+ }
188
+ }
189
+ }
190
+ }
191
+ const sorted = this.order
192
+ .map((id) => this.racers.get(id))
193
+ .slice()
194
+ .sort((a, b) => this.compare(a, b, posMap));
195
+ for (let i = 0; i < sorted.length; i += 1) {
196
+ const r = sorted[i];
197
+ const newPos = i + 1;
198
+ if (r.position !== newPos) {
199
+ events.push({ type: "position.changed", racerId: r.racerId, position: newPos, previous: r.position });
200
+ r.position = newPos;
201
+ }
202
+ }
203
+ if (!this.raceFinished) {
204
+ const ranking = this.win(sorted.map((r) => this.snapshot(r)), this.track);
205
+ if (ranking !== null) {
206
+ this.raceFinished = true;
207
+ this.finalRanking = ranking;
208
+ events.push({ type: "race.finished", ranking, time: now });
209
+ }
210
+ }
211
+ return events;
212
+ }
213
+ }
214
+ export function createRaceState(config) {
215
+ return new RaceState(config);
216
+ }
@@ -0,0 +1,34 @@
1
+ import type { StatModifier, StatModifierSet, Stats } from "../stats/statModifiers.js";
2
+ export interface RunModifierOffer<TStat extends string = string, TData = unknown> {
3
+ id: string;
4
+ weight: number;
5
+ maxStacks?: number;
6
+ label?: string;
7
+ stats?: StatModifierSet<TStat>;
8
+ data?: TData;
9
+ }
10
+ export interface RunModifierPick {
11
+ id: string;
12
+ stacks: number;
13
+ }
14
+ export interface RunModifierStack<TStat extends string = string, TData = unknown> {
15
+ add(offerId: string): boolean;
16
+ count(offerId: string): number;
17
+ atMax(offerId: string): boolean;
18
+ picks(): RunModifierPick[];
19
+ offer(offerId: string): RunModifierOffer<TStat, TData> | null;
20
+ total(stat: TStat): StatModifier;
21
+ apply(stats: Stats<TStat>, sourceId?: string): void;
22
+ clear(): void;
23
+ }
24
+ export declare function createRunModifierStack<TStat extends string = string, TData = unknown>(offers: readonly RunModifierOffer<TStat, TData>[]): RunModifierStack<TStat, TData>;
25
+ export interface RunDraftConfig<TStat extends string = string, TData = unknown> {
26
+ offers: readonly RunModifierOffer<TStat, TData>[];
27
+ rng?: () => number;
28
+ }
29
+ export interface RunDraft<TStat extends string = string, TData = unknown> {
30
+ present(count: number, rng?: () => number): RunModifierOffer<TStat, TData>[];
31
+ choose(offerId: string): boolean;
32
+ stack(): RunModifierStack<TStat, TData>;
33
+ }
34
+ export declare function createRunDraft<TStat extends string = string, TData = unknown>(config: RunDraftConfig<TStat, TData>): RunDraft<TStat, TData>;
@@ -0,0 +1,97 @@
1
+ import { pickWeighted } from "../world/scatterItems.js";
2
+ export function createRunModifierStack(offers) {
3
+ const byId = new Map();
4
+ for (const offer of offers) {
5
+ if (byId.has(offer.id))
6
+ throw new Error(`duplicate run modifier offer: ${offer.id}`);
7
+ byId.set(offer.id, offer);
8
+ }
9
+ const stacks = new Map();
10
+ function count(offerId) {
11
+ return stacks.get(offerId) ?? 0;
12
+ }
13
+ function atMax(offerId) {
14
+ const offer = byId.get(offerId);
15
+ if (offer === undefined)
16
+ return true;
17
+ return offer.maxStacks !== undefined && count(offerId) >= offer.maxStacks;
18
+ }
19
+ function total(stat) {
20
+ let add = 0;
21
+ let multiply = 1;
22
+ for (const [offerId, n] of stacks) {
23
+ const modifier = byId.get(offerId)?.stats?.[stat];
24
+ if (modifier === undefined)
25
+ continue;
26
+ if (modifier.add !== undefined)
27
+ add += modifier.add * n;
28
+ if (modifier.multiply !== undefined)
29
+ multiply *= Math.pow(modifier.multiply, n);
30
+ }
31
+ return { add, multiply };
32
+ }
33
+ function statKeys() {
34
+ const keys = new Set();
35
+ for (const offerId of stacks.keys()) {
36
+ const stat = byId.get(offerId)?.stats;
37
+ if (stat === undefined)
38
+ continue;
39
+ for (const key of Object.keys(stat))
40
+ keys.add(key);
41
+ }
42
+ return [...keys];
43
+ }
44
+ return {
45
+ add(offerId) {
46
+ if (!byId.has(offerId) || atMax(offerId))
47
+ return false;
48
+ stacks.set(offerId, count(offerId) + 1);
49
+ return true;
50
+ },
51
+ count,
52
+ atMax,
53
+ picks() {
54
+ return [...stacks.entries()].map(([id, s]) => ({ id, stacks: s }));
55
+ },
56
+ offer(offerId) {
57
+ return byId.get(offerId) ?? null;
58
+ },
59
+ total,
60
+ apply(stats, sourceId = "run-modifiers") {
61
+ const set = {};
62
+ for (const key of statKeys())
63
+ set[key] = total(key);
64
+ stats.addSource(sourceId, set);
65
+ },
66
+ clear() {
67
+ stacks.clear();
68
+ },
69
+ };
70
+ }
71
+ export function createRunDraft(config) {
72
+ const stack = createRunModifierStack(config.offers);
73
+ const defaultRng = config.rng ?? Math.random;
74
+ return {
75
+ present(count, rng = defaultRng) {
76
+ const pool = config.offers.filter((offer) => !stack.atMax(offer.id));
77
+ const remaining = pool.map((offer) => ({ value: offer, weight: offer.weight }));
78
+ const drawn = [];
79
+ while (drawn.length < count && remaining.length > 0) {
80
+ const picked = pickWeighted(remaining, rng());
81
+ if (picked === null)
82
+ break;
83
+ drawn.push(picked);
84
+ const index = remaining.findIndex((entry) => entry.value.id === picked.id);
85
+ if (index >= 0)
86
+ remaining.splice(index, 1);
87
+ }
88
+ return drawn;
89
+ },
90
+ choose(offerId) {
91
+ return stack.add(offerId);
92
+ },
93
+ stack() {
94
+ return stack;
95
+ },
96
+ };
97
+ }
@@ -5,10 +5,24 @@ export interface PresenceInfo {
5
5
  zoneId?: string;
6
6
  instanceId?: string;
7
7
  }
8
+ export interface EmoteEntityLookup {
9
+ get(id: string): {
10
+ position: readonly [number, number, number];
11
+ role: string;
12
+ } | null;
13
+ }
14
+ export interface EmoteSpatialLookup {
15
+ inRadius(center: readonly [number, number, number], radius: number, filter?: (id: string) => boolean): readonly string[];
16
+ }
17
+ export interface EmotesDeps {
18
+ entities: EmoteEntityLookup;
19
+ spatial: EmoteSpatialLookup;
20
+ }
8
21
  export interface SocialDeps {
9
22
  events: GameEvents;
10
23
  presence?: (userId: string) => PresenceInfo;
11
24
  now?: () => number;
25
+ emotes?: EmotesDeps;
12
26
  }
13
27
  export interface FriendEntry {
14
28
  userId: string;
@@ -68,11 +82,24 @@ export interface Party {
68
82
  list(userId: string): PartyMemberEntry[];
69
83
  membersOf(userId: string): string[];
70
84
  }
85
+ export declare const DEFAULT_EMOTE_RADIUS = 20;
86
+ export interface EmoteBroadcastResult {
87
+ from: string;
88
+ emoteId: string;
89
+ at: readonly [number, number, number];
90
+ recipients: readonly string[];
91
+ }
92
+ export interface Emotes {
93
+ play(fromUserId: string, emoteId: string, radius?: number): EmoteBroadcastResult | {
94
+ reason: string;
95
+ };
96
+ }
71
97
  export interface Social {
72
98
  friends: Friends;
73
99
  party: Party;
74
100
  presence: {
75
101
  get(userId: string): PresenceInfo;
76
102
  };
103
+ emotes: Emotes;
77
104
  }
78
105
  export declare function createSocial(deps: SocialDeps): Social;
@@ -1,3 +1,4 @@
1
+ export const DEFAULT_EMOTE_RADIUS = 20;
1
2
  const DEFAULT_INVITE_TTL_MS = 60_000;
2
3
  export function createSocial(deps) {
3
4
  const now = deps.now ?? Date.now;
@@ -215,6 +216,20 @@ export function createSocial(deps) {
215
216
  return partyOf(userId)?.members.slice() ?? [];
216
217
  },
217
218
  };
219
+ const emotes = {
220
+ play(fromUserId, emoteId, radius) {
221
+ const emoteDeps = deps.emotes;
222
+ if (emoteDeps === undefined)
223
+ return { reason: "emotes not configured" };
224
+ const origin = emoteDeps.entities.get(fromUserId);
225
+ if (origin === null)
226
+ return { reason: `entity "${fromUserId}" is not spawned` };
227
+ const recipients = emoteDeps.spatial.inRadius(origin.position, radius ?? DEFAULT_EMOTE_RADIUS, (id) => id !== fromUserId && emoteDeps.entities.get(id)?.role === "player");
228
+ const result = { from: fromUserId, emoteId, at: origin.position, recipients };
229
+ events.emit("emote.played", result);
230
+ return result;
231
+ },
232
+ };
218
233
  return {
219
234
  friends,
220
235
  party,
@@ -223,5 +238,6 @@ export function createSocial(deps) {
223
238
  return deps.presence?.(userId) ?? { online: false };
224
239
  },
225
240
  },
241
+ emotes,
226
242
  };
227
243
  }
@@ -0,0 +1,92 @@
1
+ import type { EntityPosition } from "../scene/entityStore.js";
2
+ import { type LootFilterItem, type LootFilterRule } from "./lootFilter.js";
3
+ import type { Drop } from "./lootTable.js";
4
+ /** Scene-entity catalog name every dropped-item instance spawns under (see the three buckets: worldItem is an entity, never an inventory item or object). */
5
+ export declare const WORLD_ITEM_ENTITY_NAME = "world_item";
6
+ export declare const DEFAULT_RARITY = "common";
7
+ export declare const DEFAULT_PICKUP_RADIUS = 2;
8
+ export interface ScatterOptions {
9
+ radius: number;
10
+ minRadius?: number;
11
+ height?: number;
12
+ }
13
+ export declare const DEFAULT_SCATTER: ScatterOptions;
14
+ /** Random offset within an annulus `[minRadius, radius]` around the origin — the on-death scatter impulse. */
15
+ export declare function scatterOffset(rng: () => number, options?: ScatterOptions): EntityPosition;
16
+ export declare function scatterPosition(origin: EntityPosition, rng: () => number, options?: ScatterOptions): EntityPosition;
17
+ export interface WorldItemRecord {
18
+ instanceId: string;
19
+ itemId: string;
20
+ rarity: string;
21
+ baseType: string;
22
+ count: number;
23
+ affixTier?: number;
24
+ source?: string;
25
+ droppedAt: number;
26
+ }
27
+ export interface WorldItemSpawnInput {
28
+ itemId: string;
29
+ position: EntityPosition;
30
+ rarity?: string;
31
+ baseType?: string;
32
+ count?: number;
33
+ affixTier?: number;
34
+ source?: string;
35
+ }
36
+ export interface WorldItemStoreDeps {
37
+ spawnEntity(position: EntityPosition): string;
38
+ despawnEntity(instanceId: string): boolean;
39
+ resolvePosition(instanceId: string): EntityPosition | undefined;
40
+ now?(): number;
41
+ }
42
+ export interface WorldItemStore {
43
+ spawn(input: WorldItemSpawnInput): WorldItemRecord;
44
+ get(instanceId: string): WorldItemRecord | null;
45
+ list(): readonly WorldItemRecord[];
46
+ nearestInRadius(from: EntityPosition, radius: number, filter?: (record: WorldItemRecord) => boolean): string | null;
47
+ take(instanceId: string): WorldItemRecord | null;
48
+ }
49
+ /** Pure pickup-radius + nearest-item selection, usable without a live store (click-to-grab, proximity prompts). */
50
+ export declare function selectNearestWorldItem(candidates: readonly {
51
+ instanceId: string;
52
+ position: EntityPosition;
53
+ }[], from: EntityPosition, radius: number): string | null;
54
+ export declare function createWorldItemStore(deps: WorldItemStoreDeps): WorldItemStore;
55
+ export interface ResolveDeathDropsOptions {
56
+ mode: "grant" | "world";
57
+ origin: EntityPosition;
58
+ resolveRarity(itemId: string): string;
59
+ resolveBaseType?(itemId: string): string;
60
+ scatter?: ScatterOptions;
61
+ rng?(): number;
62
+ source?: string;
63
+ }
64
+ export interface ResolvedDeathDrops {
65
+ worldSpawns: WorldItemSpawnInput[];
66
+ grants: Drop[];
67
+ }
68
+ /**
69
+ * Splits rolled death drops between direct grants and scattered ground items.
70
+ * `mode: "grant"` is the legacy behavior (loot straight to inventory); `mode:
71
+ * "world"` routes item drops through a scatter impulse and leaves currency
72
+ * drops granting directly (coins fly to the killer, gear hits the ground).
73
+ */
74
+ export declare function resolveDeathDrops(drops: readonly Drop[], options: ResolveDeathDropsOptions): ResolvedDeathDrops;
75
+ export interface RarityStyle {
76
+ color?: string;
77
+ beam?: boolean;
78
+ label?: string;
79
+ }
80
+ export interface WorldItemPresentation {
81
+ hidden: boolean;
82
+ color?: string;
83
+ beam: boolean;
84
+ label?: string;
85
+ }
86
+ /**
87
+ * Composes the baseline rarity→beam/color/label render binding (#32, catalog
88
+ * data the game supplies) with the loot-filter rule overrides (#33). A
89
+ * matching rule wins field-by-field; unmatched fields fall back to the
90
+ * rarity's baseline style.
91
+ */
92
+ export declare function resolveWorldItemPresentation(item: LootFilterItem, rarityStyle: Record<string, RarityStyle> | undefined, rules: readonly LootFilterRule[] | undefined): WorldItemPresentation;
@@ -0,0 +1,132 @@
1
+ import { distanceBetween } from "../scene/spatial.js";
2
+ import { evaluateLootFilter } from "./lootFilter.js";
3
+ /** Scene-entity catalog name every dropped-item instance spawns under (see the three buckets: worldItem is an entity, never an inventory item or object). */
4
+ export const WORLD_ITEM_ENTITY_NAME = "world_item";
5
+ export const DEFAULT_RARITY = "common";
6
+ export const DEFAULT_PICKUP_RADIUS = 2;
7
+ export const DEFAULT_SCATTER = { radius: 1.5, minRadius: 0.4, height: 0 };
8
+ /** Random offset within an annulus `[minRadius, radius]` around the origin — the on-death scatter impulse. */
9
+ export function scatterOffset(rng, options = DEFAULT_SCATTER) {
10
+ const angle = rng() * Math.PI * 2;
11
+ const min = options.minRadius ?? 0;
12
+ const span = Math.max(options.radius - min, 0);
13
+ const distance = min + rng() * span;
14
+ return [Math.cos(angle) * distance, options.height ?? 0, Math.sin(angle) * distance];
15
+ }
16
+ export function scatterPosition(origin, rng, options = DEFAULT_SCATTER) {
17
+ const offset = scatterOffset(rng, options);
18
+ return [origin[0] + offset[0], origin[1] + offset[1], origin[2] + offset[2]];
19
+ }
20
+ /** Pure pickup-radius + nearest-item selection, usable without a live store (click-to-grab, proximity prompts). */
21
+ export function selectNearestWorldItem(candidates, from, radius) {
22
+ let bestId = null;
23
+ let bestDistance = Number.POSITIVE_INFINITY;
24
+ for (const candidate of candidates) {
25
+ const distance = distanceBetween(from, candidate.position);
26
+ if (distance <= radius && distance < bestDistance) {
27
+ bestDistance = distance;
28
+ bestId = candidate.instanceId;
29
+ }
30
+ }
31
+ return bestId;
32
+ }
33
+ export function createWorldItemStore(deps) {
34
+ const records = new Map();
35
+ const now = deps.now ?? Date.now;
36
+ return {
37
+ spawn(input) {
38
+ const instanceId = deps.spawnEntity(input.position);
39
+ const record = {
40
+ instanceId,
41
+ itemId: input.itemId,
42
+ rarity: input.rarity ?? DEFAULT_RARITY,
43
+ baseType: input.baseType ?? input.itemId,
44
+ count: input.count ?? 1,
45
+ droppedAt: now(),
46
+ };
47
+ if (input.affixTier !== undefined)
48
+ record.affixTier = input.affixTier;
49
+ if (input.source !== undefined)
50
+ record.source = input.source;
51
+ records.set(instanceId, record);
52
+ return record;
53
+ },
54
+ get(instanceId) {
55
+ return records.get(instanceId) ?? null;
56
+ },
57
+ list() {
58
+ return [...records.values()];
59
+ },
60
+ nearestInRadius(from, radius, filter) {
61
+ const candidates = [];
62
+ for (const record of records.values()) {
63
+ if (filter !== undefined && !filter(record))
64
+ continue;
65
+ const position = deps.resolvePosition(record.instanceId);
66
+ if (position !== undefined)
67
+ candidates.push({ instanceId: record.instanceId, position });
68
+ }
69
+ return selectNearestWorldItem(candidates, from, radius);
70
+ },
71
+ take(instanceId) {
72
+ const record = records.get(instanceId);
73
+ if (record === undefined)
74
+ return null;
75
+ records.delete(instanceId);
76
+ deps.despawnEntity(instanceId);
77
+ return record;
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * Splits rolled death drops between direct grants and scattered ground items.
83
+ * `mode: "grant"` is the legacy behavior (loot straight to inventory); `mode:
84
+ * "world"` routes item drops through a scatter impulse and leaves currency
85
+ * drops granting directly (coins fly to the killer, gear hits the ground).
86
+ */
87
+ export function resolveDeathDrops(drops, options) {
88
+ if (options.mode !== "world")
89
+ return { worldSpawns: [], grants: [...drops] };
90
+ const rng = options.rng ?? Math.random;
91
+ const worldSpawns = [];
92
+ const grants = [];
93
+ for (const drop of drops) {
94
+ if (drop.item === undefined) {
95
+ grants.push(drop);
96
+ continue;
97
+ }
98
+ const spawn = {
99
+ itemId: drop.item,
100
+ count: drop.count,
101
+ rarity: options.resolveRarity(drop.item),
102
+ position: scatterPosition(options.origin, rng, options.scatter),
103
+ };
104
+ if (options.resolveBaseType !== undefined)
105
+ spawn.baseType = options.resolveBaseType(drop.item);
106
+ if (options.source !== undefined)
107
+ spawn.source = options.source;
108
+ worldSpawns.push(spawn);
109
+ }
110
+ return { worldSpawns, grants };
111
+ }
112
+ /**
113
+ * Composes the baseline rarity→beam/color/label render binding (#32, catalog
114
+ * data the game supplies) with the loot-filter rule overrides (#33). A
115
+ * matching rule wins field-by-field; unmatched fields fall back to the
116
+ * rarity's baseline style.
117
+ */
118
+ export function resolveWorldItemPresentation(item, rarityStyle, rules) {
119
+ const base = rarityStyle?.[item.rarity];
120
+ const override = rules === undefined || rules.length === 0 ? {} : evaluateLootFilter(rules, item);
121
+ const presentation = {
122
+ hidden: override.hidden ?? false,
123
+ beam: override.beam ?? base?.beam ?? false,
124
+ };
125
+ const color = override.color ?? base?.color;
126
+ if (color !== undefined)
127
+ presentation.color = color;
128
+ const label = override.label ?? base?.label;
129
+ if (label !== undefined)
130
+ presentation.label = label;
131
+ return presentation;
132
+ }