Update class mechanics, stat rulesets, and CoA reference catalogs
This commit is contained in:
1 parent
b16821e92e
commit
39458d5de6
221 files changed
+2584962
-568
No files matched your search
@@ -0,0 +1,311 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore, stableCombatUnit } from "./combatStore";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura } from "./combatAuras";
|
||||
import {
|
||||
abilitiesForCharacter,
|
||||
abilityById,
|
||||
learnedAbilityForBuild,
|
||||
} from "./abilityCatalog";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
updatePartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "./partyRuntimeRegistry";
|
||||
|
||||
const STRIKE_ID = "rom-champion-rom-warlock-elite-30";
|
||||
const SIPHON_ID = "rom-champion-rom-warlock-elite-20";
|
||||
const TARGET_ID = "dark-energy-strike-source-target";
|
||||
|
||||
function seededStrikeTime(actorId: string, suffix: "direct" | "special-crit"): number {
|
||||
const strike = abilityById(STRIKE_ID)!;
|
||||
for (let now = 10_000; now < 100_000; now += 1) {
|
||||
const resetRoll = stableCombatUnit(
|
||||
`${actorId}:${strike.id}:${TARGET_ID}:${now}:rom-champion-dark-energy-strike-rune-siphon`,
|
||||
);
|
||||
const outcomeRoll = stableCombatUnit(`${actorId}:${strike.id}:${TARGET_ID}:${now}:${suffix}:outcome`);
|
||||
if (resetRoll < 0.15 && outcomeRoll >= 0.2 && outcomeRoll < 0.8) return now;
|
||||
}
|
||||
throw new Error("Could not find a deterministic landed Dark Energy Strike reset seed.");
|
||||
}
|
||||
|
||||
function setupPlayerStrike(
|
||||
armor: number,
|
||||
shadowResistance: number,
|
||||
now: number,
|
||||
targetLevel = 1,
|
||||
shadowImmune = false,
|
||||
targetHealth = 1_000_000,
|
||||
) {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-champion",
|
||||
secondaryClassId: "rom-warlock",
|
||||
raceId: "rom-dwarf",
|
||||
level: 30,
|
||||
});
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: TARGET_ID,
|
||||
level: targetLevel,
|
||||
maxHealth: targetHealth,
|
||||
resistances: { shadow: shadowResistance },
|
||||
...(shadowImmune ? { schoolImmunities: ["shadow"] } : {}),
|
||||
});
|
||||
setMobPosition(TARGET_ID, [3, 0, 0]);
|
||||
useCombatStore.setState((state) => {
|
||||
const target = state.mobs[TARGET_ID]!;
|
||||
return {
|
||||
mobs: { ...state.mobs, [TARGET_ID]: { ...target, armor } },
|
||||
resource: 100,
|
||||
resourcePools: { ...state.resourcePools, rage: 100 },
|
||||
cooldowns: { ...state.cooldowns, [SIPHON_ID]: now + 60_000 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function playerStrikeDamage(armor: number, shadowResistance: number): number {
|
||||
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
|
||||
setupPlayerStrike(armor, shadowResistance, now);
|
||||
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
|
||||
targetActorId: TARGET_ID,
|
||||
now,
|
||||
});
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, affectedMobIds: [TARGET_ID] });
|
||||
return result.damage;
|
||||
}
|
||||
|
||||
describe("Dark Energy Strike ordered-group integration", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
});
|
||||
|
||||
it("uses armor for the physical group and shadow resistance for the Darkness group", () => {
|
||||
const noArmorNoShadowResistance = playerStrikeDamage(0, 0);
|
||||
const armorNoShadowResistance = playerStrikeDamage(50_000, 0);
|
||||
const armorHighShadowResistance = playerStrikeDamage(50_000, 1_000_000);
|
||||
|
||||
expect(noArmorNoShadowResistance).toBeGreaterThan(armorNoShadowResistance);
|
||||
expect(armorNoShadowResistance).toBeGreaterThan(armorHighShadowResistance);
|
||||
expect(armorHighShadowResistance).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resets the player's separately learned Rune Siphon after physical success even when Darkness is fully resisted", () => {
|
||||
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
|
||||
setupPlayerStrike(50_000, 0, now, 1, true);
|
||||
|
||||
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
|
||||
targetActorId: TARGET_ID,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: expect.any(Number) });
|
||||
expect(result.damage).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
|
||||
&& event.abilityId === STRIKE_ID && event.school === "shadow")).toEqual([
|
||||
expect.objectContaining({ effectiveAmount: 0 }),
|
||||
]);
|
||||
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(0);
|
||||
});
|
||||
|
||||
it("does not run the Darkness packet or reset after the player's physical packet misses", () => {
|
||||
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
|
||||
setupPlayerStrike(0, 0, now, 255);
|
||||
|
||||
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
|
||||
targetActorId: TARGET_ID,
|
||||
now,
|
||||
});
|
||||
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === STRIKE_ID
|
||||
));
|
||||
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 0 });
|
||||
expect(strikeDamageEvents).toHaveLength(1);
|
||||
expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", outcome: "miss" });
|
||||
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(now + 60_000);
|
||||
});
|
||||
|
||||
it("does not run the Darkness packet or reset when an absorb reduces physical HP damage to zero", () => {
|
||||
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
|
||||
setupPlayerStrike(0, 0, now);
|
||||
const shield = applyAura(useCombatStore.getState().auras, {
|
||||
definition: {
|
||||
id: "test:dark-energy-physical-absorb",
|
||||
name: "Physical absorb fixture",
|
||||
disposition: "buff",
|
||||
durationMs: null,
|
||||
absorbs: [{ id: "test:dark-energy-physical-absorb-pool", amount: 1_000_000, school: "physical" }],
|
||||
},
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId: TARGET_ID,
|
||||
now,
|
||||
}).auras;
|
||||
useCombatStore.setState({ auras: shield });
|
||||
|
||||
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
|
||||
targetActorId: TARGET_ID,
|
||||
now,
|
||||
});
|
||||
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === STRIKE_ID
|
||||
));
|
||||
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 0 });
|
||||
expect(strikeDamageEvents).toHaveLength(1);
|
||||
expect(strikeDamageEvents[0]).toMatchObject({
|
||||
school: "physical",
|
||||
effectiveAmount: 0,
|
||||
absorbed: expect.any(Number),
|
||||
});
|
||||
expect(strikeDamageEvents[0]!.absorbed).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(now + 60_000);
|
||||
});
|
||||
|
||||
it("keeps the physical success eligible for reset when that packet kills its target", () => {
|
||||
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
|
||||
setupPlayerStrike(0, 0, now, 1, false, 1);
|
||||
|
||||
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
|
||||
targetActorId: TARGET_ID,
|
||||
now,
|
||||
});
|
||||
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === STRIKE_ID
|
||||
));
|
||||
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 1 });
|
||||
expect(useCombatStore.getState().mobs[TARGET_ID]?.dead).toBe(true);
|
||||
expect(strikeDamageEvents).toHaveLength(1);
|
||||
expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", effectiveAmount: 1 });
|
||||
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(0);
|
||||
});
|
||||
|
||||
it("resets a companion's Rune Siphon in both owner and combat-actor cooldown mirrors", () => {
|
||||
const seed = "dark-energy-strike-companion";
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 30 });
|
||||
const companion = createConfiguredPartyMember({
|
||||
name: "Champion/Warlock",
|
||||
classId: "rom-champion",
|
||||
secondaryClassId: "rom-warlock",
|
||||
raceId: "rom-dwarf",
|
||||
specialization: "Primary",
|
||||
specializationId: "rom-champion:rom-warlock",
|
||||
talentTreeId: "rom-champion-rom-warlock",
|
||||
role: "damage",
|
||||
}, 30, 0, seed);
|
||||
const strike = abilitiesForCharacter("rom-champion", "rom-warlock")
|
||||
.find((ability) => ability.dbcSpellId === 498_599)!;
|
||||
const siphon = abilitiesForCharacter("rom-champion", "rom-warlock")
|
||||
.find((ability) => ability.dbcSpellId === 498_595)!;
|
||||
const learnedStrike = learnedAbilityForBuild(strike, companion.level, companion.talentRanks)!;
|
||||
const learnedSiphon = learnedAbilityForBuild(siphon, companion.level, companion.talentRanks)!;
|
||||
const now = seededStrikeTime(companion.id, "special-crit");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 30, useCombatStore.getState().health, "damage", 1, "rom");
|
||||
useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, level: 1, maxHealth: 1_000_000, xpReward: 0 });
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, now);
|
||||
updatePartyRuntimePosition(companion.id, 0, 0, 0);
|
||||
usePartyStore.setState({
|
||||
command: "attack",
|
||||
members: [{
|
||||
...companion,
|
||||
resource: 100,
|
||||
resourcePools: { ...companion.resourcePools, rage: 100 },
|
||||
cooldowns: { ...companion.cooldowns, [learnedSiphon.id]: now + 60_000 },
|
||||
activeCast: {
|
||||
abilityId: learnedStrike.id,
|
||||
school: "physical",
|
||||
mode: "cast",
|
||||
startedAt: now,
|
||||
completesAt: now,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
},
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
}],
|
||||
});
|
||||
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(now);
|
||||
|
||||
expect(usePartyStore.getState().members[0]?.cooldowns[learnedSiphon.id]).toBe(0);
|
||||
expect(useCombatStore.getState().actors[companion.id]?.cooldowns[learnedSiphon.id]).toBe(0);
|
||||
});
|
||||
|
||||
it("does not run the companion Darkness packet or reset after its physical packet misses", () => {
|
||||
const seed = "dark-energy-strike-companion-miss";
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 30 });
|
||||
const companion = createConfiguredPartyMember({
|
||||
name: "Champion/Warlock",
|
||||
classId: "rom-champion",
|
||||
secondaryClassId: "rom-warlock",
|
||||
raceId: "rom-dwarf",
|
||||
specialization: "Primary",
|
||||
specializationId: "rom-champion:rom-warlock",
|
||||
talentTreeId: "rom-champion-rom-warlock",
|
||||
role: "damage",
|
||||
}, 30, 0, seed);
|
||||
const strike = abilitiesForCharacter("rom-champion", "rom-warlock")
|
||||
.find((ability) => ability.dbcSpellId === 498_599)!;
|
||||
const siphon = abilitiesForCharacter("rom-champion", "rom-warlock")
|
||||
.find((ability) => ability.dbcSpellId === 498_595)!;
|
||||
const learnedStrike = learnedAbilityForBuild(strike, companion.level, companion.talentRanks)!;
|
||||
const learnedSiphon = learnedAbilityForBuild(siphon, companion.level, companion.talentRanks)!;
|
||||
const now = seededStrikeTime(companion.id, "special-crit");
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, 30, useCombatStore.getState().health, "damage", 1, "rom");
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: TARGET_ID,
|
||||
level: 255,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(TARGET_ID, 1, now);
|
||||
updatePartyRuntimePosition(companion.id, 0, 0, 0);
|
||||
usePartyStore.setState({
|
||||
command: "attack",
|
||||
members: [{
|
||||
...companion,
|
||||
resource: 100,
|
||||
resourcePools: { ...companion.resourcePools, rage: 100 },
|
||||
cooldowns: { ...companion.cooldowns, [learnedSiphon.id]: now + 60_000 },
|
||||
activeCast: {
|
||||
abilityId: learnedStrike.id,
|
||||
school: "physical",
|
||||
mode: "cast",
|
||||
startedAt: now,
|
||||
completesAt: now,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: TARGET_ID,
|
||||
},
|
||||
activeCastTargetId: TARGET_ID,
|
||||
status: "attacking",
|
||||
}],
|
||||
});
|
||||
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
advanceDungeonPartyCombat(now);
|
||||
|
||||
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === learnedStrike.id
|
||||
));
|
||||
expect(strikeDamageEvents).toHaveLength(1);
|
||||
expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", outcome: "miss" });
|
||||
expect(usePartyStore.getState().members[0]?.cooldowns[learnedSiphon.id]).toBe(now + 60_000);
|
||||
expect(useCombatStore.getState().actors[companion.id]?.cooldowns[learnedSiphon.id]).toBe(now + 60_000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user