Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,518 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter } from "./abilityCatalog";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type ActiveCast } from "./combatStore";
|
||||
import { applyAura, resolveDamageValue, resolveStatValue } from "./combatAuras";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { romNativeGearReference } from "./romNativeGear";
|
||||
import { deriveCharacterStats, hastedDuration } from "./wotlkStats";
|
||||
import { nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
||||
import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs";
|
||||
import {
|
||||
ROM_SCOUT_AUTO_SHOT_ABILITY_ID,
|
||||
ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID,
|
||||
} from "./romScoutAutoShot";
|
||||
|
||||
const MAX_TARGET_HEALTH = 10_000_000;
|
||||
const AUTO_SHOT_ID = ROM_SCOUT_AUTO_SHOT_ABILITY_ID;
|
||||
const BLOOD_ARROW_ID = "rom-scout-general-4";
|
||||
|
||||
function registerTarget(id: string, x = 2): void {
|
||||
useCombatStore.getState().registerMob(id, {
|
||||
name: id,
|
||||
level: 1,
|
||||
maxHealth: MAX_TARGET_HEALTH,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(id, [x, 0, 0]);
|
||||
}
|
||||
|
||||
function rangedItem(instanceId: string, options: { native?: boolean; speedMs?: number } = {}) {
|
||||
const native = options.native ? romNativeGearReference("rom-scout", "ranged", 70) : null;
|
||||
if (options.native && !native) throw new Error("The native Scout ranged gear reference is missing.");
|
||||
return {
|
||||
instanceId,
|
||||
itemId: options.native ? 9_000_071 : 90_071,
|
||||
itemKey: instanceId,
|
||||
name: "Test Bow",
|
||||
quality: "rare" as const,
|
||||
inventorySlot: "ranged",
|
||||
armorType: null,
|
||||
weaponType: "bow",
|
||||
itemLevel: 70,
|
||||
sourceItemLevel: 70,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
rangedWeapon: {
|
||||
damageMin: options.native ? 7_000 : 500,
|
||||
damageMax: options.native ? 9_000 : 700,
|
||||
damageSchool: "physical" as const,
|
||||
speedMs: options.speedMs ?? (options.native ? 4_900 : 4_000),
|
||||
},
|
||||
...(native ? { romNative: native } : {}),
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "test-bow",
|
||||
sourceBossName: "Auto Shot fixture",
|
||||
acquiredAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeScout(item = rangedItem("test-bow"), statRuleset: "rom" | "wow" = "rom"): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-scout",
|
||||
secondaryClassId: "rom-rogue",
|
||||
raceId: "rom-human",
|
||||
statRuleset,
|
||||
level: 70,
|
||||
inventory: [item],
|
||||
equipment: { player: { ranged: item.instanceId } },
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
}
|
||||
|
||||
function initializeScoutWithEmptyInventory(secondaryClassId: "rom-rogue" | "rom-warrior" = "rom-rogue"): void {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-scout",
|
||||
secondaryClassId,
|
||||
raceId: "rom-human",
|
||||
statRuleset: "rom",
|
||||
level: 70,
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
}
|
||||
|
||||
function activateAutoShot(targetId: string, now = 1_000) {
|
||||
useCombatStore.getState().selectMob(targetId);
|
||||
return useCombatStore.getState().castAbility(AUTO_SHOT_ID, {
|
||||
targetActorId: targetId,
|
||||
origin: [0, 0, 0],
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
function autoShotEvents(targetId?: string) {
|
||||
return useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AURA_ENTITY_ID
|
||||
&& event.abilityId === AUTO_SHOT_ID
|
||||
&& (!targetId || event.targetActorId === targetId));
|
||||
}
|
||||
|
||||
function giveRangedHaste(value = 50, stat = "ranged-haste"): void {
|
||||
const result = applyAura(useCombatStore.getState().auras, {
|
||||
definition: {
|
||||
id: `test-ranged-haste-${stat}`,
|
||||
name: "Test ranged haste",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
modifiers: [{ kind: "stat", stat, operation: "flat", value }],
|
||||
},
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now: 1_000,
|
||||
});
|
||||
useCombatStore.setState({ auras: result.auras });
|
||||
}
|
||||
|
||||
function giveBloodArrowAura(now = 900): void {
|
||||
const state = useCombatStore.getState();
|
||||
const ability = state.abilities.find((candidate) => candidate.id === BLOOD_ARROW_ID);
|
||||
const effect = ability?.effects.find((candidate) => candidate.kind === "apply-aura"
|
||||
&& candidate.recipient === "caster"
|
||||
&& candidate.aura.utilityTags?.includes("rom-self-health-drain"));
|
||||
if (!ability || effect?.kind !== "apply-aura") throw new Error("Scout Blood Arrow aura was missing.");
|
||||
const result = applyAura(state.auras, {
|
||||
definition: effect.aura,
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
now,
|
||||
});
|
||||
useCombatStore.setState({ auras: result.auras });
|
||||
}
|
||||
|
||||
function setSpatialProvider(options: { yaw?: number; visible?: boolean } = {}): void {
|
||||
registerCombatSpatialProvider({
|
||||
actor: (id) => id === PLAYER_AURA_ENTITY_ID
|
||||
? { id, position: useCombatStore.getState().playerPosition, yaw: options.yaw ?? Math.PI / 2 }
|
||||
: { id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 },
|
||||
hasLineOfSight: () => options.visible ?? true,
|
||||
projectGroundPoint: (point) => point,
|
||||
});
|
||||
}
|
||||
|
||||
function activeCastFixture(targetId: string, now: number): ActiveCast {
|
||||
return {
|
||||
id: 1,
|
||||
abilityId: "rom-scout-primary-1",
|
||||
school: "physical",
|
||||
mode: "cast",
|
||||
startedAt: now,
|
||||
completesAt: now + 1_000,
|
||||
originalDurationMs: 1_000,
|
||||
pushbackHits: 0,
|
||||
canCastWhileMoving: false,
|
||||
origin: [0, 0, 0],
|
||||
targetId,
|
||||
groundPosition: null,
|
||||
totalTicks: 1,
|
||||
ticksCompleted: 0,
|
||||
nextTickAt: now + 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Scout/Rogue Auto Shot player integration", () => {
|
||||
beforeEach(() => {
|
||||
resetCombatSpatialProvider();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 70 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("rom-auto-shot", 70, useCombatStore.getState().health, "damage", 1);
|
||||
});
|
||||
|
||||
it("learns only the Scout Primary skill and exposes a plain toggle tooltip", () => {
|
||||
const scoutRogue = abilitiesForCharacter("rom-scout", "rom-rogue");
|
||||
const autoShot = scoutRogue.find((ability) => ability.id === AUTO_SHOT_ID);
|
||||
expect(autoShot).toMatchObject({
|
||||
dbcSpellId: ROM_SCOUT_AUTO_SHOT_SOURCE_SKILL_ID,
|
||||
source: "runewaker",
|
||||
classId: "rom-scout",
|
||||
sourceAttackKind: "ranged",
|
||||
target: "hostile",
|
||||
range: { min: 0, max: 6 },
|
||||
targeting: { shape: "unit", requiresFacing: true, requiresLineOfSight: true },
|
||||
});
|
||||
expect(autoShot?.secondaryClassId).toBeUndefined();
|
||||
expect(autoShot?.description).toContain("Use this ability again to stop Auto Shot");
|
||||
expect(autoShot?.ranks).toHaveLength(1);
|
||||
expect(abilitiesForCharacter("rom-knight", "rom-scout").some((ability) => ability.id === AUTO_SHOT_ID)).toBe(false);
|
||||
initializeScout();
|
||||
expect(useCombatStore.getState().abilities.some((ability) => ability.id === AUTO_SHOT_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["haste", "ranged-haste", "attack-speed"])(
|
||||
"uses native ranged DPS and cadence independently of the compatibility budget (%s)", (hasteStat) => {
|
||||
const item = rangedItem("native-test-bow", { native: true, speedMs: 4_900 });
|
||||
initializeScout(item, "rom");
|
||||
registerTarget("native-shot-target");
|
||||
giveRangedHaste(50, hasteStat);
|
||||
const state = useCombatStore.getState();
|
||||
const derived = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
|
||||
{}, state.secondaryClassId, state.gender, state.statRuleset);
|
||||
const nativeAuto = nativeTrainingAutoAttackProfile(derived.romNative, "ranged");
|
||||
expect(nativeAuto).toBeDefined();
|
||||
if (!nativeAuto) throw new Error("Native ranged Auto Attack profile was unsupported.");
|
||||
const expectedDamage = Math.max(1, Math.round(nativeAuto.damage / (nativeAuto.intervalMs / 1_000) * 0.8));
|
||||
const expectedHaste = ["haste", "ranged-haste", "attack-speed"].reduce(
|
||||
(current, stat) => resolveStatValue(current, useCombatStore.getState().auras, stat),
|
||||
derived.rangedHastePercent,
|
||||
);
|
||||
const expectedInterval = Math.max(500, Math.round(
|
||||
hastedDuration(nativeAuto.intervalMs, expectedHaste) / raceAffixHasteMultiplier(1_000),
|
||||
));
|
||||
const compatibilityDps = ((item.stats.rangedWeapon!.damageMin + item.stats.rangedWeapon!.damageMax) / 2)
|
||||
/ (item.stats.rangedWeapon!.speedMs / 1_000);
|
||||
expect(Math.round(compatibilityDps * 0.8)).not.toBe(expectedDamage);
|
||||
|
||||
expect(activateAutoShot("native-shot-target")).toMatchObject({ ok: true, damage: 0 });
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBe(1_000 + expectedInterval);
|
||||
expect(useCombatStore.getState().mobs["native-shot-target"]?.health).toBe(MAX_TARGET_HEALTH);
|
||||
const due = useCombatStore.getState().nextAutoShotAt;
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
const expectedModifiedDamage = Math.round(resolveDamageValue(expectedDamage, useCombatStore.getState().auras, {
|
||||
direction: "dealt",
|
||||
school: "physical",
|
||||
attackKind: "ranged",
|
||||
}));
|
||||
expect(autoShotEvents("native-shot-target")).toContainEqual(expect.objectContaining({
|
||||
occurredAt: due,
|
||||
rawAmount: expectedModifiedDamage,
|
||||
school: "physical",
|
||||
}));
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBe(due + expectedInterval);
|
||||
});
|
||||
|
||||
it("uses compatibility ranged DPS and haste when the native training profile is unsupported", () => {
|
||||
const item = rangedItem("custom-test-bow", { speedMs: 4_000 });
|
||||
initializeScout(item, "wow");
|
||||
registerTarget("compat-shot-target");
|
||||
giveRangedHaste();
|
||||
const state = useCombatStore.getState();
|
||||
const derived = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
|
||||
{}, state.secondaryClassId, state.gender, state.statRuleset);
|
||||
const effectiveHaste = ["haste", "ranged-haste", "attack-speed"].reduce(
|
||||
(current, stat) => resolveStatValue(current, useCombatStore.getState().auras, stat),
|
||||
derived.rangedHastePercent,
|
||||
);
|
||||
const expectedDamage = Math.max(1, Math.round(
|
||||
((item.stats.rangedWeapon!.damageMin + item.stats.rangedWeapon!.damageMax) / 2)
|
||||
/ (item.stats.rangedWeapon!.speedMs / 1_000) * 0.8,
|
||||
));
|
||||
const expectedInterval = Math.max(500, Math.round(
|
||||
hastedDuration(item.stats.rangedWeapon!.speedMs, effectiveHaste)
|
||||
/ raceAffixHasteMultiplier(1_000),
|
||||
));
|
||||
expect(activateAutoShot("compat-shot-target")).toMatchObject({ ok: true, damage: 0 });
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBe(1_000 + expectedInterval);
|
||||
const due = useCombatStore.getState().nextAutoShotAt;
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
const expectedModifiedDamage = Math.round(resolveDamageValue(expectedDamage, useCombatStore.getState().auras, {
|
||||
direction: "dealt",
|
||||
school: "physical",
|
||||
attackKind: "ranged",
|
||||
}));
|
||||
expect(autoShotEvents("compat-shot-target")).toContainEqual(expect.objectContaining({
|
||||
occurredAt: due,
|
||||
rawAmount: expectedModifiedDamage,
|
||||
school: "physical",
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses the automatically supplied Scout Training Gear bow with an empty inventory", () => {
|
||||
initializeScoutWithEmptyInventory();
|
||||
expect(useCombatStore.getState().inventory).toHaveLength(0);
|
||||
expect(useCombatStore.getState().equipment.player?.ranged).toBeUndefined();
|
||||
registerTarget("automatic-training-bow-target");
|
||||
expect(activateAutoShot("automatic-training-bow-target")).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().autoShotTargetId).toBe("automatic-training-bow-target");
|
||||
const due = useCombatStore.getState().nextAutoShotAt;
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents("automatic-training-bow-target")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("falls back to the automatic training bow when a custom ranged item is unequipped", () => {
|
||||
initializeScout(rangedItem("unequipped-custom-bow", { speedMs: 4_000 }), "wow");
|
||||
registerTarget("unequipped-custom-bow-target");
|
||||
expect(activateAutoShot("unequipped-custom-bow-target")).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().unequipItem("player", "ranged")).toBe(true);
|
||||
expect(useCombatStore.getState().equipment.player?.ranged).toBeUndefined();
|
||||
const due = useCombatStore.getState().nextAutoShotAt;
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents("unequipped-custom-bow-target")).toHaveLength(1);
|
||||
expect(useCombatStore.getState().autoShotTargetId).toBe("unequipped-custom-bow-target");
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBeGreaterThan(due);
|
||||
});
|
||||
|
||||
it("retains represented haste when unsupported native gear uses the compatibility weapon fallback", () => {
|
||||
const sourceItem = rangedItem("custom-native-test-bow", { native: true, speedMs: 4_300 });
|
||||
const sourceNative = sourceItem.stats.romNative!;
|
||||
const customItem = {
|
||||
...sourceItem,
|
||||
stats: { ...sourceItem.stats, romNative: {
|
||||
...sourceNative,
|
||||
physicalAttack: (sourceNative.physicalAttack ?? 0) + 1,
|
||||
} },
|
||||
};
|
||||
initializeScout(customItem, "rom");
|
||||
registerTarget("custom-native-shot-target");
|
||||
giveRangedHaste(50, "attack-speed");
|
||||
const state = useCombatStore.getState();
|
||||
const derived = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
|
||||
{}, state.secondaryClassId, state.gender, state.statRuleset);
|
||||
expect(nativeTrainingAutoAttackProfile(derived.romNative, "ranged")).toBeUndefined();
|
||||
const effectiveHaste = ["haste", "ranged-haste", "attack-speed"].reduce(
|
||||
(current, stat) => resolveStatValue(current, state.auras, stat),
|
||||
derived.rangedHastePercent,
|
||||
);
|
||||
const expectedInterval = Math.max(500, Math.round(
|
||||
hastedDuration(customItem.stats.rangedWeapon!.speedMs, effectiveHaste)
|
||||
/ raceAffixHasteMultiplier(1_000),
|
||||
));
|
||||
expect(activateAutoShot("custom-native-shot-target")).toMatchObject({ ok: true });
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBe(1_000 + expectedInterval);
|
||||
});
|
||||
|
||||
it("fires repeatedly on schedule without direct activation damage or parallel melee/offhand swings", () => {
|
||||
initializeScout();
|
||||
registerTarget("repeat-shot-target");
|
||||
giveBloodArrowAura();
|
||||
useCombatStore.setState({
|
||||
autoAttackTargetId: "repeat-shot-target",
|
||||
nextAutoAttackAt: 1_000,
|
||||
nextOffhandAutoAttackAt: 1_000,
|
||||
});
|
||||
expect(activateAutoShot("repeat-shot-target")).toMatchObject({ ok: true, damage: 0 });
|
||||
expect(useCombatStore.getState()).toMatchObject({
|
||||
autoShotTargetId: "repeat-shot-target",
|
||||
autoAttackTargetId: null,
|
||||
nextAutoAttackAt: 0,
|
||||
nextOffhandAutoAttackAt: 0,
|
||||
});
|
||||
expect(autoShotEvents()).toHaveLength(0);
|
||||
const resource = useCombatStore.getState().resource;
|
||||
const firstDue = useCombatStore.getState().nextAutoShotAt;
|
||||
const interval = firstDue - 1_000;
|
||||
useCombatStore.getState().tick(0.1, firstDue - 1);
|
||||
expect(autoShotEvents()).toHaveLength(0);
|
||||
useCombatStore.getState().tick(0.1, firstDue);
|
||||
expect(autoShotEvents()).toHaveLength(1);
|
||||
useCombatStore.getState().tick(0.1, firstDue + interval - 1);
|
||||
expect(autoShotEvents()).toHaveLength(1);
|
||||
useCombatStore.getState().tick(0.1, firstDue + interval);
|
||||
expect(autoShotEvents()).toHaveLength(2);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AURA_ENTITY_ID
|
||||
&& ["player-auto-attack", "player-offhand-auto-attack"].includes(event.abilityId ?? ""))).toBe(false);
|
||||
expect(useCombatStore.getState().resource).toBe(resource);
|
||||
|
||||
const healthAfterSecondShot = useCombatStore.getState().health;
|
||||
const thirdDue = firstDue + interval * 2;
|
||||
const stopped = useCombatStore.getState().castAbility(AUTO_SHOT_ID, {
|
||||
targetActorId: "repeat-shot-target",
|
||||
origin: [0, 0, 0],
|
||||
now: thirdDue,
|
||||
});
|
||||
expect(stopped).toMatchObject({ ok: true, damage: 0 });
|
||||
expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 });
|
||||
|
||||
// The old third-shot deadline passes with the owner's mode stopped: no shot or Blood Arrow cost.
|
||||
useCombatStore.getState().tick(0.1, thirdDue);
|
||||
expect(autoShotEvents()).toHaveLength(2);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThanOrEqual(healthAfterSecondShot);
|
||||
|
||||
// Reusing the ordinary ability after the initial GCD starts a fresh cadence for the same target.
|
||||
const restartAt = thirdDue + 1_500;
|
||||
expect(activateAutoShot("repeat-shot-target", restartAt)).toMatchObject({ ok: true, damage: 0 });
|
||||
const restartDue = useCombatStore.getState().nextAutoShotAt;
|
||||
expect(restartDue).toBe(restartAt + interval);
|
||||
expect(autoShotEvents()).toHaveLength(2);
|
||||
useCombatStore.getState().tick(0.1, restartDue - 1);
|
||||
expect(autoShotEvents()).toHaveLength(2);
|
||||
useCombatStore.getState().tick(0.1, restartDue);
|
||||
expect(autoShotEvents()).toHaveLength(3);
|
||||
expect(autoShotEvents("repeat-shot-target").at(-1)?.occurredAt).toBe(restartDue);
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBe(restartDue + interval);
|
||||
});
|
||||
|
||||
it("charges Blood Arrow once per due shot, skips a lethal cost, and lets the owner stop in any state", () => {
|
||||
initializeScout();
|
||||
registerTarget("paid-shot-target");
|
||||
giveBloodArrowAura();
|
||||
const startHealth = useCombatStore.getState().health;
|
||||
expect(activateAutoShot("paid-shot-target")).toMatchObject({ ok: true });
|
||||
const firstDue = useCombatStore.getState().nextAutoShotAt;
|
||||
const cost = Math.round(useCombatStore.getState().maxHealth * 0.02);
|
||||
useCombatStore.getState().tick(0.1, firstDue);
|
||||
expect(useCombatStore.getState().health).toBe(startHealth - cost);
|
||||
expect(autoShotEvents("paid-shot-target")).toHaveLength(1);
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AURA_ENTITY_ID && event.targetActorId === PLAYER_AURA_ENTITY_ID)).toHaveLength(0);
|
||||
const secondDue = useCombatStore.getState().nextAutoShotAt;
|
||||
useCombatStore.setState({ health: 1 });
|
||||
useCombatStore.getState().tick(0.1, secondDue);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(0);
|
||||
expect(autoShotEvents("paid-shot-target")).toHaveLength(1);
|
||||
expect(useCombatStore.getState().autoShotTargetId).toBe("paid-shot-target");
|
||||
|
||||
setMobPosition("paid-shot-target", [20, 0, 0]);
|
||||
setSpatialProvider({ yaw: -Math.PI / 2, visible: false });
|
||||
useCombatStore.setState({ activeCast: activeCastFixture("paid-shot-target", secondDue) });
|
||||
expect(useCombatStore.getState().castAbility(AUTO_SHOT_ID, { now: secondDue + 1 })).toMatchObject({
|
||||
ok: true,
|
||||
damage: 0,
|
||||
});
|
||||
expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 });
|
||||
expect(useCombatStore.getState().activeCast?.abilityId).toBe("rom-scout-primary-1");
|
||||
});
|
||||
|
||||
it("blocks scheduled shots for range, line of sight, facing, control, or casting without draining health", () => {
|
||||
initializeScout();
|
||||
registerTarget("blocked-shot-target");
|
||||
giveBloodArrowAura();
|
||||
expect(activateAutoShot("blocked-shot-target")).toMatchObject({ ok: true });
|
||||
const due = useCombatStore.getState().nextAutoShotAt;
|
||||
const startHealth = useCombatStore.getState().health;
|
||||
setSpatialProvider({ yaw: -Math.PI / 2, visible: false });
|
||||
setMobPosition("blocked-shot-target", [20, 0, 0]);
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents()).toHaveLength(0);
|
||||
expect(useCombatStore.getState().health).toBe(startHealth);
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBe(due);
|
||||
|
||||
setMobPosition("blocked-shot-target", [2, 0, 0]);
|
||||
setSpatialProvider({ yaw: -Math.PI / 2, visible: false });
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents()).toHaveLength(0);
|
||||
setSpatialProvider({ yaw: -Math.PI / 2, visible: true });
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents()).toHaveLength(0);
|
||||
setSpatialProvider({ yaw: Math.PI / 2, visible: true });
|
||||
useCombatStore.setState({ controlledUntil: due + 100, controlMechanic: "stun" });
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents()).toHaveLength(0);
|
||||
useCombatStore.setState({
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
activeCast: activeCastFixture("blocked-shot-target", due),
|
||||
});
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents()).toHaveLength(0);
|
||||
expect(useCombatStore.getState().health).toBe(startHealth);
|
||||
});
|
||||
|
||||
it("follows target selection, remains active through movement, and clears on deselection, death, and reset", () => {
|
||||
initializeScoutWithEmptyInventory("rom-warrior");
|
||||
registerTarget("unsupported-scout-secondary-target");
|
||||
expect(useCombatStore.getState().castAbility(AUTO_SHOT_ID, {
|
||||
targetActorId: "unsupported-scout-secondary-target",
|
||||
now: 1_000,
|
||||
})).toMatchObject({ ok: false });
|
||||
initializeScout();
|
||||
registerTarget("switch-first-target");
|
||||
registerTarget("switch-second-target", 3);
|
||||
expect(activateAutoShot("switch-first-target")).toMatchObject({ ok: true });
|
||||
const due = useCombatStore.getState().nextAutoShotAt;
|
||||
useCombatStore.getState().selectMob("switch-second-target");
|
||||
expect(useCombatStore.getState().autoShotTargetId).toBe("switch-second-target");
|
||||
expect(useCombatStore.getState().nextAutoShotAt).toBe(due);
|
||||
useCombatStore.getState().setPlayerPosition([1, 0, 0]);
|
||||
expect(useCombatStore.getState().autoShotTargetId).toBe("switch-second-target");
|
||||
useCombatStore.getState().tick(0.1, due);
|
||||
expect(autoShotEvents("switch-second-target")).toHaveLength(1);
|
||||
expect(autoShotEvents("switch-first-target")).toHaveLength(0);
|
||||
|
||||
useCombatStore.getState().clearTarget();
|
||||
expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 });
|
||||
activateAutoShot("switch-first-target", due + 1_000);
|
||||
useCombatStore.getState().damageMob("switch-first-target", MAX_TARGET_HEALTH, due + 2_000);
|
||||
expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 });
|
||||
activateAutoShot("switch-second-target", due + 3_000);
|
||||
useCombatStore.getState().resetEncounter();
|
||||
expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 });
|
||||
});
|
||||
|
||||
it("clears Auto Shot immediately when a periodic effect kills the player during the same tick", () => {
|
||||
initializeScout();
|
||||
registerTarget("same-tick-death-target");
|
||||
giveBloodArrowAura();
|
||||
expect(activateAutoShot("same-tick-death-target")).toMatchObject({ ok: true });
|
||||
useCombatStore.setState({
|
||||
health: 50,
|
||||
nextAutoShotAt: 10_000,
|
||||
effects: [{
|
||||
id: "auto-shot-same-tick-player-death",
|
||||
kind: "dot",
|
||||
sourceAbilityId: BLOOD_ARROW_ID,
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: PLAYER_AURA_ENTITY_ID,
|
||||
coefficient: 0,
|
||||
fixedAmount: 100_000,
|
||||
remainingTicks: 1,
|
||||
intervalMs: 1_000,
|
||||
nextTickAt: 1_500,
|
||||
damageSchool: "physical",
|
||||
tag: "rom-self-health-drain",
|
||||
}],
|
||||
});
|
||||
useCombatStore.getState().tick(0.1, 1_500);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState()).toMatchObject({ autoShotTargetId: null, nextAutoShotAt: 0 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user