@rpgjs/action-battle 5.0.0-beta.4 → 5.0.0-beta.6

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 (63) hide show
  1. package/README.md +115 -0
  2. package/dist/ai.server.d.ts +17 -2
  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 +1281 -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 +6 -1
  29. package/dist/server/index.js +12 -7
  30. package/dist/server/index10.js +1278 -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/index3.js +25 -87
  37. package/dist/server/index4.js +45 -141
  38. package/dist/server/index5.js +104 -21
  39. package/dist/server/index6.js +137 -1215
  40. package/dist/server/index7.js +22 -34
  41. package/dist/server/index8.js +70 -44
  42. package/dist/server/index9.js +44 -437
  43. package/dist/server.d.ts +7 -1
  44. package/package.json +5 -5
  45. package/src/ai.server.ts +172 -43
  46. package/src/client.ts +21 -12
  47. package/src/config.ts +17 -2
  48. package/src/core/attack-profile.spec.ts +118 -0
  49. package/src/core/attack-profile.ts +100 -0
  50. package/src/core/attack-runtime.spec.ts +103 -0
  51. package/src/core/attack-runtime.ts +83 -0
  52. package/src/core/contracts.ts +3 -0
  53. package/src/core/enemy-attack-profiles.spec.ts +35 -0
  54. package/src/core/enemy-attack-profiles.ts +103 -0
  55. package/src/core/equipment.spec.ts +37 -0
  56. package/src/core/equipment.ts +17 -0
  57. package/src/core/hit-reaction.spec.ts +43 -0
  58. package/src/core/hit-reaction.ts +70 -0
  59. package/src/core/hit.spec.ts +54 -1
  60. package/src/core/hit.ts +26 -0
  61. package/src/index.ts +36 -0
  62. package/src/server.ts +180 -33
  63. package/src/types.ts +62 -6
