@rpgjs/action-battle 5.0.0-beta.5 → 5.0.0-beta.7

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 (66) hide show
  1. package/README.md +161 -22
  2. package/dist/ai.server.d.ts +55 -4
  3. package/dist/client/index.js +13 -8
  4. package/dist/client/index10.js +54 -136
  5. package/dist/client/index11.js +52 -23
  6. package/dist/client/index12.js +101 -1217
  7. package/dist/client/index13.js +139 -42
  8. package/dist/client/index14.js +23 -8
  9. package/dist/client/index15.js +68 -444
  10. package/dist/client/index16.js +1343 -0
  11. package/dist/client/index17.js +13 -0
  12. package/dist/client/index18.js +60 -0
  13. package/dist/client/index19.js +10 -0
  14. package/dist/client/index2.js +25 -87
  15. package/dist/client/index20.js +504 -0
  16. package/dist/client/index3.js +45 -83
  17. package/dist/client/index4.js +98 -297
  18. package/dist/client/index5.js +81 -33
  19. package/dist/client/index6.js +284 -78
  20. package/dist/client/index7.js +33 -74
  21. package/dist/client/index8.js +95 -55
  22. package/dist/client/index9.js +75 -96
  23. package/dist/core/attack-profile.d.ts +9 -0
  24. package/dist/core/attack-runtime.d.ts +20 -0
  25. package/dist/core/enemy-attack-profiles.d.ts +6 -0
  26. package/dist/core/equipment.d.ts +2 -0
  27. package/dist/core/hit-reaction.d.ts +5 -0
  28. package/dist/index.d.ts +7 -2
  29. package/dist/server/index.js +12 -7
  30. package/dist/server/index10.js +1340 -8
  31. package/dist/server/index11.js +37 -0
  32. package/dist/server/index12.js +60 -0
  33. package/dist/server/index13.js +13 -0
  34. package/dist/server/index14.js +503 -0
  35. package/dist/server/index15.js +10 -0
  36. package/dist/server/index2.js +1 -1
  37. package/dist/server/index3.js +25 -87
  38. package/dist/server/index4.js +45 -141
  39. package/dist/server/index5.js +104 -21
  40. package/dist/server/index6.js +137 -1215
  41. package/dist/server/index7.js +22 -34
  42. package/dist/server/index8.js +70 -44
  43. package/dist/server/index9.js +44 -437
  44. package/dist/server.d.ts +8 -2
  45. package/dist/ui/state.d.ts +5 -5
  46. package/package.json +5 -5
  47. package/src/ai.server.spec.ts +120 -0
  48. package/src/ai.server.ts +362 -56
  49. package/src/client.ts +21 -12
  50. package/src/config.ts +17 -2
  51. package/src/core/attack-profile.spec.ts +118 -0
  52. package/src/core/attack-profile.ts +100 -0
  53. package/src/core/attack-runtime.spec.ts +103 -0
  54. package/src/core/attack-runtime.ts +83 -0
  55. package/src/core/contracts.ts +3 -0
  56. package/src/core/enemy-attack-profiles.spec.ts +35 -0
  57. package/src/core/enemy-attack-profiles.ts +103 -0
  58. package/src/core/equipment.spec.ts +37 -0
  59. package/src/core/equipment.ts +17 -0
  60. package/src/core/hit-reaction.spec.ts +43 -0
  61. package/src/core/hit-reaction.ts +70 -0
  62. package/src/core/hit.spec.ts +54 -1
  63. package/src/core/hit.ts +26 -0
  64. package/src/index.ts +48 -1
  65. package/src/server.ts +192 -34
  66. package/src/types.ts +62 -6
