274 lines
13 KiB
TypeScript
274 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AGGRO_ID } from "./aggro";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { resetCombatSpatialProvider, registerCombatSpatialProvider } from "./combatSpatial";
|
|
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { commitPartyAbilityUse, partyAbilityIsReady } from "./partyAbilityAi";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
|
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
|
import {
|
|
ROM_SCOUT_PRIEST_SCREEN_ATTACK_AURA,
|
|
ROM_SCOUT_PRIEST_SCREEN_ATTACK_ID,
|
|
} from "./romScoutPriestScreenAttack";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
|
|
const SCREEN_ATTACK = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_SCREEN_ATTACK_ID);
|
|
const SCOUT_PERIODIC_ABILITY = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-scout-primary-5");
|
|
const SCREEN_ATTACK_ID = "rom-scout-rom-priest-elite-70";
|
|
const DIRECT_TEST_ABILITY_ID = "rom-screen-attack-direct-fixture";
|
|
const PERIODIC_TEST_ABILITY_ID = "rom-screen-attack-periodic-fixture";
|
|
const DIRECT_TEST_TARGET = "screen-attack-direct-target";
|
|
const DOT_TEST_TARGET = "screen-attack-dot-target";
|
|
const EXPIRY_TEST_TARGET = "screen-attack-expiry-target";
|
|
|
|
function makePriest(name: string, seed: string, index: number, health?: number) {
|
|
const member = createConfiguredPartyMember({
|
|
name,
|
|
classId: "rom-priest",
|
|
specialization: "Primary",
|
|
role: "damage",
|
|
}, 80, index, seed);
|
|
return health === undefined ? member : { ...member, health };
|
|
}
|
|
|
|
function configurePlayerAndParty(seed: string, includeCompanionCaster = false) {
|
|
useCombatStore.getState().initializeCharacter({
|
|
classId: "rom-scout",
|
|
secondaryClassId: "rom-priest",
|
|
raceId: "rom-human",
|
|
level: 80,
|
|
role: "damage",
|
|
});
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty(
|
|
seed, 80, useCombatStore.getState().health, "damage", 5, "rom",
|
|
);
|
|
|
|
const partyCaster = includeCompanionCaster
|
|
? createConfiguredPartyMember({
|
|
name: "Scout/Priest companion",
|
|
classId: "rom-scout",
|
|
secondaryClassId: "rom-priest",
|
|
specialization: "Primary",
|
|
role: "damage",
|
|
}, 80, 0, seed)
|
|
: null;
|
|
const roster = [
|
|
...(partyCaster ? [partyCaster] : []),
|
|
makePriest("Inclusive-radius edge", seed, 1),
|
|
makePriest("Outside-radius ally", seed, 2),
|
|
makePriest("Fallen ally", seed, 3, 0),
|
|
makePriest("Blocked ally", seed, 4),
|
|
makePriest("Ally without position", seed, 5),
|
|
];
|
|
usePartyStore.setState({ members: roster });
|
|
for (const member of roster) {
|
|
if (member.name === "Inclusive-radius edge") registerPartyRuntimePosition(member.id, [9, 12, 0]);
|
|
if (member.name === "Outside-radius ally") registerPartyRuntimePosition(member.id, [15.01, 0, 0]);
|
|
if (member.name === "Fallen ally") registerPartyRuntimePosition(member.id, [1, 0, 0]);
|
|
if (member.name === "Blocked ally") registerPartyRuntimePosition(member.id, [3, 0, 0]);
|
|
if (member.name === "Scout/Priest companion") registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
}
|
|
registerCombatSpatialProvider({
|
|
actor: () => null,
|
|
hasLineOfSight: (_source, target) => target[0] !== 3,
|
|
projectGroundPoint: (position) => position,
|
|
});
|
|
useCombatStore.getState().synchronizeActors();
|
|
for (const id of [DIRECT_TEST_TARGET, DOT_TEST_TARGET, EXPIRY_TEST_TARGET]) {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
level: 1,
|
|
maxHealth: 1_000_000,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition(id, [2, 0, 0]);
|
|
}
|
|
return {
|
|
partyCaster,
|
|
edge: roster.find((member) => member.name === "Inclusive-radius edge")!,
|
|
outside: roster.find((member) => member.name === "Outside-radius ally")!,
|
|
fallen: roster.find((member) => member.name === "Fallen ally")!,
|
|
blocked: roster.find((member) => member.name === "Blocked ally")!,
|
|
unpositioned: roster.find((member) => member.name === "Ally without position")!,
|
|
};
|
|
}
|
|
|
|
function applyScreenAttackFromCompanion(casterId: string, now: number): void {
|
|
const member = usePartyStore.getState().members.find((candidate) => candidate.id === casterId);
|
|
if (!member || !SCREEN_ATTACK) throw new Error("Missing Scout/Priest companion or Screen Attack");
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((candidate) => candidate.id === casterId
|
|
? {
|
|
...candidate,
|
|
activeCast: {
|
|
abilityId: SCREEN_ATTACK_ID,
|
|
school: "physical",
|
|
mode: "cast",
|
|
startedAt: now,
|
|
completesAt: now,
|
|
originalDurationMs: 0,
|
|
pushbackHits: 0,
|
|
origin: [0, 0, 0],
|
|
targetId: casterId,
|
|
},
|
|
activeCastTargetId: casterId,
|
|
status: "attacking",
|
|
}
|
|
: candidate),
|
|
}));
|
|
useCombatStore.getState().synchronizeActors();
|
|
const syncedMember = usePartyStore.getState().members.find((candidate) => candidate.id === casterId);
|
|
const committed = syncedMember ? commitPartyAbilityUse(syncedMember, SCREEN_ATTACK, now) : null;
|
|
if (!committed) throw new Error("Companion could not commit Screen Attack before casting");
|
|
usePartyStore.setState((state) => ({
|
|
members: state.members.map((candidate) => candidate.id === casterId
|
|
? {
|
|
...committed,
|
|
activeCast: candidate.activeCast,
|
|
activeCastTargetId: candidate.activeCastTargetId,
|
|
}
|
|
: candidate),
|
|
}));
|
|
useCombatStore.getState().commitCompanionActors([committed]);
|
|
advanceDungeonPartyCombat(now);
|
|
}
|
|
|
|
function directDamage(sourceActorId: string, targetId: string, now: number): number {
|
|
return useCombatStore.getState().damageMob(
|
|
targetId,
|
|
100,
|
|
now,
|
|
{ actorId: sourceActorId, role: "damage", abilityId: DIRECT_TEST_ABILITY_ID },
|
|
"physical",
|
|
{
|
|
attackKind: "spell",
|
|
seed: "rom-screen-attack-fixed-direct-hit",
|
|
rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false },
|
|
},
|
|
).amount;
|
|
}
|
|
|
|
function applyAndResolvePeriodicDamage(sourceActorId: string, targetId: string, now: number): number {
|
|
if (!SCOUT_PERIODIC_ABILITY) throw new Error("Missing Scout Piercing Arrow test ability");
|
|
const testAbility = { ...SCOUT_PERIODIC_ABILITY, id: PERIODIC_TEST_ABILITY_ID };
|
|
const effect = {
|
|
kind: "dot",
|
|
coefficient: 0,
|
|
basePoints: 100,
|
|
school: "physical",
|
|
ticks: 1,
|
|
intervalMs: 1_000,
|
|
tag: "screen-attack-integration-dot",
|
|
} as const;
|
|
if (!useCombatStore.getState().applyTimedActorEffect(
|
|
sourceActorId, targetId, testAbility, effect, now,
|
|
)) throw new Error("Could not apply the periodic damage test effect");
|
|
const pending = useCombatStore.getState().effects.find((candidate) => candidate.kind === "dot"
|
|
&& candidate.sourceAbilityId === PERIODIC_TEST_ABILITY_ID && candidate.targetId === targetId);
|
|
if (!pending || pending.kind !== "dot") throw new Error("Periodic damage test effect was not scheduled");
|
|
const tickAt = pending.nextTickAt;
|
|
const seconds = Math.max(0, (tickAt - now) / 1_000);
|
|
useCombatStore.getState().tick(seconds, tickAt);
|
|
const matchingEvents = useCombatStore.getState().combatEvents.filter((candidate) => candidate.kind === "damage"
|
|
&& candidate.sourceActorId === sourceActorId
|
|
&& candidate.targetActorId === targetId
|
|
&& candidate.abilityId === PERIODIC_TEST_ABILITY_ID);
|
|
const event = matchingEvents[matchingEvents.length - 1];
|
|
return event?.kind === "damage" ? event.effectiveAmount : 0;
|
|
}
|
|
|
|
function screenAura(targetId: string) {
|
|
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
|
|
&& aura.definition.id === ROM_SCOUT_PRIEST_SCREEN_ATTACK_AURA.id);
|
|
}
|
|
|
|
describe("CoA Scout/Priest Screen Attack runtime integration", () => {
|
|
beforeEach(() => {
|
|
resetPartyRuntimeClock();
|
|
resetCombatSpatialProvider();
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
useDungeonSimulationStore.setState({ sessionMode: "play" });
|
|
});
|
|
|
|
afterEach(() => resetCombatSpatialProvider());
|
|
|
|
it("activates for the player, selects only living nearby visible party actors, scales direct and periodic damage, expires, and observes cooldown", () => {
|
|
if (!SCREEN_ATTACK) throw new Error("Screen Attack (499555) is missing from the generated catalog");
|
|
if (!SCOUT_PERIODIC_ABILITY) throw new Error("Scout periodic fixture ability is missing");
|
|
const recipients = configurePlayerAndParty("screen-attack-player-runtime");
|
|
|
|
const baselineDirect = directDamage(PLAYER_AGGRO_ID, DIRECT_TEST_TARGET, 1_000);
|
|
const baselinePeriodic = applyAndResolvePeriodicDamage(PLAYER_AGGRO_ID, DOT_TEST_TARGET, 1_000);
|
|
expect(baselineDirect).toBeGreaterThan(0);
|
|
expect(baselinePeriodic).toBeGreaterThan(0);
|
|
|
|
expect(useCombatStore.getState().castAbility(SCREEN_ATTACK.id, undefined, 3_000)).toMatchObject({ ok: true });
|
|
expect(screenAura(PLAYER_AGGRO_ID)?.expiresAt).toBe(23_000);
|
|
expect(screenAura(recipients.edge.id)?.expiresAt).toBe(23_000);
|
|
expect(screenAura(recipients.outside.id)).toBeUndefined();
|
|
expect(screenAura(recipients.fallen.id)).toBeUndefined();
|
|
expect(screenAura(recipients.blocked.id)).toBeUndefined();
|
|
expect(screenAura(recipients.unpositioned.id)).toBeUndefined();
|
|
|
|
const buffedDirect = directDamage(PLAYER_AGGRO_ID, DIRECT_TEST_TARGET, 4_000);
|
|
const buffedPeriodic = applyAndResolvePeriodicDamage(PLAYER_AGGRO_ID, DOT_TEST_TARGET, 4_000);
|
|
expect(buffedDirect / baselineDirect).toBeCloseTo(1 + 0.174, 1);
|
|
expect(buffedPeriodic / baselinePeriodic).toBeCloseTo(1 + 0.174, 1);
|
|
|
|
const beforeExpiry = directDamage(PLAYER_AGGRO_ID, EXPIRY_TEST_TARGET, 22_999);
|
|
const atExpiry = directDamage(PLAYER_AGGRO_ID, EXPIRY_TEST_TARGET, 23_000);
|
|
expect(beforeExpiry / baselineDirect).toBeCloseTo(1 + 0.174, 1);
|
|
expect(atExpiry).toBe(baselineDirect);
|
|
|
|
expect(useCombatStore.getState().cooldowns[SCREEN_ATTACK.id]).toBe(63_000);
|
|
expect(useCombatStore.getState().castAbility(SCREEN_ATTACK.id, undefined, 62_999)).toMatchObject({
|
|
ok: false,
|
|
reason: "cooldown",
|
|
});
|
|
expect(useCombatStore.getState().castAbility(SCREEN_ATTACK.id, undefined, 63_000)).toMatchObject({ ok: true });
|
|
});
|
|
|
|
it("activates for a generated Scout/Priest companion and applies its own direct and periodic damage bonuses to eligible recipients", () => {
|
|
if (!SCOUT_PERIODIC_ABILITY) throw new Error("Scout periodic fixture ability is missing");
|
|
const recipients = configurePlayerAndParty("screen-attack-companion-runtime", true);
|
|
const casterId = recipients.partyCaster?.id;
|
|
if (!casterId) throw new Error("Missing generated Scout/Priest caster");
|
|
|
|
const baselineDirect = directDamage(casterId, DIRECT_TEST_TARGET, 1_000);
|
|
const baselinePeriodic = applyAndResolvePeriodicDamage(casterId, DOT_TEST_TARGET, 1_000);
|
|
expect(baselineDirect).toBeGreaterThan(0);
|
|
expect(baselinePeriodic).toBeGreaterThan(0);
|
|
|
|
applyScreenAttackFromCompanion(casterId, 3_000);
|
|
expect(screenAura(casterId)?.expiresAt).toBe(23_000);
|
|
expect(screenAura(PLAYER_AGGRO_ID)?.expiresAt).toBe(23_000);
|
|
expect(screenAura(recipients.edge.id)?.expiresAt).toBe(23_000);
|
|
expect(screenAura(recipients.outside.id)).toBeUndefined();
|
|
expect(screenAura(recipients.fallen.id)).toBeUndefined();
|
|
expect(screenAura(recipients.blocked.id)).toBeUndefined();
|
|
expect(screenAura(recipients.unpositioned.id)).toBeUndefined();
|
|
|
|
const buffedDirect = directDamage(casterId, DIRECT_TEST_TARGET, 4_000);
|
|
const buffedPeriodic = applyAndResolvePeriodicDamage(casterId, DOT_TEST_TARGET, 4_000);
|
|
expect(buffedDirect / baselineDirect).toBeCloseTo(1 + 0.174, 1);
|
|
expect(buffedPeriodic / baselinePeriodic).toBeCloseTo(1 + 0.174, 1);
|
|
|
|
const beforeExpiry = directDamage(casterId, EXPIRY_TEST_TARGET, 22_999);
|
|
const atExpiry = directDamage(casterId, EXPIRY_TEST_TARGET, 23_000);
|
|
expect(beforeExpiry / baselineDirect).toBeCloseTo(1 + 0.174, 1);
|
|
expect(atExpiry).toBe(baselineDirect);
|
|
const committedCaster = usePartyStore.getState().members.find((member) => member.id === casterId);
|
|
expect(committedCaster?.cooldowns[SCREEN_ATTACK_ID]).toBe(63_000);
|
|
if (!committedCaster || !SCREEN_ATTACK) throw new Error("Screen Attack companion cooldown was not committed");
|
|
expect(partyAbilityIsReady(committedCaster, SCREEN_ATTACK, 62_999)).toBe(false);
|
|
expect(commitPartyAbilityUse(committedCaster, SCREEN_ATTACK, 62_999)).toBeNull();
|
|
expect(partyAbilityIsReady(committedCaster, SCREEN_ATTACK, 63_000)).toBe(true);
|
|
expect(commitPartyAbilityUse(committedCaster, SCREEN_ATTACK, 63_000)?.cooldowns[SCREEN_ATTACK_ID])
|
|
.toBe(123_000);
|
|
});
|
|
});
|