Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import catalog from "../game/dungeonRaceBotCatalog.json";
|
||||
import { dungeonRaceAffixPace, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
|
||||
|
||||
export type RaceBotTier = "novice" | "regular" | "veteran" | "elite";
|
||||
export type RaceBotSource = "wow" | "rom";
|
||||
@@ -18,14 +19,17 @@ export function hashRaceSeed(value: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource): RaceBot {
|
||||
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource,
|
||||
affixIds?: DungeonRaceAffixPair): RaceBot {
|
||||
const tier = catalog.tiers.find((entry) => entry.id === tierId) ?? catalog.tiers[1];
|
||||
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
|
||||
const pace = affixIds ? dungeonRaceAffixPace(affixIds) : 1;
|
||||
const finishMs = Math.round(catalog.baselineMs * tier.multiplier * variance * pace);
|
||||
return { type: "bot", name: `${tier.label} ${source === "rom" ? "RuneWaker" : "Azeroth"} Team`,
|
||||
tier: tier.id as RaceBotTier, source, roster: catalog.teams[source],
|
||||
finishMs: Math.round(catalog.baselineMs * tier.multiplier * variance),
|
||||
finishMs,
|
||||
milestones: catalog.milestones.map((entry) => ({ ...entry,
|
||||
atMs: Math.round(catalog.baselineMs * tier.multiplier * variance * entry.at) })) };
|
||||
atMs: Math.round(finishMs * entry.at) })) };
|
||||
}
|
||||
|
||||
export function raceBotProgress(elapsedMs: number, bot: RaceBot): number {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDungeonRaceStore, type RaceServerState } from "./dungeonRaceStore";
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import { appendCombatEvent } from "../game/combatEvents";
|
||||
import { startRaceCombatLog } from "../game/dungeonRaceCombatLog";
|
||||
|
||||
const request = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./onlineAccountClient", () => ({ requestOnlineJson: request }));
|
||||
@@ -80,4 +82,53 @@ describe("dungeon race exit", () => {
|
||||
matchId: null, startAt: null, result: null });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the full recorded stream with a completed online clear", async () => {
|
||||
request.mockResolvedValue(queued);
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
const startedAt = Date.now() - 1_000;
|
||||
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
|
||||
matchId: "race-combat-clear", seed: "race-combat-clear",
|
||||
dungeonId: "bite-sized-stormwind-stockade", startAt: startedAt, serverOffset: 0 });
|
||||
startRaceCombatLog("race-combat-clear", "bite-sized-stormwind-stockade");
|
||||
appendCombatEvent([], { occurredAt: startedAt + 100, kind: "damage",
|
||||
sourceActorId: "player", targetActorId: "mob-1", rawAmount: 20,
|
||||
effectiveAmount: 20, overheal: 0, absorbed: 0, critical: false }, 1);
|
||||
await useDungeonRaceStore.getState().reportClear(["boss-1"]);
|
||||
const finish = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-clear/finish");
|
||||
expect(finish).toBeDefined();
|
||||
const body = JSON.parse(finish![1].body);
|
||||
expect(body.combatLog).toMatchObject({ completed: true, runId: "race-combat-clear",
|
||||
events: [{ kind: "damage", effective: 20 }] });
|
||||
});
|
||||
|
||||
it("uploads a partial log before leaving a started online race", async () => {
|
||||
request.mockResolvedValue(queued);
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
|
||||
matchId: "race-combat-leave", seed: "race-combat-leave",
|
||||
dungeonId: "bite-sized-stormwind-stockade", startAt: Date.now() - 1_000, serverOffset: 0 });
|
||||
startRaceCombatLog("race-combat-leave", "bite-sized-stormwind-stockade");
|
||||
await useDungeonRaceStore.getState().leave();
|
||||
const partial = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-leave/combat-log");
|
||||
expect(partial).toBeDefined();
|
||||
expect(JSON.parse(partial![1].body).combatLog.completed).toBe(false);
|
||||
expect(request.mock.calls.map(([path, init]) => `${init.method}:${path}`)).toEqual([
|
||||
"POST:/api/dungeon-races/queue",
|
||||
"POST:/api/dungeon-races/race-combat-leave/combat-log",
|
||||
"DELETE:/api/dungeon-races/queue",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an unknown server mechanic pair before entering or readying", async () => {
|
||||
request.mockImplementation((path: string) => Promise.resolve(path === "/api/dungeon-races/queue"
|
||||
? { status: "preparing", serverTime: Date.now(), matchId: "future-race",
|
||||
seed: "future-race", dungeonId: "bite-sized-stormwind-stockade",
|
||||
affixIds: ["unknown-kill", "falling-stars"] } : queued));
|
||||
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
||||
await vi.waitFor(() => expect(useDungeonRaceStore.getState().error).toContain("does not support"));
|
||||
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false, ready: false });
|
||||
expect(request.mock.calls.map(([path]) => path)).toContain("/api/dungeon-races/queue");
|
||||
expect(request.mock.calls.some(([, init]) => init.method === "DELETE")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa
|
||||
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
|
||||
import { createRaceBot, raceBotProgress, type RaceBot, type RaceBotSource, type RaceBotTier } from "./dungeonRaceBot";
|
||||
import type { PartyRole } from "../game/partyRoles";
|
||||
import { finishRaceCombatLog, startRaceCombatLog, stopRaceCombatLog,
|
||||
type RaceCombatLog } from "../game/dungeonRaceCombatLog";
|
||||
import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
|
||||
import { cleanupDungeonRaceAffixes } from "../game/dungeonRaceAffixLifecycle";
|
||||
|
||||
export type RacePool = "authored" | "randomized";
|
||||
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
|
||||
@@ -26,6 +30,7 @@ export interface RaceServerState {
|
||||
readonly seed?: string;
|
||||
readonly dungeonId?: string | null;
|
||||
readonly difficultyId?: string;
|
||||
readonly affixIds?: readonly string[];
|
||||
readonly startAt?: number | null;
|
||||
readonly prepareDeadline?: number;
|
||||
readonly ready?: boolean;
|
||||
@@ -42,6 +47,7 @@ interface RaceState {
|
||||
seed: string | null;
|
||||
dungeonId: string | null;
|
||||
difficultyId: string | null;
|
||||
affixIds: DungeonRaceAffixPair | null;
|
||||
startAt: number | null;
|
||||
serverOffset: number;
|
||||
opponent: RaceOpponent | null;
|
||||
@@ -77,6 +83,7 @@ let queueRequest: Promise<RaceServerState> | null = null;
|
||||
let pendingLeave: Promise<void> | null = null;
|
||||
let pendingCleanupToken: string | null = null;
|
||||
let raceRevision = 0;
|
||||
let pendingCombatLog: RaceCombatLog | null = null;
|
||||
|
||||
function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; }
|
||||
function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The race could not continue."; }
|
||||
@@ -99,6 +106,9 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
try {
|
||||
await prepareContent(dungeonId, seed);
|
||||
if (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
|
||||
if (!validDungeonRaceAffixes(get().affixIds)) {
|
||||
throw new Error("This race uses mechanics your client does not support. Update the game to race.");
|
||||
}
|
||||
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||
if (raceRevision !== revision || get().seed !== seed) return;
|
||||
installDungeonShellRuntime();
|
||||
@@ -106,8 +116,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
|
||||
});
|
||||
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
|
||||
startRaceCombatLog(seed, dungeonId);
|
||||
set({ inGame: true, preparingContent: false });
|
||||
} catch (error) {
|
||||
stopRaceCombatLog();
|
||||
if (raceRevision === revision) {
|
||||
set({ error: errorMessage(error), preparingContent: false });
|
||||
if (get().mode === "online") await get().leave();
|
||||
@@ -125,8 +137,16 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
&& RACE_STATUS_ORDER[response.status] < RACE_STATUS_ORDER[previous.status]) return;
|
||||
const result = response.result ?? null;
|
||||
const offset = response.serverTime - Date.now();
|
||||
if (response.matchId && !validDungeonRaceAffixes(response.affixIds)) {
|
||||
const message = "This race uses mechanics your client does not support. Update the game to race.";
|
||||
void get().leave().finally(() => {
|
||||
if (get().mode === null) set({ error: message });
|
||||
});
|
||||
return;
|
||||
}
|
||||
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
|
||||
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
|
||||
affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null,
|
||||
startAt: response.startAt ?? null,
|
||||
serverOffset: offset, opponent: response.opponent ?? null,
|
||||
opponentProgress: response.opponentProgress ?? null, result,
|
||||
@@ -138,7 +158,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
|
||||
}
|
||||
return {
|
||||
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
|
||||
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
|
||||
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
|
||||
queueAt: null, botAt: null,
|
||||
@@ -187,9 +207,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
set({ error: "No Bite-Sized dungeon is available for practice." }); return;
|
||||
}
|
||||
const seed = `practice-${globalThis.crypto.randomUUID()}`;
|
||||
const affixIds = rollDungeonRaceAffixes(seed);
|
||||
set({ mode: "practice", status: "preparing", matchId: seed, seed, dungeonId: resolvedDungeon,
|
||||
difficultyId: null,
|
||||
opponent: createRaceBot(seed, tier, source) });
|
||||
difficultyId: null, affixIds,
|
||||
opponent: createRaceBot(seed, tier, source, affixIds) });
|
||||
await enterPreparedRun(resolvedDungeon, seed);
|
||||
},
|
||||
sync: async () => {
|
||||
@@ -223,6 +244,8 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
if (finishPromise) return finishPromise;
|
||||
if (!state.matchId || !state.startAt || ["void", "idle"].includes(state.status)
|
||||
|| state.clearDurationMs != null) return Promise.resolve();
|
||||
pendingCombatLog ??= finishRaceCombatLog(state.seed ?? state.matchId,
|
||||
state.startAt - state.serverOffset, state.mode === "practice");
|
||||
if (state.mode === "practice") {
|
||||
const now = Date.now();
|
||||
const result = state.result ?? (now < state.startAt + (state.opponent as RaceBot).finishMs ? "win" : "loss");
|
||||
@@ -234,9 +257,9 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
const matchId = state.matchId;
|
||||
const report = requestOnlineJson<RaceServerState>(
|
||||
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, {
|
||||
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds }),
|
||||
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds, combatLog: pendingCombatLog }),
|
||||
}, sessionToken)
|
||||
.then((response) => { if (get().matchId === matchId) applyServerState(response); })
|
||||
.then((response) => { if (get().matchId === matchId) { pendingCombatLog = null; applyServerState(response); } })
|
||||
.catch((error: unknown) => { if (get().matchId === matchId) set({ error: errorMessage(error) }); })
|
||||
.finally(() => { if (finishPromise === report) finishPromise = null; });
|
||||
finishPromise = report;
|
||||
@@ -252,7 +275,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
opponentProgress: raceBotProgress(now - state.startAt, state.opponent) });
|
||||
},
|
||||
leave: async () => {
|
||||
const token = get().mode === "online" ? sessionToken : null;
|
||||
const departing = get();
|
||||
const token = departing.mode === "online" ? sessionToken : null;
|
||||
const partialLog = departing.seed && departing.startAt && departing.clearDurationMs === null
|
||||
? finishRaceCombatLog(departing.seed, departing.startAt - departing.serverOffset,
|
||||
departing.mode === "practice", false) : null;
|
||||
const request = queueRequest;
|
||||
get().reset();
|
||||
const revision = raceRevision;
|
||||
@@ -260,6 +287,12 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
pendingCleanupToken = token;
|
||||
const closing = (async () => {
|
||||
try { await request; } catch { /* A failed queue request needs no server cleanup. */ }
|
||||
if (partialLog && departing.matchId) {
|
||||
try {
|
||||
await requestOnlineJson(`/api/dungeon-races/${encodeURIComponent(departing.matchId)}/combat-log`,
|
||||
{ method: "POST", body: JSON.stringify({ combatLog: partialLog }) }, token);
|
||||
} catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); }
|
||||
}
|
||||
await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, token);
|
||||
if (pendingCleanupToken === token) pendingCleanupToken = null;
|
||||
})();
|
||||
@@ -270,8 +303,10 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
},
|
||||
reset: () => {
|
||||
raceRevision += 1;
|
||||
cleanupDungeonRaceAffixes();
|
||||
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
|
||||
stopRaceCombatLog(); pendingCombatLog = null;
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
|
||||
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Development-only inspection surface for the live combat-summon renderer.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { Grid, OrbitControls } from "@react-three/drei";
|
||||
import { preloadAbilityCatalogsForClasses } from "../game/abilityCatalog";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { setMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { CombatSummonPopulation } from "../scene/CombatSummonPopulation";
|
||||
|
||||
function Preview() {
|
||||
const [status, setStatus] = useState("Casting Shadowfiend…");
|
||||
const canvas = useRef<HTMLCanvasElement | null>(null);
|
||||
useEffect(() => {
|
||||
void preloadAbilityCatalogsForClasses(["priest"]).then(() => {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({ classId: "priest", level: 80 });
|
||||
combat.registerMob("shadowfiend-preview-target", {
|
||||
name: "Target", level: 80, maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("shadowfiend-preview-target", [3, 0, 0]);
|
||||
combat.selectMob("shadowfiend-preview-target");
|
||||
const result = combat.castAbility("wow335-priest-shadowfiend", [0, 0, 0], 1_000);
|
||||
setStatus(result.ok ? "Shadowfiend summon active" : `Cast failed: ${result.reason}`);
|
||||
}).catch((error) => setStatus(`Catalog failed: ${String(error)}`));
|
||||
}, []);
|
||||
return <>
|
||||
<div style={{ padding: 16, display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<span>{status}</span>
|
||||
<button onClick={() => {
|
||||
if (!canvas.current) return;
|
||||
const link = document.createElement("a");
|
||||
link.download = "shadowfiend-summon-preview.png";
|
||||
link.href = canvas.current.toDataURL("image/png");
|
||||
link.click();
|
||||
}}>Save screenshot</button>
|
||||
</div>
|
||||
<div style={{ height: "calc(100vh - 55px)" }}>
|
||||
<Canvas camera={{ position: [4, 3, 5], fov: 50 }} gl={{ preserveDrawingBuffer: true }}
|
||||
onCreated={({ gl }) => { canvas.current = gl.domElement; }}>
|
||||
<color attach="background" args={["#0b121b"]} />
|
||||
<ambientLight intensity={1} />
|
||||
<Grid args={[20, 20]} cellColor="#304051" sectionColor="#4a637d" />
|
||||
<CombatSummonPopulation active />
|
||||
<OrbitControls target={[0, 0.9, 0]} />
|
||||
</Canvas>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) createRoot(document.getElementById("root")!).render(<Preview />);
|
||||
@@ -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))),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
updateCombatEffectQueue,
|
||||
originalInstantCastEvent,
|
||||
originalInstantTerminalEvent,
|
||||
risingTideArrivalDelayMs,
|
||||
originalProjectileArrivalDelayMs,
|
||||
type ActiveCombatEffect,
|
||||
} from "./combatEffectRuntime";
|
||||
import { OriginalCombatEffect } from "./OriginalCombatEffect";
|
||||
@@ -391,7 +391,7 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
|
||||
return;
|
||||
}
|
||||
const event = message.event;
|
||||
const arrivalDelay = risingTideArrivalDelayMs(event);
|
||||
const arrivalDelay = originalProjectileArrivalDelayMs(event);
|
||||
if (arrivalDelay > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
pendingArrivals.delete(timer);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { DoubleSide, Group, SRGBColorSpace, Texture, TextureLoader } from "three";
|
||||
import { subscribeContentManager } from "../content/contentManager";
|
||||
import { fetchCombatMediaAsset, combatMediaPackReady } from "../game/combatMediaPack";
|
||||
import { preloadCombatEffectManifest, shadowfiendOriginalModelAsset } from "../game/combatPresentation";
|
||||
import { type CombatSummon, useCombatStore } from "../game/combatStore";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { OriginalWowModel } from "./OriginalWowModel";
|
||||
import { parseOriginalWowModelGraph, type OriginalWowModelGraph } from "./originalWowModelRuntime";
|
||||
|
||||
const SHADOWFIEND_CREATURE_ID = 19668;
|
||||
|
||||
interface ShadowfiendMedia {
|
||||
readonly graph: OriginalWowModelGraph;
|
||||
readonly textures: ReadonlyMap<number, Texture>;
|
||||
}
|
||||
|
||||
function ShadowfiendFallback() {
|
||||
return <group name="shadowfiend-fallback">
|
||||
<mesh position={[0, 0.66, 0]} scale={[0.56, 0.77, 0.46]}>
|
||||
<icosahedronGeometry args={[1, 1]} />
|
||||
<meshStandardMaterial color="#241335" emissive="#492269" emissiveIntensity={0.7} roughness={0.85} />
|
||||
</mesh>
|
||||
{[-1, 1].map((side) => <group key={side} position={[side * 0.55, 0.9, 0]} rotation={[0, 0, side * -0.42]}>
|
||||
<mesh position={[0, -0.37, 0]}><coneGeometry args={[0.18, 0.88, 5]} />
|
||||
<meshStandardMaterial color="#2c1741" emissive="#371456" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
<mesh position={[0, -0.83, 0.1]}><coneGeometry args={[0.12, 0.4, 5]} />
|
||||
<meshStandardMaterial color="#a352dd" emissive="#8d35cc" emissiveIntensity={1.4} />
|
||||
</mesh>
|
||||
</group>)}
|
||||
{[-1, 1].map((side) => <mesh key={side} position={[0.49, 0.87, side * 0.18]}>
|
||||
<sphereGeometry args={[0.075, 8, 8]} />
|
||||
<meshBasicMaterial color="#d68cff" />
|
||||
</mesh>)}
|
||||
<pointLight color="#7739b8" intensity={0.7} distance={2.5} />
|
||||
</group>;
|
||||
}
|
||||
|
||||
function ShadowfiendMember({ summon, active }: { readonly summon: CombatSummon; readonly active: boolean }) {
|
||||
const [media, setMedia] = useState<ShadowfiendMedia | null>(null);
|
||||
const mediaReady = useSyncExternalStore(subscribeContentManager, combatMediaPackReady, () => false);
|
||||
const group = useRef<Group>(null);
|
||||
const playback = useRef({ elapsedMs: 0, decaying: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaReady) {
|
||||
setMedia(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const ownedTextures: Texture[] = [];
|
||||
void (async () => {
|
||||
await preloadCombatEffectManifest();
|
||||
const url = shadowfiendOriginalModelAsset()?.effectGraphUrl;
|
||||
if (!url) return;
|
||||
const response = await fetchCombatMediaAsset(url);
|
||||
if (!response.ok) throw new Error(`Shadowfiend model request failed: ${response.status}`);
|
||||
const graph = parseOriginalWowModelGraph(await response.json());
|
||||
const textures = new Map<number, Texture>();
|
||||
const loader = new TextureLoader();
|
||||
for (const [index, source] of graph.textures.entries()) {
|
||||
if (!source.url) continue;
|
||||
const image = await fetchCombatMediaAsset(source.url);
|
||||
if (!image.ok) throw new Error(`Shadowfiend texture request failed: ${image.status}`);
|
||||
const objectUrl = URL.createObjectURL(await image.blob());
|
||||
try {
|
||||
const texture = await loader.loadAsync(objectUrl);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
ownedTextures.push(texture);
|
||||
textures.set(index, texture);
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
if (cancelled) ownedTextures.forEach((texture) => texture.dispose());
|
||||
else setMedia({ graph, textures });
|
||||
})().catch(() => { if (cancelled) ownedTextures.forEach((texture) => texture.dispose()); });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ownedTextures.forEach((texture) => texture.dispose());
|
||||
};
|
||||
}, [mediaReady]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!group.current) return;
|
||||
const live = useCombatStore.getState().summons.find((candidate) => candidate.id === summon.id);
|
||||
if (!live) return;
|
||||
const goal = live.position;
|
||||
const blend = Math.min(1, delta * 9);
|
||||
group.current.position.x += (goal[0] - group.current.position.x) * blend;
|
||||
group.current.position.y += (goal[1] + 0.13 - group.current.position.y) * blend;
|
||||
group.current.position.z += (goal[2] - group.current.position.z) * blend;
|
||||
const target = live.targetId ? getMobPosition(live.targetId) : null;
|
||||
if (target) group.current.rotation.y = Math.atan2(goal[2] - target[2], target[0] - goal[0]);
|
||||
if (active) playback.current.elapsedMs += Math.min(delta, 0.1) * 1_000;
|
||||
});
|
||||
|
||||
return <group ref={group} name={`combat-summon:${summon.id}`}
|
||||
position={[summon.position[0], summon.position[1] + 0.13, summon.position[2]]}>
|
||||
<mesh name="shadowfiend-ground-aura" rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.1, 0]}>
|
||||
<ringGeometry args={[0.38, 0.76, 28]} />
|
||||
<meshBasicMaterial color="#8a46be" transparent opacity={0.38} depthWrite={false} side={DoubleSide} />
|
||||
</mesh>
|
||||
{media
|
||||
? <group position={[0, 1.3, 0]} scale={0.65}><OriginalWowModel graph={media.graph} textures={media.textures}
|
||||
active={active} playbackRef={playback} loop /></group>
|
||||
: <ShadowfiendFallback />}
|
||||
</group>;
|
||||
}
|
||||
|
||||
/** Player and companion combat summons live outside the static dungeon population. */
|
||||
export function CombatSummonPopulation({ active }: { readonly active: boolean }) {
|
||||
const summons = useCombatStore((state) => state.summons);
|
||||
return <group name="combat-summon-population">
|
||||
{summons.filter((summon) => summon.creatureId === SHADOWFIEND_CREATURE_ID && summon.health > 0)
|
||||
.map((summon) => <ShadowfiendMember key={summon.id} summon={summon} active={active} />)}
|
||||
</group>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { DoubleSide } from "three";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { activeLastStand } from "../game/dungeonRaceAffixBuffs";
|
||||
import { useDungeonRaceAffixStore, type RaceAffixEffect } from "../game/dungeonRaceAffixRuntime";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||
|
||||
const COLORS: Record<RaceAffixEffect["kind"], string> = {
|
||||
volatile: "#ff7b36", ash: "#e65327", orb: "#d073ff", boon: "#f5cc56",
|
||||
star: "#ffb43e", storm: "#69c8ff", ring: "#ae7dff", predator: "#d34781",
|
||||
};
|
||||
|
||||
function visualPosition(effect: RaceAffixEffect, now: number): readonly [number, number, number] {
|
||||
if ((effect.kind === "storm" || (effect.kind === "predator" && now < effect.resolvesAt)) && effect.targetId) {
|
||||
const position = effect.targetId === "__player__"
|
||||
? useCombatStore.getState().playerPosition : getPartyRuntimePosition(effect.targetId);
|
||||
if (position) return position;
|
||||
}
|
||||
return effect.position;
|
||||
}
|
||||
|
||||
function GroundMarker({ effect, now }: { readonly effect: RaceAffixEffect; readonly now: number }) {
|
||||
const position = visualPosition(effect, now);
|
||||
const warning = now < effect.resolvesAt;
|
||||
const isRing = effect.kind === "ring";
|
||||
const color = COLORS[effect.kind];
|
||||
return <group position={[position[0], position[1] + 0.09, position[2]]}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{isRing ? <ringGeometry args={[4, 7, 64]} /> : <circleGeometry args={[effect.radius, 48]} />}
|
||||
<meshBasicMaterial color={color} transparent opacity={warning ? 0.3 : 0.48}
|
||||
side={DoubleSide} depthWrite={false} />
|
||||
</mesh>
|
||||
{effect.kind === "star" && <mesh position={[0, warning
|
||||
? 2.4 - Math.min(1, (now - effect.createdAt) / 2_000) * 1.9 : 0.45, 0]}>
|
||||
<octahedronGeometry args={[warning ? 0.32 : 0.55, 0]} />
|
||||
<meshBasicMaterial color="#fff2b0" transparent opacity={warning ? 0.95 : 0.65}
|
||||
depthTest={false} />
|
||||
</mesh>}
|
||||
{warning && <Html center position={[0, 0.7, 0]} style={{ pointerEvents: "none" }}>
|
||||
<span style={{ color: "white", background: "#101015dc", border: `1px solid ${color}`,
|
||||
borderRadius: 6, padding: "2px 6px", fontWeight: 700, whiteSpace: "nowrap" }}>
|
||||
{Math.max(1, Math.ceil((effect.resolvesAt - now) / 1_000))}
|
||||
</span>
|
||||
</Html>}
|
||||
</group>;
|
||||
}
|
||||
|
||||
export function DungeonRaceAffixEffects() {
|
||||
const effects = useDungeonRaceAffixStore((state) => state.effects);
|
||||
const now = Date.now();
|
||||
const buffs = activeLastStand(now);
|
||||
return <>
|
||||
{effects.map((effect) => effect.kind === "orb" ? (
|
||||
<group key={effect.id} position={[effect.position[0], effect.position[1] + 1.2, effect.position[2]]}>
|
||||
<mesh onClick={(event) => { event.stopPropagation(); useCombatStore.getState().selectMob(effect.id); }}>
|
||||
<icosahedronGeometry args={[0.65, 1]} />
|
||||
<meshStandardMaterial color={COLORS.orb} emissive={COLORS.orb} emissiveIntensity={0.75} />
|
||||
</mesh>
|
||||
<Html center position={[0, 1, 0]} style={{ pointerEvents: "none" }}>
|
||||
<span className="race-affix-world-countdown">Orb · {Math.max(1, Math.ceil((effect.expiresAt - now) / 1_000))}s</span>
|
||||
</Html>
|
||||
</group>
|
||||
) : effect.kind === "boon" ? (
|
||||
<group key={effect.id} position={[effect.position[0], effect.position[1] + 0.75, effect.position[2]]}>
|
||||
<mesh><octahedronGeometry args={[0.5, 0]} />
|
||||
<meshStandardMaterial color={COLORS.boon} emissive={COLORS.boon} emissiveIntensity={0.65} />
|
||||
</mesh>
|
||||
<Html center position={[0, 0.9, 0]} style={{ pointerEvents: "none" }}>
|
||||
<span className="race-affix-world-countdown">Boon · {Math.max(1, Math.ceil((effect.expiresAt - now) / 1_000))}s</span>
|
||||
</Html>
|
||||
</group>
|
||||
) : <GroundMarker key={effect.id} effect={effect} now={now} />)}
|
||||
{buffs.map(({ id, stacks }) => {
|
||||
const position = getMobPosition(id);
|
||||
return position ? <mesh key={id} position={[position[0], position[1] + 1.8, position[2]]}>
|
||||
<sphereGeometry args={[0.18 + stacks * 0.06, 10, 10]} />
|
||||
<meshBasicMaterial color="#ff4b37" />
|
||||
</mesh> : null;
|
||||
})}
|
||||
</>;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useGameStore } from "../game/store";
|
||||
import { DungeonEnvironment } from "./DungeonEnvironment";
|
||||
import { dungeonRenderDistance } from "./dungeonRenderDistance";
|
||||
import { MobPopulation } from "./MobPopulation";
|
||||
import { CombatSummonPopulation } from "./CombatSummonPopulation";
|
||||
import { PartyPopulation } from "./PartyPopulation";
|
||||
import { GroundEffectTelegraphs } from "./GroundEffectTelegraphs";
|
||||
import { PlayerRig } from "./PlayerRig";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import { WailingNaralexEvent } from "./WailingNaralexEvent";
|
||||
import { GameGltfLoaderLifecycle } from "./useGameGLTF";
|
||||
import { CombatEffects } from "./CombatEffects";
|
||||
import { DungeonRaceAffixEffects } from "./DungeonRaceAffixEffects";
|
||||
import { PLAYER_GRAVITY } from "../game/playerMovement";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
@@ -389,6 +391,10 @@ export function GameScene() {
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
shadow-bias={-0.00025}
|
||||
/>
|
||||
{dungeon.presentation.pointLights?.map((light, index) => (
|
||||
<pointLight key={index} position={light.position} color={light.color}
|
||||
intensity={light.intensity} distance={light.distance} decay={2} />
|
||||
))}
|
||||
<Physics
|
||||
key={encounterWorldKey(activeDungeonId, sessionRevision)}
|
||||
gravity={[0, PLAYER_GRAVITY, 0]}
|
||||
@@ -406,6 +412,7 @@ export function GameScene() {
|
||||
/>
|
||||
<DungeonSimulationSceneBridge />
|
||||
<GroundEffectTelegraphs />
|
||||
<DungeonRaceAffixEffects />
|
||||
{gameMode === "dungeon" && (
|
||||
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
|
||||
)}
|
||||
@@ -416,6 +423,7 @@ export function GameScene() {
|
||||
staticSpawns={staticSpawns}
|
||||
allowedRuntimeIds={allowedRuntimeIds}
|
||||
/>
|
||||
<CombatSummonPopulation active={!simulationBlocked} />
|
||||
<WailingNaralexEvent />
|
||||
<ShelvedGameSceneFeatures />
|
||||
<CombatEffects active={!simulationBlocked} safeGraphics={safeGraphics} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "three";
|
||||
import {
|
||||
combatOriginalPhaseAssets,
|
||||
isPenancePulseVisual,
|
||||
preloadCombatEffectManifest,
|
||||
type CombatEffectPhaseAsset,
|
||||
type CombatPresentationEvent,
|
||||
@@ -723,7 +724,7 @@ function LoadedOriginalCombatEffect({
|
||||
)
|
||||
: maximum
|
||||
), 0), [loaded]);
|
||||
const projectile = event.phase === "release"
|
||||
const projectile = (event.phase === "release" || (event.phase === "tick" && isPenancePulseVisual(event)))
|
||||
&& event.delivery === "projectile"
|
||||
&& event.targetPosition !== undefined;
|
||||
const projectileDurationMs = projectile ? projectileTravelDurationMs(event) : 0;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { isBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import { raceAffixAvoidanceDestination } from "../game/dungeonRaceAffixAvoidance";
|
||||
import {
|
||||
enemyControlMovementMultiplier,
|
||||
enemyControlPreventsMovement,
|
||||
@@ -1113,10 +1114,37 @@ function PartyActors({
|
||||
const activeControlMechanic = member.controlledUntil > combatNow
|
||||
? member.controlMechanic
|
||||
: null;
|
||||
const avoidanceGoal = member.health > 0 && !enemyControlPreventsMovement(activeControlMechanic)
|
||||
? raceAffixAvoidanceDestination(member.id, current, combatNow) : null;
|
||||
|
||||
if (member.health <= 0) {
|
||||
const signal = movingSignalsRef.current.get(member.id);
|
||||
if (signal) signal.current = false;
|
||||
} else if (avoidanceGoal) {
|
||||
const avoidance = avoidanceGoal;
|
||||
const safe = navigationGraph
|
||||
? findNearestPartyNavigationSurface(navigationGraph, avoidance, 5)?.position ?? avoidance
|
||||
: avoidance;
|
||||
const goalKey = `member:${member.id}|race-affix-avoid`;
|
||||
let route = combatRoutesRef.current.get(member.id) ?? emptyPartyRouteState();
|
||||
if (partyRouteNeedsPlanning(route, goalKey, current, safe, routeNow, {
|
||||
replanDistance: 0.6, replanIntervalMs: 200, blockedRetryMs: 300,
|
||||
})) {
|
||||
route = planPartyRoute(navigationGraph, goalKey, current, safe, routeNow, {
|
||||
maxOriginSnapDistance: PARTY_NAVIGATION_SNAP_DISTANCE,
|
||||
maxDestinationSnapDistance: 5, localFinishDistance: 0.4,
|
||||
replanDistance: 0.6, replanIntervalMs: 200, blockedRetryMs: 300,
|
||||
directSegmentAllowed, routeSegmentAllowed: navigationRouteSegmentAllowed,
|
||||
});
|
||||
combatRoutesRef.current.set(member.id, route);
|
||||
}
|
||||
hasDesired = nextPartyRouteWaypoint(route, current, desired);
|
||||
if (!hasDesired && directSegmentAllowed(current, safe)) {
|
||||
desired[0] = safe[0]; desired[1] = safe[1]; desired[2] = safe[2];
|
||||
hasDesired = true;
|
||||
}
|
||||
movementExpected = hasDesired;
|
||||
faceX = desired[0] - current[0]; faceZ = desired[2] - current[2];
|
||||
} else if (member.activeCast || enemyControlPreventsMovement(activeControlMechanic)) {
|
||||
// Casting and crowd control are intentional immobility, not stuck navigation.
|
||||
} else if (rejoining) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
originalInstantCastEvent,
|
||||
originalInstantTerminalEvent,
|
||||
projectileTravelDurationMs,
|
||||
risingTideArrivalDelayMs,
|
||||
originalProjectileArrivalDelayMs,
|
||||
risingTideImpactPlacement,
|
||||
shouldUseGeneratedOriginalFallback,
|
||||
updateCombatEffectQueue,
|
||||
@@ -59,19 +59,35 @@ describe("combat-effect runtime budget", () => {
|
||||
.toEqual({ scale: 1, opacity: 1 });
|
||||
});
|
||||
|
||||
it("delays only the Rising Tide target arrival by the projectile's travel time", () => {
|
||||
it("delays source-backed target impacts until their projectiles arrive", () => {
|
||||
const release = event(1, { source: "runewaker", sourceSpellId: 490256,
|
||||
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
|
||||
const impact = { ...release, id: 2, phase: "impact" as const };
|
||||
expect(projectileTravelDurationMs(release)).toBe(500);
|
||||
expect(risingTideArrivalDelayMs(release)).toBe(0);
|
||||
expect(risingTideArrivalDelayMs(impact)).toBe(500);
|
||||
expect(risingTideArrivalDelayMs({ ...impact, sourceSpellId: 491147 })).toBe(0);
|
||||
expect(risingTideArrivalDelayMs({ ...impact, delivery: "area" })).toBe(0);
|
||||
expect(originalProjectileArrivalDelayMs(release)).toBe(0);
|
||||
expect(originalProjectileArrivalDelayMs(impact)).toBe(500);
|
||||
expect(originalProjectileArrivalDelayMs({ ...impact, sourceSpellId: 491147 })).toBe(0);
|
||||
expect(originalProjectileArrivalDelayMs({ ...impact, delivery: "area" })).toBe(0);
|
||||
expect(originalProjectileArrivalDelayMs({ ...impact, source: "wow335",
|
||||
abilityId: "wow335-priest-penance", sourceSpellId: 47666 })).toBe(500);
|
||||
expect(originalProjectileArrivalDelayMs({ ...release, source: "wow335", phase: "tick",
|
||||
abilityId: "wow335-priest-penance", sourceSpellId: 47750 })).toBe(0);
|
||||
expect(projectileTravelDurationMs({ ...release, targetPosition: [100, 0, 0] })).toBe(550);
|
||||
expect(projectileTravelDurationMs({ ...release, targetPosition: [1, 0, 0] })).toBe(180);
|
||||
});
|
||||
|
||||
it("keeps Penance's channel hand during pulses and drops its empty terminal flash", () => {
|
||||
const cast = event(1, { phase: "cast-start", actorGroup: "player", sourceActorId: "player",
|
||||
abilityId: "wow335-priest-penance", sourceSpellId: 47540 });
|
||||
const pulse = event(2, { ...cast, id: 2, phase: "tick", sourceSpellId: 47666 });
|
||||
const terminal = event(3, { ...cast, id: 3, phase: "release" });
|
||||
const during = enqueue([cast, pulse], "high");
|
||||
expect(during.map((effect) => effect.event.phase)).toEqual(["cast-start", "tick"]);
|
||||
expect(updateCombatEffectQueue(during, terminal, "high")).toEqual([
|
||||
{ key: during[1].key, event: pulse },
|
||||
]);
|
||||
});
|
||||
|
||||
it("places Rising Tide impact on a giant target's caster-facing body surface", () => {
|
||||
const cast = event(1, { origin: [0, 0, 0], targetPosition: [10, 0, 0] });
|
||||
const verdan = risingTideImpactPlacement(cast, { height: 15.85, radius: 5.3 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
CombatPresentationEvent,
|
||||
CombatVfxQuality,
|
||||
} from "../game/combatPresentation";
|
||||
import { isPenancePulseVisual } from "../game/combatPresentation";
|
||||
import type { CombatEffectTargetVisualSize } from "./combatEffectAnchorRegistry";
|
||||
|
||||
export interface ActiveCombatEffect {
|
||||
@@ -38,12 +39,13 @@ export function projectileTravelDurationMs(
|
||||
return Number.isFinite(distance) ? Math.max(180, Math.min(550, distance / 28 * 1_000)) : 0;
|
||||
}
|
||||
|
||||
/** RuneWaker's Rising Tide impact model and sound are target arrival events. */
|
||||
export function risingTideArrivalDelayMs(
|
||||
/** Keep source impact models and sounds aligned with travelling projectiles. */
|
||||
export function originalProjectileArrivalDelayMs(
|
||||
event: Pick<CombatPresentationEvent,
|
||||
"phase" | "source" | "sourceSpellId" | "origin" | "targetPosition" | "delivery">,
|
||||
"phase" | "source" | "sourceSpellId" | "abilityId" | "origin" | "targetPosition" | "delivery">,
|
||||
): number {
|
||||
return event.phase === "impact" && event.source === "runewaker" && event.sourceSpellId === 490256
|
||||
return event.phase === "impact" && ((event.source === "runewaker" && event.sourceSpellId === 490256)
|
||||
|| isPenancePulseVisual(event))
|
||||
? projectileTravelDurationMs(event) : 0;
|
||||
}
|
||||
|
||||
@@ -169,6 +171,8 @@ export function updateCombatEffectQueue(
|
||||
&& (event.phase === "cast-cancel" || !effect.event.instantCast)
|
||||
))
|
||||
: current;
|
||||
if (event.abilityId === "wow335-priest-penance" && event.sourceSpellId === 47540
|
||||
&& (event.phase === "release" || event.phase === "impact")) return withoutCast;
|
||||
if (quality === "off") return current.length === 0 ? current : [];
|
||||
if (event.phase === "cast-cancel") {
|
||||
return withoutCast.length === current.length ? current : withoutCast;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { formatDungeonDuration } from "../game/dungeonRunTypes";
|
||||
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
|
||||
import { raceAffixBoonUntil } from "../game/dungeonRaceAffixBuffs";
|
||||
import { useGameStore } from "../game/store";
|
||||
import "./dungeonRace.css";
|
||||
|
||||
@@ -18,18 +20,30 @@ export function DungeonRaceHud({ region }: { readonly region: "status" | "overla
|
||||
const elapsed = race.startAt ? Math.max(0, serverNow - race.startAt) : 0;
|
||||
const shownTime = race.clearDurationMs ?? elapsed;
|
||||
const toast = race.result && race.toastAt && now - race.toastAt < 4_000;
|
||||
const affixes = race.affixIds?.map((id) => dungeonRaceAffix(id)).filter((entry) => entry !== undefined) ?? [];
|
||||
if (region === "status") return (
|
||||
<section className="dungeon-race-hud" aria-label="Dungeon race status">
|
||||
<span>Dungeon Race · {race.mode === "practice" ? "Practice" : "Ranked"}</span>
|
||||
<strong>{formatDungeonDuration(shownTime)}</strong>
|
||||
<small>vs {race.opponent.name}{race.opponent.type === "bot" ? ` · ${race.opponent.tier}` : ""}
|
||||
{race.opponentProgress !== null ? ` · ${race.opponentProgress}%` : ""}</small>
|
||||
<small className="dungeon-race-hud__affixes">Mechanics: {affixes.map((affix) => affix.name).join(" + ")}</small>
|
||||
{raceAffixBoonUntil() > now && <small className="dungeon-race-hud__boon">
|
||||
Party haste · {Math.ceil((raceAffixBoonUntil() - now) / 1_000)}s</small>}
|
||||
</section>
|
||||
);
|
||||
return <>
|
||||
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">Waiting for the rival party to be ready…</div>}
|
||||
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">
|
||||
<strong>Race mechanics</strong>
|
||||
<div className="dungeon-race-countdown__affixes">{affixes.map((affix) => <p key={affix.id}>
|
||||
<b>{affix.name}</b> — {affix.description}</p>)}</div>
|
||||
<small>Waiting for the rival party to be ready…</small>
|
||||
</div>}
|
||||
{remaining !== null && remaining > 0 && race.status === "countdown" &&
|
||||
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong></div>}
|
||||
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong>
|
||||
<div className="dungeon-race-countdown__affixes">{affixes.map((affix) => <p key={affix.id}>
|
||||
<b>{affix.name}</b> — {affix.description}</p>)}</div>
|
||||
</div>}
|
||||
{toast && <div className={`dungeon-race-toast dungeon-race-toast--${race.result}`} role="status" aria-live="polite">
|
||||
{race.result === "win" ? "You win" : "You lose"}
|
||||
{race.result === "loss" ? <small>Keep going to claim the boss card.</small> : null}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
|
||||
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
|
||||
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
|
||||
import { formatDungeonDuration } from "../game/dungeonRunTypes";
|
||||
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
|
||||
import { dungeonLeaderboardCatalog as leaderboardCatalog } from "../game/dungeonLeaderboardCatalog";
|
||||
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
@@ -14,7 +15,8 @@ import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import "./dungeonRace.css";
|
||||
|
||||
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
|
||||
readonly wins?: number; readonly losses?: number; readonly durationMs?: number }
|
||||
readonly wins?: number; readonly losses?: number; readonly durationMs?: number;
|
||||
readonly affixIds?: readonly string[] }
|
||||
interface BoardResponse { readonly entries: readonly BoardEntry[]; readonly total: number }
|
||||
|
||||
export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
@@ -76,7 +78,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
|
||||
<h1>Bite-Sized Dungeon Race</h1></div><button className="front-button" type="button" onClick={() => { void race.leave(); onClose(); }}>Main menu</button></header>
|
||||
<main className="dungeon-race-screen__layout">
|
||||
<section className="dungeon-race-screen__setup">
|
||||
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss.</p>
|
||||
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss. Each race rolls one kill mechanic and one movement mechanic for both sides.</p>
|
||||
<div className="dungeon-race-screen__choices" role="group" aria-label="Race type">
|
||||
<button className="front-button" type="button" aria-pressed={!practice} disabled={!session?.accessToken || busy} onClick={() => setPractice(false)}>Online ranked</button>
|
||||
<button className="front-button" type="button" aria-pressed={practice} disabled={busy} onClick={() => setPractice(true)}>Offline practice</button>
|
||||
@@ -133,9 +135,12 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
|
||||
{!boardError && board?.entries.length === 0 && <p>No results yet.</p>}
|
||||
{board?.entries.length ? <table><thead><tr><th>Rank</th><th>Player</th>
|
||||
<th>{boardScope === "dungeon" && boardView === "times" ? "Best time" : "Wins"}</th>
|
||||
{boardScope === "dungeon" && boardView === "times" && <th>Mechanics</th>}
|
||||
{!(boardScope === "dungeon" && boardView === "times") && <th>Losses</th>}</tr></thead><tbody>
|
||||
{board.entries.map((entry) => <tr key={entry.accountId}><td>#{entry.rank}</td><td>{entry.username}</td>
|
||||
<td>{entry.durationMs !== undefined ? formatDungeonDuration(entry.durationMs) : entry.wins}</td>
|
||||
{boardScope === "dungeon" && boardView === "times" && <td>{entry.affixIds?.length
|
||||
? entry.affixIds.map((id) => dungeonRaceAffix(id)?.name ?? id).join(" + ") : "Legacy"}</td>}
|
||||
{entry.durationMs === undefined && <td>{entry.losses}</td>}</tr>)}</tbody></table> : null}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -16,6 +16,8 @@ import { DungeonRunDetails } from "./DungeonRunDetails";
|
||||
import "./dungeonRuns.css";
|
||||
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
|
||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { recentPracticeRaceCombatLog } from "../game/dungeonRaceCombatLog";
|
||||
import { dungeonRaceAffix } from "../game/dungeonRaceAffixes";
|
||||
|
||||
export interface RunPlacementState {
|
||||
readonly scope: LeaderboardScope | "race";
|
||||
@@ -26,13 +28,14 @@ export interface RunPlacementState {
|
||||
}
|
||||
|
||||
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry, onAnother,
|
||||
startingAnother = false, anotherMessage }: {
|
||||
onExportCombatLog, startingAnother = false, anotherMessage }: {
|
||||
readonly progress: DungeonRunProgress;
|
||||
readonly ranking: RunPlacementState;
|
||||
readonly onFinish: () => void;
|
||||
readonly onLoot: () => void;
|
||||
readonly onRetry: () => void;
|
||||
readonly onAnother?: () => void;
|
||||
readonly onExportCombatLog?: () => void;
|
||||
readonly startingAnother?: boolean;
|
||||
readonly anotherMessage?: string;
|
||||
}) {
|
||||
@@ -80,10 +83,18 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
|
||||
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
|
||||
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
|
||||
{ranking.scope === "race" && ranking.raceResult === "loss" ? <p className="dungeon-results__note">Your boss card and loot are still yours.</p> : null}
|
||||
{ranking.scope === "race" && <div className="dungeon-results__note">Mechanics:
|
||||
{useDungeonRaceStore.getState().affixIds?.map((id) => {
|
||||
const affix = dungeonRaceAffix(id);
|
||||
return <p key={id}><b>{affix?.name ?? id}</b>{affix ? ` — ${affix.description}` : ""}</p>;
|
||||
}) ?? " None"}
|
||||
</div>}
|
||||
{anotherMessage ? <p className="dungeon-results__note" role="status">{anotherMessage}</p> : null}
|
||||
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
|
||||
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
|
||||
<footer>
|
||||
{onExportCombatLog ? <ControllerButton {...controls} controlId="run-combat-log" className="button"
|
||||
onClick={onExportCombatLog}>Download combat log</ControllerButton> : null}
|
||||
{onAnother ? <ControllerButton {...controls} controlId="run-another" className="button button--primary"
|
||||
disabled={startingAnother} onClick={onAnother}>
|
||||
{startingAnother ? "Starting next dungeon…" : randomizedBiteSized
|
||||
@@ -139,6 +150,17 @@ export function DungeonRunResults() {
|
||||
? "The party leader can start the next random Bite-Sized dungeon."
|
||||
: "";
|
||||
return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)}
|
||||
onExportCombatLog={raceRun && race.mode === "practice" && recentPracticeRaceCombatLog()
|
||||
? () => {
|
||||
const log = recentPracticeRaceCombatLog();
|
||||
if (!log) return;
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(log, null, 2)], { type: "application/json" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = `${log.dungeonId}-${log.runId}-combat.json`;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(href), 0);
|
||||
} : undefined}
|
||||
onAnother={canStartAnother ? () => {
|
||||
setStartingAnother(true);
|
||||
setAnotherError("");
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
.dungeon-race-hud span{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#c2ac78}
|
||||
.dungeon-race-hud strong{font-variant-numeric:tabular-nums;font-size:21px;line-height:1.1}
|
||||
.dungeon-race-hud small{font-size:10px;white-space:nowrap}
|
||||
.dungeon-race-hud .dungeon-race-hud__affixes{max-width:220px;white-space:normal;color:#ffdf9b;letter-spacing:.03em;line-height:1.2}
|
||||
.dungeon-race-hud__boon{color:#f5cc56}
|
||||
.race-affix-world-countdown{color:white;background:#101015e8;border:1px solid #c3a462;border-radius:6px;padding:2px 6px;font-size:11px;font-weight:700;white-space:nowrap}
|
||||
.dungeon-race-wait,.dungeon-race-countdown{position:absolute;z-index:55;top:38%;left:50%;transform:translateX(-50%);padding:14px 22px;border:1px solid #ab945f;border-radius:12px;background:#091217ef;color:#f5e8ca;text-align:center;pointer-events:none}
|
||||
.dungeon-race-countdown strong{display:block;font-size:42px}
|
||||
.dungeon-race-countdown__affixes{max-width:440px;text-align:left;font-size:12px}
|
||||
.dungeon-race-countdown__affixes p{margin:5px 0}
|
||||
.dungeon-race-toast{position:absolute;z-index:65;top:74px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;padding:9px 18px;border:1px solid #a79261;border-radius:9px;background:#101516f2;color:#ffe4a3;font-size:22px;font-weight:700;pointer-events:none}
|
||||
.dungeon-race-toast--loss{border-color:#bd6b63;color:#ffb2a8}
|
||||
.dungeon-race-toast small{font-size:11px;color:#f5e8ca}
|
||||
|
||||
Reference in new issue
Block a user