@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
@@ -1,9 +1,114 @@
1
1
  import type { RpgEvent, RpgPlayer } from "@rpgjs/server";
2
2
  import type { AttackPattern, EnemyType, AiState } from "../ai.server";
3
3
  import type { NormalizedActionBattleHitReactionProfile } from "../types";
4
+ import type {
5
+ ActionBattleAiIntent,
6
+ ActionBattleAiSimpleBehavior,
7
+ ActionBattleAiTreeInput,
8
+ } from "./ai-behavior-tree";
4
9
 
5
10
  export type ActionBattleEntity = RpgPlayer | RpgEvent;
6
11
 
12
+ export type ActionBattleTargetSelector =
13
+ | "players"
14
+ | "events"
15
+ | "all"
16
+ | "hostile"
17
+ | string[]
18
+ | ((context: ActionBattleTargetContext) => boolean);
19
+
20
+ export interface ActionBattleTargetContext {
21
+ attacker: ActionBattleEntity;
22
+ target: ActionBattleEntity;
23
+ attackerFaction?: string;
24
+ targetFaction?: string;
25
+ }
26
+
27
+ export interface ActionBattleTargetOptions {
28
+ faction?: string;
29
+ targets?: ActionBattleTargetSelector;
30
+ getFaction?: (entity: ActionBattleEntity) => string | undefined;
31
+ canTarget?: (context: ActionBattleTargetContext) => boolean;
32
+ }
33
+
34
+ export type ActionBattleActionMode = "instant" | "melee" | "projectile";
35
+
36
+ export type ActionBattleActionTarget = "enemy" | "ally" | "self" | "any";
37
+
38
+ export interface ActionBattleProjectileOptions {
39
+ type: string;
40
+ speed?: number;
41
+ range?: number;
42
+ /**
43
+ * Random direction offset in degrees, applied as +/- half this value.
44
+ * Useful for less accurate ranged attacks.
45
+ */
46
+ spreadDegrees?: number;
47
+ /**
48
+ * Convenience precision value from 0 to 1. Ignored when `spreadDegrees` is set.
49
+ * `1` is perfectly accurate, `0` can deviate up to 30 degrees.
50
+ */
51
+ accuracy?: number;
52
+ trajectory?: any;
53
+ direction?: { x: number; y: number };
54
+ origin?: { x: number; y: number };
55
+ collision?: any;
56
+ repeat?: any;
57
+ pattern?: any;
58
+ payload?: Record<string, unknown>;
59
+ params?: Record<string, unknown>;
60
+ onImpact?: (
61
+ context: ActionBattleProjectileImpactContext,
62
+ action: ActionBattleUseContext
63
+ ) => void;
64
+ }
65
+
66
+ export interface ActionBattleActionConfig {
67
+ target?: ActionBattleActionTarget;
68
+ range?: number;
69
+ cooldownMs?: number;
70
+ mode?: ActionBattleActionMode;
71
+ projectile?: Omit<ActionBattleProjectileOptions, "onImpact">;
72
+ }
73
+
74
+ export interface ActionBattleProjectileImpactContext {
75
+ attacker: ActionBattleEntity;
76
+ target?: ActionBattleEntity;
77
+ projectile: any;
78
+ hit: any;
79
+ map: any;
80
+ }
81
+
82
+ export interface ActionBattleUseContext {
83
+ attacker: ActionBattleEntity;
84
+ user: ActionBattleEntity;
85
+ target?: ActionBattleEntity | ActionBattleEntity[] | null;
86
+ usable: any;
87
+ skill?: any;
88
+ weapon?: any;
89
+ action?: ActionBattleActionConfig;
90
+ pattern?: AttackPattern | string;
91
+ defaultEffect(target?: ActionBattleEntity | ActionBattleEntity[] | null): any;
92
+ damage(target?: ActionBattleEntity | null): any;
93
+ heal(
94
+ target: ActionBattleEntity | ActionBattleEntity[] | null | undefined,
95
+ amount: number
96
+ ): number;
97
+ projectile(options?: ActionBattleProjectileOptions): any[];
98
+ }
99
+
100
+ export interface ActionBattleUsable {
101
+ id?: string;
102
+ _type?: string;
103
+ action?: ActionBattleActionConfig;
104
+ actionBattle?: ActionBattleActionConfig;
105
+ onUse?: (
106
+ user: ActionBattleEntity,
107
+ target: ActionBattleEntity | ActionBattleEntity[] | null | undefined,
108
+ action: ActionBattleUseContext
109
+ ) => any;
110
+ }
111
+
7
112
  export interface ActionBattleHitbox {
8
113
  x: number;
9
114
  y: number;
@@ -98,7 +203,7 @@ export interface ActionBattleCombatSystem {
98
203
 
99
204
  export interface ActionBattleAiContext {
100
205
  event: RpgEvent;
101
- target: RpgPlayer | null;
206
+ target: ActionBattleEntity | null;
102
207
  state: AiState;
103
208
  enemyType: EnemyType;
104
209
  distance: number | null;
@@ -111,6 +216,7 @@ export interface ActionBattleAiDecision {
111
216
  attackPatterns?: AttackPattern[];
112
217
  attackCooldown?: number;
113
218
  moveToCooldown?: number;
219
+ intent?: ActionBattleAiIntent | ActionBattleAiIntent[];
114
220
  metadata?: Record<string, any>;
115
221
  }
116
222
 
@@ -118,9 +224,48 @@ export type ActionBattleAiBehavior = (
118
224
  context: ActionBattleAiContext
119
225
  ) => ActionBattleAiDecision | void;
120
226
 
227
+ export interface ActionBattleAiPreset {
228
+ preset?: string | ActionBattleAiPreset;
229
+ faction?: string;
230
+ targets?: ActionBattleTargetSelector;
231
+ enemyType?: EnemyType;
232
+ attackCooldown?: number;
233
+ visionRange?: number;
234
+ attackRange?: number;
235
+ dodgeChance?: number;
236
+ dodgeCooldown?: number;
237
+ fleeThreshold?: number;
238
+ attackSkill?: any;
239
+ attackPatterns?: AttackPattern[];
240
+ attackProfiles?: any;
241
+ patrolWaypoints?: Array<{ x: number; y: number }>;
242
+ groupBehavior?: boolean;
243
+ moveToCooldown?: number;
244
+ retreatCooldown?: number;
245
+ poise?: number;
246
+ hitstunMs?: number;
247
+ invincibilityMs?: number;
248
+ behavior?: {
249
+ baseScore?: number;
250
+ updateInterval?: number;
251
+ minStateDuration?: number;
252
+ assaultThreshold?: number;
253
+ retreatThreshold?: number;
254
+ };
255
+ behaviorKey?: string;
256
+ tree?: ActionBattleAiTreeInput;
257
+ behaviorTree?: ActionBattleAiTreeInput;
258
+ simpleBehavior?: ActionBattleAiSimpleBehavior;
259
+ animations?: any;
260
+ rewards?: any;
261
+ autoAwardRewards?: boolean;
262
+ onDefeated?: any;
263
+ }
264
+
121
265
  export interface ActionBattleSystems {
122
266
  combat: ActionBattleCombatSystem;
123
267
  ai: {
124
268
  behaviors: Record<string, ActionBattleAiBehavior>;
269
+ presets: Record<string, ActionBattleAiPreset>;
125
270
  };
126
271
  }
@@ -1,6 +1,7 @@
1
1
  import type { RpgPlayer } from "@rpgjs/server";
2
2
  import type {
3
3
  ActionBattleAiBehavior,
4
+ ActionBattleAiPreset,
4
5
  ActionBattleAttackContext,
5
6
  ActionBattleCombatSystem,
6
7
  ActionBattleDamageContext,
@@ -84,9 +85,24 @@ export const defaultRpgjsDamageResolver = (
84
85
  context: ActionBattleDamageContext
85
86
  ) => {
86
87
  const target = context.target as any;
88
+ const previousHp =
89
+ typeof target.hp === "number" && Number.isFinite(target.hp)
90
+ ? target.hp
91
+ : undefined;
87
92
  const raw = target.applyDamage(context.attacker as any, context.skill);
93
+ const resolvedDamage = Number(raw?.damage ?? 0);
94
+ if (!Number.isFinite(resolvedDamage)) {
95
+ if (previousHp !== undefined) {
96
+ target.hp = previousHp;
97
+ }
98
+ return {
99
+ damage: 0,
100
+ defeated: false,
101
+ raw,
102
+ };
103
+ }
88
104
  return {
89
- damage: raw?.damage ?? 0,
105
+ damage: resolvedDamage,
90
106
  defeated: target.hp <= 0,
91
107
  raw,
92
108
  };
@@ -146,10 +162,65 @@ export const defaultEnemyBehaviors: Record<string, ActionBattleAiBehavior> = {
146
162
  }),
147
163
  };
148
164
 
165
+ export const defaultEnemyPresets: Record<string, ActionBattleAiPreset> = {
166
+ [CoreEnemyType.Aggressive]: {
167
+ enemyType: CoreEnemyType.Aggressive as any,
168
+ attackCooldown: 600,
169
+ visionRange: 150,
170
+ attackRange: 50,
171
+ dodgeChance: 0.1,
172
+ dodgeCooldown: 3000,
173
+ fleeThreshold: 0.15,
174
+ behaviorKey: CoreEnemyType.Aggressive,
175
+ },
176
+ [CoreEnemyType.Defensive]: {
177
+ enemyType: CoreEnemyType.Defensive as any,
178
+ attackCooldown: 1500,
179
+ visionRange: 120,
180
+ attackRange: 60,
181
+ dodgeChance: 0.5,
182
+ dodgeCooldown: 1500,
183
+ fleeThreshold: 0.3,
184
+ behaviorKey: CoreEnemyType.Defensive,
185
+ },
186
+ [CoreEnemyType.Ranged]: {
187
+ enemyType: CoreEnemyType.Ranged as any,
188
+ attackCooldown: 1200,
189
+ visionRange: 200,
190
+ attackRange: 120,
191
+ dodgeChance: 0.4,
192
+ dodgeCooldown: 2000,
193
+ fleeThreshold: 0.25,
194
+ behaviorKey: CoreEnemyType.Ranged,
195
+ },
196
+ [CoreEnemyType.Tank]: {
197
+ enemyType: CoreEnemyType.Tank as any,
198
+ attackCooldown: 2000,
199
+ visionRange: 100,
200
+ attackRange: 50,
201
+ dodgeChance: 0,
202
+ dodgeCooldown: 5000,
203
+ fleeThreshold: 0.1,
204
+ poise: 2,
205
+ behaviorKey: CoreEnemyType.Tank,
206
+ },
207
+ [CoreEnemyType.Berserker]: {
208
+ enemyType: CoreEnemyType.Berserker as any,
209
+ attackCooldown: 800,
210
+ visionRange: 180,
211
+ attackRange: 55,
212
+ dodgeChance: 0.15,
213
+ dodgeCooldown: 2500,
214
+ fleeThreshold: 0.05,
215
+ behaviorKey: CoreEnemyType.Berserker,
216
+ },
217
+ };
218
+
149
219
  export const defaultActionBattleSystems: ActionBattleSystems = {
150
220
  combat: defaultCombatSystem,
151
221
  ai: {
152
222
  behaviors: defaultEnemyBehaviors,
223
+ presets: defaultEnemyPresets,
153
224
  },
154
225
  };
155
226
 
@@ -2,16 +2,20 @@ import type { ActionBattleAttackProfile } from "../types";
2
2
 
3
3
  const resolveItemId = (item: any) => item?.id?.() ?? item?.id;
4
4
 
5
- export function resolveActionBattleWeaponAttackProfile(
6
- entity: any
7
- ): ActionBattleAttackProfile | null {
5
+ export function resolveActionBattleWeapon(entity: any): any | null {
8
6
  const equipments = entity?.equipments?.() || [];
9
7
  for (const item of equipments) {
10
8
  const itemId = resolveItemId(item);
11
9
  const itemData = entity?.databaseById?.(itemId);
12
- if (itemData?._type === "weapon" && itemData.attackProfile) {
13
- return itemData.attackProfile;
10
+ if (itemData?._type === "weapon") {
11
+ return itemData;
14
12
  }
15
13
  }
16
14
  return null;
17
15
  }
16
+
17
+ export function resolveActionBattleWeaponAttackProfile(
18
+ entity: any
19
+ ): ActionBattleAttackProfile | null {
20
+ return resolveActionBattleWeapon(entity)?.attackProfile ?? null;
21
+ }
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, test, vi } from "vitest";
2
2
  import { applyActionBattleHit } from "./hit";
3
+ import { defaultRpgjsDamageResolver } from "./defaults";
3
4
  import type { ActionBattleCombatSystem } from "./contracts";
4
5
  import { setActionBattleInvincibility } from "./hit-reaction";
5
6
 
@@ -108,4 +109,24 @@ describe("applyActionBattleHit", () => {
108
109
  target: target as any,
109
110
  }).cancelled).toBe(true);
110
111
  });
112
+
113
+ test("normalizes invalid RPGJS damage without poisoning target hp", () => {
114
+ const attacker = entity();
115
+ const target = {
116
+ ...entity(100),
117
+ applyDamage() {
118
+ this.hp = Number.NaN;
119
+ return { damage: Number.NaN };
120
+ },
121
+ };
122
+
123
+ const result = defaultRpgjsDamageResolver({
124
+ attacker: attacker as any,
125
+ target: target as any,
126
+ });
127
+
128
+ expect(result.damage).toBe(0);
129
+ expect(result.defeated).toBe(false);
130
+ expect(target.hp).toBe(100);
131
+ });
111
132
  });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { RpgEvent } from "@rpgjs/server";
