@rpgjs/action-battle 5.0.0-beta.11 → 5.0.0-beta.13

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 (111) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/client/ai.server.d.ts +57 -8
  3. package/dist/client/attack-input.d.ts +3 -0
  4. package/dist/client/core/action-use.d.ts +18 -0
  5. package/dist/client/core/ai-behavior-tree.d.ts +99 -0
  6. package/dist/client/core/attack-runtime.d.ts +2 -0
  7. package/dist/client/core/defaults.d.ts +3 -2
  8. package/dist/client/core/equipment.d.ts +1 -0
  9. package/dist/client/core/targets.d.ts +15 -0
  10. package/dist/client/enemies/factory.d.ts +2 -0
  11. package/dist/client/index.d.ts +12 -7
  12. package/dist/client/index.js +16 -11
  13. package/dist/client/index10.js +32 -56
  14. package/dist/client/index11.js +99 -52
  15. package/dist/client/index12.js +76 -103
  16. package/dist/client/index13.js +72 -135
  17. package/dist/client/index14.js +67 -23
  18. package/dist/client/index15.js +197 -63
  19. package/dist/client/index16.js +112 -1337
  20. package/dist/client/index17.js +203 -7
  21. package/dist/client/index18.js +32 -58
  22. package/dist/client/index19.js +70 -8
  23. package/dist/client/index20.js +57 -501
  24. package/dist/client/index21.js +70 -0
  25. package/dist/client/index22.js +226 -0
  26. package/dist/client/index23.js +16 -0
  27. package/dist/client/index24.js +25 -0
  28. package/dist/client/index25.js +107 -0
  29. package/dist/client/index26.js +1949 -0
  30. package/dist/client/index27.js +12 -0
  31. package/dist/client/index28.js +589 -0
  32. package/dist/client/index4.js +79 -38
  33. package/dist/client/index6.js +65 -306
  34. package/dist/client/index7.js +33 -33
  35. package/dist/client/index8.js +24 -100
  36. package/dist/client/index9.js +293 -61
  37. package/dist/client/locomotion.d.ts +16 -0
  38. package/dist/client/movement.d.ts +14 -0
  39. package/dist/client/server.d.ts +7 -3
  40. package/dist/client/ui.d.ts +22 -0
  41. package/dist/client/visual.d.ts +15 -0
  42. package/dist/server/ai.server.d.ts +57 -8
  43. package/dist/server/attack-input.d.ts +3 -0
  44. package/dist/server/core/action-use.d.ts +18 -0
  45. package/dist/server/core/ai-behavior-tree.d.ts +99 -0
  46. package/dist/server/core/attack-runtime.d.ts +2 -0
  47. package/dist/server/core/defaults.d.ts +3 -2
  48. package/dist/server/core/equipment.d.ts +1 -0
  49. package/dist/server/core/targets.d.ts +15 -0
  50. package/dist/server/enemies/factory.d.ts +2 -0
  51. package/dist/server/index.d.ts +12 -7
  52. package/dist/server/index.js +14 -9
  53. package/dist/server/index10.js +64 -1336
  54. package/dist/server/index11.js +33 -33
  55. package/dist/server/index13.js +67 -11
  56. package/dist/server/index14.js +207 -484
  57. package/dist/server/index15.js +15 -9
  58. package/dist/server/index16.js +26 -0
  59. package/dist/server/index17.js +25 -0
  60. package/dist/server/index18.js +107 -0
  61. package/dist/server/index19.js +1949 -0
  62. package/dist/server/index2.js +10 -2
  63. package/dist/server/index20.js +37 -0
  64. package/dist/server/index21.js +588 -0
  65. package/dist/server/index22.js +78 -0
  66. package/dist/server/index23.js +12 -0
  67. package/dist/server/index5.js +79 -38
  68. package/dist/server/index6.js +192 -129
  69. package/dist/server/index7.js +208 -24
  70. package/dist/server/index8.js +28 -66
  71. package/dist/server/index9.js +68 -51
  72. package/dist/server/locomotion.d.ts +16 -0
  73. package/dist/server/movement.d.ts +14 -0
  74. package/dist/server/server.d.ts +7 -3
  75. package/dist/server/ui.d.ts +22 -0
  76. package/dist/server/visual.d.ts +15 -0
  77. package/package.json +5 -5
  78. package/src/ai.server.spec.ts +380 -1
  79. package/src/ai.server.ts +963 -137
  80. package/src/animations.spec.ts +40 -0
  81. package/src/animations.ts +31 -9
  82. package/src/attack-input.spec.ts +51 -0
  83. package/src/attack-input.ts +59 -0
  84. package/src/client.ts +75 -62
  85. package/src/config.ts +84 -37
  86. package/src/core/action-use.spec.ts +317 -0
  87. package/src/core/action-use.ts +387 -0
  88. package/src/core/ai-behavior-tree.spec.ts +116 -0
  89. package/src/core/ai-behavior-tree.ts +272 -0
  90. package/src/core/attack-profile.spec.ts +46 -0
  91. package/src/core/attack-runtime.spec.ts +35 -0
  92. package/src/core/attack-runtime.ts +32 -0
  93. package/src/core/context.ts +9 -0
  94. package/src/core/contracts.ts +146 -1
  95. package/src/core/defaults.ts +72 -1
  96. package/src/core/equipment.ts +9 -5
  97. package/src/core/hit.spec.ts +21 -0
  98. package/src/core/targets.spec.ts +124 -0
  99. package/src/core/targets.ts +150 -0
  100. package/src/enemies/factory.ts +8 -0
  101. package/src/index.ts +111 -2
  102. package/src/locomotion.spec.ts +51 -0
  103. package/src/locomotion.ts +48 -0
  104. package/src/movement.spec.ts +78 -0
  105. package/src/movement.ts +46 -0
  106. package/src/server.ts +242 -66
  107. package/src/types.ts +105 -35
  108. package/src/ui.ts +113 -0
  109. package/src/visual.spec.ts +166 -0
  110. package/src/visual.ts +285 -0
  111. package/README.md +0 -1242