@@ -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
+ });
@@ -0,0 +1,17 @@
1
+ import type { ActionBattleAttackProfile } from "../types";
2
+
3
+ const resolveItemId = (item: any) => item?.id?.() ?? item?.id;
4
+
5
+ export function resolveActionBattleWeaponAttackProfile(
6
+ entity: any
7
+ ): ActionBattleAttackProfile | null {
8
+ const equipments = entity?.equipments?.() || [];
9
+ for (const item of equipments) {
10
+ const itemId = resolveItemId(item);
11
+ const itemData = entity?.databaseById?.(itemId);
12
+ if (itemData?._type === "weapon" && itemData.attackProfile) {
13
+ return itemData.attackProfile;
14
+ }
15
+ }
16
+ return null;
17
+ }
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import {
3
+ DEFAULT_ACTION_BATTLE_HIT_REACTION,
4
+ isActionBattleEntityInvincible,
5
+ normalizeActionBattleHitReaction,
6
+ setActionBattleInvincibility,
7
+ } from "./hit-reaction";
8
+
9
+ describe("hit reaction helpers", () => {
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ test("normalizes hit reaction defaults", () => {
15
+ expect(normalizeActionBattleHitReaction(undefined)).toEqual(
16
+ DEFAULT_ACTION_BATTLE_HIT_REACTION
17
+ );
18
+ });
19
+
20
+ test("normalizes unsafe reaction values", () => {
21
+ expect(
22
+ normalizeActionBattleHitReaction({
23
+ invincibilityMs: -10,
24
+ hitstunMs: -20,
25
+ staggerPower: -1,
26
+ })
27
+ ).toEqual({
28
+ invincibilityMs: 0,
29
+ hitstunMs: 0,
30
+ staggerPower: 0,
31
+ });
32
+ });
33
+
34
+ test("tracks invincibility windows on entities", () => {
35
+ const entity = {};
36
+ vi.spyOn(Date, "now").mockReturnValue(1000);
37
+
38
+ setActionBattleInvincibility(entity, 250);
39
+
40
+ expect(isActionBattleEntityInvincible(entity, 1100)).toBe(true);
41
+ expect(isActionBattleEntityInvincible(entity, 1300)).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,70 @@
1
+ import type {
2
+ ActionBattleHitReactionProfile,
3
+ NormalizedActionBattleHitReactionProfile,
4
+ } from "../types";
5
+
6
+ export const DEFAULT_ACTION_BATTLE_HIT_REACTION: NormalizedActionBattleHitReactionProfile = {
7
+ invincibilityMs: 250,
8
+ hitstunMs: 150,
9
+ staggerPower: 1,
10
+ };
11
+
12
+ const STATE_KEY = "__actionBattleHitReaction";
13
+
14
+ interface ActionBattleHitReactionRuntimeState {
15
+ invincibleUntil: number;
16
+ }
17
+
18
+ const isFiniteNumber = (value: unknown): value is number =>
19
+ typeof value === "number" && Number.isFinite(value);
20
+
21
+ const nonNegativeMs = (value: unknown, fallback: number) =>
22
+ isFiniteNumber(value) ? Math.max(0, value) : fallback;
23
+
24
+ const nonNegativeValue = (value: unknown, fallback: number) =>
25
+ isFiniteNumber(value) ? Math.max(0, value) : fallback;
26
+
27
+ const getRuntimeState = (entity: any): ActionBattleHitReactionRuntimeState => {
28
+ if (!entity[STATE_KEY]) {
29
+ entity[STATE_KEY] = {
30
+ invincibleUntil: 0,
31
+ };
32
+ }
33
+ return entity[STATE_KEY];
34
+ };
35
+
36
+ export function normalizeActionBattleHitReaction(
37
+ reaction: ActionBattleHitReactionProfile | undefined,
38
+ defaults: NormalizedActionBattleHitReactionProfile =
39
+ DEFAULT_ACTION_BATTLE_HIT_REACTION
40
+ ): NormalizedActionBattleHitReactionProfile {
41
+ return {
42
+ invincibilityMs: nonNegativeMs(
43
+ reaction?.invincibilityMs,
44
+ defaults.invincibilityMs
45
+ ),
46
+ hitstunMs: nonNegativeMs(reaction?.hitstunMs, defaults.hitstunMs),
47
+ staggerPower: nonNegativeValue(
48
+ reaction?.staggerPower,
49
+ defaults.staggerPower
50
+ ),
51
+ };
52
+ }
53
+
54
+ export function isActionBattleEntityInvincible(
55
+ entity: any,
56
+ now = Date.now()
57
+ ): boolean {
58
+ if (!entity) return false;
59
+ return getRuntimeState(entity).invincibleUntil > now;
60
+ }
61
+
62
+ export function setActionBattleInvincibility(
63
+ entity: any,
64
+ durationMs: number,
65
+ now = Date.now()
66
+ ): void {
67
+ if (!entity || durationMs <= 0) return;
68
+ const state = getRuntimeState(entity);
69
+ state.invincibleUntil = Math.max(state.invincibleUntil, now + durationMs);
70
+ }
@@ -1,6 +1,7 @@
1
- import { describe, expect, test, vi } from "vitest";
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
2
  import { applyActionBattleHit } from "./hit";
3
3
  import type { ActionBattleCombatSystem } from "./contracts";
4
+ import { setActionBattleInvincibility } from "./hit-reaction";
4
5
 
5
6
  const entity = (hp = 100) => ({
6
7
  hp,
@@ -10,6 +11,10 @@ const entity = (hp = 100) => ({
10
11
  });
11
12
 
12
13
  describe("applyActionBattleHit", () => {
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
13
18
  test("runs beforeHit before resolving damage", () => {
14
19
  const calls: string[] = [];
15
20
  const attacker = entity();
@@ -55,4 +60,52 @@ describe("applyActionBattleHit", () => {
55
60
  expect(result.cancelled).toBe(true);
56
61
  expect(resolveDamage).not.toHaveBeenCalled();
57
62
  });
63
+
64
+ test("cancels a hit when the target is invincible", () => {
65
+ const resolveDamage = vi.fn();
66
+ const attacker = entity();
67
+ const target = entity();
68
+ setActionBattleInvincibility(target, 500, 1000);
69
+ vi.spyOn(Date, "now").mockReturnValue(1100);
70
+ const system: ActionBattleCombatSystem = {
71
+ resolveHitboxes: () => [],
72
+ resolveDamage,
73
+ resolveKnockback: () => ({ force: 0, duration: 0 }),
74
+ };
75
+
76
+ const result = applyActionBattleHit(system, {
77
+ attacker: attacker as any,
78
+ target: target as any,
79
+ });
80
+
81
+ expect(result.cancelled).toBe(true);
82
+ expect(resolveDamage).not.toHaveBeenCalled();
83
+ });
84
+
85
+ test("applies invincibility from hit reaction after damage", () => {
86
+ vi.spyOn(Date, "now").mockReturnValue(2000);
87
+ const attacker = entity();
88
+ const target = entity();
89
+ const system: ActionBattleCombatSystem = {
90
+ resolveHitboxes: () => [],
91
+ resolveDamage: () => ({ damage: 12, defeated: false }),
92
+ resolveKnockback: () => ({ force: 0, duration: 0 }),
93
+ };
94
+
95
+ const result = applyActionBattleHit(system, {
96
+ attacker: attacker as any,
97
+ target: target as any,
98
+ reaction: {
99
+ invincibilityMs: 300,
100
+ hitstunMs: 120,
101
+ staggerPower: 1,
102
+ },
103
+ });
104
+
105
+ expect(result.cancelled).toBeUndefined();
106
+ expect(applyActionBattleHit(system, {
107
+ attacker: attacker as any,
108
+ target: target as any,
109
+ }).cancelled).toBe(true);
110
+ });
58
111
  });