Files
healer-man/src/game/romScoutPriestScreenAttack.integration.test.ts
T
2026-10-02 10:38:12 -04:00

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);
});
});