3
+ import { BattleAi } from "../ai.server";
4
+ import {
5
+ canActionBattleTarget,
6
+ getActionBattleEntityKind,
7
+ getActionBattleFaction,
8
+ getActionBattleTargets,
9
+ } from "./targets";
10
+
11
+ const player = (id: string, faction?: string) =>
12
+ ({
13
+ id,
14
+ hp: 100,
15
+ actionBattleFaction: faction,
16
+ }) as any;
17
+
18
+ const battleEvent = (id: string, faction?: string) =>
19
+ ({
20
+ id,
21
+ hp: 100,
22
+ actionBattleFaction: faction,
23
+ battleAi: {
24
+ getFaction: () => faction,
25
+ getTargets: () => "players",
26
+ },
27
+ attachShape() {},
28
+ }) as any;
29
+
30
+ describe("action battle targets", () => {
31
+ test("keeps player default targets on action battle events", () => {
32
+ const attacker = player("player-1");
33
+ const target = battleEvent("enemy-1", "enemies");
34
+
35
+ expect(getActionBattleTargets(attacker, "events")).toBe("events");
36
+ expect(canActionBattleTarget(attacker, target, "events")).toBe(true);
37
+ expect(canActionBattleTarget(attacker, player("player-2"), "events")).toBe(false);
38
+ });
39
+
40
+ test("classifies runtime RpgEvent enemies before their RpgPlayer base class", () => {
41
+ const attacker = player("player-1");
42
+ const target = new RpgEvent() as any;
43
+ target.id = "enemy-1";
44
+ target.hp = 100;
45
+ target.battleAi = {
46
+ getFaction: () => "enemies",
47
+ getTargets: () => "players",
48
+ };
49
+
50
+ expect(getActionBattleEntityKind(target)).toBe("event");
51
+ expect(canActionBattleTarget(attacker, target, "events")).toBe(true);
52
+ expect(canActionBattleTarget(attacker, target, "players")).toBe(false);
53
+ });
54
+
55
+ test("classifies runtime players with shape helpers as players", () => {
56
+ const attacker = battleEvent("enemy-1", "enemies");
57
+ const target = {
58
+ ...player("player-1"),
59
+ attachShape() {},
60
+ isEvent: () => false,
61
+ };
62
+
63
+ expect(getActionBattleEntityKind(target as any)).toBe("player");
64
+ expect(canActionBattleTarget(attacker, target as any, "players")).toBe(true);
65
+ });
66
+
67
+ test("supports player targets explicitly", () => {
68
+ const attacker = player("player-1");
69
+ const target = player("player-2");
70
+
71
+ expect(canActionBattleTarget(attacker, target, "players")).toBe(true);
72
+ });
73
+
74
+ test("supports all targets", () => {
75
+ const attacker = battleEvent("enemy-1", "enemies");
76
+
77
+ expect(canActionBattleTarget(attacker, player("player-1"), "all")).toBe(true);
78
+ expect(canActionBattleTarget(attacker, battleEvent("enemy-2", "enemies"), "all")).toBe(true);
79
+ });
80
+
81
+ test("supports hostile targets from different factions", () => {
82
+ const guard = battleEvent("guard-1", "guards");
83
+ const guardAlly = battleEvent("guard-2", "guards");
84
+ const bandit = battleEvent("bandit-1", "bandits");
85
+
86
+ expect(canActionBattleTarget(guard, guardAlly, "hostile")).toBe(false);
87
+ expect(canActionBattleTarget(guard, bandit, "hostile")).toBe(true);
88
+ });
89
+
90
+ test("supports explicit faction target lists", () => {
91
+ const guard = battleEvent("guard-1", "guards");
92
+ const bandit = battleEvent("bandit-1", "bandits");
93
+ const monster = battleEvent("monster-1", "monsters");
94
+
95
+ expect(canActionBattleTarget(guard, bandit, ["bandits"])).toBe(true);
96
+ expect(canActionBattleTarget(guard, monster, ["bandits"])).toBe(false);
97
+ });
98
+
99
+ test("BattleAi exposes runtime faction and targets", () => {
100
+ const event = {
101
+ id: "guard-1",
102
+ hp: 100,
103
+ param: {},
104
+ attachShape: () => ({ id: "vision_guard-1" }),
105
+ getCurrentMap: () => ({}),
106
+ stopMoveTo() {},
107
+ };
108
+ const ai = new BattleAi(event as any, {
109
+ faction: "guards",
110
+ targets: ["bandits"],
111
+ });
112
+
113
+ expect(getActionBattleFaction(event as any)).toBe("guards");
114
+ expect(getActionBattleTargets(event as any, "players")).toEqual(["bandits"]);
115
+
116
+ ai.setFaction("bandits");
117
+ ai.setTargets("hostile");
118
+
119
+ expect(getActionBattleFaction(event as any)).toBe("bandits");
120
+ expect(getActionBattleTargets(event as any, "players")).toBe("hostile");
121
+
122
+ ai.destroy();
123
+ });
124
+ });
@@ -0,0 +1,150 @@
1
+ import { RpgEvent, RpgPlayer } from "@rpgjs/server";
2
+ import type {
3
+ ActionBattleEntity,
4
+ ActionBattleTargetContext,
5
+ ActionBattleTargetOptions,
6
+ ActionBattleTargetSelector,
7
+ } from "./contracts";
8
+
9
+ export const ACTION_BATTLE_PLAYER_FACTION = "players";
10
+ export const ACTION_BATTLE_ENEMY_FACTION = "enemies";
11
+
12
+ type EntityKind = "player" | "event";
13
+
14
+ const getBattleAi = (entity: ActionBattleEntity) => (entity as any)?.battleAi;
15
+
16
+ export const getActionBattleEntityKind = (
17
+ entity: ActionBattleEntity
18
+ ): EntityKind => {
19
+ if (getBattleAi(entity)) return "event";
20
+ if (typeof (entity as any)?.isEvent === "function") {
21
+ return (entity as any).isEvent() ? "event" : "player";
22
+ }
23
+ if ((entity as any) instanceof RpgEvent) return "event";
24
+ if ((entity as any) instanceof RpgPlayer) return "player";
25
+ if (typeof (entity as any)?.attachShape === "function") return "event";
26
+ return "player";
27
+ };
28
+
29
+ export const isActionBattlePlayer = (entity: ActionBattleEntity): boolean =>
30
+ getActionBattleEntityKind(entity) === "player";
31
+
32
+ export const isActionBattleEvent = (entity: ActionBattleEntity): boolean =>
33
+ getActionBattleEntityKind(entity) === "event";
34
+
35
+ export const isActionBattleCombatEntity = (
36
+ entity: ActionBattleEntity | undefined | null
37
+ ): entity is ActionBattleEntity => {
38
+ if (!entity) return false;
39
+ if (isActionBattlePlayer(entity as ActionBattleEntity)) return true;
40
+ return !!getBattleAi(entity as ActionBattleEntity);
41
+ };
42
+
43
+ export const getActionBattleFaction = (
44
+ entity: ActionBattleEntity,
45
+ options: ActionBattleTargetOptions = {}
46
+ ): string | undefined => {
47
+ const configured = options.getFaction?.(entity);
48
+ if (configured !== undefined) return configured;
49
+
50
+ const battleAi = getBattleAi(entity);
51
+ if (battleAi && typeof battleAi.getFaction === "function") {
52
+ const faction = battleAi.getFaction();
53
+ if (faction !== undefined) return faction;
54
+ }
55
+
56
+ const entityFaction =
57
+ (entity as any).actionBattleFaction ?? (entity as any).faction;
58
+ if (entityFaction !== undefined) return String(entityFaction);
59
+
60
+ if (isActionBattlePlayer(entity)) return ACTION_BATTLE_PLAYER_FACTION;
61
+ if (battleAi) return ACTION_BATTLE_ENEMY_FACTION;
62
+ return undefined;
63
+ };
64
+
65
+ export const getActionBattleTargets = (
66
+ entity: ActionBattleEntity,
67
+ fallback: ActionBattleTargetSelector
68
+ ): ActionBattleTargetSelector => {
69
+ const battleAi = getBattleAi(entity);
70
+ if (battleAi && typeof battleAi.getTargets === "function") {
71
+ return battleAi.getTargets();
72
+ }
73
+ return (entity as any).actionBattleTargets ?? fallback;
74
+ };
75
+
76
+ export const isActionBattleTargetDefeated = (
77
+ target: ActionBattleEntity | null | undefined
78
+ ): boolean => {
79
+ if (!target) return true;
80
+ const hp = (target as any).hp;
81
+ return typeof hp === "number" && hp <= 0;
82
+ };
83
+
84
+ export const matchesActionBattleTargetSelector = (
85
+ selector: ActionBattleTargetSelector | undefined,
86
+ context: ActionBattleTargetContext
87
+ ): boolean => {
88
+ if (!selector) return false;
89
+ if (typeof selector === "function") return selector(context);
90
+ if (selector === "all") return true;
91
+ if (selector === "players") return isActionBattlePlayer(context.target);
92
+ if (selector === "events") return isActionBattleEvent(context.target);
93
+ if (selector === "hostile") {
94
+ return (
95
+ !!context.attackerFaction &&
96
+ !!context.targetFaction &&
97
+ context.attackerFaction !== context.targetFaction
98
+ );
99
+ }
100
+ if (Array.isArray(selector)) {
101
+ return !!context.targetFaction && selector.includes(context.targetFaction);
102
+ }
103
+ return false;
104
+ };
105
+
106
+ export const canActionBattleTarget = (
107
+ attacker: ActionBattleEntity,
108
+ target: ActionBattleEntity,
109
+ selector: ActionBattleTargetSelector | undefined,
110
+ options: ActionBattleTargetOptions = {}
111
+ ): boolean => {
112
+ if (attacker === target) return false;
113
+ if (!isActionBattleCombatEntity(target)) return false;
114
+ if (isActionBattleTargetDefeated(target)) return false;
115
+
116
+ const context: ActionBattleTargetContext = {
117
+ attacker,
118
+ target,
119
+ attackerFaction: getActionBattleFaction(attacker, options),
120
+ targetFaction: getActionBattleFaction(target, options),
121
+ };
122
+
123
+ const allowed = options.canTarget?.(context);
124
+ if (allowed !== undefined) return allowed;
125
+
126
+ return matchesActionBattleTargetSelector(selector, context);
127
+ };
128
+
129
+ export const getActionBattleEntitiesInRange = (
130
+ attacker: ActionBattleEntity,
131
+ radius: number,
132
+ selector: ActionBattleTargetSelector | undefined,
133
+ options: ActionBattleTargetOptions = {}
134
+ ): ActionBattleEntity[] => {
135
+ const map = (attacker as any).getCurrentMap?.();
136
+ if (!map) return [];
137
+
138
+ const candidates: ActionBattleEntity[] = [];
139
+ map.getPlayers?.().forEach((player: RpgPlayer) => candidates.push(player));
140
+ map.getEvents?.().forEach((event: RpgEvent) => candidates.push(event));
141
+
142
+ return candidates.filter((candidate) => {
143
+ if (!canActionBattleTarget(attacker, candidate, selector, options)) {
144
+ return false;
145
+ }
146
+ const dx = (attacker as any).x() - (candidate as any).x();
147
+ const dy = (attacker as any).y() - (candidate as any).y();
148
+ return Math.sqrt(dx * dx + dy * dy) <= radius;
149
+ });
150
+ };
@@ -7,6 +7,14 @@ export interface ActionBattleEnemyPreset extends BattleAiOptions {
7
7
 
8
8
  export type ActionBattleEnemyPresetMap = Record<string, ActionBattleEnemyPreset>;
9
9
 
10
+ export const defineActionBattleEnemy = <T extends ActionBattleEnemyPreset>(
11
+ preset: T
12
+ ): T => preset;
13
+
14
+ export const defineActionBattleAiPreset = <T extends BattleAiOptions>(
15
+ preset: T
16
+ ): T => preset;
17
+
10
18
  export const createActionEnemy = (
11
19
  event: RpgEvent,
12
20
  presetOrOptions: string | BattleAiOptions,