434 lines
18 KiB
TypeScript
434 lines
18 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { applyAura } from "./combatAuras";
|
|
import {
|
|
abilitiesForClass,
|
|
learnedAbilityForBuild,
|
|
preloadAbilityCatalogsForClasses,
|
|
abilityAtLevel as resolveAbilityAtLevel,
|
|
} from "./abilityCatalog";
|
|
import { useCombatStore, stableCombatUnit } from "./combatStore";
|
|
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
|
import { creatureTemplateReference } from "./azerothCoreReference";
|
|
import { clearMobRuntimeRegistry, registerMobRuntime, type CombatPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
|
|
const SOURCE_ENTRY = 1666;
|
|
const PLAYER_TARGET = "spell-block-player-source-npc";
|
|
const PARTY_TARGET = "spell-block-party-source-npc";
|
|
const TEST_PARTY_ID = "sourced-spell-block-test-party";
|
|
const SYNTHETIC_CRIT_AURA = "integration-test:guaranteed-critical";
|
|
const FRONT_POSITION: CombatPosition = [0, 0, -2];
|
|
const BACK_POSITION: CombatPosition = [0, 0, 2];
|
|
const PARTY_FRONT_POSITION: CombatPosition = [0, 0, -10];
|
|
|
|
const spatialActors = new Map<string, { readonly position: CombatPosition; readonly yaw: number }>();
|
|
|
|
beforeAll(async () => {
|
|
await preloadAbilityCatalogsForClasses(["warrior", "hunter", "mage"]);
|
|
});
|
|
|
|
function setSpatialActors(
|
|
entries: Readonly<Record<string, CombatPosition>>,
|
|
yaws: Readonly<Record<string, number>> = {},
|
|
): void {
|
|
spatialActors.clear();
|
|
for (const [id, position] of Object.entries(entries)) spatialActors.set(id, { position, yaw: yaws[id] ?? 0 });
|
|
registerCombatSpatialProvider({
|
|
actor: (id) => {
|
|
const actor = spatialActors.get(id);
|
|
return actor ? { id, ...actor } : null;
|
|
},
|
|
hasLineOfSight: () => true,
|
|
projectGroundPoint: (position) => position,
|
|
});
|
|
}
|
|
|
|
function registerSourceNpc(id: string): void {
|
|
const template = creatureTemplateReference(SOURCE_ENTRY);
|
|
if (!template) throw new Error(`Missing AzerothCore CreatureTemplate ${SOURCE_ENTRY}.`);
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: template.name,
|
|
serverEntry: SOURCE_ENTRY,
|
|
level: 30,
|
|
maxHealth: 100_000,
|
|
xpReward: 0,
|
|
});
|
|
registerMobRuntime(id, [0, 0, 0], Math.PI);
|
|
spatialActors.set(id, { position: [0, 0, 0], yaw: Math.PI });
|
|
}
|
|
|
|
function applyGuaranteedCriticalAura(actorId: string, now: number): void {
|
|
useCombatStore.setState((state) => ({
|
|
auras: [...applyAura(state.auras, {
|
|
definition: {
|
|
id: SYNTHETIC_CRIT_AURA,
|
|
name: "Synthetic Integration Critical",
|
|
description: "Test-only fixture that makes one real spell cast critically hit.",
|
|
disposition: "buff",
|
|
durationMs: 60_000,
|
|
utilityTags: ["next-offensive-critical"],
|
|
},
|
|
sourceId: actorId,
|
|
targetId: actorId,
|
|
now,
|
|
}).auras],
|
|
}));
|
|
}
|
|
|
|
function preparePlayer(
|
|
classId: "warrior" | "hunter" | "mage",
|
|
targetId: string,
|
|
position = FRONT_POSITION,
|
|
playerYaw = 0,
|
|
): void {
|
|
useCombatStore.getState().initializeCharacter({ classId, level: 30 });
|
|
usePartyStore.getState().initializeParty(
|
|
TEST_PARTY_ID, 30, useCombatStore.getState().health, "damage", 1, "wow",
|
|
);
|
|
useCombatStore.getState().setPlayerPosition(position);
|
|
registerSourceNpc(targetId);
|
|
setSpatialActors({ [PLAYER_AGGRO_ID]: position, [targetId]: [0, 0, 0] }, {
|
|
[PLAYER_AGGRO_ID]: playerYaw,
|
|
[targetId]: Math.PI,
|
|
});
|
|
useCombatStore.getState().selectMob(targetId);
|
|
useCombatStore.setState((state) => ({
|
|
resource: state.maxResource,
|
|
resourcePools: { ...state.resourcePools, [state.resourceName === "Rage" ? "rage" : "mana"]: state.maxResource },
|
|
}));
|
|
}
|
|
|
|
function nextCriticalBlockedTime(
|
|
sourceId: string,
|
|
abilityId: string,
|
|
targetId: string,
|
|
seedKind: "direct" | "special-crit",
|
|
firstTime = 1_000,
|
|
): number {
|
|
for (let now = firstTime; now < 200_000; now += 1) {
|
|
const seed = seedKind === "direct"
|
|
? `${sourceId}:${abilityId}:${targetId}:${now}:direct`
|
|
: `${sourceId}:${abilityId}:${targetId}:${now}:special-crit`;
|
|
const outcome = stableCombatUnit(`${seed}:outcome`);
|
|
if (stableCombatUnit(`${seed}:block`) < 0.05 && outcome > 0.2 && outcome < 0.8) return now;
|
|
}
|
|
throw new Error("Could not find a deterministic crit-and-block source-spell roll.");
|
|
}
|
|
|
|
function nextPlayerCriticalTime(abilityId: string, targetId: string): number {
|
|
for (let now = 1_000; now < 200_000; now += 1) {
|
|
const seed = `${PLAYER_AGGRO_ID}:${abilityId}:${targetId}:${now}:direct`;
|
|
const outcome = stableCombatUnit(`${seed}:outcome`);
|
|
if (outcome > 0.2 && outcome < 0.8) return now;
|
|
}
|
|
throw new Error("Could not find a deterministic landed critical spell roll.");
|
|
}
|
|
|
|
function playerDamageEvent(targetId: string, abilityId: string) {
|
|
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
|
|
candidate.kind === "damage"
|
|
&& candidate.sourceActorId === PLAYER_AGGRO_ID
|
|
&& candidate.targetActorId === targetId
|
|
&& candidate.abilityId === abilityId
|
|
));
|
|
if (!event) throw new Error(`Missing player damage event for ${abilityId} against ${targetId}.`);
|
|
return event;
|
|
}
|
|
|
|
function partyDamageEvent(targetId: string, actorId: string, abilityId: string) {
|
|
const event = [...useCombatStore.getState().combatEvents].reverse().find((candidate) => (
|
|
candidate.kind === "damage"
|
|
&& candidate.sourceActorId === actorId
|
|
&& candidate.targetActorId === targetId
|
|
&& candidate.abilityId === abilityId
|
|
));
|
|
if (!event) throw new Error(`Missing companion damage event for ${abilityId} against ${targetId}.`);
|
|
return event;
|
|
}
|
|
|
|
function completePlayerCastIfNeeded(now: number): void {
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().tick(0.1, Math.max(now, activeCast.completesAt));
|
|
}
|
|
|
|
function setSelectedMobActiveCast(targetId: string, now: number): void {
|
|
const target = useCombatStore.getState().mobs[targetId];
|
|
if (!target) throw new Error(`Missing NPC target ${targetId}.`);
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
[targetId]: {
|
|
...state.mobs[targetId]!,
|
|
activeCast: {
|
|
attackId: "synthetic-block-gate-cast",
|
|
name: "Synthetic Block Gate Cast",
|
|
school: "fire",
|
|
interruptible: true,
|
|
targetActorId: PLAYER_AGGRO_ID,
|
|
sourcePosition: [0, 0, 0] as const,
|
|
targetPosition: FRONT_POSITION,
|
|
sourceYaw: 0,
|
|
startedAt: now - 1,
|
|
completesAt: now + 5_000,
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
}
|
|
|
|
function abilityFor(classId: "warrior" | "hunter" | "mage", name: string) {
|
|
const ability = abilitiesForClass(classId).find((candidate) => candidate.name === name);
|
|
if (!ability) throw new Error(`Missing ${classId} ability ${name}.`);
|
|
return ability;
|
|
}
|
|
|
|
afterEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
resetCombatSpatialProvider();
|
|
spatialActors.clear();
|
|
});
|
|
|
|
describe("sourced direct-spell blocks against ordinary source NPCs", () => {
|
|
it("blocks a player's sourced physical melee spell after the hit, including a simultaneous critical hit", () => {
|
|
preparePlayer("warrior", PLAYER_TARGET);
|
|
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
|
const resolvedHeroicStrike = resolveAbilityAtLevel(heroicStrike, 30);
|
|
expect(resolvedHeroicStrike.wowSpellDefense).toMatchObject({
|
|
damageClass: 2,
|
|
schoolMask: 1,
|
|
attributes0: 327700,
|
|
attributes3: 1024,
|
|
directDamageFromEffects: true,
|
|
sourceProfile: "wow335",
|
|
});
|
|
const template = creatureTemplateReference(SOURCE_ENTRY)!;
|
|
expect(template.flagsExtra).toBeDefined();
|
|
expect(template.flagsExtra! & 0x10).toBe(0);
|
|
|
|
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, PLAYER_TARGET, "direct");
|
|
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
|
|
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: PLAYER_TARGET });
|
|
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
|
completePlayerCastIfNeeded(now);
|
|
|
|
const event = playerDamageEvent(PLAYER_TARGET, heroicStrike.id);
|
|
expect(event).toMatchObject({ critical: true, outcome: "crit", blocked: true, blockedAmount: 15 });
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
|
landed: true,
|
|
outcome: "crit",
|
|
critical: true,
|
|
blocked: true,
|
|
blockedAmount: 15,
|
|
postHitBlockChancePercent: 5,
|
|
});
|
|
});
|
|
|
|
it("blocks a real generated WoW hunter companion's sourced physical ranged spell", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 50 });
|
|
usePartyStore.getState().initializeParty(
|
|
TEST_PARTY_ID, 50, useCombatStore.getState().health, "damage", 1, "wow",
|
|
);
|
|
const steadyShotBase = abilityFor("hunter", "Steady Shot");
|
|
const steadyShot = resolveAbilityAtLevel(steadyShotBase, 50);
|
|
expect(steadyShot.wowSpellDefense).toMatchObject({
|
|
damageClass: 3,
|
|
schoolMask: 1,
|
|
attributes0: 4259858,
|
|
attributes3: 0,
|
|
directDamageFromEffects: true,
|
|
sourceProfile: "wow335",
|
|
});
|
|
const memberSeed = createConfiguredPartyMember({
|
|
name: "Sourced Spell Block Hunter",
|
|
classId: "hunter",
|
|
specialization: "Marksmanship",
|
|
specializationId: "hunter:marksmanship",
|
|
talentTreeId: "hunter-marksmanship",
|
|
talentRanks: {},
|
|
role: "damage",
|
|
}, 50, 0, TEST_PARTY_ID);
|
|
const learnedAbilities = abilitiesForClass("hunter")
|
|
.map((ability) => learnedAbilityForBuild(ability, memberSeed.level, memberSeed.talentRanks))
|
|
.filter((ability): ability is NonNullable<typeof ability> => ability !== null);
|
|
const disabledCooldowns = Object.fromEntries(learnedAbilities
|
|
.filter((ability) => ability.id !== steadyShot.id)
|
|
.map((ability) => [ability.id, 100_000]));
|
|
const member = {
|
|
...memberSeed,
|
|
cooldowns: disabledCooldowns,
|
|
globalCooldownEndsAt: 0,
|
|
nextActionAt: 0,
|
|
status: "ready" as const,
|
|
resource: memberSeed.maxResource,
|
|
resourcePools: { ...memberSeed.resourcePools, [memberSeed.resourceType]: memberSeed.maxResource },
|
|
};
|
|
usePartyStore.setState({ members: [member], command: "attack", activeMobId: PARTY_TARGET });
|
|
registerSourceNpc(PARTY_TARGET);
|
|
// Synthetic engagement makes the companion AI enter its normal hostile-target rotation.
|
|
useCombatStore.getState().damageMob(PARTY_TARGET, 1, 900, {
|
|
actorId: PLAYER_AGGRO_ID,
|
|
role: "damage",
|
|
});
|
|
registerPartyRuntimePosition(member.id, PARTY_FRONT_POSITION);
|
|
updatePartyRuntimeTargetVisibility(member.id, PARTY_TARGET, true);
|
|
setSpatialActors({ [member.id]: PARTY_FRONT_POSITION, [PARTY_TARGET]: [0, 0, 0] }, {
|
|
[PARTY_TARGET]: Math.PI,
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
|
|
const startAt = nextCriticalBlockedTime(
|
|
member.id,
|
|
steadyShot.id,
|
|
PARTY_TARGET,
|
|
"special-crit",
|
|
steadyShot.castTimeMs + 1_000,
|
|
)
|
|
- steadyShot.castTimeMs;
|
|
applyGuaranteedCriticalAura(member.id, startAt - 1);
|
|
advanceDungeonPartyCombat(startAt);
|
|
const activeCast = usePartyStore.getState().members[0]?.activeCast;
|
|
expect(activeCast).toMatchObject({ abilityId: steadyShot.id, targetId: PARTY_TARGET });
|
|
expect(activeCast?.completesAt).toBe(startAt + steadyShot.castTimeMs);
|
|
advanceDungeonPartyCombat(activeCast!.completesAt);
|
|
|
|
const event = partyDamageEvent(PARTY_TARGET, member.id, steadyShot.id);
|
|
expect(event).toMatchObject({ critical: true, outcome: "crit", blocked: true, blockedAmount: 15 });
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
|
outcome: "crit",
|
|
critical: true,
|
|
blocked: true,
|
|
blockedAmount: 15,
|
|
postHitBlockChancePercent: 9,
|
|
});
|
|
});
|
|
|
|
it("keeps source magic spells outside physical NPC blocking", () => {
|
|
preparePlayer("mage", PLAYER_TARGET, [0, 0, -10]);
|
|
const fireball = resolveAbilityAtLevel(abilityFor("mage", "Fireball"), 30);
|
|
expect(fireball.wowSpellDefense).toMatchObject({ damageClass: 1, schoolMask: 4, directDamageFromEffects: true });
|
|
expect(creatureTemplateReference(SOURCE_ENTRY)!.schoolImmunities).not.toContain("fire");
|
|
const fireballAt = nextPlayerCriticalTime(fireball.id, PLAYER_TARGET);
|
|
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, fireballAt - 1);
|
|
const fireballCast = useCombatStore.getState().castAbility(fireball.id, {
|
|
now: fireballAt,
|
|
targetActorId: PLAYER_TARGET,
|
|
});
|
|
expect(fireballCast, JSON.stringify(fireballCast)).toMatchObject({ ok: true });
|
|
completePlayerCastIfNeeded(fireballAt);
|
|
|
|
const fireballEvent = playerDamageEvent(PLAYER_TARGET, fireball.id);
|
|
expect(fireballEvent).toMatchObject({ school: "fire", critical: true, outcome: "crit", blocked: false });
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ landed: true, blocked: false });
|
|
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
|
|
|
preparePlayer("hunter", PLAYER_TARGET, [0, 0, -10]);
|
|
const arcaneShot = resolveAbilityAtLevel(abilityFor("hunter", "Arcane Shot"), 30);
|
|
expect(arcaneShot.wowSpellDefense).toMatchObject({ damageClass: 3, schoolMask: 64, directDamageFromEffects: true });
|
|
const arcaneShotAt = nextPlayerCriticalTime(arcaneShot.id, PLAYER_TARGET);
|
|
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, arcaneShotAt - 1);
|
|
const arcaneShotCast = useCombatStore.getState().castAbility(arcaneShot.id, {
|
|
now: arcaneShotAt,
|
|
targetActorId: PLAYER_TARGET,
|
|
});
|
|
expect(arcaneShotCast, JSON.stringify(arcaneShotCast)).toMatchObject({ ok: true });
|
|
completePlayerCastIfNeeded(arcaneShotAt);
|
|
|
|
const arcaneShotEvent = playerDamageEvent(PLAYER_TARGET, arcaneShot.id);
|
|
expect(arcaneShotEvent).toMatchObject({ critical: true, outcome: "crit", blocked: false });
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ landed: true, blocked: false });
|
|
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
|
});
|
|
|
|
it("requires the actual selected DBC rank and fails closed for unknown rank IDs", () => {
|
|
preparePlayer("warrior", PLAYER_TARGET);
|
|
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
|
const higherRank = heroicStrike.ranks?.at(-1);
|
|
expect(higherRank).toBeDefined();
|
|
expect(higherRank?.spellId).not.toBe(heroicStrike.dbcSpellId);
|
|
const validSeed = [...Array(20_000)].map((_, index) => `higher-rank:${index}`)
|
|
.find((seed) => stableCombatUnit(`${seed}:block`) < 0.05);
|
|
if (!validSeed) throw new Error("Could not find deterministic higher-rank block seed.");
|
|
const validRank = useCombatStore.getState().damageMob(
|
|
PLAYER_TARGET,
|
|
1_000,
|
|
50_000,
|
|
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: heroicStrike.id },
|
|
"physical",
|
|
{
|
|
attackKind: "melee",
|
|
abilityId: heroicStrike.id,
|
|
dbcSpellId: higherRank!.spellId,
|
|
seed: validSeed,
|
|
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, ignoreArmor: true },
|
|
},
|
|
);
|
|
expect(validRank.combatResult).toMatchObject({ blocked: true, blockedAmount: 15, postHitBlockChancePercent: 5 });
|
|
|
|
const unknown = useCombatStore.getState().damageMob(
|
|
PLAYER_TARGET,
|
|
1_000,
|
|
50_001,
|
|
{ actorId: PLAYER_AGGRO_ID, role: "damage", abilityId: heroicStrike.id },
|
|
"physical",
|
|
{
|
|
attackKind: "melee",
|
|
abilityId: heroicStrike.id,
|
|
dbcSpellId: 9_999_999,
|
|
seed: "unknown-selected-rank",
|
|
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, ignoreArmor: true },
|
|
},
|
|
);
|
|
expect(unknown.combatResult?.blocked).toBe(false);
|
|
expect(unknown.combatResult?.postHitBlockChancePercent).toBeUndefined();
|
|
});
|
|
|
|
it("does not allow an attacker behind the source NPC to enter its spell block roll", () => {
|
|
preparePlayer("warrior", PLAYER_TARGET, BACK_POSITION, Math.PI);
|
|
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
|
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, PLAYER_TARGET, "direct");
|
|
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
|
|
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: PLAYER_TARGET });
|
|
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
|
completePlayerCastIfNeeded(now);
|
|
|
|
const event = playerDamageEvent(PLAYER_TARGET, heroicStrike.id);
|
|
expect(event).toMatchObject({ critical: true, blocked: false });
|
|
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
|
});
|
|
|
|
it("does not allow a source NPC actively casting or controlled to block the physical spell", () => {
|
|
for (const gate of ["casting", "controlled"] as const) {
|
|
const targetId = `${PLAYER_TARGET}-${gate}`;
|
|
preparePlayer("warrior", targetId);
|
|
const heroicStrike = abilityFor("warrior", "Heroic Strike");
|
|
const now = nextCriticalBlockedTime(PLAYER_AGGRO_ID, heroicStrike.id, targetId, "direct");
|
|
if (gate === "casting") {
|
|
setSelectedMobActiveCast(targetId, now);
|
|
} else {
|
|
useCombatStore.setState((state) => ({
|
|
mobs: {
|
|
...state.mobs,
|
|
[targetId]: {
|
|
...state.mobs[targetId]!,
|
|
statuses: [{ kind: "stun", sourceAbilityId: "synthetic-spell-block-gate", endsAt: now + 5_000 }],
|
|
},
|
|
},
|
|
}));
|
|
}
|
|
applyGuaranteedCriticalAura(PLAYER_AGGRO_ID, now - 1);
|
|
const cast = useCombatStore.getState().castAbility(heroicStrike.id, { now, targetActorId: targetId });
|
|
expect(cast, JSON.stringify(cast)).toMatchObject({ ok: true });
|
|
completePlayerCastIfNeeded(now);
|
|
|
|
const event = playerDamageEvent(targetId, heroicStrike.id);
|
|
expect(event).toMatchObject({ critical: true, blocked: false });
|
|
expect(useCombatStore.getState().lastCombatResult?.postHitBlockChancePercent).toBeUndefined();
|
|
}
|
|
});
|
|
});
|