forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
@@ -23,6 +23,8 @@ import {
|
||||
onlineSessionOwnsWorldSimulation,
|
||||
} from "./onlineSessionRuntime";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
|
||||
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
|
||||
import {
|
||||
DUNGEON_SIMULATION_FIXED_STEP_MS,
|
||||
useDungeonSimulationStore,
|
||||
@@ -66,6 +68,7 @@ export function CombatBridge() {
|
||||
mountedResetRevision.current = encounterResetRevision;
|
||||
encounterMobCursor.current = {};
|
||||
useCombatStore.getState().resetEncounter();
|
||||
resetDungeonRaceAffixEffects();
|
||||
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
|
||||
}, [encounterResetRevision]);
|
||||
|
||||
@@ -153,8 +156,12 @@ export function CombatBridge() {
|
||||
epochNow,
|
||||
);
|
||||
encounterMobCursor.current = combatEvents.cursor;
|
||||
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
|
||||
const registeredEntityIds = Object.keys(mobs);
|
||||
for (const event of combatEvents.events) if (!event.entityId.startsWith("race-affix-"))
|
||||
dispatchActiveDungeonEntityEvent(event);
|
||||
advanceDungeonRaceAffixes(epochNow, combatEvents.events.filter((event) => (
|
||||
event.kind === "entity-death" && !event.entityId.startsWith("race-affix-")
|
||||
)).map((event) => event.entityId));
|
||||
const registeredEntityIds = Object.keys(mobs).filter((id) => !id.startsWith("race-affix-"));
|
||||
advanceDungeonEncounters(game.activeDungeonId, {
|
||||
kind: "tick",
|
||||
now: epochNow,
|
||||
@@ -205,8 +212,10 @@ export function CombatBridge() {
|
||||
// Reset the clock on resume so a long pause/background period cannot
|
||||
// leak into the first simulation delta.
|
||||
previous = performance.now();
|
||||
resumeDungeonRaceAffixes();
|
||||
timer = window.setInterval(advance, 100);
|
||||
} else if (!shouldRun && timer !== null) {
|
||||
suspendDungeonRaceAffixes();
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { subscribeCombatEvents } from "../combatEvents";
|
||||
import { resetCombatSpatialProvider } from "../combatSpatial";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { dungeonEnemyScaling } from "../dungeonBossScaling";
|
||||
import { requireDungeonDefinition } from "../dungeonRegistry";
|
||||
import { prepareDungeonSessionDefinition, requireDungeonDefinition } from "../dungeonRegistry";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "../mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "../partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "../partyRuntimeRegistry";
|
||||
@@ -23,10 +23,16 @@ const scenario: BenchmarkScenarioDefinition = {
|
||||
/** Uses the live boss stats, companion AI and baseline player policy with a controlled clock. */
|
||||
export async function runDungeonBossBenchmark(
|
||||
build: BenchmarkBuildDefinition, bossId: string, seed: string, mechanics = false,
|
||||
options: { readonly dungeonId?: string; readonly durationMs?: number; readonly partySeed?: string } = {},
|
||||
) {
|
||||
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
||||
const dungeon = options.dungeonId
|
||||
? prepareDungeonSessionDefinition(options.dungeonId, seed)
|
||||
: requireDungeonDefinition("ragefire-chasm");
|
||||
if (!dungeon) throw new Error(`Unknown dungeon: ${options.dungeonId}`);
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)
|
||||
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((entry) => entry.id === bossId);
|
||||
if (!spawn) throw new Error(`Unknown boss ${bossId} in ${dungeon.id}.`);
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
|
||||
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
||||
@@ -38,12 +44,14 @@ export async function runDungeonBossBenchmark(
|
||||
let selfHealing = 0; let allyHealing = 0;
|
||||
const healingByAbility: Record<string, number> = {};
|
||||
const damageByAbility: Record<string, number> = {};
|
||||
const damageByActor: Record<string, number> = {};
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.kind === "damage" && event.targetActorId === bossId) {
|
||||
if (event.sourceActorId === PLAYER_AGGRO_ID) playerDamage += event.effectiveAmount;
|
||||
else partyDamage += event.effectiveAmount;
|
||||
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}`;
|
||||
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
|
||||
damageByActor[event.sourceActorId] = (damageByActor[event.sourceActorId] ?? 0) + event.effectiveAmount;
|
||||
}
|
||||
if (event.kind === "damage" && event.sourceActorId === bossId) incomingDamage += event.effectiveAmount;
|
||||
if (event.kind === "healing") {
|
||||
@@ -60,7 +68,8 @@ export async function runDungeonBossBenchmark(
|
||||
specializationId: specialization?.id, role: build.role, level: build.level,
|
||||
talentRanks: build.talentRanks, inventory: build.inventory, equipment: build.equipment });
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||
usePartyStore.getState().initializeParty(options.partySeed ?? seed, build.level,
|
||||
useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs(now);
|
||||
combat.applyPrecombatPreparation(now);
|
||||
@@ -77,31 +86,132 @@ export async function runDungeonBossBenchmark(
|
||||
actorId: build.role === "tank" ? PLAYER_AGGRO_ID : tank!.id, role: "tank", abilityId: "benchmark-pull",
|
||||
}, "physical");
|
||||
const controller = new BenchmarkController();
|
||||
for (let elapsed = 100; elapsed <= scenario.durationMs; elapsed += 100) {
|
||||
const durationMs = options.durationMs ?? scenario.durationMs;
|
||||
const activeScenario = durationMs === scenario.durationMs ? scenario : { ...scenario, durationMs };
|
||||
for (let elapsed = 100; elapsed <= durationMs; elapsed += 100) {
|
||||
now = 1_000_000 + elapsed;
|
||||
combat.tick(.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
if (mechanics) combat.advanceMobCombat(now);
|
||||
if (controller.act(now, build.role, scenario, false).cast) casts += 1;
|
||||
if (controller.act(now, build.role, activeScenario, false).cast) casts += 1;
|
||||
const state = useCombatStore.getState();
|
||||
const mob = state.mobs[bossId];
|
||||
if (mob.dead || (mechanics && state.health <= 0)) {
|
||||
return { seconds: mob.dead ? ((mob.diedAt ?? now) - 1_000_000) / 1000 : null,
|
||||
elapsedSeconds: elapsed / 1000, outcome: mob.dead ? "defeated" : "player-death",
|
||||
maxHealth: scaling.maxHealth, playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
maxHealth: scaling.maxHealth, playerDamage, partyDamage, damageByActor, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerAlive: state.health > 0, deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
||||
attacks: mob.attackRevision, specialization: specialization?.displayName,
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: usePartyStore.getState().members.map((member) => member.id) };
|
||||
}
|
||||
}
|
||||
return { seconds: null, elapsedSeconds: scenario.durationMs / 1000, outcome: "timeout",
|
||||
return { seconds: null, elapsedSeconds: durationMs / 1000, outcome: "timeout",
|
||||
maxHealth: scaling.maxHealth, remainingHealth: useCombatStore.getState().mobs[bossId].health,
|
||||
playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerDamage, partyDamage, damageByActor, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerAlive: useCombatStore.getState().health > 0,
|
||||
deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
||||
attacks: useCombatStore.getState().mobs[bossId].attackRevision,
|
||||
specialization: specialization?.displayName,
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: usePartyStore.getState().members.map((member) => member.id) };
|
||||
} finally {
|
||||
stop(); restoreClock(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/** Measures a compact pull using the route's real RoM trash stats and the live five-seat combat runtime. */
|
||||
export async function runBiteSizedTrashBenchmark(
|
||||
build: BenchmarkBuildDefinition, dungeonId: string, seed: string,
|
||||
options: { readonly durationMs?: number; readonly partySeed?: string; readonly maxMobs?: number } = {},
|
||||
) {
|
||||
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
|
||||
const dungeon = prepareDungeonSessionDefinition(dungeonId, seed);
|
||||
if (!dungeon) throw new Error(`Unknown dungeon: ${dungeonId}`);
|
||||
const spawns = dungeon.staticSpawns.filter((spawn) => dungeon.entities[spawn.entityId]?.kind !== "boss")
|
||||
.slice(0, options.maxMobs ?? 6);
|
||||
if (spawns.length < 2) throw new Error(`${dungeonId} has fewer than two trash mobs.`);
|
||||
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
||||
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
let now = 1_000_000;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
const damageByActor: Record<string, number> = {};
|
||||
const damageByAbility: Record<string, number> = {};
|
||||
const mobIds = new Set(spawns.map((spawn) => spawn.id));
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.kind !== "damage" || !mobIds.has(event.targetActorId)) return;
|
||||
damageByActor[event.sourceActorId] = (damageByActor[event.sourceActorId] ?? 0) + event.effectiveAmount;
|
||||
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}`;
|
||||
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
|
||||
});
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({ classId: build.classId, raceId: build.raceId,
|
||||
secondaryClassId: build.secondaryClassId, specializationId: specialization?.id,
|
||||
role: build.role, level: build.level, talentRanks: build.talentRanks,
|
||||
inventory: build.inventory, equipment: build.equipment });
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(options.partySeed ?? seed, build.level,
|
||||
useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs(now);
|
||||
combat.applyPrecombatPreparation(now);
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
let totalHealth = 0;
|
||||
for (const [index, spawn] of spawns.entries()) {
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, spawn.id, build.level, "normal", 1, build.source);
|
||||
totalHealth += scaling.maxHealth;
|
||||
combat.registerMob(spawn.id, { name: entity.name, serverEntry: entity.combat?.serverEntry,
|
||||
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
||||
const angle = index / spawns.length * Math.PI * 2;
|
||||
setMobPosition(spawn.id, [Math.cos(angle) * 1.5, 0, Math.sin(angle) * 1.5]);
|
||||
}
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
const preferred = partyCombatRangeBand(member).preferred;
|
||||
registerPartyRuntimePosition(member.id, [preferred <= 6 ? -1 : -preferred, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, spawns[0].id, true);
|
||||
}
|
||||
combat.selectMob(spawns[0].id);
|
||||
for (const spawn of spawns) combat.damageMob(spawn.id, 1, now, {
|
||||
actorId: build.role === "tank" ? PLAYER_AGGRO_ID : tank!.id,
|
||||
role: "tank", abilityId: "benchmark-pull",
|
||||
}, "physical");
|
||||
const controller = new BenchmarkController();
|
||||
const durationMs = options.durationMs ?? 120_000;
|
||||
const areaScenario: BenchmarkScenarioDefinition = { ...scenario, id: "bite-sized-trash",
|
||||
hostileTargets: spawns.length, durationMs };
|
||||
for (let elapsed = 100; elapsed <= durationMs; elapsed += 100) {
|
||||
now = 1_000_000 + elapsed;
|
||||
combat.tick(.1, now);
|
||||
// The scene normally publishes one current line-of-sight target per
|
||||
// companion. Mirror that update when a benchmark has several mobs.
|
||||
const currentParty = usePartyStore.getState();
|
||||
for (const member of currentParty.members) {
|
||||
const targetId = currentParty.memberTargetIds[member.id]
|
||||
?? useCombatStore.getState().selectedTargetId;
|
||||
if (targetId) updatePartyRuntimeTargetVisibility(member.id, targetId, true);
|
||||
}
|
||||
advanceDungeonPartyCombat(now);
|
||||
const active = spawns.find((spawn) => !useCombatStore.getState().mobs[spawn.id]?.dead);
|
||||
if (!active) {
|
||||
const members = usePartyStore.getState().members;
|
||||
return { seconds: elapsed / 1_000, outcome: "defeated", totalHealth,
|
||||
mobCount: spawns.length, damageByActor, damageByAbility,
|
||||
party: members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: members.map((member) => member.id) };
|
||||
}
|
||||
if (useCombatStore.getState().selectedTargetId !== active.id) combat.selectMob(active.id);
|
||||
const livingCount = spawns.filter((spawn) => !useCombatStore.getState().mobs[spawn.id]?.dead).length;
|
||||
controller.act(now, build.role, { ...areaScenario, hostileTargets: livingCount }, false);
|
||||
}
|
||||
const members = usePartyStore.getState().members;
|
||||
return { seconds: durationMs / 1_000, outcome: "timeout", totalHealth,
|
||||
mobCount: spawns.length, damageByActor, damageByAbility,
|
||||
party: members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: members.map((member) => member.id) };
|
||||
} finally {
|
||||
stop(); restoreClock(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dungeonEnemyScaling } from "./dungeonBossScaling";
|
||||
import { prepareDungeonSessionDefinition, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
|
||||
describe("Bite-Sized boss durability", () => {
|
||||
for (const [id, sourceId] of [
|
||||
["bite-sized-hoto-teeth", "hoto"],
|
||||
["bite-sized-stormwind-stockade", "stormwind-stockade"],
|
||||
] as const) {
|
||||
it(`shortens ${id} without changing ${sourceId} or its trash`, () => {
|
||||
const route = prepareDungeonSessionDefinition(id, "bite-sized-balance-0")!;
|
||||
const source = requireDungeonDefinition(sourceId);
|
||||
const boss = route.bosses[0];
|
||||
const spawn = route.encounterSpawnGroups?.flatMap((group) => group.spawns)
|
||||
.find((candidate) => candidate.id === boss.id)
|
||||
?? route.staticSpawns.find((candidate) => candidate.id === boss.id);
|
||||
expect(spawn).toBeDefined();
|
||||
const entity = route.entities[spawn!.entityId];
|
||||
const short = dungeonEnemyScaling(route, entity, boss.id, 60, "normal", 1, "wow");
|
||||
const campaign = dungeonEnemyScaling(source, entity, boss.id, 60, "normal", 1, "wow");
|
||||
expect(short.maxHealth).toBeGreaterThan(25_000);
|
||||
expect(short.maxHealth).toBeLessThan(50_000);
|
||||
expect(campaign.maxHealth).toBeGreaterThan(short.maxHealth);
|
||||
const trash = route.staticSpawns.find((candidate) => route.entities[candidate.entityId]?.kind === "mob")!;
|
||||
expect(dungeonEnemyScaling(route, route.entities[trash.entityId], trash.id,
|
||||
60, "normal", 1, "wow")).toEqual(dungeonEnemyScaling(source,
|
||||
route.entities[trash.entityId], trash.id, 60, "normal", 1, "wow"));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -392,6 +392,8 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.abilityId === "wow335-priest-penance" && event.sourceSpellId === 47540
|
||||
&& (event.phase === "release" || event.phase === "impact")) return;
|
||||
if (
|
||||
!configuredSettings
|
||||
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
|
||||
|
||||
@@ -154,6 +154,44 @@ describe("combat presentation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses WoW's channel hand, Penance missile, and distinct heal and damage impacts", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"), "utf8",
|
||||
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
|
||||
const entry = (spellId: number) => manifest.entries.find((candidate) => (
|
||||
candidate.source === "wow335" && candidate.spellId === spellId
|
||||
));
|
||||
const models = (spellId: number, phase: CombatPresentationPhase) =>
|
||||
originalCombatPhaseAssets(entry(spellId), phase, "wow335-priest-penance").map((asset) => asset.model);
|
||||
expect(models(47540, "cast-start")).toEqual(["Spells/Holy_Precast_High_Hand.mdx"]);
|
||||
expect(models(47666, "tick")).toEqual(["spells/priest_pennance_missile.mdx"]);
|
||||
expect(models(47750, "tick")).toEqual(["spells/priest_pennance_missile.mdx"]);
|
||||
expect(models(47666, "impact")).toEqual(["Spells/Holy_ImpactDD_Low_Chest.mdx"]);
|
||||
expect(models(47750, "impact")).toEqual(["Spells/FlashHeal_Base.mdx"]);
|
||||
expect(models(47540, "impact")).toEqual([]);
|
||||
for (const spellId of [47540, 47666, 47750]) {
|
||||
expect(Object.values(entry(spellId)?.phases ?? {}).flat().every((asset) => asset.effectGraphUrl))
|
||||
.toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("packages Shadowfiend's creature display as a textured, renderable model", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"), "utf8",
|
||||
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
|
||||
const summon = manifest.entries.find((entry) => entry.key === "wow335:34433")?.phases?.summon?.[0];
|
||||
expect(summon?.model).toBe("Creature/FleshBeast/FleshBeast.mdx");
|
||||
expect(summon?.effectKind).toBe("wow-m2-model");
|
||||
const graph = JSON.parse(readFileSync(path.resolve("public", summon!.effectGraphUrl!.slice(1)), "utf8")) as {
|
||||
readonly sourceModel: string;
|
||||
readonly mesh: { readonly indices: readonly number[] };
|
||||
readonly textures: readonly { readonly url?: string }[];
|
||||
};
|
||||
expect(graph.sourceModel).toBe("creature/fleshbeast/fleshbeast.m2");
|
||||
expect(graph.mesh.indices.length).toBeGreaterThan(0);
|
||||
expect(graph.textures[0]?.url).toMatch(/\/textures\/.*\.png$/);
|
||||
});
|
||||
|
||||
it("selects source-linked RuneWaker sprite phases for live combat", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"),
|
||||
|
||||
@@ -28,6 +28,21 @@ export type CombatPresentationSchool =
|
||||
export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "fallback";
|
||||
export type CombatPresentationPosition = readonly [number, number, number];
|
||||
|
||||
export const PENANCE_DAMAGE_VISUAL_SPELL_ID = 47666;
|
||||
export const PENANCE_HEAL_VISUAL_SPELL_ID = 47750;
|
||||
|
||||
export function penancePulseVisualSpellId(hostile: boolean): number {
|
||||
return hostile ? PENANCE_DAMAGE_VISUAL_SPELL_ID : PENANCE_HEAL_VISUAL_SPELL_ID;
|
||||
}
|
||||
|
||||
export function isPenancePulseVisual(
|
||||
event: Pick<CombatPresentationEvent, "abilityId" | "sourceSpellId">,
|
||||
): boolean {
|
||||
return event.abilityId === "wow335-priest-penance"
|
||||
&& (event.sourceSpellId === PENANCE_DAMAGE_VISUAL_SPELL_ID
|
||||
|| event.sourceSpellId === PENANCE_HEAL_VISUAL_SPELL_ID);
|
||||
}
|
||||
|
||||
export interface CombatPresentationSettings {
|
||||
readonly originalMediaEnabled: boolean;
|
||||
readonly vfxMode: CombatVfxMode;
|
||||
@@ -200,7 +215,7 @@ export interface CombatEffectManifestEntry {
|
||||
readonly source: Exclude<CombatPresentationSource, "fallback">;
|
||||
readonly spellId: number;
|
||||
readonly visualIds?: readonly number[];
|
||||
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase, readonly CombatEffectPhaseAsset[]>>>;
|
||||
readonly phases?: Readonly<Partial<Record<CombatPresentationPhase | "summon", readonly CombatEffectPhaseAsset[]>>>;
|
||||
readonly sounds?: readonly CombatEffectSoundAsset[];
|
||||
}
|
||||
|
||||
@@ -249,6 +264,13 @@ export function combatEffectManifestEntry(
|
||||
return manifestBySpellId.get(spellId) ?? null;
|
||||
}
|
||||
|
||||
/** Client display 19110 for the priest's creature 19668. */
|
||||
export function shadowfiendOriginalModelAsset(): CombatEffectPhaseAsset | null {
|
||||
return combatEffectManifestEntry(34433, "wow335")?.phases?.summon?.find((asset) => (
|
||||
asset.effectKind === "wow-m2-model" && Boolean(asset.effectGraphUrl)
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
export function originalCombatPhaseAssets(
|
||||
entry: CombatEffectManifestEntry | null | undefined,
|
||||
phase: CombatPresentationPhase,
|
||||
@@ -256,7 +278,11 @@ export function originalCombatPhaseAssets(
|
||||
): readonly CombatEffectPhaseAsset[] {
|
||||
const sourcePhase = abilityId === "wow335-priest-holy-nova" && phase === "impact"
|
||||
? "cast-start"
|
||||
: phase;
|
||||
: abilityId === "wow335-priest-penance" && phase === "cast-start"
|
||||
? "tick"
|
||||
: abilityId === "wow335-priest-penance" && phase === "tick"
|
||||
? "release"
|
||||
: phase;
|
||||
if (abilityId === "wow335-priest-holy-nova" && phase !== "impact") return [];
|
||||
return (entry?.phases?.[sourcePhase] ?? []).filter((asset) => (
|
||||
typeof asset.effectGraphUrl === "string"
|
||||
|
||||
@@ -1879,12 +1879,27 @@ describe("combat store", () => {
|
||||
resourcePools: { ...state.resourcePools, mana: Math.round(state.maxResource / 2) },
|
||||
}));
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-shadowfiend", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-shadowfiend"))
|
||||
.toHaveLength(1);
|
||||
const fiends = useCombatStore.getState().summons.filter((summon) => (
|
||||
summon.sourceAbilityId === "wow335-priest-shadowfiend"
|
||||
));
|
||||
expect(fiends).toHaveLength(1);
|
||||
expect(fiends[0]?.creatureId).toBe(19668);
|
||||
expect(fiends[0]?.pulseIntervalMs).toBe(1_500);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "resource"
|
||||
&& event.sourceActorId === fiends[0]?.id)).toBe(false);
|
||||
const manaBeforeAttack = useCombatStore.getState().resource;
|
||||
useCombatStore.getState().tick(1, 2_000);
|
||||
expect(useCombatStore.getState().mobs["fiend-target"].health).toBeLessThan(100_000);
|
||||
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.01));
|
||||
expect(useCombatStore.getState().combatEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "damage", sourceActorId: fiends[0]?.id,
|
||||
targetActorId: "fiend-target", abilityId: "wow335-priest-shadowfiend", school: "shadow" }),
|
||||
expect.objectContaining({ kind: "resource", sourceActorId: fiends[0]?.id,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: "wow335-priest-shadowfiend",
|
||||
effectiveAmount: useCombatStore.getState().maxResource * 0.05 }),
|
||||
]));
|
||||
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(
|
||||
manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.05),
|
||||
);
|
||||
});
|
||||
|
||||
it("casts Kill Shot only below 20 percent health and resolves one direct hit", () => {
|
||||
@@ -1946,6 +1961,10 @@ describe("combat store", () => {
|
||||
registerTarget("penance-target", [8, 0, 0], 100_000, 0);
|
||||
useCombatStore.getState().damagePlayer(500);
|
||||
const casterHealth = useCombatStore.getState().health;
|
||||
const visuals: CombatPresentationEvent[] = [];
|
||||
const stopVisuals = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.abilityId === "wow335-priest-penance") visuals.push(message.event);
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 3,
|
||||
completesAt: 3_000, nextTickAt: 1_000 });
|
||||
@@ -1964,6 +1983,12 @@ describe("combat store", () => {
|
||||
const after = useCombatStore.getState().mobs["penance-target"].health;
|
||||
useCombatStore.getState().tick(0, 4_000);
|
||||
expect(useCombatStore.getState().mobs["penance-target"].health).toBe(after);
|
||||
stopVisuals();
|
||||
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
|
||||
["cast-start", 47540],
|
||||
...Array.from({ length: 3 }, () => [["tick", 47666], ["impact", 47666]]).flat(),
|
||||
["release", 47540],
|
||||
]);
|
||||
});
|
||||
|
||||
it("channels Penance healing and stops future pulses when interrupted", () => {
|
||||
@@ -1972,6 +1997,10 @@ describe("combat store", () => {
|
||||
usePartyStore.getState().selectSelf();
|
||||
useCombatStore.getState().damagePlayer(2_000);
|
||||
const before = useCombatStore.getState().health;
|
||||
const visuals: CombatPresentationEvent[] = [];
|
||||
const stopVisuals = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.abilityId === "wow335-priest-penance") visuals.push(message.event);
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
useCombatStore.getState().tick(0, 1_000);
|
||||
const first = useCombatStore.getState().health;
|
||||
@@ -1983,6 +2012,11 @@ describe("combat store", () => {
|
||||
useCombatStore.getState().tick(0, 3_000);
|
||||
expect(useCombatStore.getState().health).toBe(interruptedAt);
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
stopVisuals();
|
||||
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
|
||||
["cast-start", 47540], ["tick", 47750], ["impact", 47750],
|
||||
["tick", 47750], ["impact", 47750], ["cast-cancel", 47540],
|
||||
]);
|
||||
});
|
||||
|
||||
it("channels ground-area Volley over its selected point without a target or post-channel damage", () => {
|
||||
|
||||
+26
-15
@@ -101,6 +101,7 @@ import {
|
||||
type CombatPosition,
|
||||
} from "./mobRuntimeRegistry";
|
||||
import { enemyTelegraphContainsPosition } from "./enemyTelegraphGeometry";
|
||||
import { raceAffixHasteMultiplier, raceAffixMobDamageMultiplier } from "./dungeonRaceAffixBuffs";
|
||||
import {
|
||||
getPartyRuntimePosition,
|
||||
partyRuntimeDistanceSquared,
|
||||
@@ -251,6 +252,7 @@ import {
|
||||
} from "./combatRotation";
|
||||
import {
|
||||
emitCombatPresentation,
|
||||
penancePulseVisualSpellId,
|
||||
presentationDeliveryForAbility,
|
||||
presentationSchoolForAbility,
|
||||
presentationSourceForAbility,
|
||||
@@ -975,17 +977,19 @@ function emitPlayerAbilityPresentation(
|
||||
targetId: string | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
instantCast = false,
|
||||
penanceHostile = false,
|
||||
): void {
|
||||
const delivery = presentationDeliveryForAbility(ability);
|
||||
const penancePulse = ability.id === "wow335-priest-penance" && phase === "tick";
|
||||
const delivery = penancePulse ? "projectile" : presentationDeliveryForAbility(ability);
|
||||
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
|
||||
emitCombatPresentation({
|
||||
const event = {
|
||||
occurredAt,
|
||||
phase,
|
||||
actorGroup: "player",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
...(presentationTargetId ? { targetActorId: presentationTargetId } : {}),
|
||||
abilityId: ability.id,
|
||||
sourceSpellId: ability.dbcSpellId,
|
||||
sourceSpellId: penancePulse ? penancePulseVisualSpellId(penanceHostile) : ability.dbcSpellId,
|
||||
name: ability.name,
|
||||
source: presentationSourceForAbility(ability),
|
||||
school: presentationSchoolForAbility(ability),
|
||||
@@ -995,7 +999,9 @@ function emitPlayerAbilityPresentation(
|
||||
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
||||
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
|
||||
...(instantCast ? { instantCast: true } : {}),
|
||||
});
|
||||
} as const;
|
||||
emitCombatPresentation(event);
|
||||
if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" });
|
||||
}
|
||||
|
||||
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
|
||||
@@ -1013,6 +1019,7 @@ function emitPlayerAbilityResolutionPresentation(
|
||||
instantCast: boolean,
|
||||
): void {
|
||||
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition, instantCast);
|
||||
if (ability.id === "wow335-priest-penance") return;
|
||||
emitPlayerAbilityPresentation(
|
||||
ability,
|
||||
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
|
||||
@@ -1128,7 +1135,8 @@ function playerAutoAttackProfile(
|
||||
const weaponDamage = weapon
|
||||
? (weapon.damageMin + weapon.damageMax) / 2
|
||||
: playerCombatPower(state.level) * 0.35;
|
||||
const intervalMs = hastedDuration(speedMs, stats.meleeHastePercent);
|
||||
const intervalMs = Math.max(500, Math.round(hastedDuration(speedMs, stats.meleeHastePercent)
|
||||
/ raceAffixHasteMultiplier()));
|
||||
return {
|
||||
damage: Math.max(1, Math.round(weaponDamage + stats.attackPower / 14 * speedSeconds)),
|
||||
intervalMs,
|
||||
@@ -1827,7 +1835,7 @@ function talentAdjustedCastTime(
|
||||
duration,
|
||||
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
||||
);
|
||||
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
||||
return Math.max(0, Math.round(duration / Math.max(0.1, hasteMultiplier * raceAffixHasteMultiplier())));
|
||||
}
|
||||
|
||||
function talentAdjustedCooldown(ability: AbilityDefinition, modifiers: readonly TalentModifier[]): number {
|
||||
@@ -1852,7 +1860,7 @@ function talentAdjustedGcd(
|
||||
ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
||||
1_000,
|
||||
);
|
||||
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier)));
|
||||
return Math.max(1_000, Math.round(duration / Math.max(0.1, hasteMultiplier * raceAffixHasteMultiplier())));
|
||||
}
|
||||
|
||||
function talentAdjustedDuration(
|
||||
@@ -3285,7 +3293,7 @@ function executeTriggeredSpellInWork(
|
||||
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
||||
position: getMobPosition(targetId ?? "") ?? [0, 0, 0],
|
||||
nextActionAt: now + 1_000,
|
||||
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
|
||||
pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200,
|
||||
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
|
||||
...initialSummonCombatRuntime(),
|
||||
});
|
||||
@@ -5779,7 +5787,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
maxHealth: Math.max(1, Math.round(work.maxHealth * 0.3)),
|
||||
position: groundPosition ?? playerOrigin,
|
||||
nextActionAt: now + 1_000,
|
||||
pulseIntervalMs: /totem/i.test(ability.name) ? 2_000 : 2_200,
|
||||
pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : /totem/i.test(ability.name) ? 2_000 : 2_200,
|
||||
attackDamage: Math.max(1, Math.round(playerCombatPower(work.progression.level) * Math.max(0.15, effect.coefficient))),
|
||||
...initialSummonCombatRuntime(),
|
||||
...(/totem/i.test(ability.name) ? { exclusiveGroup: "totem" } : {}),
|
||||
@@ -7590,7 +7598,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
maxHealth,
|
||||
position,
|
||||
nextActionAt: now + 1_000,
|
||||
pulseIntervalMs: isTotem ? 2_000 : 2_200,
|
||||
pulseIntervalMs: ability.name === "Shadowfiend" ? 1_500 : isTotem ? 2_000 : 2_200,
|
||||
attackDamage: Math.max(1, Math.round(owner.level * 4 * Math.max(0.15, effect.coefficient))),
|
||||
...initialSummonCombatRuntime(),
|
||||
...(isTotem ? { exclusiveGroup: `${ownerActorId}:totem` } : {}),
|
||||
@@ -8685,7 +8693,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.mob,
|
||||
hit.attack,
|
||||
defender,
|
||||
talentAdjustedIncomingDamage(hit.amount, current.talentModifiers),
|
||||
talentAdjustedIncomingDamage(Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now)), current.talentModifiers),
|
||||
hit.actorId,
|
||||
hit.attackRevision,
|
||||
now,
|
||||
@@ -8943,7 +8951,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.mob,
|
||||
hit.attack,
|
||||
defender,
|
||||
hit.amount,
|
||||
Math.round(hit.amount * raceAffixMobDamageMultiplier(hit.mob.id, now)),
|
||||
hit.actorId,
|
||||
hit.attackRevision,
|
||||
now,
|
||||
@@ -9644,8 +9652,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId,
|
||||
summon.attackDamage,
|
||||
now,
|
||||
{ actorId: summon.ownerActorId, role: "damage", abilityId: summon.sourceAbilityId },
|
||||
"physical",
|
||||
{ actorId: summonAbility?.name === "Shadowfiend" ? summon.id : summon.ownerActorId,
|
||||
role: "damage", abilityId: summon.sourceAbilityId },
|
||||
summonAbility?.name === "Shadowfiend" ? "shadow" : "physical",
|
||||
);
|
||||
if (summonAbility?.name === "Shadowfiend" && result.amount > 0) {
|
||||
shadowfiendManaRestores.push({
|
||||
@@ -9758,7 +9767,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
for (const restore of shadowfiendManaRestores) {
|
||||
get().restoreActorResource(
|
||||
restore.ownerActorId, "mana", 1, true, restore.summonId, restore.abilityId, now,
|
||||
restore.ownerActorId, "mana", 5, true, restore.summonId, restore.abilityId, now,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10009,6 +10018,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
get().playerPosition,
|
||||
cast.targetId,
|
||||
cast.groundPosition ?? (cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition),
|
||||
false,
|
||||
channelHostile,
|
||||
);
|
||||
ticksCompleted += 1;
|
||||
nextTickAt = channelPulseAt(ability, cast.startedAt, cast.originalDurationMs, ticksCompleted);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { finalDungeonBoss } from "./dungeonRunStore";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { PopulationEntityDefinition } from "./mobPopulation";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import { isBiteSizedDungeonId } from "./dungeonContentSource";
|
||||
import { resolveBossBalanceOverride } from "./bossBalanceOverrides";
|
||||
|
||||
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||
@@ -39,6 +40,15 @@ const WOW_BOSS_TEST_DAMAGE_SCALE: readonly (readonly [level: number, scale: numb
|
||||
];
|
||||
const BOSS_TEST_HEALTH_FLOOR_MULTIPLIER = 0.6;
|
||||
const FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER = 1.9;
|
||||
// A single boss follows one or two short packs. Measured Ready Priest party
|
||||
// damage changes sharply with unlocks, so use a small bracket curve to keep
|
||||
// these bosses near the existing 25–45 second mechanic window at levels 1–80.
|
||||
const BITE_SIZED_BOSS_HEALTH_BANDS: readonly (readonly [number, number, number])[] = [
|
||||
[1, 2.2, 2.45], [9, 0.95, 1.1], [10, 0.65, 0.8], [20, 0.7, 0.85],
|
||||
[30, 0.82, 0.94], [40, 0.85, 1], [50, 0.7, 0.82],
|
||||
[55, 0.43, 0.53], [60, 0.4, 0.5], [65, 0.33, 0.4],
|
||||
[70, 0.35, 0.43], [80, 0.35, 0.43],
|
||||
];
|
||||
|
||||
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
|
||||
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
|
||||
@@ -81,8 +91,16 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
||||
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
|
||||
const biteSizedBoss = boss && !testing && isBiteSizedDungeonId(dungeon.id);
|
||||
const biteSizedFloorScale = biteSizedBoss
|
||||
? interpolatedFloor(characterLevel, BITE_SIZED_BOSS_HEALTH_BANDS.map(([levelAnchor, floor]) => [levelAnchor, floor]))
|
||||
: 1;
|
||||
const biteSizedCeilingScale = biteSizedBoss
|
||||
? interpolatedFloor(characterLevel, BITE_SIZED_BOSS_HEALTH_BANDS.map(([levelAnchor, , ceiling]) => [levelAnchor, ceiling]))
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const finaleFloor = finalBoss && dungeon.finalBossDurability !== "standard";
|
||||
const bossFloor = partyHealthFloor * (finaleFloor ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1);
|
||||
const bossFloor = partyHealthFloor * (finaleFloor ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER
|
||||
: biteSizedFloorScale);
|
||||
// A test room's sole objective must not promote every boss to a campaign
|
||||
// finale. A reduced floor still prevents high-level source creatures with
|
||||
// tiny extrapolated level-two health from dying before mechanics can run.
|
||||
@@ -93,7 +111,9 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
||||
const scaledHealth = boss
|
||||
? testing
|
||||
? Math.max(Math.min(originalHealth, testingBossCeiling), testingBossFloor)
|
||||
: Math.max(originalHealth, bossFloor)
|
||||
: biteSizedBoss
|
||||
? Math.min(Math.max(originalHealth, bossFloor), partyHealthFloor * biteSizedCeilingScale)
|
||||
: Math.max(originalHealth, bossFloor)
|
||||
: originalHealth;
|
||||
const testingDamageScale = testing && characterSource === "wow"
|
||||
? interpolatedFloor(level, WOW_BOSS_TEST_DAMAGE_SCALE)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { useDungeonRaceAffixStore } from "./dungeonRaceAffixRuntime";
|
||||
import { getPartyRuntimePosition, type PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
|
||||
function away(position: PartyWorldPosition, origin: PartyWorldPosition, distance: number): PartyWorldPosition {
|
||||
const dx = position[0] - origin[0]; const dz = position[2] - origin[2];
|
||||
const length = Math.hypot(dx, dz);
|
||||
const x = length > 0.01 ? dx / length : 1;
|
||||
const z = length > 0.01 ? dz / length : 0;
|
||||
return [position[0] + x * distance, position[1], position[2] + z * distance];
|
||||
}
|
||||
|
||||
/** Returns a temporary safe movement goal; the scene still validates and routes it on navmesh. */
|
||||
export function raceAffixAvoidanceDestination(
|
||||
memberId: string,
|
||||
position: PartyWorldPosition,
|
||||
now: number,
|
||||
): PartyWorldPosition | null {
|
||||
for (const effect of useDungeonRaceAffixStore.getState().effects) {
|
||||
if (now >= effect.expiresAt) continue;
|
||||
const center = effect.kind === "storm" && effect.targetId
|
||||
? effect.targetId === "__player__" ? useCombatStore.getState().playerPosition
|
||||
: getPartyRuntimePosition(effect.targetId) ?? effect.position
|
||||
: effect.position;
|
||||
if (Math.abs(position[1] - center[1]) > 3) continue;
|
||||
const planar = Math.hypot(position[0] - center[0], position[2] - center[2]);
|
||||
if (effect.kind === "storm" && effect.targetId === memberId && now < effect.resolvesAt) {
|
||||
const others = [useCombatStore.getState().playerPosition,
|
||||
...usePartyStore.getState().members.filter((member) => member.id !== memberId && member.health > 0)
|
||||
.map((member) => getPartyRuntimePosition(member.id)).filter((point): point is PartyWorldPosition => point !== null)];
|
||||
const nearest = others.sort((left, right) => Math.hypot(left[0] - position[0], left[2] - position[2])
|
||||
- Math.hypot(right[0] - position[0], right[2] - position[2]))[0];
|
||||
if (nearest && Math.hypot(nearest[0] - position[0], nearest[2] - position[2]) < 4)
|
||||
return away(position, nearest, 4);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "ring" && now < effect.resolvesAt && planar >= 3.5 && planar <= 7.5) {
|
||||
if (planar <= 5.5) return away(position, center, -(planar - 3));
|
||||
return away(position, center, 8 - planar);
|
||||
}
|
||||
if (["volatile", "ash", "star", "storm", "predator"].includes(effect.kind)
|
||||
&& (now < effect.resolvesAt || ["ash", "predator"].includes(effect.kind))
|
||||
&& planar <= effect.radius + 0.7) {
|
||||
if (effect.kind === "storm" && effect.targetId === memberId) continue;
|
||||
return away(position, center, effect.radius + 1 - planar);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const lastStand = new Map<string, { stacks: number; expiresAt: number }>();
|
||||
let boonUntil = 0;
|
||||
|
||||
export function applyLastStand(id: string, now: number): number {
|
||||
const previous = lastStand.get(id);
|
||||
const stacks = Math.min(3, (previous && previous.expiresAt > now ? previous.stacks : 0) + 1);
|
||||
lastStand.set(id, { stacks, expiresAt: now + 6_000 });
|
||||
return stacks;
|
||||
}
|
||||
|
||||
export function lastStandStacks(id: string, now: number): number {
|
||||
const state = lastStand.get(id);
|
||||
if (!state || state.expiresAt <= now) { lastStand.delete(id); return 0; }
|
||||
return state.stacks;
|
||||
}
|
||||
export function activeLastStand(now: number): readonly { id: string; stacks: number }[] {
|
||||
return [...lastStand.keys()].flatMap((id) => {
|
||||
const stacks = lastStandStacks(id, now);
|
||||
return stacks ? [{ id, stacks }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function raceAffixMobDamageMultiplier(id: string, now: number): number {
|
||||
return 1 + lastStandStacks(id, now) * 0.25;
|
||||
}
|
||||
|
||||
export function grantRaceAffixBoon(now: number): void { boonUntil = now + 8_000; }
|
||||
export function raceAffixHasteMultiplier(now = Date.now()): number { return boonUntil > now ? 1.15 : 1; }
|
||||
export function raceAffixBoonUntil(): number { return boonUntil; }
|
||||
|
||||
export function shiftDungeonRaceAffixBuffs(elapsedMs: number): void {
|
||||
if (elapsedMs <= 0) return;
|
||||
for (const [id, state] of lastStand) lastStand.set(id, { ...state, expiresAt: state.expiresAt + elapsedMs });
|
||||
if (boonUntil > 0) boonUntil += elapsedMs;
|
||||
}
|
||||
|
||||
export function resetDungeonRaceAffixBuffs(): void {
|
||||
lastStand.clear();
|
||||
boonUntil = 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
export function registerDungeonRaceAffixCleanup(handler: () => void): void { cleanup = handler; }
|
||||
export function cleanupDungeonRaceAffixes(): void { cleanup?.(); }
|
||||
@@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createRaceBot } from "../app/dungeonRaceBot";
|
||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { lastStandStacks, raceAffixBoonUntil, raceAffixHasteMultiplier,
|
||||
raceAffixMobDamageMultiplier } from "./dungeonRaceAffixBuffs";
|
||||
import { raceAffixAvoidanceDestination } from "./dungeonRaceAffixAvoidance";
|
||||
import { advanceDungeonRaceAffixes, activeRaceOrbId, clearDungeonRaceAffixes,
|
||||
resumeDungeonRaceAffixes, suspendDungeonRaceAffixes, useDungeonRaceAffixStore } from "./dungeonRaceAffixRuntime";
|
||||
import { dungeonRaceAffixPace, rollDungeonRaceAffixes, validDungeonRaceAffixes } from "./dungeonRaceAffixes";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
const dungeonId = "bite-sized-stormwind-stockade";
|
||||
const definition = dungeonDefinitionById(dungeonId)!;
|
||||
const trashIds = definition.staticSpawns.filter((spawn) => !definition.bosses.some((boss) => boss.id === spawn.id))
|
||||
.map((spawn) => spawn.id);
|
||||
|
||||
function start(killId: string, movementId = "falling-stars") {
|
||||
useDungeonRaceStore.setState({ mode: "practice", inGame: true, seed: "affix-test", dungeonId,
|
||||
startAt: 10_000, affixIds: [killId, movementId] });
|
||||
advanceDungeonRaceAffixes(10_000, []);
|
||||
}
|
||||
|
||||
function deadTrash(id: string, position: readonly [number, number, number] = [0, 0, 0]) {
|
||||
setMobPosition(id, position);
|
||||
useCombatStore.getState().registerMob(id, { name: id, level: 20, maxHealth: 100 });
|
||||
const mob = useCombatStore.getState().mobs[id];
|
||||
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
|
||||
[id]: { ...mob, dead: true, health: 0, diedAt: 10_000 } } }));
|
||||
}
|
||||
|
||||
describe("Bite-Sized race mechanics", () => {
|
||||
beforeEach(() => {
|
||||
useDungeonRaceStore.getState().reset();
|
||||
clearDungeonRaceAffixes();
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 20 });
|
||||
useCombatStore.setState((state) => ({ mobs: {}, playerPosition: [0, 0, 0], health: state.maxHealth }));
|
||||
usePartyStore.setState({ members: [] });
|
||||
useDungeonRunStore.setState({ progress: null });
|
||||
useGameStore.setState({ activeDungeonId: dungeonId, gameMode: "dungeon" });
|
||||
});
|
||||
|
||||
it("rolls a valid shared pair and uses both affixes in the NPC pace", () => {
|
||||
const pairs = new Set<string>();
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
const pair = rollDungeonRaceAffixes(`race-${index}`);
|
||||
expect(validDungeonRaceAffixes(pair)).toBe(true);
|
||||
expect(pair).toEqual(rollDungeonRaceAffixes(`race-${index}`));
|
||||
pairs.add(pair.join("+"));
|
||||
}
|
||||
expect(pairs.size).toBe(20);
|
||||
const pair = rollDungeonRaceAffixes("bot-pair");
|
||||
const base = createRaceBot("bot-pair", "regular", "wow");
|
||||
const tuned = createRaceBot("bot-pair", "regular", "wow", pair);
|
||||
expect(Math.abs(tuned.finishMs - base.finishMs * dungeonRaceAffixPace(pair))).toBeLessThan(2);
|
||||
expect(tuned.milestones.at(-1)?.atMs).toBe(tuned.finishMs);
|
||||
});
|
||||
|
||||
it("deduplicates simultaneous volatile blasts and does not hit another floor", () => {
|
||||
start("volatile-remains");
|
||||
deadTrash(trashIds[0]); deadTrash(trashIds[1]);
|
||||
advanceDungeonRaceAffixes(10_100, trashIds.slice(0, 2));
|
||||
const full = useCombatStore.getState().health;
|
||||
advanceDungeonRaceAffixes(15_099, []);
|
||||
expect(useCombatStore.getState().health).toBe(full);
|
||||
advanceDungeonRaceAffixes(15_100, []);
|
||||
expect(useCombatStore.getState().health).toBeLessThan(full);
|
||||
expect(useCombatStore.getState().health).toBeGreaterThan(full * 0.6);
|
||||
const hit = useCombatStore.getState().health;
|
||||
advanceDungeonRaceAffixes(15_200, []);
|
||||
expect(useCombatStore.getState().health).toBe(hit);
|
||||
useCombatStore.setState({ playerPosition: [0, 8, 0] });
|
||||
deadTrash(trashIds[2]);
|
||||
advanceDungeonRaceAffixes(16_000, [trashIds[2]]);
|
||||
advanceDungeonRaceAffixes(21_000, []);
|
||||
expect(useCombatStore.getState().health).toBe(hit);
|
||||
});
|
||||
|
||||
it("stacks nearby Last Stand to three and expires after six seconds", () => {
|
||||
start("last-stand");
|
||||
const survivor = trashIds[0]; const far = trashIds[1];
|
||||
setMobPosition(survivor, [2, 0, 0]);
|
||||
useCombatStore.getState().registerMob(survivor, { name: "survivor", maxHealth: 100 });
|
||||
setMobPosition(far, [12, 0, 0]);
|
||||
useCombatStore.getState().registerMob(far, { name: "far", maxHealth: 100 });
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const id = trashIds[index + 2]; deadTrash(id);
|
||||
advanceDungeonRaceAffixes(10_100 + index * 100, [id]);
|
||||
}
|
||||
expect(lastStandStacks(survivor, 10_400)).toBe(3);
|
||||
expect(raceAffixMobDamageMultiplier(survivor, 10_400)).toBe(1.75);
|
||||
expect(raceAffixMobDamageMultiplier(far, 10_400)).toBe(1);
|
||||
expect(raceAffixMobDamageMultiplier(survivor, 16_401)).toBe(1);
|
||||
});
|
||||
|
||||
it("limits burning patches to three and ticks only on their floor", () => {
|
||||
start("lingering-ash");
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const id = trashIds[index]; deadTrash(id);
|
||||
advanceDungeonRaceAffixes(10_100 + index * 100, [id]);
|
||||
}
|
||||
expect(useDungeonRaceAffixStore.getState().effects.filter((effect) => effect.kind === "ash")).toHaveLength(3);
|
||||
const full = useCombatStore.getState().health;
|
||||
advanceDungeonRaceAffixes(11_500, []);
|
||||
expect(useCombatStore.getState().health).toBeLessThan(full);
|
||||
useCombatStore.setState({ playerPosition: [0, 8, 0] });
|
||||
const after = useCombatStore.getState().health;
|
||||
advanceDungeonRaceAffixes(12_500, []);
|
||||
expect(useCombatStore.getState().health).toBe(after);
|
||||
});
|
||||
|
||||
it("ticks Ash for eight seconds and Predator for six seconds", () => {
|
||||
start("lingering-ash");
|
||||
deadTrash(trashIds[0]);
|
||||
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
|
||||
let previous = useCombatStore.getState().health;
|
||||
for (let second = 1; second <= 8; second += 1) {
|
||||
advanceDungeonRaceAffixes(10_100 + second * 1_000, []);
|
||||
const current = useCombatStore.getState().health;
|
||||
expect(current).toBeLessThan(previous);
|
||||
previous = current;
|
||||
}
|
||||
expect(useDungeonRaceAffixStore.getState().effects.some((effect) => effect.kind === "ash")).toBe(false);
|
||||
clearDungeonRaceAffixes();
|
||||
useDungeonRaceStore.setState({ seed: "predator-test", affixIds: ["last-stand", "predator-mark"] });
|
||||
advanceDungeonRaceAffixes(10_000, []);
|
||||
advanceDungeonRaceAffixes(20_000, []);
|
||||
previous = useCombatStore.getState().health;
|
||||
for (let second = 3; second <= 8; second += 1) {
|
||||
advanceDungeonRaceAffixes(20_000 + second * 1_000, []);
|
||||
const current = useCombatStore.getState().health;
|
||||
expect(current).toBeLessThan(previous);
|
||||
previous = current;
|
||||
}
|
||||
expect(useDungeonRaceAffixStore.getState().effects.some((effect) => effect.kind === "predator")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets damage defeat an orb and pulses if a later orb expires", () => {
|
||||
start("unstable-orb");
|
||||
deadTrash(trashIds[0]);
|
||||
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
|
||||
const orb = activeRaceOrbId();
|
||||
expect(orb).toBeTruthy();
|
||||
useCombatStore.getState().damageMob(orb!, 1_000, 10_200);
|
||||
const full = useCombatStore.getState().health;
|
||||
advanceDungeonRaceAffixes(18_100, []);
|
||||
expect(activeRaceOrbId()).toBeNull();
|
||||
expect(useCombatStore.getState().health).toBe(full);
|
||||
deadTrash(trashIds[1]);
|
||||
advanceDungeonRaceAffixes(25_100, [trashIds[1]]);
|
||||
expect(activeRaceOrbId()).toBeTruthy();
|
||||
advanceDungeonRaceAffixes(33_100, []);
|
||||
expect(useCombatStore.getState().health).toBeLessThan(full);
|
||||
expect(activeRaceOrbId()).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a pickup before granting eight seconds of party haste", () => {
|
||||
start("risky-boon");
|
||||
deadTrash(trashIds[0]);
|
||||
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
|
||||
expect(raceAffixBoonUntil()).toBe(0);
|
||||
advanceDungeonRaceAffixes(10_600, []);
|
||||
expect(raceAffixHasteMultiplier(10_600)).toBe(1.15);
|
||||
expect(raceAffixHasteMultiplier(18_601)).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["falling-stars", "star"], ["stormcall", "storm"],
|
||||
["arcane-pulse", "ring"], ["predator-mark", "predator"],
|
||||
])("creates the %s warning on the ten-second cadence", (movementId, kind) => {
|
||||
start("last-stand", movementId);
|
||||
advanceDungeonRaceAffixes(19_999, []);
|
||||
expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0);
|
||||
advanceDungeonRaceAffixes(20_000, []);
|
||||
const effect = useDungeonRaceAffixStore.getState().effects[0];
|
||||
expect(effect.kind).toBe(kind);
|
||||
expect(effect.resolvesAt).toBe(22_000);
|
||||
const destination = raceAffixAvoidanceDestination("npc", kind === "ring" ? [5, 0, 0] : [0, 0, 0], 20_100);
|
||||
expect(destination).toBeTruthy();
|
||||
const distance = Math.hypot(destination![0] - effect.position[0], destination![2] - effect.position[2]);
|
||||
expect(kind === "ring" ? distance < 4 || distance > 7 : distance > effect.radius).toBe(true);
|
||||
});
|
||||
|
||||
it("freezes a warning and the next hazard across an offline pause", () => {
|
||||
start("volatile-remains");
|
||||
deadTrash(trashIds[0]);
|
||||
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
|
||||
suspendDungeonRaceAffixes(11_000);
|
||||
resumeDungeonRaceAffixes(21_000);
|
||||
expect(useDungeonRaceAffixStore.getState().effects[0].resolvesAt).toBe(25_100);
|
||||
expect(useDungeonRaceAffixStore.getState().nextMovementAt).toBe(30_000);
|
||||
const full = useCombatStore.getState().health;
|
||||
advanceDungeonRaceAffixes(25_099, []);
|
||||
expect(useCombatStore.getState().health).toBe(full);
|
||||
advanceDungeonRaceAffixes(25_100, []);
|
||||
expect(useCombatStore.getState().health).toBeLessThan(full);
|
||||
});
|
||||
|
||||
it("damages only the warned ring band on its floor", () => {
|
||||
start("last-stand", "arcane-pulse");
|
||||
advanceDungeonRaceAffixes(20_000, []);
|
||||
useCombatStore.setState({ playerPosition: [3, 0, 0] });
|
||||
const full = useCombatStore.getState().health;
|
||||
advanceDungeonRaceAffixes(22_000, []);
|
||||
expect(useCombatStore.getState().health).toBe(full);
|
||||
advanceDungeonRaceAffixes(30_000, []);
|
||||
useCombatStore.setState({ playerPosition: [5, 8, 0] });
|
||||
advanceDungeonRaceAffixes(32_000, []);
|
||||
expect(useCombatStore.getState().health).toBe(full);
|
||||
useCombatStore.setState({ playerPosition: [0, 0, 0] });
|
||||
advanceDungeonRaceAffixes(40_000, []);
|
||||
useCombatStore.setState({ playerPosition: [5, 0, 0] });
|
||||
advanceDungeonRaceAffixes(42_000, []);
|
||||
expect(useCombatStore.getState().health).toBeLessThan(full);
|
||||
});
|
||||
|
||||
it("clears live hazards and buffs when the race run ends", () => {
|
||||
start("risky-boon");
|
||||
deadTrash(trashIds[0]);
|
||||
advanceDungeonRaceAffixes(10_100, [trashIds[0]]);
|
||||
advanceDungeonRaceAffixes(10_600, []);
|
||||
expect(raceAffixBoonUntil()).toBeGreaterThan(0);
|
||||
useDungeonRaceStore.getState().reset();
|
||||
expect(useDungeonRaceAffixStore.getState().effects).toHaveLength(0);
|
||||
expect(raceAffixBoonUntil()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { create } from "zustand";
|
||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { hashDungeonRaceAffixSeed, type DungeonRaceAffixPair } from "./dungeonRaceAffixes";
|
||||
import { applyLastStand, grantRaceAffixBoon, resetDungeonRaceAffixBuffs, shiftDungeonRaceAffixBuffs } from "./dungeonRaceAffixBuffs";
|
||||
import { registerDungeonRaceAffixCleanup } from "./dungeonRaceAffixLifecycle";
|
||||
import { getMobPosition, removeMobPosition, setMobPosition, type CombatPosition } from "./mobRuntimeRegistry";
|
||||
import { getPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
|
||||
export type RaceAffixEffectKind = "volatile" | "ash" | "orb" | "boon" | "star" | "storm" | "ring" | "predator";
|
||||
export interface RaceAffixEffect {
|
||||
readonly id: string;
|
||||
readonly kind: RaceAffixEffectKind;
|
||||
readonly position: CombatPosition;
|
||||
readonly createdAt: number;
|
||||
readonly resolvesAt: number;
|
||||
readonly expiresAt: number;
|
||||
readonly radius: number;
|
||||
readonly targetId?: string;
|
||||
readonly tickAt?: number;
|
||||
readonly trail?: readonly { readonly at: number; readonly position: CombatPosition }[];
|
||||
}
|
||||
|
||||
interface RaceAffixRuntimeState {
|
||||
readonly seed: string | null;
|
||||
readonly pair: DungeonRaceAffixPair | null;
|
||||
readonly effects: readonly RaceAffixEffect[];
|
||||
readonly nextMovementAt: number;
|
||||
readonly lastKillProcAt: number;
|
||||
readonly sequence: number;
|
||||
readonly movementIndex: number;
|
||||
readonly wiped: boolean;
|
||||
}
|
||||
|
||||
const emptyState = (): RaceAffixRuntimeState => ({
|
||||
seed: null, pair: null, effects: [], nextMovementAt: 0,
|
||||
lastKillProcAt: Number.NEGATIVE_INFINITY, sequence: 0, movementIndex: 0, wiped: false,
|
||||
});
|
||||
export const useDungeonRaceAffixStore = create<RaceAffixRuntimeState>(() => emptyState());
|
||||
let suspendedAt: number | null = null;
|
||||
|
||||
/** The offline simulation stops while paused or hidden; preserve remaining warning and buff time. */
|
||||
export function suspendDungeonRaceAffixes(now = Date.now()): void {
|
||||
if (suspendedAt === null && useDungeonRaceAffixStore.getState().seed) suspendedAt = now;
|
||||
}
|
||||
|
||||
export function resumeDungeonRaceAffixes(now = Date.now()): void {
|
||||
if (suspendedAt === null) return;
|
||||
const elapsedMs = Math.max(0, now - suspendedAt);
|
||||
suspendedAt = null;
|
||||
if (!elapsedMs) return;
|
||||
const state = useDungeonRaceAffixStore.getState();
|
||||
if (!state.seed) return;
|
||||
shiftDungeonRaceAffixBuffs(elapsedMs);
|
||||
useDungeonRaceAffixStore.setState({
|
||||
effects: state.effects.map((effect) => ({ ...effect,
|
||||
createdAt: effect.createdAt + elapsedMs, resolvesAt: effect.resolvesAt + elapsedMs,
|
||||
expiresAt: effect.expiresAt + elapsedMs,
|
||||
...(effect.tickAt === undefined ? {} : { tickAt: effect.tickAt + elapsedMs }),
|
||||
...(effect.trail ? { trail: effect.trail.map((point) => ({ ...point, at: point.at + elapsedMs })) } : {}),
|
||||
})),
|
||||
nextMovementAt: state.nextMovementAt + elapsedMs,
|
||||
lastKillProcAt: state.lastKillProcAt + elapsedMs,
|
||||
});
|
||||
}
|
||||
|
||||
function livingActors(): readonly { id: string; health: number; maxHealth: number; position: CombatPosition }[] {
|
||||
const combat = useCombatStore.getState();
|
||||
const members = usePartyStore.getState().members;
|
||||
return [
|
||||
...(combat.health > 0 ? [{ id: "__player__", health: combat.health,
|
||||
maxHealth: combat.maxHealth, position: combat.playerPosition }] : []),
|
||||
...members.filter((member) => member.health > 0).flatMap((member) => {
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
return position ? [{ id: member.id, health: member.health, maxHealth: member.maxHealth, position }] : [];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function near(a: CombatPosition, b: CombatPosition, radius: number): boolean {
|
||||
const dx = a[0] - b[0]; const dz = a[2] - b[2];
|
||||
return Math.abs(a[1] - b[1]) <= 3 && dx * dx + dz * dz <= radius * radius;
|
||||
}
|
||||
|
||||
function hitActors(ids: Iterable<string>, percent: number, school: "fire" | "nature" | "arcane" | "shadow"): void {
|
||||
const targets = new Set(ids);
|
||||
if (targets.has("__player__")) {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0) combat.damagePlayer(Math.max(1, Math.round(combat.maxHealth * percent)), school, combat.level);
|
||||
}
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
if (member.health > 0 && targets.has(member.id)) {
|
||||
usePartyStore.getState().damageMember(member.id, Math.max(1, Math.round(member.maxHealth * percent)),
|
||||
school, useCombatStore.getState().level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mobIsOriginalTrash(mob: MobCombatState | undefined, originalIds: ReadonlySet<string>): mob is MobCombatState {
|
||||
return Boolean(mob && !mob.boss && originalIds.has(mob.id));
|
||||
}
|
||||
|
||||
function removeEffectMob(effect: RaceAffixEffect): void {
|
||||
if (effect.kind !== "orb") return;
|
||||
useCombatStore.getState().removeMob(effect.id);
|
||||
removeMobPosition(effect.id);
|
||||
}
|
||||
|
||||
export function resetDungeonRaceAffixEffects(now = Date.now()): void {
|
||||
const state = useDungeonRaceAffixStore.getState();
|
||||
for (const effect of state.effects) removeEffectMob(effect);
|
||||
resetDungeonRaceAffixBuffs();
|
||||
useDungeonRaceAffixStore.setState({ effects: [], nextMovementAt: now + 10_000,
|
||||
lastKillProcAt: Number.NEGATIVE_INFINITY, wiped: false });
|
||||
}
|
||||
|
||||
export function clearDungeonRaceAffixes(): void {
|
||||
suspendedAt = null;
|
||||
for (const effect of useDungeonRaceAffixStore.getState().effects) removeEffectMob(effect);
|
||||
resetDungeonRaceAffixBuffs();
|
||||
useDungeonRaceAffixStore.setState(emptyState());
|
||||
}
|
||||
registerDungeonRaceAffixCleanup(clearDungeonRaceAffixes);
|
||||
|
||||
export function activeRaceOrbId(): string | null {
|
||||
const effect = useDungeonRaceAffixStore.getState().effects.find((candidate) => candidate.kind === "orb");
|
||||
return effect && !useCombatStore.getState().mobs[effect.id]?.dead ? effect.id : null;
|
||||
}
|
||||
|
||||
function createMovementEffect(id: string, kind: string, now: number, targetRoll: number): RaceAffixEffect | null {
|
||||
const actors = livingActors();
|
||||
if (!actors.length) return null;
|
||||
const target = actors[targetRoll % actors.length];
|
||||
const base = { id, position: target.position, createdAt: now, resolvesAt: now + 2_000,
|
||||
expiresAt: now + 2_500, radius: 3 };
|
||||
if (kind === "falling-stars") return { ...base, kind: "star" };
|
||||
if (kind === "stormcall") return { ...base, kind: "storm", targetId: target.id };
|
||||
if (kind === "arcane-pulse") return { ...base, kind: "ring", radius: 7 };
|
||||
return { ...base, kind: "predator", targetId: target.id, radius: 2,
|
||||
expiresAt: now + 8_000, tickAt: now + 3_000,
|
||||
trail: [{ at: now, position: target.position }] };
|
||||
}
|
||||
|
||||
function actorPosition(id: string): CombatPosition | null {
|
||||
return id === "__player__" ? useCombatStore.getState().playerPosition : getPartyRuntimePosition(id);
|
||||
}
|
||||
|
||||
/** Advances only while the playable dungeon combat tick is active. */
|
||||
export function advanceDungeonRaceAffixes(now: number, deathIds: readonly string[]): void {
|
||||
const race = useDungeonRaceStore.getState();
|
||||
const game = useGameStore.getState();
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
if (!race.inGame || !race.seed || !race.affixIds || race.dungeonId !== game.activeDungeonId) {
|
||||
if (useDungeonRaceAffixStore.getState().seed) clearDungeonRaceAffixes();
|
||||
return;
|
||||
}
|
||||
if (progress?.finishedAt !== null && progress?.finishedAt !== undefined) {
|
||||
if (useDungeonRaceAffixStore.getState().seed) clearDungeonRaceAffixes();
|
||||
return;
|
||||
}
|
||||
let state = useDungeonRaceAffixStore.getState();
|
||||
if (state.seed !== race.seed) {
|
||||
clearDungeonRaceAffixes();
|
||||
state = { ...emptyState(), seed: race.seed, pair: race.affixIds, nextMovementAt: now + 10_000 };
|
||||
useDungeonRaceAffixStore.setState(state);
|
||||
}
|
||||
const actors = livingActors();
|
||||
if (!actors.length) {
|
||||
if (!state.wiped) resetDungeonRaceAffixEffects(now);
|
||||
useDungeonRaceAffixStore.setState({ wiped: true });
|
||||
return;
|
||||
}
|
||||
if (state.wiped) {
|
||||
useDungeonRaceAffixStore.setState({ wiped: false, nextMovementAt: now + 10_000 });
|
||||
state = useDungeonRaceAffixStore.getState();
|
||||
}
|
||||
let effects = [...state.effects];
|
||||
let sequence = state.sequence;
|
||||
let lastKillProcAt = state.lastKillProcAt;
|
||||
const [killId, movementId] = race.affixIds;
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const definition = dungeonDefinitionById(game.activeDungeonId);
|
||||
const originalIds = new Set(definition?.staticSpawns.map((spawn) => spawn.id) ?? []);
|
||||
for (const deathId of deathIds) {
|
||||
const mob = mobs[deathId];
|
||||
if (!mobIsOriginalTrash(mob, originalIds)) continue;
|
||||
const position = getMobPosition(deathId) ?? mob.homePosition;
|
||||
if (!position) continue;
|
||||
const id = `race-affix-${race.seed}-${++sequence}`;
|
||||
if (killId === "volatile-remains") {
|
||||
effects.push({ id, kind: "volatile", position, createdAt: now, resolvesAt: now + 5_000,
|
||||
expiresAt: now + 5_500, radius: 4 });
|
||||
} else if (killId === "last-stand") {
|
||||
for (const survivor of Object.values(mobs)) {
|
||||
const survivorPosition = getMobPosition(survivor.id);
|
||||
if (survivor.id !== deathId && mobIsOriginalTrash(survivor, originalIds) && !survivor.dead
|
||||
&& survivorPosition && near(position, survivorPosition, 8)) applyLastStand(survivor.id, now);
|
||||
}
|
||||
} else if (killId === "lingering-ash") {
|
||||
effects.push({ id, kind: "ash", position, createdAt: now, resolvesAt: now,
|
||||
expiresAt: now + 8_000, radius: 2.5, tickAt: now + 1_000 });
|
||||
const ash = effects.filter((effect) => effect.kind === "ash");
|
||||
if (ash.length > 3) effects = effects.filter((effect) => effect.id !== ash[0].id);
|
||||
} else if (now - lastKillProcAt >= 15_000 && !effects.some((effect) => effect.kind === "orb" || effect.kind === "boon")) {
|
||||
lastKillProcAt = now;
|
||||
if (killId === "unstable-orb") {
|
||||
setMobPosition(id, position);
|
||||
useCombatStore.getState().registerMob(id, { name: "Unstable Orb", level: mob.level,
|
||||
maxHealth: Math.max(20, Math.round(mob.maxHealth * 0.1)), xpReward: 0, hasLoot: false,
|
||||
aggroRange: 0, moveSpeed: 0, damageMultiplier: 0.01 });
|
||||
effects.push({ id, kind: "orb", position, createdAt: now, resolvesAt: now + 8_000,
|
||||
expiresAt: now + 8_000, radius: 1 });
|
||||
} else if (killId === "risky-boon") {
|
||||
effects.push({ id, kind: "boon", position, createdAt: now, resolvesAt: now,
|
||||
expiresAt: now + 8_000, radius: 1.5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
let nextMovementAt = state.nextMovementAt;
|
||||
let movementIndex = state.movementIndex;
|
||||
if (now >= nextMovementAt) {
|
||||
const id = `race-affix-${race.seed}-${++sequence}`;
|
||||
const effect = createMovementEffect(id, movementId, now,
|
||||
hashDungeonRaceAffixSeed(`${race.seed}:hazard:${movementIndex++}`));
|
||||
if (effect) effects.push(effect);
|
||||
nextMovementAt = now + 10_000;
|
||||
}
|
||||
const volatileHits = new Set<string>();
|
||||
const nextEffects: RaceAffixEffect[] = [];
|
||||
for (const effect of effects) {
|
||||
if (effect.kind === "volatile" && now >= effect.resolvesAt && effect.tickAt === undefined) {
|
||||
for (const actor of livingActors()) if (near(effect.position, actor.position, effect.radius)) volatileHits.add(actor.id);
|
||||
nextEffects.push({ ...effect, tickAt: now });
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "ash" && now >= (effect.tickAt ?? Infinity)
|
||||
&& (effect.tickAt ?? Infinity) <= effect.expiresAt) {
|
||||
hitActors(livingActors().filter((actor) => near(effect.position, actor.position, effect.radius)).map((actor) => actor.id), 0.04, "fire");
|
||||
if (now < effect.expiresAt) nextEffects.push({ ...effect, tickAt: (effect.tickAt ?? now) + 1_000 });
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "boon" && now >= effect.createdAt + 500
|
||||
&& livingActors().some((actor) => actor.id === "__player__"
|
||||
&& near(effect.position, actor.position, effect.radius))) {
|
||||
grantRaceAffixBoon(now);
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "orb") {
|
||||
if (useCombatStore.getState().mobs[effect.id]?.dead) { removeEffectMob(effect); continue; }
|
||||
if (now >= effect.expiresAt) {
|
||||
hitActors(livingActors().map((actor) => actor.id), 0.15, "arcane");
|
||||
removeEffectMob(effect);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (["star", "storm", "ring"].includes(effect.kind) && now >= effect.resolvesAt && effect.tickAt === undefined) {
|
||||
const center = effect.kind === "storm" ? actorPosition(effect.targetId ?? "") ?? effect.position : effect.position;
|
||||
const hits = livingActors().filter((actor) => {
|
||||
if (effect.kind === "storm" && actor.id === effect.targetId) return false;
|
||||
if (effect.kind === "ring") {
|
||||
const dx = actor.position[0] - center[0]; const dz = actor.position[2] - center[2];
|
||||
const squared = dx * dx + dz * dz;
|
||||
return Math.abs(actor.position[1] - center[1]) <= 3 && squared >= 16 && squared <= 49;
|
||||
}
|
||||
return near(center, actor.position, effect.radius);
|
||||
});
|
||||
hitActors(hits.map((actor) => actor.id), effect.kind === "ring" ? 0.18 : 0.2,
|
||||
effect.kind === "storm" ? "nature" : effect.kind === "ring" ? "arcane" : "fire");
|
||||
nextEffects.push({ ...effect, position: center, tickAt: now });
|
||||
continue;
|
||||
}
|
||||
if (effect.kind === "predator") {
|
||||
const current = actorPosition(effect.targetId ?? "");
|
||||
const trail = [...(effect.trail ?? []), ...(current ? [{ at: now, position: current }] : [])]
|
||||
.filter((point) => point.at >= now - 1_500);
|
||||
const delayed = trail.find((point) => point.at >= now - 1_000) ?? trail[0];
|
||||
const position = now < effect.resolvesAt ? current ?? effect.position
|
||||
: delayed?.position ?? effect.position;
|
||||
if (now >= (effect.tickAt ?? Infinity) && (effect.tickAt ?? Infinity) <= effect.expiresAt) {
|
||||
hitActors(livingActors().filter((actor) => near(position, actor.position, 2)).map((actor) => actor.id), 0.05, "shadow");
|
||||
if (now < effect.expiresAt) nextEffects.push({ ...effect, position, trail,
|
||||
tickAt: (effect.tickAt ?? now) + 1_000 });
|
||||
} else if (now < effect.expiresAt) nextEffects.push({ ...effect, position, trail });
|
||||
continue;
|
||||
}
|
||||
if (now < effect.expiresAt) nextEffects.push(effect);
|
||||
}
|
||||
if (volatileHits.size) hitActors(volatileHits, 0.2, "fire");
|
||||
useDungeonRaceAffixStore.setState({ effects: nextEffects, nextMovementAt,
|
||||
lastKillProcAt, sequence, movementIndex });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"kill": [
|
||||
{ "id": "volatile-remains", "name": "Volatile Remains", "description": "Trash corpses explode after five seconds. Move out of the marked blast.", "botMultiplier": 1.04 },
|
||||
{ "id": "last-stand", "name": "Last Stand", "description": "Nearby trash gains stacking damage when an ally dies.", "botMultiplier": 1.05 },
|
||||
{ "id": "lingering-ash", "name": "Lingering Ash", "description": "Trash deaths leave short-lived burning ground.", "botMultiplier": 1.04 },
|
||||
{ "id": "unstable-orb", "name": "Unstable Orb", "description": "Destroy the orb from a trash death before it pulses.", "botMultiplier": 1.06 },
|
||||
{ "id": "risky-boon", "name": "Risky Boon", "description": "Collect a short-lived pickup for party haste.", "botMultiplier": 0.97 }
|
||||
],
|
||||
"movement": [
|
||||
{ "id": "falling-stars", "name": "Falling Stars", "description": "Marked ground is struck by a falling star every ten seconds.", "botMultiplier": 1.05 },
|
||||
{ "id": "stormcall", "name": "Stormcall", "description": "A marked ally must separate before lightning strikes.", "botMultiplier": 1.05 },
|
||||
{ "id": "arcane-pulse", "name": "Arcane Pulse", "description": "Move inside or outside the warned arcane ring.", "botMultiplier": 1.04 },
|
||||
{ "id": "predator-mark", "name": "Predator Mark", "description": "A ground hazard pursues a marked ally for six seconds.", "botMultiplier": 1.05 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import catalog from "./dungeonRaceAffixes.json";
|
||||
|
||||
export type DungeonRaceAffix = (typeof catalog.kill)[number] | (typeof catalog.movement)[number];
|
||||
export type DungeonRaceAffixPair = readonly [killId: string, movementId: string];
|
||||
|
||||
export function hashDungeonRaceAffixSeed(value: string): number {
|
||||
let hash = 2_166_136_261;
|
||||
for (const char of value) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16_777_619); }
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function rollDungeonRaceAffixes(seed: string): DungeonRaceAffixPair {
|
||||
return [
|
||||
catalog.kill[hashDungeonRaceAffixSeed(`${seed}:kill-affix`) % catalog.kill.length].id,
|
||||
catalog.movement[hashDungeonRaceAffixSeed(`${seed}:movement-affix`) % catalog.movement.length].id,
|
||||
];
|
||||
}
|
||||
|
||||
export function validDungeonRaceAffixes(value: unknown): value is DungeonRaceAffixPair {
|
||||
return Array.isArray(value) && value.length === 2
|
||||
&& catalog.kill.some((affix) => affix.id === value[0])
|
||||
&& catalog.movement.some((affix) => affix.id === value[1]);
|
||||
}
|
||||
|
||||
export function dungeonRaceAffix(id: string): DungeonRaceAffix | undefined {
|
||||
return [...catalog.kill, ...catalog.movement].find((entry) => entry.id === id);
|
||||
}
|
||||
|
||||
export function dungeonRaceAffixPace(pair: DungeonRaceAffixPair): number {
|
||||
return pair.reduce((factor, id) => factor * (dungeonRaceAffix(id)?.botMultiplier ?? 1), 1);
|
||||
}
|
||||
|
||||
export const DUNGEON_RACE_KILL_AFFIXES = catalog.kill;
|
||||
export const DUNGEON_RACE_MOVEMENT_AFFIXES = catalog.movement;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { appendCombatEvent, MAX_COMBAT_EVENT_HISTORY } from "./combatEvents";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore, type MobCombatState } from "./combatStore";
|
||||
import { RaceCombatRecorder } from "./dungeonRaceCombatLog";
|
||||
|
||||
function mob(id: string, dead = false, diedAt: number | null = null): MobCombatState {
|
||||
return { id, name: id, boss: id === "boss", maxHealth: 1_000,
|
||||
engaged: true, dead, diedAt, despawned: false } as MobCombatState;
|
||||
}
|
||||
|
||||
describe("race combat recording", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
});
|
||||
|
||||
it("keeps earlier events beyond the HUD ring and times kills and overlapping packs", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
useCombatStore.setState({ mobs: {} });
|
||||
const recorder = new RaceCombatRecorder("race-test", "bite-sized-test");
|
||||
try {
|
||||
vi.setSystemTime(1_200);
|
||||
useCombatStore.setState({ mobs: { first: mob("first"), boss: mob("boss") } });
|
||||
let hudHistory = [] as ReturnType<typeof appendCombatEvent>;
|
||||
for (let id = 1; id <= MAX_COMBAT_EVENT_HISTORY + 75; id += 1) {
|
||||
hudHistory = appendCombatEvent(hudHistory, { occurredAt: 1_200 + id,
|
||||
kind: "damage", sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: id % 2 ? "first" : "boss", abilityId: "test-aoe",
|
||||
rawAmount: 2, effectiveAmount: 2, overheal: 0, absorbed: 0,
|
||||
critical: false }, id);
|
||||
}
|
||||
vi.setSystemTime(2_000);
|
||||
useCombatStore.setState({ mobs: { first: mob("first", true, 2_000),
|
||||
boss: mob("boss", true, 2_000) } });
|
||||
const log = recorder.finish(1_000, 2_000);
|
||||
expect(hudHistory).toHaveLength(MAX_COMBAT_EVENT_HISTORY);
|
||||
expect(log.events).toHaveLength(MAX_COMBAT_EVENT_HISTORY + 75);
|
||||
expect(log.droppedEventCount).toBe(0);
|
||||
expect(log.kills).toHaveLength(2);
|
||||
expect(log.kills.every((kill) => kill.firstDamageAtMs !== null
|
||||
&& kill.durationMs < 800)).toBe(true);
|
||||
expect(log.packs).toHaveLength(1);
|
||||
expect(log.packs[0].mobIds).toEqual(["first", "boss"]);
|
||||
expect(log.packs[0].partyDamage.find((entry) => entry.actorId === PLAYER_AURA_ENTITY_ID)?.damage)
|
||||
.toBe((MAX_COMBAT_EVENT_HISTORY + 75) * 2);
|
||||
} finally { recorder.stop(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
|
||||
export const MAX_RACE_COMBAT_EVENTS = 10_000;
|
||||
export const MAX_RACE_COMBAT_KILLS = 1_000;
|
||||
|
||||
export interface RaceCombatEvent {
|
||||
readonly atMs: number;
|
||||
readonly kind: StructuredCombatEvent["kind"];
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly ability?: string;
|
||||
readonly raw: number;
|
||||
readonly effective: number;
|
||||
readonly overheal: number;
|
||||
readonly absorbed: number;
|
||||
readonly critical: boolean;
|
||||
readonly outcome?: string;
|
||||
}
|
||||
|
||||
export interface RaceMobKill {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly boss: boolean;
|
||||
readonly maxHealth: number;
|
||||
readonly engagedAtMs: number;
|
||||
readonly firstDamageAtMs: number | null;
|
||||
readonly diedAtMs: number;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
|
||||
export interface RaceCombatPack {
|
||||
readonly startedAtMs: number;
|
||||
readonly endedAtMs: number;
|
||||
readonly durationMs: number;
|
||||
readonly mobIds: readonly string[];
|
||||
readonly partyDamage: readonly { actorId: string; damage: number }[];
|
||||
}
|
||||
|
||||
export interface RaceCombatLog {
|
||||
readonly version: 1;
|
||||
readonly runId: string;
|
||||
readonly dungeonId: string;
|
||||
readonly durationMs: number;
|
||||
readonly completed: boolean;
|
||||
readonly events: readonly RaceCombatEvent[];
|
||||
readonly droppedEventCount: number;
|
||||
readonly kills: readonly RaceMobKill[];
|
||||
readonly packs: readonly RaceCombatPack[];
|
||||
readonly droppedKillCount: number;
|
||||
readonly party: readonly { actorId: string; label: string; classId: string;
|
||||
level: number; role: string }[];
|
||||
readonly partyDamage: readonly { actorId: string; damage: number }[];
|
||||
}
|
||||
|
||||
interface MobTiming {
|
||||
id: string;
|
||||
name: string;
|
||||
boss: boolean;
|
||||
maxHealth: number;
|
||||
engagedAt: number;
|
||||
diedAt: number | null;
|
||||
}
|
||||
|
||||
let active: RaceCombatRecorder | null = null;
|
||||
let lastPracticeLog: RaceCombatLog | null = null;
|
||||
|
||||
function boundedText(value: string): string { return value.slice(0, 128); }
|
||||
function elapsed(now: number, startedAt: number): number {
|
||||
return Math.max(0, Math.round(now - startedAt));
|
||||
}
|
||||
|
||||
/** Captures the entire race stream, independent of the 200-event HUD history. */
|
||||
export class RaceCombatRecorder {
|
||||
private readonly events: StructuredCombatEvent[] = [];
|
||||
private readonly mobs = new Map<string, MobTiming>();
|
||||
private readonly firstDamageAt = new Map<string, number>();
|
||||
private readonly damageByTarget = new Map<string, Map<string, number>>();
|
||||
private readonly ignoredMobIds = new Set<string>();
|
||||
private droppedEventCount = 0;
|
||||
private droppedKillCount = 0;
|
||||
private readonly unsubscribeEvents: () => void;
|
||||
private readonly unsubscribeMobs: () => void;
|
||||
|
||||
constructor(readonly runId: string, readonly dungeonId: string) {
|
||||
this.unsubscribeEvents = subscribeCombatEvents((event) => {
|
||||
if (event.kind === "damage" && event.effectiveAmount > 0
|
||||
&& !this.firstDamageAt.has(event.targetActorId) && this.firstDamageAt.size < 2_000) {
|
||||
this.firstDamageAt.set(event.targetActorId, event.occurredAt);
|
||||
}
|
||||
if (event.kind === "damage" && event.effectiveAmount > 0) {
|
||||
let sources = this.damageByTarget.get(event.targetActorId);
|
||||
if (!sources && this.damageByTarget.size < 2_000) {
|
||||
sources = new Map(); this.damageByTarget.set(event.targetActorId, sources);
|
||||
}
|
||||
if (sources && (sources.has(event.sourceActorId) || sources.size < 20)) {
|
||||
sources.set(event.sourceActorId,
|
||||
(sources.get(event.sourceActorId) ?? 0) + event.effectiveAmount);
|
||||
}
|
||||
}
|
||||
if (this.events.length < MAX_RACE_COMBAT_EVENTS) this.events.push(event);
|
||||
else this.droppedEventCount += 1;
|
||||
});
|
||||
this.unsubscribeMobs = useCombatStore.subscribe((state, previous) => {
|
||||
if (state.mobs !== previous.mobs) this.observeMobs(state.mobs);
|
||||
});
|
||||
}
|
||||
|
||||
private observeMobs(mobs: ReturnType<typeof useCombatStore.getState>["mobs"]): void {
|
||||
const now = Date.now();
|
||||
for (const mob of Object.values(mobs)) {
|
||||
if (mob.despawned || (!mob.engaged && !mob.dead)) continue;
|
||||
let timing = this.mobs.get(mob.id);
|
||||
if (!timing) {
|
||||
if (this.mobs.size >= MAX_RACE_COMBAT_KILLS) {
|
||||
if (!this.ignoredMobIds.has(mob.id)) { this.ignoredMobIds.add(mob.id); this.droppedKillCount += 1; }
|
||||
continue;
|
||||
}
|
||||
timing = { id: boundedText(mob.id), name: boundedText(mob.name), boss: mob.boss,
|
||||
maxHealth: Math.max(0, Math.round(mob.maxHealth)), engagedAt: Math.min(now, mob.diedAt ?? now),
|
||||
diedAt: null };
|
||||
this.mobs.set(mob.id, timing);
|
||||
}
|
||||
if (mob.dead && timing.diedAt === null) timing.diedAt = mob.diedAt ?? now;
|
||||
}
|
||||
}
|
||||
|
||||
finish(startedAt: number, finishedAt = Date.now(), completed = true): RaceCombatLog {
|
||||
this.unsubscribeEvents();
|
||||
this.unsubscribeMobs();
|
||||
const combat = useCombatStore.getState();
|
||||
const party = [{ actorId: PLAYER_AURA_ENTITY_ID, label: "Player", classId: combat.classId,
|
||||
level: combat.level, role: combat.gearRole }, ...usePartyStore.getState().members.map((member) => ({
|
||||
actorId: member.id, label: boundedText(member.name), classId: member.classId,
|
||||
level: member.level, role: member.role,
|
||||
}))];
|
||||
const partyIds = new Set(party.map((member) => member.actorId));
|
||||
const damage = new Map<string, number>([...partyIds].map((id) => [id, 0]));
|
||||
for (const mobId of this.mobs.keys()) for (const [actorId, dealt] of this.damageByTarget.get(mobId) ?? []) {
|
||||
if (partyIds.has(actorId)) damage.set(actorId, (damage.get(actorId) ?? 0) + dealt);
|
||||
}
|
||||
const completedMobs = [...this.mobs.values()].filter((mob) => mob.diedAt !== null)
|
||||
.sort((left, right) => (this.firstDamageAt.get(left.id) ?? left.engagedAt)
|
||||
- (this.firstDamageAt.get(right.id) ?? right.engagedAt));
|
||||
const packWindows: { start: number; end: number; ids: string[] }[] = [];
|
||||
for (const mob of completedMobs) {
|
||||
const firstDamageAt = this.firstDamageAt.get(mob.id) ?? mob.engagedAt;
|
||||
const latest = packWindows.at(-1);
|
||||
if (latest && firstDamageAt <= latest.end + 2_000) {
|
||||
latest.end = Math.max(latest.end, mob.diedAt!);
|
||||
latest.ids.push(mob.id);
|
||||
} else packWindows.push({ start: firstDamageAt, end: mob.diedAt!, ids: [mob.id] });
|
||||
}
|
||||
const packs = packWindows.map((window) => {
|
||||
const ids = new Set(window.ids);
|
||||
const partyDamage = new Map<string, number>([...partyIds].map((id) => [id, 0]));
|
||||
for (const mobId of ids) for (const [actorId, dealt] of this.damageByTarget.get(mobId) ?? []) {
|
||||
if (partyIds.has(actorId)) partyDamage.set(actorId, (partyDamage.get(actorId) ?? 0) + dealt);
|
||||
}
|
||||
return { startedAtMs: elapsed(window.start, startedAt), endedAtMs: elapsed(window.end, startedAt),
|
||||
durationMs: Math.max(1, Math.round(window.end - window.start)), mobIds: window.ids,
|
||||
partyDamage: [...partyDamage].map(([actorId, total]) => ({ actorId: boundedText(actorId),
|
||||
damage: Math.round(total) })) };
|
||||
});
|
||||
const events = this.events.map((event) => ({ atMs: elapsed(event.occurredAt, startedAt), kind: event.kind,
|
||||
source: boundedText(event.sourceActorId), target: boundedText(event.targetActorId),
|
||||
...(event.abilityId ? { ability: boundedText(event.abilityId) } : {}),
|
||||
raw: Math.round(event.rawAmount), effective: Math.round(event.effectiveAmount),
|
||||
overheal: Math.round(event.overheal), absorbed: Math.round(event.absorbed),
|
||||
critical: event.critical, ...(event.outcome ? { outcome: boundedText(event.outcome) } : {}) }));
|
||||
let removedForSize = 0;
|
||||
let log: RaceCombatLog = {
|
||||
version: 1, runId: this.runId, dungeonId: this.dungeonId,
|
||||
durationMs: elapsed(finishedAt, startedAt),
|
||||
completed,
|
||||
events,
|
||||
droppedEventCount: this.droppedEventCount,
|
||||
kills: completedMobs.map((mob) => ({
|
||||
id: mob.id, name: mob.name, boss: mob.boss, maxHealth: mob.maxHealth,
|
||||
engagedAtMs: elapsed(mob.engagedAt, startedAt), diedAtMs: elapsed(mob.diedAt!, startedAt),
|
||||
firstDamageAtMs: this.firstDamageAt.has(mob.id)
|
||||
? elapsed(this.firstDamageAt.get(mob.id)!, startedAt) : null,
|
||||
durationMs: Math.max(1, Math.round(mob.diedAt!
|
||||
- (this.firstDamageAt.get(mob.id) ?? mob.engagedAt))),
|
||||
})),
|
||||
packs,
|
||||
droppedKillCount: this.droppedKillCount,
|
||||
party,
|
||||
partyDamage: [...damage].map(([actorId, total]) => ({ actorId: boundedText(actorId),
|
||||
damage: Math.round(total) })),
|
||||
};
|
||||
while (events.length && new TextEncoder().encode(JSON.stringify(log)).byteLength > 3_500_000) {
|
||||
removedForSize += Math.min(100, events.length);
|
||||
events.splice(-100);
|
||||
log = { ...log, events, droppedEventCount: this.droppedEventCount + removedForSize };
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
stop(): void { this.unsubscribeEvents(); this.unsubscribeMobs(); }
|
||||
}
|
||||
|
||||
export function startRaceCombatLog(runId: string, dungeonId: string): void {
|
||||
active?.stop();
|
||||
active = new RaceCombatRecorder(runId, dungeonId);
|
||||
lastPracticeLog = null;
|
||||
}
|
||||
|
||||
export function finishRaceCombatLog(runId: string, startedAt: number, practice: boolean,
|
||||
completed = true): RaceCombatLog | null {
|
||||
if (active?.runId !== runId) return null;
|
||||
const log = active.finish(startedAt, Date.now(), completed);
|
||||
active = null;
|
||||
if (practice) lastPracticeLog = log;
|
||||
return log;
|
||||
}
|
||||
|
||||
export function stopRaceCombatLog(): void { active?.stop(); active = null; }
|
||||
export function recentPracticeRaceCombatLog(): RaceCombatLog | null { return lastPracticeLog; }
|
||||
@@ -210,6 +210,12 @@ export interface DungeonPresentation {
|
||||
readonly intensity: number;
|
||||
};
|
||||
readonly directionalLight: { readonly color: string; readonly intensity: number; readonly offset: Vector3Tuple };
|
||||
readonly pointLights?: readonly {
|
||||
readonly position: Vector3Tuple;
|
||||
readonly color: string;
|
||||
readonly intensity: number;
|
||||
readonly distance: number;
|
||||
}[];
|
||||
readonly materials: DungeonMaterialRoles;
|
||||
readonly map: DungeonMapPresentation;
|
||||
}
|
||||
|
||||
@@ -49,8 +49,10 @@ describe("RoM bite-sized finales", () => {
|
||||
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
|
||||
expect(slice.assets).toBe(source.assets);
|
||||
expect(slice.staticSpawns).toHaveLength(id === "bite-sized-barren-caves-anglo" ? 4
|
||||
: id === "bite-sized-kawaks-tomb-linobia" || id === "bite-sized-hall-of-survivors-pursuer" ? 5 : 6);
|
||||
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.includes(spawn))).toBe(true);
|
||||
: id === "bite-sized-kawaks-tomb-linobia" || id === "bite-sized-hall-of-survivors-pursuer" ? 5
|
||||
: id === "bite-sized-hoto-teeth" ? 10 : 6);
|
||||
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.some((candidate) =>
|
||||
candidate.id === spawn.id && candidate.entityId === spawn.entityId))).toBe(true);
|
||||
expect(slice.bosses).toHaveLength(1);
|
||||
expect(slice.bosses[0].eventOnly).toBe(true);
|
||||
expect(slice.staticSpawns).not.toContainEqual(expect.objectContaining({ id: slice.bosses[0].id }));
|
||||
@@ -117,4 +119,38 @@ describe("RoM bite-sized finales", () => {
|
||||
}), `${id} route to ${spawn.id}`).not.toBeNull();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("stages both Hall packs and the finale in the twin-statue chamber", () => {
|
||||
const slice = DUNGEON_REGISTRY["bite-sized-hall-of-survivors-pursuer"];
|
||||
const guardians = slice.staticSpawns.filter((spawn) => spawn.id.includes("59919"));
|
||||
const hunters = slice.staticSpawns.filter((spawn) => spawn.id.includes("119628"));
|
||||
expect(guardians).toHaveLength(3);
|
||||
expect(hunters).toHaveLength(2);
|
||||
// The western side room lies beyond x=-185; the statue chamber is to its east.
|
||||
expect([...slice.staticSpawns, slice.encounterSpawnGroups![0].spawns[0]].every(
|
||||
(spawn) => spawn.position[0] > -185 && spawn.position[0] < -158)).toBe(true);
|
||||
expect(Math.min(...hunters.map((spawn) => spawn.position[2]))
|
||||
- Math.max(...guardians.map((spawn) => spawn.position[2]))).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("fills Hoto with three readable source Ayam clusters and a cooler cavern palette", () => {
|
||||
const slice = DUNGEON_REGISTRY["bite-sized-hoto-teeth"];
|
||||
const first = slice.staticSpawns.filter((spawn) => spawn.position[2] > 40);
|
||||
const middle = slice.staticSpawns.filter((spawn) => spawn.position[2] > 8 && spawn.position[2] < 25);
|
||||
const last = slice.staticSpawns.filter((spawn) => spawn.position[2] < 2);
|
||||
expect([first.length, middle.length, last.length]).toEqual([3, 4, 3]);
|
||||
const entrance = slice.entrance.footPosition;
|
||||
expect(first.every((spawn) => Math.hypot(spawn.position[0] - entrance[0],
|
||||
spawn.position[2] - entrance[2]) < 16)).toBe(true);
|
||||
expect(first.every((spawn) => first.every((other) => Math.hypot(
|
||||
spawn.position[0] - other.position[0], spawn.position[2] - other.position[2]) < 8))).toBe(true);
|
||||
expect(slice.presentation.ambientLight.intensity).toBe(0.48);
|
||||
expect(slice.presentation.ambientLight.color).toBe("#91acb0");
|
||||
expect(slice.presentation.hemisphereLight.groundColor).toBe("#244451");
|
||||
expect(slice.presentation.directionalLight.color).toBe("#f3c28b");
|
||||
expect(slice.presentation.pointLights).toHaveLength(3);
|
||||
expect(slice.presentation.pointLights?.map((light) => light.color)).toEqual([
|
||||
"#58afc6", "#ffc06d", "#68bfad",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,21 @@ interface RoMSlice {
|
||||
readonly start: Vector3Tuple;
|
||||
readonly forward: Vector3Tuple;
|
||||
readonly finaleBossName: string;
|
||||
readonly finalePosition?: Vector3Tuple;
|
||||
readonly mobSpawnIds: readonly string[];
|
||||
readonly mobSpawnPositions?: Readonly<Record<string, Vector3Tuple>>;
|
||||
readonly pointLights?: NonNullable<DungeonDefinition["presentation"]["pointLights"]>;
|
||||
readonly summary: string;
|
||||
readonly adaptation: string;
|
||||
readonly lighting?: { readonly ambient: number; readonly hemisphere: number; readonly directional: number };
|
||||
readonly lighting?: {
|
||||
readonly ambient: number;
|
||||
readonly hemisphere: number;
|
||||
readonly directional: number;
|
||||
readonly ambientColor?: string;
|
||||
readonly skyColor?: string;
|
||||
readonly groundColor?: string;
|
||||
readonly directionalColor?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ROM_SLICES: readonly RoMSlice[] = [
|
||||
@@ -130,12 +141,29 @@ const ROM_SLICES: readonly RoMSlice[] = [
|
||||
forward: [0.70710678, 0, -0.70710678],
|
||||
finaleBossName: "Teeth of the Reef",
|
||||
mobSpawnIds: [
|
||||
"rw-hoto-spawn-5978644", "rw-hoto-spawn-5978652", "rw-hoto-spawn-5978647",
|
||||
"rw-hoto-spawn-5978485", "rw-hoto-spawn-5978483",
|
||||
"rw-hoto-spawn-5978486", "rw-hoto-spawn-5978484",
|
||||
"rw-hoto-spawn-5978631", "rw-hoto-spawn-5979808",
|
||||
"rw-hoto-spawn-5978631", "rw-hoto-spawn-5979808", "rw-hoto-spawn-5978629",
|
||||
],
|
||||
// Keep all three within the 16 m entry aggro circle and close enough for
|
||||
// three-target companion AoE; the old staggered positions pulled one at a time.
|
||||
mobSpawnPositions: {
|
||||
"rw-hoto-spawn-5978644": [120, -33.6, 53],
|
||||
"rw-hoto-spawn-5978652": [123, -33.6, 54],
|
||||
"rw-hoto-spawn-5978647": [126, -33.6, 55],
|
||||
},
|
||||
summary: "Clear three Ayam packs across the reef cavern, then defeat one surprise Hoto boss.",
|
||||
adaptation: "The approach guard, mage, and warrior are moved onto the short route. Jiasha, earlier boss rooms, and unrelated Hoto population are outside this run.",
|
||||
lighting: {
|
||||
ambient: 0.48, hemisphere: 0.7, directional: 0.88,
|
||||
ambientColor: "#91acb0", skyColor: "#c2c6ad", groundColor: "#244451", directionalColor: "#f3c28b",
|
||||
},
|
||||
pointLights: [
|
||||
{ position: [123, -30.1, 50], color: "#58afc6", intensity: 30, distance: 17 },
|
||||
{ position: [132, -34, 16], color: "#ffc06d", intensity: 27, distance: 16 },
|
||||
{ position: [135, -33, -4], color: "#68bfad", intensity: 30, distance: 16 },
|
||||
],
|
||||
summary: "Clear the Ayam guards, casters, and defenders, then defeat one surprise Hoto boss.",
|
||||
adaptation: "Jiasha, earlier boss rooms, and unrelated Hoto population are outside this short run.",
|
||||
},
|
||||
{
|
||||
sourceId: "kawaks-tomb",
|
||||
@@ -146,6 +174,8 @@ const ROM_SLICES: readonly RoMSlice[] = [
|
||||
start: [-85, -9.4, -243],
|
||||
forward: [-1, 0, 0],
|
||||
finaleBossName: "Linobia",
|
||||
// Move Linobia off the source room's raised northwest edge onto its open center floor.
|
||||
finalePosition: [-170, -9.295, -242],
|
||||
mobSpawnIds: [
|
||||
"rw-kawaks-tomb-spawn-41889142", "rw-kawaks-tomb-spawn-41889139",
|
||||
"rw-kawaks-tomb-spawn-41892423", "rw-kawaks-tomb-spawn-41890637",
|
||||
@@ -163,13 +193,20 @@ const ROM_SLICES: readonly RoMSlice[] = [
|
||||
start: [-155, -17.55, 35],
|
||||
forward: [-1, 0, 0],
|
||||
finaleBossName: "Divine Punishment Pursuer",
|
||||
finalePosition: [-171, -17.55, 31],
|
||||
mobSpawnIds: [
|
||||
"rw-hall-of-survivors-spawn-11962803", "rw-hall-of-survivors-spawn-11962817",
|
||||
"rw-hall-of-survivors-spawn-5991940", "rw-hall-of-survivors-spawn-5991941",
|
||||
"rw-hall-of-survivors-spawn-5991939",
|
||||
"rw-hall-of-survivors-spawn-11962803", "rw-hall-of-survivors-spawn-11962817",
|
||||
],
|
||||
summary: "Clear the hunters and Rune Guardians, then defeat one surprise Hall boss.",
|
||||
adaptation: "The other Pursuer and Seeker bosses, earlier rooms, and their combined source scripts are outside this short run.",
|
||||
// The two Hunters originally stood in the western side room. Reposition them
|
||||
// as a distinct pack at the far end of the open chamber with the statues.
|
||||
mobSpawnPositions: {
|
||||
"rw-hall-of-survivors-spawn-11962803": [-165, -17.55, 38],
|
||||
"rw-hall-of-survivors-spawn-11962817": [-175, -17.55, 38],
|
||||
},
|
||||
summary: "Clear two packs around the ward's twin statues, then defeat one surprise Hall boss in the chamber.",
|
||||
adaptation: "The Hunters and selected boss are moved out of the western side room into the statue chamber. Other Pursuer and Seeker bosses, earlier rooms, and combined source scripts are outside this short run.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -180,18 +217,20 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string
|
||||
throw new Error(`${slice.sourceId} is missing ${slice.finaleBossName} or its boss list.`);
|
||||
}
|
||||
const boss = source.bosses[seededBossIndex(seed, source.bosses.length)];
|
||||
const finalePosition = slice.finalePosition ?? finale.position;
|
||||
const sourceBossSpawn = source.staticSpawns.find((spawn) => spawn.id === boss.id);
|
||||
if (!sourceBossSpawn || source.entities[sourceBossSpawn.entityId]?.kind !== "boss") {
|
||||
throw new Error(`${slice.sourceId} is missing source boss ${boss.name}.`);
|
||||
}
|
||||
const bossSpawn = { ...sourceBossSpawn, position: finale.position,
|
||||
const bossSpawn = { ...sourceBossSpawn, position: finalePosition,
|
||||
spawnMask: source.difficultyVariants.reduce((mask, difficulty) => mask | difficulty.spawnMask, 0) };
|
||||
const mobSpawns = slice.mobSpawnIds.map((id) => {
|
||||
const spawn = source.staticSpawns.find((candidate) => candidate.id === id);
|
||||
if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) {
|
||||
throw new Error(`${slice.sourceId} is missing source mob ${id}.`);
|
||||
}
|
||||
return spawn;
|
||||
const position = slice.mobSpawnPositions?.[id];
|
||||
return position ? { ...spawn, position } : spawn;
|
||||
});
|
||||
const bossGroup: DungeonEncounterSpawnGroupDefinition = {
|
||||
id: `${slice.id}:boss`,
|
||||
@@ -234,12 +273,12 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string
|
||||
},
|
||||
],
|
||||
};
|
||||
const objective: DungeonBossObjective = { ...boss, position: finale.position, optional: false, eventOnly: true };
|
||||
const objective: DungeonBossObjective = { ...boss, position: finalePosition, optional: false, eventOnly: true };
|
||||
const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId));
|
||||
const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id)));
|
||||
const areas = [
|
||||
{ id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 },
|
||||
{ id: "boss-room", name: `${boss.name}'s Room`, center: finale.position, radius: 32 },
|
||||
{ id: "boss-room", name: `${boss.name}'s Room`, center: finalePosition, radius: 32 },
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -252,11 +291,13 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string
|
||||
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
|
||||
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
|
||||
unchartedAreaName: slice.entranceName,
|
||||
ambientLight: { color: "#e2dccc", intensity: slice.lighting?.ambient ?? 0.9 },
|
||||
hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: slice.lighting?.hemisphere ?? 1.2 },
|
||||
ambientLight: { color: slice.lighting?.ambientColor ?? "#e2dccc", intensity: slice.lighting?.ambient ?? 0.9 },
|
||||
hemisphereLight: { skyColor: slice.lighting?.skyColor ?? "#ded9d0",
|
||||
groundColor: slice.lighting?.groundColor ?? "#5a5350", intensity: slice.lighting?.hemisphere ?? 1.2 },
|
||||
directionalLight: { ...source.presentation.directionalLight,
|
||||
color: slice.lighting ? source.presentation.directionalLight.color : "#fff0d9",
|
||||
intensity: slice.lighting?.directional ?? 1.35 } },
|
||||
color: slice.lighting?.directionalColor ?? (slice.lighting ? source.presentation.directionalLight.color : "#fff0d9"),
|
||||
intensity: slice.lighting?.directional ?? 1.35 },
|
||||
pointLights: slice.pointLights ?? source.presentation.pointLights },
|
||||
areas,
|
||||
entities,
|
||||
staticSpawns: mobSpawns,
|
||||
|
||||
@@ -379,6 +379,12 @@ describe("party combat runtime", () => {
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("penance-boss", 100_000);
|
||||
|
||||
const visuals: CombatPresentationEvent[] = [];
|
||||
const stopVisuals = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.sourceActorId === healer.id
|
||||
&& message.event.abilityId === penance.id) visuals.push(message.event);
|
||||
});
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast)
|
||||
.toMatchObject({ mode: "channel", startedAt: 1_000, completesAt: 3_000 });
|
||||
@@ -394,6 +400,13 @@ describe("party combat runtime", () => {
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast).toBeNull();
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.nextActionAt).toBe(3_250);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === penance.id)).toBe(false);
|
||||
stopVisuals();
|
||||
expect(visuals[0]?.durationMs).toBe(2_000);
|
||||
expect(visuals.map((event) => [event.phase, event.sourceSpellId])).toEqual([
|
||||
["cast-start", 47540],
|
||||
...Array.from({ length: 3 }, () => [["tick", 47750], ["impact", 47750]]).flat(),
|
||||
["release", 47540],
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||
|
||||
+32
-15
@@ -19,6 +19,8 @@ import {
|
||||
} from "./abilityRules";
|
||||
import type { MobCombatState } from "./combatStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs";
|
||||
import { activeRaceOrbId } from "./dungeonRaceAffixRuntime";
|
||||
import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
|
||||
import type { MobVector3 } from "./mobPopulation";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
@@ -48,6 +50,7 @@ import { useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||
import {
|
||||
emitCombatPresentation,
|
||||
penancePulseVisualSpellId,
|
||||
presentationDeliveryForAbility,
|
||||
presentationSchoolForAbility,
|
||||
type CombatPresentationDelivery,
|
||||
@@ -664,7 +667,7 @@ function memberSwingMs(member: PartyMember): number {
|
||||
|| member.specialization === "Feral (Cat)"
|
||||
? 1_550
|
||||
: 1_900;
|
||||
return hastedDuration(
|
||||
return Math.round(hastedDuration(
|
||||
base,
|
||||
ratingPercent(
|
||||
member.gearStats.hasteRating,
|
||||
@@ -675,7 +678,7 @@ function memberSwingMs(member: PartyMember): number {
|
||||
member.classId,
|
||||
),
|
||||
500,
|
||||
);
|
||||
) / raceAffixHasteMultiplier());
|
||||
}
|
||||
|
||||
function emitPartyAbility(
|
||||
@@ -686,25 +689,33 @@ function emitPartyAbility(
|
||||
now: number,
|
||||
instantCast = false,
|
||||
): void {
|
||||
const penancePulse = ability.id === "wow335-priest-penance"
|
||||
&& ability.effects.some((effect) => effect.kind === "heal" || effect.kind === "damage")
|
||||
&& (phase === "release" || phase === "impact");
|
||||
if (ability.id === "wow335-priest-penance" && phase === "impact" && !penancePulse) return;
|
||||
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
|
||||
const targetPosition = targetId === PLAYER_AGGRO_ID
|
||||
? useCombatStore.getState().playerPosition
|
||||
: getMobPosition(targetId) ?? getPartyRuntimePosition(targetId) ?? origin;
|
||||
emitCombatPresentation({
|
||||
occurredAt: now,
|
||||
phase,
|
||||
phase: penancePulse && phase === "release" ? "tick" : phase,
|
||||
actorGroup: "party",
|
||||
sourceActorId: member.id,
|
||||
targetActorId: targetId,
|
||||
abilityId: ability.id,
|
||||
sourceSpellId: ability.dbcSpellId,
|
||||
sourceSpellId: penancePulse
|
||||
? penancePulseVisualSpellId(Boolean(useCombatStore.getState().mobs[targetId]))
|
||||
: ability.dbcSpellId,
|
||||
name: ability.name,
|
||||
source: ability.source === "runewaker" ? "runewaker" : ability.source?.startsWith("coa") ? "ascension" : "wow335",
|
||||
school: presentationSchoolForAbility(ability),
|
||||
delivery: presentationDeliveryForAbility(ability),
|
||||
delivery: penancePulse ? "projectile" : presentationDeliveryForAbility(ability),
|
||||
origin,
|
||||
targetPosition,
|
||||
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
||||
...(ability.id === "wow335-priest-penance" && phase === "cast-start"
|
||||
? { durationMs: ability.castTimeMs } : {}),
|
||||
...(instantCast ? { instantCast: true } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1686,6 +1697,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
party.activeMobId,
|
||||
partyOrigin,
|
||||
);
|
||||
if (!targetId && party.command !== "stop") targetId = activeRaceOrbId();
|
||||
members = members.map((member) => {
|
||||
const profile = resourceProfileForClass(member.classId);
|
||||
const passiveRates: Partial<Record<ResourceType, number>> = {
|
||||
@@ -1867,7 +1879,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
status: desiredStatus,
|
||||
nextActionAt: activeCast.mode === "channel"
|
||||
? Math.max(now + 250, execution.member.globalCooldownEndsAt)
|
||||
: now + Math.max(250, castingAbility.gcdMs),
|
||||
: now + Math.max(250, Math.round(castingAbility.gcdMs / raceAffixHasteMultiplier(now))),
|
||||
};
|
||||
} else {
|
||||
nextMembers[index] = {
|
||||
@@ -1900,6 +1912,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const instantOutOfCombatResurrection = !targetId
|
||||
&& supportIntent.ability.effects.some((effect) => effect.kind === "resurrection");
|
||||
if (supportIntent.ability.castTimeMs > 0 && !instantOutOfCombatResurrection) {
|
||||
const castTimeMs = Math.round(supportIntent.ability.castTimeMs / raceAffixHasteMultiplier(now));
|
||||
const castOrigin = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition;
|
||||
nextMembers[index] = {
|
||||
...committed,
|
||||
@@ -1909,14 +1922,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
school: presentationSchoolForAbility(supportIntent.ability),
|
||||
mode: supportIntent.ability.castMode === "channel" ? "channel" : "cast",
|
||||
startedAt: now,
|
||||
completesAt: now + supportIntent.ability.castTimeMs,
|
||||
originalDurationMs: supportIntent.ability.castTimeMs,
|
||||
completesAt: now + castTimeMs,
|
||||
originalDurationMs: castTimeMs,
|
||||
pushbackHits: 0,
|
||||
origin: castOrigin,
|
||||
targetId: supportIntent.targetId,
|
||||
},
|
||||
activeCastTargetId: supportIntent.targetId,
|
||||
nextActionAt: now + supportIntent.ability.castTimeMs,
|
||||
nextActionAt: now + castTimeMs,
|
||||
};
|
||||
emitPartyAbility(member, supportIntent.ability, supportIntent.targetId, "cast-start", now);
|
||||
} else {
|
||||
@@ -1930,7 +1943,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
nextMembers[index] = {
|
||||
...execution.member,
|
||||
status: "healing",
|
||||
nextActionAt: now + Math.max(250, supportIntent.ability.gcdMs),
|
||||
nextActionAt: now + Math.max(250, Math.round(supportIntent.ability.gcdMs / raceAffixHasteMultiplier(now))),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
@@ -1942,6 +1955,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
targetId,
|
||||
partyOrigin,
|
||||
);
|
||||
if (!targetId && party.command !== "stop") targetId = activeRaceOrbId();
|
||||
if (!targetId) {
|
||||
nextMembers[index] = member.status === "ready" && member.nextActionAt === 0
|
||||
? member
|
||||
@@ -1950,7 +1964,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
}
|
||||
const desiredStatus = member.role === "tank" ? "guarding" : "attacking";
|
||||
const memberPosition = getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition;
|
||||
const memberTargetId = member.role === "tank"
|
||||
const memberTargetId = member.role === "damage" && party.command !== "stop" && activeRaceOrbId()
|
||||
? activeRaceOrbId()
|
||||
: member.role === "tank"
|
||||
? tankPriorityTargetId(
|
||||
currentCombat.mobs,
|
||||
nextMembers,
|
||||
@@ -2115,6 +2131,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
});
|
||||
}
|
||||
if (selectedAbility.castTimeMs > 0) {
|
||||
const castTimeMs = Math.round(selectedAbility.castTimeMs / raceAffixHasteMultiplier(now));
|
||||
nextMembers[index] = {
|
||||
...committed,
|
||||
status: desiredStatus,
|
||||
@@ -2123,14 +2140,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
school: presentationSchoolForAbility(selectedAbility),
|
||||
mode: selectedAbility.castMode === "channel" ? "channel" : "cast",
|
||||
startedAt: now,
|
||||
completesAt: now + selectedAbility.castTimeMs,
|
||||
originalDurationMs: selectedAbility.castTimeMs,
|
||||
completesAt: now + castTimeMs,
|
||||
originalDurationMs: castTimeMs,
|
||||
pushbackHits: 0,
|
||||
origin: memberPosition,
|
||||
targetId: abilityTargetId,
|
||||
},
|
||||
activeCastTargetId: abilityTargetId,
|
||||
nextActionAt: now + selectedAbility.castTimeMs,
|
||||
nextActionAt: now + castTimeMs,
|
||||
};
|
||||
emitPartyAbility(member, selectedAbility, abilityTargetId, "cast-start", now);
|
||||
} else {
|
||||
@@ -2138,7 +2155,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
nextMembers[index] = {
|
||||
...execution.member,
|
||||
status: desiredStatus,
|
||||
nextActionAt: now + Math.max(250, selectedAbility.gcdMs),
|
||||
nextActionAt: now + Math.max(250, Math.round(selectedAbility.gcdMs / raceAffixHasteMultiplier(now))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user