package/src/client.ts CHANGED
@@ -14,12 +14,14 @@ import {
14
14
  import { ActionBattleOptions } from "./types";
15
15
  import { normalizeActionBattleOptions } from "./config";
16
16
  import { resolveActionBattleAnimation } from "./animations";
17
+ import { getNormalizedActionBattleAttackProfile } from "./core/attack-runtime";
17
18
 
18
19
  const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
19
20
 
20
21
  const beginLocalPlayerAttackLock = (
21
22
  engine: RpgClientEngine,
22
- durationMs: number
23
+ durationMs: number,
24
+ locks: { movement: boolean; direction: boolean }
23
25
  ): boolean => {
24
26
  if (durationMs <= 0) return true;
25
27
 
@@ -44,13 +46,17 @@ const beginLocalPlayerAttackLock = (
44
46
  const previousDirectionFixed = player.directionFixed;
45
47
  const previousAnimationFixed = player.animationFixed;
46
48
 
47
- if (typeof engine.interruptCurrentPlayerMovement === "function") {
48
- engine.interruptCurrentPlayerMovement(player);
49
- } else {
50
- (engine.scene as any)?.stopMovement?.(player);
49
+ if (locks.movement) {
50
+ if (typeof engine.interruptCurrentPlayerMovement === "function") {
51
+ engine.interruptCurrentPlayerMovement(player);
52
+ } else {
53
+ (engine.scene as any)?.stopMovement?.(player);
54
+ }
55
+ player.canMove.set(false);
56
+ }
57
+ if (locks.direction) {
58
+ player.directionFixed = true;
51
59
  }
52
- player.canMove.set(false);
53
- player.directionFixed = true;
54
60
  player.animationFixed = true;
55
61
 
56
62
  setTimeout(() => {
@@ -154,14 +160,17 @@ export const createActionBattleClient = (
154
160
  if (input !== "action") return;
155
161
  const player = engine.scene?.getCurrentPlayer?.() as any;
156
162
  if (!player) return;
163
+ const attackProfile = getNormalizedActionBattleAttackProfile(normalized);
157
164
  const lockDurationMs = Math.max(
158
165
  0,
159
- normalized.attack?.lockDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
160
- );
161
- beginLocalPlayerAttackLock(
162
- engine,
163
- normalized.attack?.lockMovement === false ? 0 : lockDurationMs
166
+ attackProfile.totalDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
164
167
  );
168
+ if (attackProfile.movementLock || attackProfile.directionLock) {
169
+ beginLocalPlayerAttackLock(engine, lockDurationMs, {
170
+ movement: attackProfile.movementLock,
171
+ direction: attackProfile.directionLock,
172
+ });
173
+ }
165
174
  playLocalPlayerAttackAnimation(player, normalized);
166
175
  showLocalAttackPreview(player, normalized);
167
176
  },
package/src/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ActionBattleOptions } from "./types";
2
+ import { normalizeActionBattleAttackProfile } from "./core/attack-profile";
2
3
 
3
4
  export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
4
5
  ui: {
@@ -41,6 +42,16 @@ let currentActionBattleOptions: ActionBattleOptions =
41
42
  export function normalizeActionBattleOptions(
42
43
  options: ActionBattleOptions = {}
43
44
  ): ActionBattleOptions {
45
+ const attack = {
46
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.attack,
47
+ ...options.attack,
48
+ };
49
+ const attackProfile = normalizeActionBattleAttackProfile(attack.profile, {
50
+ lockMovement: attack.lockMovement,
51
+ lockDurationMs: attack.lockDurationMs,
52
+ hitboxes: attack.hitboxes,
53
+ });
54
+
44
55
  return {
45
56
  ui: {
46
57
  actionBar: {
@@ -64,9 +75,13 @@ export function normalizeActionBattleOptions(
64
75
  ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
65
76
  ...options.targeting,
66
77
  },
78
+ debug: {
79
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.debug,
80
+ ...options.debug,
81
+ },
67
82
  attack: {
68
- ...DEFAULT_ACTION_BATTLE_OPTIONS.attack,
69
- ...options.attack,
83
+ ...attack,
84
+ profile: attackProfile,
70
85
  },
71
86
  animations: {
72
87
  ...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE,
4
+ normalizeActionBattleAttackProfile,
5
+ } from "./attack-profile";
6
+ import { normalizeActionBattleOptions } from "../config";
7
+ import type { NormalizedActionBattleAttackProfile } from "../types";
8
+
9
+ describe("normalizeActionBattleAttackProfile", () => {
10
+ test("creates a default profile compatible with the legacy 350ms attack lock", () => {
11
+ const profile = normalizeActionBattleAttackProfile();
12
+
13
+ expect(profile).toEqual(DEFAULT_ACTION_BATTLE_ATTACK_PROFILE);
14
+ });
15
+
16
+ test("derives recovery from the legacy lock duration when recovery is omitted", () => {
17
+ const profile = normalizeActionBattleAttackProfile(
18
+ {
19
+ startupMs: 80,
20
+ activeMs: 90,
21
+ },
22
+ {
23
+ lockDurationMs: 400,
24
+ }
25
+ );
26
+
27
+ expect(profile.recoveryMs).toBe(230);
28
+ expect(profile.totalDurationMs).toBe(400);
29
+ expect(profile.cooldownMs).toBe(400);
30
+ });
31
+
32
+ test("keeps explicit timing, movement, hit policy, animation, and hitboxes", () => {
33
+ const hitboxes = {
34
+ right: { offsetX: 18, offsetY: -18, width: 42, height: 36 },
35
+ };
36
+ const profile = normalizeActionBattleAttackProfile({
37
+ id: "heavy-sword",
38
+ startupMs: 140,
39
+ activeMs: 100,
40
+ recoveryMs: 260,
41
+ cooldownMs: 650,
42
+ movementLock: false,
43
+ directionLock: false,
44
+ animationKey: "castSkill",
45
+ hitPolicy: "allowRepeatHits",
46
+ hitboxes,
47
+ });
48
+
49
+ expect(profile).toMatchObject({
50
+ id: "heavy-sword",
51
+ startupMs: 140,
52
+ activeMs: 100,
53
+ recoveryMs: 260,
54
+ cooldownMs: 650,
55
+ movementLock: false,
56
+ directionLock: false,
57
+ animationKey: "castSkill",
58
+ hitPolicy: "allowRepeatHits",
59
+ totalDurationMs: 500,
60
+ });
61
+ expect(profile.hitboxes).toBe(hitboxes);
62
+ });
63
+
64
+ test("normalizes unsafe timing values to playable bounds", () => {
65
+ const profile = normalizeActionBattleAttackProfile({
66
+ startupMs: -20,
67
+ activeMs: 0,
68
+ recoveryMs: -10,
69
+ cooldownMs: -1,
70
+ });
71
+
72
+ expect(profile.startupMs).toBe(0);
73
+ expect(profile.activeMs).toBe(1);
74
+ expect(profile.recoveryMs).toBe(0);
75
+ expect(profile.cooldownMs).toBe(0);
76
+ expect(profile.totalDurationMs).toBe(1);
77
+ });
78
+
79
+ test("normalizes attack.profile through action battle options", () => {
80
+ const options = normalizeActionBattleOptions({
81
+ attack: {
82
+ lockMovement: false,
83
+ lockDurationMs: 300,
84
+ profile: {
85
+ id: "quick-slash",
86
+ startupMs: 60,
87
+ activeMs: 80,
88
+ },
89
+ },
90
+ });
91
+ const profile = options.attack
92
+ ?.profile as NormalizedActionBattleAttackProfile;
93
+
94
+ expect(profile).toMatchObject({
95
+ id: "quick-slash",
96
+ startupMs: 60,
97
+ activeMs: 80,
98
+ recoveryMs: 160,
99
+ cooldownMs: 300,
100
+ movementLock: false,
101
+ totalDurationMs: 300,
102
+ });
103
+ });
104
+
105
+ test("keeps legacy lockDurationMs when no explicit profile is provided", () => {
106
+ const options = normalizeActionBattleOptions({
107
+ attack: {
108
+ lockDurationMs: 500,
109
+ },
110
+ });
111
+ const profile = options.attack
112
+ ?.profile as NormalizedActionBattleAttackProfile;
113
+
114
+ expect(profile.totalDurationMs).toBe(500);
115
+ expect(profile.recoveryMs).toBe(380);
116
+ expect(profile.cooldownMs).toBe(500);
117
+ });
118
+ });
@@ -0,0 +1,100 @@
1
+ import type {
2
+ ActionBattleAttackHitboxMap,
3
+ ActionBattleAttackHitPolicy,
4
+ ActionBattleAttackProfile,
5
+ ActionBattleAnimationKey,
6
+ NormalizedActionBattleAttackProfile,
7
+ } from "../types";
8
+ import {
9
+ DEFAULT_ACTION_BATTLE_HIT_REACTION,
10
+ normalizeActionBattleHitReaction,
11
+ } from "./hit-reaction";
12
+
13
+ export const DEFAULT_ACTION_BATTLE_ATTACK_PROFILE:
14
+ NormalizedActionBattleAttackProfile = {
15
+ id: "basic",
16
+ startupMs: 0,
17
+ activeMs: 120,
18
+ recoveryMs: 230,
19
+ cooldownMs: 350,
20
+ movementLock: true,
21
+ directionLock: true,
22
+ animationKey: "attack",
23
+ hitPolicy: "oncePerTarget",
24
+ reaction: DEFAULT_ACTION_BATTLE_HIT_REACTION,
25
+ totalDurationMs: 350,
26
+ };
27
+
28
+ export interface ActionBattleAttackProfileFallbacks {
29
+ id?: string;
30
+ lockMovement?: boolean;
31
+ lockDurationMs?: number;
32
+ hitboxes?: ActionBattleAttackHitboxMap;
33
+ }
34
+
35
+ const isFiniteNumber = (value: unknown): value is number =>
36
+ typeof value === "number" && Number.isFinite(value);
37
+
38
+ const nonNegativeMs = (value: unknown, fallback: number) =>
39
+ isFiniteNumber(value) ? Math.max(0, value) : fallback;
40
+
41
+ const positiveMs = (value: unknown, fallback: number) =>
42
+ isFiniteNumber(value) ? Math.max(1, value) : fallback;
43
+
44
+ const resolveHitPolicy = (
45
+ value: ActionBattleAttackHitPolicy | undefined
46
+ ): ActionBattleAttackHitPolicy =>
47
+ value === "allowRepeatHits" ? "allowRepeatHits" : "oncePerTarget";
48
+
49
+ const resolveAnimationKey = (
50
+ value: ActionBattleAnimationKey | undefined
51
+ ): ActionBattleAnimationKey =>
52
+ value ?? DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.animationKey;
53
+
54
+ export function normalizeActionBattleAttackProfile(
55
+ profile: ActionBattleAttackProfile | undefined = {},
56
+ fallbacks: ActionBattleAttackProfileFallbacks = {}
57
+ ): NormalizedActionBattleAttackProfile {
58
+ const startupMs = nonNegativeMs(
59
+ profile.startupMs,
60
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.startupMs
61
+ );
62
+ const activeMs = positiveMs(
63
+ profile.activeMs,
64
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.activeMs
65
+ );
66
+ const legacyDuration = nonNegativeMs(
67
+ fallbacks.lockDurationMs,
68
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.totalDurationMs
69
+ );
70
+ const fallbackRecoveryMs = Math.max(0, legacyDuration - startupMs - activeMs);
71
+ const recoveryMs = nonNegativeMs(profile.recoveryMs, fallbackRecoveryMs);
72
+ const totalDurationMs = startupMs + activeMs + recoveryMs;
73
+ const cooldownMs = nonNegativeMs(profile.cooldownMs, totalDurationMs);
74
+ const hitboxes = profile.hitboxes ?? fallbacks.hitboxes;
75
+
76
+ const normalized: NormalizedActionBattleAttackProfile = {
77
+ id: profile.id || fallbacks.id || DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.id,
78
+ startupMs,
79
+ activeMs,
80
+ recoveryMs,
81
+ cooldownMs,
82
+ movementLock:
83
+ profile.movementLock ??
84
+ fallbacks.lockMovement ??
85
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.movementLock,
86
+ directionLock:
87
+ profile.directionLock ??
88
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.directionLock,
89
+ animationKey: resolveAnimationKey(profile.animationKey),
90
+ hitPolicy: resolveHitPolicy(profile.hitPolicy),
91
+ reaction: normalizeActionBattleHitReaction(profile.reaction),
92
+ totalDurationMs,
93
+ };
94
+
95
+ if (hitboxes) {
96
+ normalized.hitboxes = hitboxes;
97
+ }
98
+
99
+ return normalized;
100
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import {
3
+ ActionBattleHitTracker,
4
+ createActionBattleAttackId,
5
+ getNormalizedActionBattleAttackProfile,
6
+ resolveActionBattleHitboxSpeed,
7
+ scheduleActionBattleStartup,
8
+ } from "./attack-runtime";
9
+
10
+ describe("attack runtime helpers", () => {
11
+ test("resolves a normalized profile from action battle options", () => {
12
+ const profile = getNormalizedActionBattleAttackProfile({
13
+ attack: {
14
+ lockDurationMs: 420,
15
+ profile: {
16
+ startupMs: 100,
17
+ activeMs: 80,
18
+ },
19
+ },
20
+ });
21
+
22
+ expect(profile).toMatchObject({
23
+ startupMs: 100,
24
+ activeMs: 80,
25
+ recoveryMs: 240,
26
+ totalDurationMs: 420,
27
+ });
28
+ });
29
+
30
+ test("maps activeMs to moving hitbox speed", () => {
31
+ const profile = getNormalizedActionBattleAttackProfile({
32
+ attack: {
33
+ profile: {
34
+ activeMs: 96,
35
+ },
36
+ },
37
+ });
38
+
39
+ expect(resolveActionBattleHitboxSpeed(profile, 1)).toBe(6);
40
+ expect(resolveActionBattleHitboxSpeed(profile, 3)).toBe(2);
41
+ });
42
+
43
+ test("runs startup immediately when there is no wind-up", () => {
44
+ const callback = vi.fn();
45
+ const scheduler = vi.fn();
46
+ const profile = getNormalizedActionBattleAttackProfile({
47
+ attack: {
48
+ profile: {
49
+ startupMs: 0,
50
+ },
51
+ },
52
+ });
53
+
54
+ const timer = scheduleActionBattleStartup(profile, callback, scheduler);
55
+
56
+ expect(timer).toBeNull();
57
+ expect(callback).toHaveBeenCalledOnce();
58
+ expect(scheduler).not.toHaveBeenCalled();
59
+ });
60
+
61
+ test("schedules startup when wind-up is configured", () => {
62
+ const callback = vi.fn();
63
+ const scheduler = vi.fn(() => "timer-id");
64
+ const profile = getNormalizedActionBattleAttackProfile({
65
+ attack: {
66
+ profile: {
67
+ startupMs: 120,
68
+ },
69
+ },
70
+ });
71
+
72
+ const timer = scheduleActionBattleStartup(profile, callback, scheduler);
73
+
74
+ expect(timer).toBe("timer-id");
75
+ expect(callback).not.toHaveBeenCalled();
76
+ expect(scheduler).toHaveBeenCalledWith(callback, 120);
77
+ });
78
+
79
+ test("creates stable unique attack ids", () => {
80
+ const first = createActionBattleAttackId("player-1", "sword");
81
+ const second = createActionBattleAttackId("player-1", "sword");
82
+
83
+ expect(first).toContain("player-1:sword:");
84
+ expect(second).toContain("player-1:sword:");
85
+ expect(first).not.toBe(second);
86
+ });
87
+
88
+ test("tracks once-per-target hit policy", () => {
89
+ const tracker = new ActionBattleHitTracker("oncePerTarget");
90
+ const target = { id: "enemy-1" };
91
+
92
+ expect(tracker.tryHit(target)).toBe(true);
93
+ expect(tracker.tryHit(target)).toBe(false);
94
+ });
95
+
96
+ test("allows repeated hits when configured", () => {
97
+ const tracker = new ActionBattleHitTracker("allowRepeatHits");
98
+ const target = { id: "enemy-1" };
99
+
100
+ expect(tracker.tryHit(target)).toBe(true);
101
+ expect(tracker.tryHit(target)).toBe(true);
102
+ });
103
+ });
@@ -0,0 +1,83 @@
1
+ import type {
2
+ ActionBattleAttackHitPolicy,
3
+ ActionBattleOptions,
4
+ NormalizedActionBattleAttackProfile,
5
+ } from "../types";
6
+ import { normalizeActionBattleAttackProfile } from "./attack-profile";
7
+
8
+ export const ACTION_BATTLE_HITBOX_FRAME_MS = 16;
9
+
10
+ export function getNormalizedActionBattleAttackProfile(
11
+ options: ActionBattleOptions = {}
12
+ ): NormalizedActionBattleAttackProfile {
13
+ const attack = options.attack ?? {};
14
+ return normalizeActionBattleAttackProfile(attack.profile, {
15
+ lockMovement: attack.lockMovement,
16
+ lockDurationMs: attack.lockDurationMs,
17
+ hitboxes: attack.hitboxes,
18
+ });
19
+ }
20
+
21
+ export function resolveActionBattleHitboxSpeed(
22
+ profile: NormalizedActionBattleAttackProfile,
23
+ hitboxCount: number
24
+ ): number {
25
+ const positions = Math.max(1, Math.floor(hitboxCount));
26
+ const activeFrames = Math.max(
27
+ 1,
28
+ Math.ceil(profile.activeMs / ACTION_BATTLE_HITBOX_FRAME_MS)
29
+ );
30
+ return Math.max(1, Math.ceil(activeFrames / positions));
31
+ }
32
+
33
+ export function scheduleActionBattleStartup(
34
+ profile: NormalizedActionBattleAttackProfile,
35
+ callback: () => void,
36
+ scheduler: (callback: () => void, delayMs: number) => unknown = setTimeout
37
+ ) {
38
+ if (profile.startupMs <= 0) {
39
+ callback();
40
+ return null;
41
+ }
42
+ return scheduler(callback, profile.startupMs);
43
+ }
44
+
45
+ let attackIdCounter = 0;
46
+
47
+ export function createActionBattleAttackId(
48
+ attackerId: string | number | undefined,
49
+ profileId: string
50
+ ): string {
51
+ attackIdCounter++;
52
+ return `${attackerId ?? "unknown"}:${profileId}:${Date.now()}:${attackIdCounter}`;
53
+ }
54
+
55
+ const getTargetKey = (target: { id?: string | number } | undefined) => {
56
+ if (!target || target.id === undefined || target.id === null) return null;
57
+ return String(target.id);
58
+ };
59
+
60
+ export class ActionBattleHitTracker {
61
+ private hitTargets = new Set<string>();
62
+
63
+ constructor(private readonly hitPolicy: ActionBattleAttackHitPolicy) {}
64
+
65
+ canHit(target: { id?: string | number } | undefined): boolean {
66
+ if (this.hitPolicy === "allowRepeatHits") return true;
67
+ const key = getTargetKey(target);
68
+ return !key || !this.hitTargets.has(key);
69
+ }
70
+
71
+ recordHit(target: { id?: string | number } | undefined): void {
72
+ const key = getTargetKey(target);
73
+ if (key) {
74
+ this.hitTargets.add(key);
75
+ }
76
+ }
77
+
78
+ tryHit(target: { id?: string | number } | undefined): boolean {
79
+ if (!this.canHit(target)) return false;
80
+ this.recordHit(target);
81
+ return true;
82
+ }
83
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RpgEvent, RpgPlayer } from "@rpgjs/server";
2
2
  import type { AttackPattern, EnemyType, AiState } from "../ai.server";
3
+ import type { NormalizedActionBattleHitReactionProfile } from "../types";
3
4
 
4
5
  export type ActionBattleEntity = RpgPlayer | RpgEvent;
5
6
 
@@ -58,6 +59,7 @@ export interface ActionBattleHitContext {
58
59
  pattern?: AttackPattern | string;
59
60
  damage?: ActionBattleDamageResult;
60
61
  knockback?: ActionBattleKnockbackResult;
62
+ reaction?: NormalizedActionBattleHitReactionProfile;
61
63
  cancelled?: boolean;
62
64
  metadata?: Record<string, any>;
63
65
  }
@@ -70,6 +72,7 @@ export interface ActionBattleHitResult {
70
72
  attacker: ActionBattleEntity;
71
73
  target: ActionBattleEntity;
72
74
  rawDamage?: any;
75
+ reaction?: NormalizedActionBattleHitReactionProfile;
73
76
  cancelled?: boolean;
74
77
  metadata?: Record<string, any>;
75
78
  }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES,
4
+ normalizeActionBattleEnemyAttackProfiles,
5
+ } from "./enemy-attack-profiles";
6
+
7
+ describe("enemy attack profiles", () => {
8
+ test("normalizes every built-in enemy attack pattern", () => {
9
+ const profiles = normalizeActionBattleEnemyAttackProfiles();
10
+
11
+ expect(Object.keys(profiles).sort()).toEqual(
12
+ Object.keys(DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES).sort()
13
+ );
14
+ expect(profiles.charged.startupMs).toBe(800);
15
+ expect(profiles.zone.reaction.staggerPower).toBe(1.25);
16
+ });
17
+
18
+ test("overrides individual pattern timing", () => {
19
+ const profiles = normalizeActionBattleEnemyAttackProfiles({
20
+ melee: {
21
+ startupMs: 60,
22
+ activeMs: 40,
23
+ recoveryMs: 100,
24
+ },
25
+ });
26
+
27
+ expect(profiles.melee).toMatchObject({
28
+ startupMs: 60,
29
+ activeMs: 40,
30
+ recoveryMs: 100,
31
+ totalDurationMs: 200,
32
+ });
33
+ expect(profiles.charged.startupMs).toBe(800);
34
+ });
35
+ });
@@ -0,0 +1,103 @@
1
+ import type {
2
+ ActionBattleAttackProfile,
3
+ NormalizedActionBattleAttackProfile,
4
+ } from "../types";
5
+ import { normalizeActionBattleAttackProfile } from "./attack-profile";
6
+
7
+ export type ActionBattleEnemyAttackProfileKey =
8
+ | "melee"
9
+ | "combo"
10
+ | "charged"
11
+ | "zone"
12
+ | "dashAttack";
13
+
14
+ export const DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES: Record<
15
+ ActionBattleEnemyAttackProfileKey,
16
+ ActionBattleAttackProfile
17
+ > = {
18
+ melee: {
19
+ id: "enemy-melee",
20
+ startupMs: 120,
21
+ activeMs: 100,
22
+ recoveryMs: 220,
23
+ cooldownMs: 440,
24
+ reaction: {
25
+ invincibilityMs: 250,
26
+ hitstunMs: 120,
27
+ staggerPower: 1,
28
+ },
29
+ },
30
+ combo: {
31
+ id: "enemy-combo",
32
+ startupMs: 80,
33
+ activeMs: 80,
34
+ recoveryMs: 140,
35
+ cooldownMs: 300,
36
+ reaction: {
37
+ invincibilityMs: 180,
38
+ hitstunMs: 90,
39
+ staggerPower: 0.75,
40
+ },
41
+ },
42
+ charged: {
43
+ id: "enemy-charged",
44
+ startupMs: 800,
45
+ activeMs: 140,
46
+ recoveryMs: 320,
47
+ cooldownMs: 1260,
48
+ reaction: {
49
+ invincibilityMs: 350,
50
+ hitstunMs: 220,
51
+ staggerPower: 2,
52
+ },
53
+ },
54
+ zone: {
55
+ id: "enemy-zone",
56
+ startupMs: 450,
57
+ activeMs: 180,
58
+ recoveryMs: 320,
59
+ cooldownMs: 950,
60
+ reaction: {
61
+ invincibilityMs: 300,
62
+ hitstunMs: 160,
63
+ staggerPower: 1.25,
64
+ },
65
+ },
66
+ dashAttack: {
67
+ id: "enemy-dash",
68
+ startupMs: 180,
69
+ activeMs: 120,
70
+ recoveryMs: 260,
71
+ cooldownMs: 560,
72
+ reaction: {
73
+ invincibilityMs: 280,
74
+ hitstunMs: 150,
75
+ staggerPower: 1.2,
76
+ },
77
+ },
78
+ };
79
+
80
+ export type ActionBattleEnemyAttackProfileMap = Partial<
81
+ Record<ActionBattleEnemyAttackProfileKey, ActionBattleAttackProfile>
82
+ >;
83
+
84
+ export type NormalizedActionBattleEnemyAttackProfileMap = Record<
85
+ ActionBattleEnemyAttackProfileKey,
86
+ NormalizedActionBattleAttackProfile
87
+ >;
88
+
89
+ export function normalizeActionBattleEnemyAttackProfiles(
90
+ overrides: ActionBattleEnemyAttackProfileMap = {}
91
+ ): NormalizedActionBattleEnemyAttackProfileMap {
92
+ return Object.fromEntries(
93
+ Object.entries(DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES).map(
94
+ ([key, defaultProfile]) => [
95
+ key,
96
+ normalizeActionBattleAttackProfile({
97
+ ...defaultProfile,
98
+ ...overrides[key as ActionBattleEnemyAttackProfileKey],
99
+ }),
100
+ ]
101
+ )
102
+ ) as NormalizedActionBattleEnemyAttackProfileMap;
103
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { resolveActionBattleWeaponAttackProfile } from "./equipment";
3
+
4
+ describe("equipment helpers", () => {
5
+ test("resolves attack profile from equipped weapon data", () => {
6
+ const attackProfile = {
7
+ id: "dagger",
8
+ startupMs: 40,
9
+ activeMs: 70,
10
+ recoveryMs: 120,
11
+ };
12
+ const entity = {
13
+ equipments: () => [{ id: () => "dagger" }],
14
+ databaseById: (id: string) =>
15
+ id === "dagger"
16
+ ? {
17
+ _type: "weapon",
18
+ attackProfile,
19
+ }
20
+ : null,
21
+ };
22
+
23
+ expect(resolveActionBattleWeaponAttackProfile(entity)).toBe(attackProfile);
24
+ });
25
+
26
+ test("ignores non-weapon equipment", () => {
27
+ const entity = {
28
+ equipments: () => [{ id: () => "ring" }],
29
+ databaseById: () => ({
30
+ _type: "armor",
31
+ attackProfile: { id: "invalid" },
32
+ }),
33
+ };
34
+
35
+ expect(resolveActionBattleWeaponAttackProfile(entity)).toBeNull();
36
+ });
37
+ });