@@ -0,0 +1,317 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import {
3
+ executeActionBattleUse,
4
+ handleActionBattleProjectileImpact,
5
+ } from "./action-use";
6
+ import { setActionBattleSystems } from "./context";
7
+
8
+ const createEntity = (id: string, hp = 100) => ({
9
+ id,
10
+ hp,
11
+ sp: 100,
12
+ param: { maxhp: hp },
13
+ x: () => 0,
14
+ y: () => 0,
15
+ knockback: vi.fn(),
16
+ applyStates: vi.fn(),
17
+ applyDamage: vi.fn(),
18
+ setGraphicAnimation: vi.fn(),
19
+ flash: vi.fn(),
20
+ showHit: vi.fn(),
21
+ });
22
+
23
+ describe("executeActionBattleUse", () => {
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ setActionBattleSystems({});
27
+ });
28
+
29
+ test("applies the standard skill effect when no onUse hook is defined", () => {
30
+ const attacker = createEntity("caster");
31
+ const target = createEntity("target");
32
+ target.applyDamage.mockReturnValue({ damage: 25 });
33
+
34
+ const handled = executeActionBattleUse({
35
+ attacker: attacker as any,
36
+ target: target as any,
37
+ usable: {
38
+ id: "fire",
39
+ _type: "skill",
40
+ spCost: 10,
41
+ hitRate: 1,
42
+ },
43
+ skill: {
44
+ id: "fire",
45
+ _type: "skill",
46
+ spCost: 10,
47
+ hitRate: 1,
48
+ },
49
+ });
50
+
51
+ expect(handled).toBe(true);
52
+ expect(attacker.sp).toBe(90);
53
+ expect(attacker.applyStates).toHaveBeenCalled();
54
+ expect(target.applyDamage).toHaveBeenCalledWith(attacker, expect.objectContaining({ id: "fire" }));
55
+ });
56
+
57
+ test("lets onUse compose custom behavior with defaultEffect", () => {
58
+ const attacker = createEntity("caster");
59
+ const target = createEntity("target");
60
+ target.applyDamage.mockReturnValue({ damage: 18 });
61
+ const onUse = vi.fn((_user, _target, action) => {
62
+ action.defaultEffect();
63
+ action.heal(_user, 5);
64
+ });
65
+
66
+ executeActionBattleUse({
67
+ attacker: attacker as any,
68
+ target: target as any,
69
+ usable: {
70
+ id: "drain",
71
+ _type: "skill",
72
+ spCost: 4,
73
+ hitRate: 1,
74
+ onUse,
75
+ },
76
+ skill: {
77
+ id: "drain",
78
+ _type: "skill",
79
+ spCost: 4,
80
+ hitRate: 1,
81
+ onUse,
82
+ },
83
+ });
84
+
85
+ expect(onUse).toHaveBeenCalledOnce();
86
+ expect(attacker.sp).toBe(96);
87
+ expect(target.applyDamage).toHaveBeenCalledOnce();
88
+ });
89
+
90
+ test("applies default effects to action target arrays", () => {
91
+ const attacker = createEntity("caster");
92
+ const first = createEntity("first");
93
+ const second = createEntity("second");
94
+ first.applyDamage.mockReturnValue({ damage: 11 });
95
+ second.applyDamage.mockReturnValue({ damage: 12 });
96
+
97
+ executeActionBattleUse({
98
+ attacker: attacker as any,
99
+ target: [first as any, second as any],
100
+ usable: {
101
+ id: "burst",
102
+ _type: "skill",
103
+ spCost: 3,
104
+ hitRate: 1,
105
+ },
106
+ skill: {
107
+ id: "burst",
108
+ _type: "skill",
109
+ spCost: 3,
110
+ hitRate: 1,
111
+ },
112
+ });
113
+
114
+ expect(first.applyDamage).toHaveBeenCalledOnce();
115
+ expect(second.applyDamage).toHaveBeenCalledOnce();
116
+ });
117
+
118
+ test("supports full custom heal skills without default damage", () => {
119
+ const attacker = createEntity("healer");
120
+ const target = createEntity("ally", 40);
121
+ target.hp = 10;
122
+
123
+ executeActionBattleUse({
124
+ attacker: attacker as any,
125
+ target: target as any,
126
+ usable: {
127
+ id: "heal",
128
+ _type: "skill",
129
+ spCost: 8,
130
+ hitRate: 1,
131
+ onUse(_user: any, ally: any, action: any) {
132
+ action.heal(ally, 20);
133
+ },
134
+ },
135
+ skill: {
136
+ id: "heal",
137
+ _type: "skill",
138
+ spCost: 8,
139
+ hitRate: 1,
140
+ },
141
+ });
142
+
143
+ expect(attacker.sp).toBe(92);
144
+ expect(target.hp).toBe(30);
145
+ expect(target.applyDamage).not.toHaveBeenCalled();
146
+ });
147
+
148
+ test("applies the standard weapon effect for configured weapons", () => {
149
+ const attacker = createEntity("monster");
150
+ const target = createEntity("target");
151
+ target.applyDamage.mockReturnValue({ damage: 12 });
152
+
153
+ const handled = executeActionBattleUse({
154
+ attacker: attacker as any,
155
+ target: target as any,
156
+ usable: {
157
+ id: "claw",
158
+ _type: "weapon",
159
+ action: { mode: "instant", range: 40 },
160
+ },
161
+ weapon: {
162
+ id: "claw",
163
+ _type: "weapon",
164
+ },
165
+ });
166
+
167
+ expect(handled).toBe(true);
168
+ expect(target.applyDamage).toHaveBeenCalledWith(attacker, undefined);
169
+ });
170
+
171
+ test("defers the default effect until projectile impact", () => {
172
+ const target = createEntity("target");
173
+ target.applyDamage.mockReturnValue({ damage: 30 });
174
+ const emitted = [{ id: "bolt-1" }];
175
+ const attacker = {
176
+ ...createEntity("caster"),
177
+ getCurrentMap: () => ({
178
+ projectiles: {
179
+ emit: vi.fn(() => emitted),
180
+ },
181
+ }),
182
+ };
183
+
184
+ executeActionBattleUse({
185
+ attacker: attacker as any,
186
+ target: target as any,
187
+ usable: {
188
+ id: "bolt",
189
+ _type: "skill",
190
+ spCost: 5,
191
+ hitRate: 1,
192
+ action: {
193
+ mode: "projectile",
194
+ range: 200,
195
+ projectile: {
196
+ type: "bolt",
197
+ speed: 200,
198
+ range: 200,
199
+ },
200
+ },
201
+ },
202
+ skill: {
203
+ id: "bolt",
204
+ _type: "skill",
205
+ spCost: 5,
206
+ hitRate: 1,
207
+ },
208
+ });
209
+
210
+ expect(target.applyDamage).not.toHaveBeenCalled();
211
+
212
+ handleActionBattleProjectileImpact({
213
+ attacker: attacker as any,
214
+ target: target as any,
215
+ projectile: { id: "bolt-1" },
216
+ hit: {},
217
+ map: {},
218
+ });
219
+
220
+ expect(target.applyDamage).toHaveBeenCalledOnce();
221
+ });
222
+
223
+ test("uses the action target policy for projectile collisions", () => {
224
+ const enemy = createEntity("enemy");
225
+ (enemy as any).actionBattleFaction = "enemy";
226
+ const ally = createEntity("ally");
227
+ (ally as any).actionBattleFaction = "party";
228
+ const emit = vi.fn(() => [{ id: "heal-bolt-1" }]);
229
+ const attacker = {
230
+ ...createEntity("caster"),
231
+ actionBattleFaction: "party",
232
+ getCurrentMap: () => ({
233
+ projectiles: {
234
+ emit,
235
+ },
236
+ }),
237
+ };
238
+
239
+ executeActionBattleUse({
240
+ attacker: attacker as any,
241
+ target: ally as any,
242
+ usable: {
243
+ id: "heal-bolt",
244
+ _type: "skill",
245
+ spCost: 5,
246
+ hitRate: 1,
247
+ action: {
248
+ mode: "projectile",
249
+ target: "ally",
250
+ range: 200,
251
+ projectile: {
252
+ type: "heal",
253
+ speed: 200,
254
+ },
255
+ },
256
+ },
257
+ skill: {
258
+ id: "heal-bolt",
259
+ _type: "skill",
260
+ spCost: 5,
261
+ hitRate: 1,
262
+ },
263
+ });
264
+
265
+ const canHit = emit.mock.calls[0][0].canHit;
266
+ expect(canHit({ target: ally })).toBe(true);
267
+ expect(canHit({ target: enemy })).toBe(false);
268
+ });
269
+
270
+ test("passes projectile precision options to the generic projectile system", () => {
271
+ const emit = vi.fn(() => [{ id: "bolt-1" }]);
272
+ const attacker = {
273
+ ...createEntity("caster"),
274
+ getCurrentMap: () => ({
275
+ projectiles: {
276
+ emit,
277
+ },
278
+ }),
279
+ };
280
+ const target = {
281
+ ...createEntity("target"),
282
+ x: () => 100,
283
+ y: () => 0,
284
+ };
285
+
286
+ executeActionBattleUse({
287
+ attacker: attacker as any,
288
+ target: target as any,
289
+ usable: {
290
+ id: "bolt",
291
+ _type: "skill",
292
+ spCost: 0,
293
+ hitRate: 1,
294
+ action: {
295
+ mode: "projectile",
296
+ projectile: {
297
+ type: "bolt",
298
+ speed: 200,
299
+ range: 200,
300
+ spreadDegrees: 20,
301
+ },
302
+ },
303
+ },
304
+ skill: {
305
+ id: "bolt",
306
+ _type: "skill",
307
+ spCost: 0,
308
+ hitRate: 1,
309
+ },
310
+ });
311
+
312
+ expect(emit.mock.calls[0][0]).toMatchObject({
313
+ direction: { x: 1, y: 0 },
314
+ spreadDegrees: 20,
315
+ });
316
+ });
317
+ });
@@ -0,0 +1,387 @@
1
+ import { MAXHP } from "@rpgjs/server";
2
+ import { getActionBattleOptions } from "../config";
3
+ import { emitActionBattleClientVisual } from "../visual";
4
+ import { applyActionBattleHit } from "./hit";
5
+ import { getActionBattleSystems } from "./context";
6
+ import {
7
+ canActionBattleTarget,
8
+ getActionBattleFaction,
9
+ getActionBattleTargets,
10
+ isActionBattleCombatEntity,
11
+ isActionBattleTargetDefeated,
12
+ } from "./targets";
13
+ import type {
14
+ ActionBattleActionConfig,
15
+ ActionBattleActionTarget,
16
+ ActionBattleEntity,
17
+ ActionBattleTargetOptions,
18
+ ActionBattleProjectileImpactContext,
19
+ ActionBattleProjectileOptions,
20
+ ActionBattleUseContext,
21
+ } from "./contracts";
22
+ import type { NormalizedActionBattleAttackProfile } from "../types";
23
+
24
+ const projectileHandlers = new Map<
25
+ string,
26
+ {
27
+ action: ActionBattleUseContext;
28
+ onImpact?: ActionBattleProjectileOptions["onImpact"];
29
+ }
30
+ >();
31
+
32
+ const normalizeDirection = (direction: { x: number; y: number }) => {
33
+ const distance = Math.sqrt(direction.x * direction.x + direction.y * direction.y);
34
+ if (distance <= 0) return { x: 0, y: 1 };
35
+ return {
36
+ x: direction.x / distance,
37
+ y: direction.y / distance,
38
+ };
39
+ };
40
+
41
+ const directionToTarget = (
42
+ attacker: ActionBattleEntity,
43
+ target?: ActionBattleEntity | ActionBattleEntity[] | null
44
+ ) => {
45
+ const first = firstTarget(target);
46
+ if (!first) return undefined;
47
+ return normalizeDirection({
48
+ x: (first as any).x() - (attacker as any).x(),
49
+ y: (first as any).y() - (attacker as any).y(),
50
+ });
51
+ };
52
+
53
+ const asArray = <T>(value: T | T[] | null | undefined): T[] => {
54
+ if (!value) return [];
55
+ return Array.isArray(value) ? value : [value];
56
+ };
57
+
58
+ const firstTarget = (
59
+ target: ActionBattleEntity | ActionBattleEntity[] | null | undefined
60
+ ) => asArray(target)[0];
61
+
62
+ const resolveActionConfig = (usable: any): ActionBattleActionConfig | undefined =>
63
+ usable?.action ??
64
+ usable?.actionBattle ??
65
+ usable?._skillInstance?.action ??
66
+ usable?._skillInstance?.actionBattle ??
67
+ usable?._skillData?.action ??
68
+ usable?._skillData?.actionBattle;
69
+
70
+ const getUseHookTarget = (usable: any) =>
71
+ usable?._skillInstance ?? usable?._skillData ?? usable;
72
+
73
+ const getUseHook = (usable: any) => {
74
+ const target = getUseHookTarget(usable);
75
+ return typeof target?.onUse === "function"
76
+ ? { hook: target.onUse, target }
77
+ : undefined;
78
+ };
79
+
80
+ const isSkill = (usable: any, explicitSkill?: any) =>
81
+ !!explicitSkill || usable?._type === "skill" || usable?.spCost !== undefined;
82
+
83
+ const consumeSkillUse = (attacker: ActionBattleEntity, skill: any) => {
84
+ const spCost = typeof skill?.spCost === "number" ? skill.spCost : 0;
85
+ if (spCost > 0) {
86
+ if (spCost > ((attacker as any).sp ?? 0)) {
87
+ throw new Error(`Not enough SP to use ${skill?.id ?? skill?.name ?? "skill"}`);
88
+ }
89
+ const halfCost =
90
+ (attacker as any).hasEffect?.("HALF_SP_COST") ||
91
+ (attacker as any).hasEffect?.("half_sp_cost");
92
+ (attacker as any).sp -= spCost / (halfCost ? 2 : 1);
93
+ }
94
+
95
+ const hitRate = typeof skill?.hitRate === "number" ? skill.hitRate : 1;
96
+ if (Math.random() > hitRate) {
97
+ throw new Error(`Action battle skill failed: ${skill?.id ?? skill?.name ?? "skill"}`);
98
+ }
99
+ };
100
+
101
+ const applyDamageEffect = (
102
+ attacker: ActionBattleEntity,
103
+ target: ActionBattleEntity,
104
+ skill: any,
105
+ reaction?: NormalizedActionBattleAttackProfile["reaction"],
106
+ metadata?: Record<string, any>
107
+ ) => {
108
+ const systems = getActionBattleSystems();
109
+ (attacker as any).applyStates?.(target, skill);
110
+ const result = applyActionBattleHit(systems.combat, {
111
+ attacker,
112
+ target,
113
+ skill,
114
+ reaction,
115
+ metadata,
116
+ });
117
+
118
+ if (!result.cancelled) {
119
+ emitActionBattleClientVisual({
120
+ moment: "hurt",
121
+ entity: attacker,
122
+ target,
123
+ attacker,
124
+ damage: result.damage,
125
+ result,
126
+ skill,
127
+ });
128
+ (target as any).battleAi?.handleDamage?.(attacker, {
129
+ damage: result.damage,
130
+ defeated: result.defeated,
131
+ raw: result.rawDamage,
132
+ reaction: result.reaction,
133
+ });
134
+ }
135
+
136
+ return result;
137
+ };
138
+
139
+ const buildActionContext = (input: {
140
+ attacker: ActionBattleEntity;
141
+ target?: ActionBattleEntity | ActionBattleEntity[] | null;
142
+ usable: any;
143
+ skill?: any;
144
+ weapon?: any;
145
+ action?: ActionBattleActionConfig;
146
+ pattern?: string;
147
+ profile?: NormalizedActionBattleAttackProfile;
148
+ }): ActionBattleUseContext => {
149
+ const action = {} as ActionBattleUseContext;
150
+ Object.assign(action, {
151
+ attacker: input.attacker,
152
+ user: input.attacker,
153
+ target: input.target,
154
+ usable: input.usable,
155
+ skill: input.skill,
156
+ weapon: input.weapon,
157
+ action: input.action,
158
+ pattern: input.pattern,
159
+ defaultEffect(target = input.target) {
160
+ return asArray(target).map((entry) =>
161
+ applyDamageEffect(
162
+ input.attacker,
163
+ entry,
164
+ input.skill,
165
+ input.profile?.reaction,
166
+ {
167
+ actionId: input.usable?.id,
168
+ actionType: input.usable?._type,
169
+ pattern: input.pattern,
170
+ }
171
+ )
172
+ );
173
+ },
174
+ damage(target = input.target) {
175
+ const entry = firstTarget(target);
176
+ if (!entry) return undefined;
177
+ return applyDamageEffect(
178
+ input.attacker,
179
+ entry,
180
+ input.skill,
181
+ input.profile?.reaction,
182
+ {
183
+ actionId: input.usable?.id,
184
+ actionType: input.usable?._type,
185
+ pattern: input.pattern,
186
+ }
187
+ );
188
+ },
189
+ heal(
190
+ target: ActionBattleEntity | ActionBattleEntity[] | null | undefined,
191
+ amount: number
192
+ ) {
193
+ if (!target || !Number.isFinite(amount) || amount <= 0) return 0;
194
+ return asArray(target).reduce((total, entry) => {
195
+ const currentHp = Number((entry as any).hp ?? 0);
196
+ const rawParams =
197
+ typeof (entry as any).param === "function"
198
+ ? (entry as any).param()
199
+ : (entry as any).param;
200
+ const maxHp = rawParams?.[MAXHP] ?? Number.POSITIVE_INFINITY;
201
+ const nextHp = Math.min(maxHp, currentHp + amount);
202
+ (entry as any).hp = nextHp;
203
+ emitActionBattleClientVisual({
204
+ moment: "hurt",
205
+ entity: entry,
206
+ target: entry,
207
+ damage: Math.max(0, nextHp - currentHp),
208
+ skill: input.skill,
209
+ });
210
+ return total + nextHp - currentHp;
211
+ }, 0);
212
+ },
213
+ projectile(options: ActionBattleProjectileOptions = { type: "action" }) {
214
+ const map = (input.attacker as any).getCurrentMap?.();
215
+ if (!map?.projectiles?.emit) return [];
216
+
217
+ const configured = input.action?.projectile ?? {};
218
+ const projectile = {
219
+ ...configured,
220
+ ...options,
221
+ };
222
+ const range = projectile.range ?? input.action?.range ?? 160;
223
+ const speed = projectile.speed ?? 180;
224
+ const emitted = map.projectiles.emit(
225
+ {
226
+ type: projectile.type,
227
+ origin: projectile.origin,
228
+ direction:
229
+ projectile.direction ?? directionToTarget(input.attacker, input.target),
230
+ spreadDegrees: projectile.spreadDegrees,
231
+ accuracy: projectile.accuracy,
232
+ trajectory: projectile.trajectory ?? {
233
+ type: "linear",
234
+ speed,
235
+ range,
236
+ },
237
+ collision: projectile.collision,
238
+ repeat: projectile.repeat,
239
+ pattern: projectile.pattern,
240
+ payload: {
241
+ ...projectile.payload,
242
+ actionBattle: true,
243
+ attackerId: input.attacker.id,
244
+ actionId: input.usable?.id,
245
+ },
246
+ params: projectile.params,
247
+ canHit: ({ target }: { target?: ActionBattleEntity }) => {
248
+ if (!target) return false;
249
+ return canActionBattleUseTarget(
250
+ input.attacker,
251
+ target,
252
+ input.action?.target ?? "enemy",
253
+ getActionBattleOptions().combat?.targets
254
+ );
255
+ },
256
+ },
257
+ input.attacker as any
258
+ );
259
+
260
+ for (const state of emitted) {
261
+ projectileHandlers.set(state.id, {
262
+ action,
263
+ onImpact: projectile.onImpact,
264
+ });
265
+ }
266
+
267
+ return emitted;
268
+ },
269
+ });
270
+ return action;
271
+ };
272
+
273
+ export const getActionBattleActionConfig = (usable: any) =>
274
+ resolveActionConfig(usable);
275
+
276
+ export const getActionBattleActionRange = (usable: any): number | undefined =>
277
+ resolveActionConfig(usable)?.range;
278
+
279
+ export const canActionBattleUseTarget = (
280
+ attacker: ActionBattleEntity,
281
+ target: ActionBattleEntity,
282
+ actionTarget: ActionBattleActionTarget = "enemy",
283
+ options: ActionBattleTargetOptions = {}
284
+ ): boolean => {
285
+ if (isActionBattleTargetDefeated(target)) return false;
286
+
287
+ if (actionTarget === "self") {
288
+ return attacker === target;
289
+ }
290
+
291
+ if (actionTarget === "any") {
292
+ return attacker === target || isActionBattleCombatEntity(target);
293
+ }
294
+
295
+ if (attacker === target || !isActionBattleCombatEntity(target)) {
296
+ return false;
297
+ }
298
+
299
+ if (actionTarget === "ally") {
300
+ const attackerFaction = getActionBattleFaction(attacker, options);
301
+ const targetFaction = getActionBattleFaction(target, options);
302
+ return !!attackerFaction && attackerFaction === targetFaction;
303
+ }
304
+
305
+ return canActionBattleTarget(
306
+ attacker,
307
+ target,
308
+ getActionBattleTargets(attacker, "hostile"),
309
+ options
310
+ );
311
+ };
312
+
313
+ export const shouldUseActionBattleUsable = (
314
+ usable: any,
315
+ explicitSkill?: any
316
+ ): boolean => {
317
+ if (!usable) return false;
318
+ return (
319
+ isSkill(usable, explicitSkill) ||
320
+ !!getUseHook(usable) ||
321
+ !!resolveActionConfig(usable)
322
+ );
323
+ };
324
+
325
+ export const executeActionBattleUse = (input: {
326
+ attacker: ActionBattleEntity;
327
+ target?: ActionBattleEntity | ActionBattleEntity[] | null;
328
+ usable: any;
329
+ skill?: any;
330
+ weapon?: any;
331
+ pattern?: string;
332
+ profile?: NormalizedActionBattleAttackProfile;
333
+ playVisual?: boolean;
334
+ }): boolean => {
335
+ if (!shouldUseActionBattleUsable(input.usable, input.skill)) return false;
336
+
337
+ const actionConfig = resolveActionConfig(input.usable);
338
+ if (isSkill(input.usable, input.skill)) {
339
+ consumeSkillUse(input.attacker, input.skill ?? input.usable);
340
+ }
341
+
342
+ const action = buildActionContext({
343
+ ...input,
344
+ action: actionConfig,
345
+ });
346
+ const hook = getUseHook(input.usable);
347
+
348
+ if (input.playVisual !== false) {
349
+ emitActionBattleClientVisual({
350
+ moment: input.skill ? "castSkill" : "attack",
351
+ entity: input.attacker,
352
+ skill: input.skill,
353
+ target: firstTarget(input.target),
354
+ });
355
+ }
356
+
357
+ if (hook) {
358
+ hook.hook.call(hook.target, input.attacker, input.target, action);
359
+ return true;
360
+ }
361
+
362
+ if (actionConfig?.mode === "projectile") {
363
+ action.projectile(actionConfig.projectile as ActionBattleProjectileOptions);
364
+ return true;
365
+ }
366
+
367
+ action.defaultEffect(input.target);
368
+ return true;
369
+ };
370
+
371
+ export const handleActionBattleProjectileImpact = (
372
+ context: ActionBattleProjectileImpactContext
373
+ ) => {
374
+ const handler = projectileHandlers.get(context.projectile.id);
375
+ if (!handler) return;
376
+ const target = context.target;
377
+ handler.action.target = target ?? handler.action.target;
378
+ if (handler.onImpact) {
379
+ handler.onImpact(context, handler.action);
380
+ } else {
381
+ handler.action.defaultEffect(target ?? undefined);
382
+ }
383
+ };
384
+
385
+ export const handleActionBattleProjectileDestroy = (projectileId: string) => {
386
+ projectileHandlers.delete(projectileId);
387
+ };