forked from phenom/healer-man
Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
+70
-36
@@ -23,6 +23,10 @@ import {
|
||||
onlineSessionOwnsWorldSimulation,
|
||||
} from "./onlineSessionRuntime";
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
import {
|
||||
DUNGEON_SIMULATION_FIXED_STEP_MS,
|
||||
useDungeonSimulationStore,
|
||||
} from "./dungeonSimulationStore";
|
||||
|
||||
/**
|
||||
* Connects the pure combat runtime to the active character and persistent
|
||||
@@ -75,7 +79,7 @@ export function CombatBridge() {
|
||||
// after Character Select has already replaced the active character.
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
|
||||
if (useGameStore.getState().bossTest) return;
|
||||
if (useGameStore.getState().gmTestMode) return;
|
||||
|
||||
// Persisted collections are replaced immutably by combat actions. A
|
||||
// reference/primitive comparison therefore detects the same changes as
|
||||
@@ -121,54 +125,80 @@ export function CombatBridge() {
|
||||
let previous = performance.now();
|
||||
let timer: number | null = null;
|
||||
|
||||
const advanceOneTick = (delta: number, epochNow: number, spectator: boolean) => {
|
||||
const game = useGameStore.getState();
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
combat.setPlayerPosition(game.playerPosition);
|
||||
combat.tick(delta, epochNow);
|
||||
if (
|
||||
onlineSessionOwnsWorldSimulation()
|
||||
&& game.gameMode === "dungeon"
|
||||
&& (!spectator || simulation.mode === "full-combat")
|
||||
) {
|
||||
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
||||
}
|
||||
if (onlineSessionOwnsWorldSimulation()) {
|
||||
if (!spectator || simulation.mode === "full-combat") {
|
||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||
}
|
||||
advanceDungeonPartyCombat(epochNow);
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const combatEvents = deriveDungeonEncounterCombatEvents(
|
||||
encounterMobCursor.current,
|
||||
mobs,
|
||||
epochNow,
|
||||
);
|
||||
encounterMobCursor.current = combatEvents.cursor;
|
||||
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
|
||||
const registeredEntityIds = Object.keys(mobs);
|
||||
advanceDungeonEncounters(game.activeDungeonId, {
|
||||
kind: "tick",
|
||||
now: epochNow,
|
||||
facts: {
|
||||
registeredEntityIds,
|
||||
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const advance = () => {
|
||||
const now = performance.now();
|
||||
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
|
||||
const wallDeltaMs = Math.min(1_000, Math.max(0, now - previous));
|
||||
const delta = Math.min(0.25, wallDeltaMs / 1_000);
|
||||
previous = now;
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
if (simulation.sessionMode === "spectate-replay") {
|
||||
simulation.consumeTicks(wallDeltaMs);
|
||||
return;
|
||||
}
|
||||
if (simulation.sessionMode === "spectate-live") {
|
||||
const assetStatus = useGameStore.getState().assetStatus;
|
||||
if (assetStatus !== "ready" && assetStatus !== "proxy") return;
|
||||
for (const tickNow of simulation.consumeTicks(wallDeltaMs)) {
|
||||
advanceOneTick(DUNGEON_SIMULATION_FIXED_STEP_MS / 1_000, tickNow, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const game = useGameStore.getState();
|
||||
const sharedOnline = onlineSessionIsActive();
|
||||
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||
const epochNow = Date.now();
|
||||
const combat = useCombatStore.getState();
|
||||
combat.setPlayerPosition(game.playerPosition);
|
||||
combat.tick(delta, epochNow);
|
||||
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
|
||||
}
|
||||
if (onlineSessionOwnsWorldSimulation()) {
|
||||
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
|
||||
advanceDungeonPartyCombat(epochNow);
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const combatEvents = deriveDungeonEncounterCombatEvents(
|
||||
encounterMobCursor.current,
|
||||
mobs,
|
||||
epochNow,
|
||||
);
|
||||
encounterMobCursor.current = combatEvents.cursor;
|
||||
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
|
||||
const registeredEntityIds = Object.keys(mobs);
|
||||
advanceDungeonEncounters(game.activeDungeonId, {
|
||||
kind: "tick",
|
||||
now: epochNow,
|
||||
facts: {
|
||||
registeredEntityIds,
|
||||
deadEntityIds: registeredEntityIds.filter((id) => mobs[id].dead),
|
||||
},
|
||||
});
|
||||
}
|
||||
advanceOneTick(delta, Date.now(), false);
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileTimer = () => {
|
||||
const game = useGameStore.getState();
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
const shouldRun = combatSimulationTimerShouldRun({
|
||||
sharedOnline: onlineSessionIsActive(),
|
||||
paused: game.paused,
|
||||
companionOpen: game.companionOpen,
|
||||
documentHidden: document.hidden,
|
||||
paused: simulation.sessionMode === "play" ? game.paused : false,
|
||||
companionOpen: simulation.sessionMode === "play" ? game.companionOpen : false,
|
||||
documentHidden: simulation.sessionMode === "play" ? document.hidden : false,
|
||||
});
|
||||
if (shouldRun && timer === null) {
|
||||
// Reset the clock on resume so a long pause/background period cannot
|
||||
@@ -193,6 +223,9 @@ export function CombatBridge() {
|
||||
// store notification gives the bridge a reactive point at which to
|
||||
// reconcile while preserving continuous shared-online simulation.
|
||||
const unsubscribeOnlineSession = useOnlineSessionStore.subscribe(reconcileTimer);
|
||||
const unsubscribeSimulation = useDungeonSimulationStore.subscribe((state, previousState) => {
|
||||
if (state.sessionMode !== previousState.sessionMode || state.runState !== previousState.runState) reconcileTimer();
|
||||
});
|
||||
document.addEventListener("visibilitychange", reconcileTimer);
|
||||
reconcileTimer();
|
||||
|
||||
@@ -200,6 +233,7 @@ export function CombatBridge() {
|
||||
if (timer !== null) window.clearInterval(timer);
|
||||
unsubscribeGame();
|
||||
unsubscribeOnlineSession();
|
||||
unsubscribeSimulation();
|
||||
document.removeEventListener("visibilitychange", reconcileTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
|
||||
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
|
||||
import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { removeMobPosition } from "./mobRuntimeRegistry";
|
||||
import { dungeonSimulationNow } from "./dungeonSimulationStore";
|
||||
|
||||
function grantEncounterLoot(lootTableId: string, now: number): boolean {
|
||||
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
|
||||
@@ -29,14 +30,14 @@ function grantEncounterLoot(lootTableId: string, now: number): boolean {
|
||||
/** Applies platform effects emitted by the renderer-neutral encounter reducer. */
|
||||
export function DungeonEncounterEffectBridge() {
|
||||
useEffect(() => registerDungeonEncounterEffectHandler((envelope) => {
|
||||
applyDungeonEncounterEffect(envelope, Date.now());
|
||||
applyDungeonEncounterEffect(envelope, dungeonSimulationNow());
|
||||
}), []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyDungeonEncounterEffect(
|
||||
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
|
||||
now = Date.now(),
|
||||
now = dungeonSimulationNow(),
|
||||
): void {
|
||||
if (effect.kind === "signal") {
|
||||
dispatchDungeonEncounterSignal(effect.signalId, now);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
|
||||
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
|
||||
export function PartyBridge() {
|
||||
@@ -15,6 +16,8 @@ export function PartyBridge() {
|
||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const simulationMode = useDungeonSimulationStore((state) => state.sessionMode);
|
||||
const simulationSeed = useDungeonSimulationStore((state) => state.seed);
|
||||
const resetRevision = useGameStore((state) => state.resetRevision);
|
||||
const mountedResetRevision = useRef(resetRevision);
|
||||
|
||||
@@ -27,19 +30,23 @@ export function PartyBridge() {
|
||||
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
|
||||
? onlineGroupAiPartySize(onlineActivity.partySize)
|
||||
: requestedPartySize;
|
||||
party.initializeParty(
|
||||
character.id,
|
||||
combat.level,
|
||||
combat.health,
|
||||
playerRole,
|
||||
aiPartySize,
|
||||
character.categoryId ?? "wow",
|
||||
);
|
||||
if (simulationMode === "play") {
|
||||
party.initializeParty(
|
||||
character.id,
|
||||
combat.level,
|
||||
combat.health,
|
||||
playerRole,
|
||||
aiPartySize,
|
||||
character.categoryId ?? "wow",
|
||||
);
|
||||
} else {
|
||||
party.initializeSpectatorParty(simulationSeed, combat.level, character.categoryId ?? "wow");
|
||||
}
|
||||
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs();
|
||||
resetPartyRuntimeClock();
|
||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
|
||||
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
|
||||
|
||||
useEffect(() => {
|
||||
const party = usePartyStore.getState();
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createConfiguredPartyMember } from "../partyStore";
|
||||
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
|
||||
import {
|
||||
loadSelectableBenchmarkBuilds,
|
||||
selectableBenchmarkBuildCatalog,
|
||||
} from "./benchmarkBuilds";
|
||||
|
||||
describe("balance-lab selectable builds", () => {
|
||||
it("lists every authored WoW specialization instead of the representative cohort", () => {
|
||||
const catalog = selectableBenchmarkBuildCatalog(60, [
|
||||
"warrior", "paladin", "hunter", "rogue", "priest", "death-knight",
|
||||
"shaman", "mage", "warlock", "druid",
|
||||
]);
|
||||
expect(catalog).toHaveLength(31);
|
||||
expect(catalog).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ classId: "hunter", orientation: "Beast Mastery", role: "damage" }),
|
||||
expect.objectContaining({ classId: "priest", orientation: "Discipline", role: "healer" }),
|
||||
expect.objectContaining({ classId: "warlock", orientation: "Destruction", role: "damage" }),
|
||||
expect.objectContaining({ classId: "druid", orientation: "Feral (Bear)", role: "tank" }),
|
||||
]));
|
||||
expect(new Set(catalog.map((build) => build.id)).size).toBe(catalog.length);
|
||||
});
|
||||
|
||||
it("legally allocates a previously omitted specialization", async () => {
|
||||
const builds = await loadSelectableBenchmarkBuilds(20, ["hunter"]);
|
||||
const beastMastery = builds.find((build) => build.orientation === "Beast Mastery");
|
||||
expect(beastMastery?.role).toBe("damage");
|
||||
expect(Object.keys(beastMastery?.talentRanks ?? {})).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps each CoA specialization tree selectable", async () => {
|
||||
const builds = await loadSelectableBenchmarkBuilds(20, ["witch-doctor"]);
|
||||
expect(builds.map((build) => `${build.orientation}:${build.role}`)).toEqual([
|
||||
"Shadowhunting:damage",
|
||||
"Voodoo:damage",
|
||||
"Brewing:healer",
|
||||
]);
|
||||
expect(builds.every((build) => Object.keys(build.talentRanks).length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("constructs a legal build for every selectable WoW, CoA, and RoM entry", async () => {
|
||||
const catalog = selectableBenchmarkBuildCatalog(20);
|
||||
const builds = await loadSelectableBenchmarkBuilds(20);
|
||||
expect(builds.map((build) => build.id)).toEqual(catalog.map((build) => build.id));
|
||||
for (const build of builds) {
|
||||
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
|
||||
.toEqual(build.talentRanks);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("constructs an exact companion without replacing selected talents", () => {
|
||||
const talents = Object.freeze({ "hunter-beast-mastery-0": 3 });
|
||||
const member = createConfiguredPartyMember({
|
||||
name: "Balance Hunter",
|
||||
classId: "hunter",
|
||||
specialization: "Beast Mastery",
|
||||
specializationId: "hunter:beast-mastery",
|
||||
talentTreeId: "hunter-beast-mastery",
|
||||
talentRanks: talents,
|
||||
role: "damage",
|
||||
}, 20, 2, "balance-test");
|
||||
expect(member.name).toBe("Balance Hunter");
|
||||
expect(member.specialization).toBe("Beast Mastery");
|
||||
expect(member.specializationId).toBe("hunter:beast-mastery");
|
||||
expect(member.talentRanks).toBe(talents);
|
||||
expect(member.id).toContain("party-damage-2-hunter");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CLASSES,
|
||||
COA_CLASS_IDS,
|
||||
ROM_CLASS_IDS,
|
||||
classById,
|
||||
contentCategoryForClass,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
@@ -20,6 +21,10 @@ import type { EquipmentAssignments } from "../equipment";
|
||||
import { partyRolesForClass, type PartyRole } from "../partyRoles";
|
||||
import { runtimePartyTalentRanksForLevel } from "../partyTalentRuntime";
|
||||
import { clampPlayerLevel } from "../progression";
|
||||
import {
|
||||
specializationsForClass,
|
||||
type SpecializationDefinition,
|
||||
} from "../specializationCatalog";
|
||||
import {
|
||||
runtimeAllocateTalentPoint,
|
||||
runtimeNormalizeTalentRanks,
|
||||
@@ -153,6 +158,16 @@ function benchmarkBuildId(classId: ClassId, role: PartyRole, level: number): str
|
||||
return `canonical-${contentCategoryForClass(classId)}-${classId}-${role}-level-${level}`;
|
||||
}
|
||||
|
||||
function benchmarkVariantBuildId(
|
||||
classId: ClassId,
|
||||
role: PartyRole,
|
||||
orientation: string,
|
||||
level: number,
|
||||
): string {
|
||||
const variant = orientation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
return `balance-${contentCategoryForClass(classId)}-${classId}-${role}-${variant}-level-${level}`;
|
||||
}
|
||||
|
||||
function commonBuildFields(classId: ClassId, role: PartyRole, level: number) {
|
||||
return {
|
||||
id: benchmarkBuildId(classId, role, level),
|
||||
@@ -365,6 +380,197 @@ function romBuild(
|
||||
});
|
||||
}
|
||||
|
||||
function specializationTree(
|
||||
catalog: TalentUiCatalog,
|
||||
specialization: SpecializationDefinition,
|
||||
): TalentUiTree {
|
||||
const normalized = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
const names = new Set([
|
||||
normalized(specialization.talentTree),
|
||||
normalized(specialization.displayName),
|
||||
]);
|
||||
const tree = catalog.trees.find((candidate) => names.has(normalized(candidate.name)));
|
||||
if (!tree) {
|
||||
throw new Error(
|
||||
`No talent tree matches ${classById(specialization.classId).name} ${specialization.displayName}.`,
|
||||
);
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
function allocateSingleTreeTalentRanks(
|
||||
classId: BaseClassId,
|
||||
catalog: TalentUiCatalog,
|
||||
tree: TalentUiTree,
|
||||
level: number,
|
||||
): RuntimeTalentRanks {
|
||||
const candidates = catalog.nodes
|
||||
.filter((node) => node.treeId === tree.id)
|
||||
.sort((left, right) => (
|
||||
left.row - right.row
|
||||
|| left.column - right.column
|
||||
|| left.index - right.index
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
const successfulRequests: Record<string, number> = {};
|
||||
let ranks: RuntimeTalentRanks = EMPTY_TALENT_RANKS;
|
||||
while (true) {
|
||||
let allocated = false;
|
||||
for (const node of candidates) {
|
||||
if ((successfulRequests[node.id] ?? 0) >= node.maxRank) continue;
|
||||
const result = runtimeAllocateTalentPoint(ranks, classId, node.id, level);
|
||||
if (!result.ok) continue;
|
||||
successfulRequests[node.id] = (successfulRequests[node.id] ?? 0) + 1;
|
||||
ranks = result.ranks;
|
||||
allocated = true;
|
||||
break;
|
||||
}
|
||||
if (!allocated) break;
|
||||
}
|
||||
return Object.freeze({ ...runtimeNormalizeTalentRanks(classId, level, ranks) });
|
||||
}
|
||||
|
||||
function selectableClassicBuild(
|
||||
specialization: SpecializationDefinition,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog | null,
|
||||
): BenchmarkBuildDefinition {
|
||||
const classId = specialization.classId as BaseClassId;
|
||||
let talentRanks: RuntimeTalentRanks;
|
||||
try {
|
||||
talentRanks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, level);
|
||||
} catch {
|
||||
if (!catalog) throw new Error(`No talent catalog was loaded for ${classId}.`);
|
||||
talentRanks = allocateSingleTreeTalentRanks(
|
||||
classId,
|
||||
catalog,
|
||||
specializationTree(catalog, specialization),
|
||||
level,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, specialization.role, level),
|
||||
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
|
||||
orientation: specialization.displayName,
|
||||
talentRanks,
|
||||
});
|
||||
}
|
||||
|
||||
function selectableCoaBuild(
|
||||
specialization: SpecializationDefinition,
|
||||
level: number,
|
||||
catalog: TalentUiCatalog,
|
||||
): BenchmarkBuildDefinition {
|
||||
const classId = specialization.classId as CoaClassId;
|
||||
const tree = specializationTree(catalog, specialization);
|
||||
const abilitiesByNode = coaNodeAbilities(classId);
|
||||
return Object.freeze({
|
||||
...commonBuildFields(classId, specialization.role, level),
|
||||
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, level),
|
||||
orientation: specialization.displayName,
|
||||
talentRanks: allocateCoaTalentRanks(
|
||||
classId,
|
||||
catalog,
|
||||
tree,
|
||||
specialization.role,
|
||||
level,
|
||||
abilitiesByNode,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export interface SelectableBenchmarkBuildCatalogEntry {
|
||||
readonly id: string;
|
||||
readonly source: ReturnType<typeof contentCategoryForClass>;
|
||||
readonly classId: ClassId;
|
||||
readonly role: PartyRole;
|
||||
readonly orientation: string;
|
||||
readonly level: number;
|
||||
}
|
||||
|
||||
/** Cheap metadata-only roster used by interactive tools before a build is selected. */
|
||||
export function selectableBenchmarkBuildCatalog(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): readonly SelectableBenchmarkBuildCatalogEntry[] {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
return CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId))
|
||||
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
|
||||
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
|
||||
id: benchmarkBuildId(classId, role, safeLevel),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role,
|
||||
orientation: "Primary",
|
||||
level: safeLevel,
|
||||
}));
|
||||
return specializationsForClass(classId).map((specialization) => ({
|
||||
id: benchmarkVariantBuildId(classId, specialization.role, specialization.displayName, safeLevel),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role: specialization.role,
|
||||
orientation: specialization.displayName,
|
||||
level: safeLevel,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds every user-selectable class/spec/role variant for the balance lab.
|
||||
* This is intentionally broader than the canonical benchmark cohort above,
|
||||
* which keeps one representative orientation per class-role pair.
|
||||
*/
|
||||
export async function loadSelectableBenchmarkBuilds(
|
||||
level = CANONICAL_BENCHMARK_LEVEL,
|
||||
classIds?: readonly ClassId[],
|
||||
): Promise<BenchmarkBuildDefinition[]> {
|
||||
const safeLevel = clampPlayerLevel(level);
|
||||
const requested = classIds ? new Set(classIds) : null;
|
||||
const selectedClassIds = CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => !requested || requested.has(classId));
|
||||
const catalogClassIds = selectedClassIds.filter((classId) => (
|
||||
isCoaClassId(classId)
|
||||
|| (isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => {
|
||||
try {
|
||||
runtimePartyTalentRanksForLevel(classId, specialization.displayName, safeLevel);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}))
|
||||
));
|
||||
|
||||
await preloadAbilityCatalogsForClasses(selectedClassIds);
|
||||
const catalogs = new Map<ClassId, TalentUiCatalog>(await Promise.all(
|
||||
catalogClassIds.map(async (classId) => [
|
||||
classId,
|
||||
await loadTalentUiCatalog(classId),
|
||||
] as const),
|
||||
));
|
||||
|
||||
const builds: BenchmarkBuildDefinition[] = [];
|
||||
for (const classId of selectedClassIds) {
|
||||
if (isRomClassId(classId)) {
|
||||
for (const role of partyRolesForClass(classId)) builds.push(romBuild(classId, role, safeLevel));
|
||||
continue;
|
||||
}
|
||||
for (const specialization of specializationsForClass(classId)) {
|
||||
if (isBaseClassId(classId)) {
|
||||
builds.push(selectableClassicBuild(specialization, safeLevel, catalogs.get(classId) ?? null));
|
||||
} else if (isCoaClassId(classId)) {
|
||||
const catalog = catalogs.get(classId);
|
||||
if (!catalog) throw new Error(`No loaded CoA talent catalog exists for ${classId}.`);
|
||||
builds.push(selectableCoaBuild(specialization, safeLevel, catalog));
|
||||
}
|
||||
}
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one stable, legal benchmark build for every declared class-role pair.
|
||||
* A filter changes only membership; output always follows the canonical CLASSES
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateBossPartyPressure, rankBossPartyPressure } from "./bossPressure";
|
||||
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||
|
||||
describe("boss party-pressure audit", () => {
|
||||
it("produces a finite deterministic ranking for every WoW boss", () => {
|
||||
const definitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
|
||||
const first = rankBossPartyPressure(definitions);
|
||||
const second = rankBossPartyPressure(definitions);
|
||||
expect(first.length).toBeGreaterThan(200);
|
||||
expect(second).toEqual(first);
|
||||
expect(first.every((entry) => Number.isFinite(entry.partyDamagePerSecond)
|
||||
&& entry.partyDamagePerSecond >= 0)).toBe(true);
|
||||
expect(first.map((entry) => entry.partyDamage)).toEqual(
|
||||
[...first.map((entry) => entry.partyDamage)].sort((left, right) => right - left),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts grouped effects across the party and excludes self-only utility", () => {
|
||||
const gundrak = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "gundrak")!;
|
||||
const eckId = Object.entries(gundrak.entities)
|
||||
.find(([, entity]) => entity.combat?.serverEntry === 29_932)![0];
|
||||
const estimate = estimateBossPartyPressure(gundrak, eckId);
|
||||
expect(estimate.boss).toBe("Eck the Ferocious");
|
||||
expect(estimate.attacks.find((attack) => attack.spellId === 55_837)?.partyDamage).toBe(0);
|
||||
expect(estimate.attacks.find((attack) => attack.spellId === 55_816)?.partyDamage).toBe(0);
|
||||
expect(estimate.attacks.find((attack) => attack.spellId === 55_815)).toMatchObject({
|
||||
target: "random-party",
|
||||
targetsPerCast: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
|
||||
import { scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type {
|
||||
EnemyAbilityEffect,
|
||||
EnemyAttackDefinition,
|
||||
EnemyAtomicAbilityEffect,
|
||||
} from "./enemyAttacks";
|
||||
import type { PopulationEntityDefinition } from "./mobPopulation";
|
||||
|
||||
export const BOSS_PRESSURE_WINDOW_MS = 60_000;
|
||||
export const BOSS_PRESSURE_PARTY_SIZE = 5;
|
||||
|
||||
export interface BossAttackPressureEstimate {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly spellId?: number;
|
||||
readonly target: EnemyAttackDefinition["target"];
|
||||
readonly school: EnemyAttackDefinition["school"];
|
||||
readonly expectedCasts: number;
|
||||
readonly targetsPerCast: number;
|
||||
readonly damagePerTarget: number;
|
||||
readonly partyDamage: number;
|
||||
}
|
||||
|
||||
export interface BossPartyPressureEstimate {
|
||||
readonly key: string;
|
||||
readonly dungeonId: string;
|
||||
readonly dungeonTitle: string;
|
||||
readonly boss: string;
|
||||
readonly serverEntry: number | null;
|
||||
readonly playerLevel: number;
|
||||
readonly enemyLevel: number;
|
||||
readonly windowSeconds: number;
|
||||
readonly attackDamage: number;
|
||||
readonly partyDamage: number;
|
||||
readonly partyDamagePerSecond: number;
|
||||
readonly tankDamage: number;
|
||||
readonly randomTargetDamage: number;
|
||||
readonly groupwideDamage: number;
|
||||
readonly attacks: readonly BossAttackPressureEstimate[];
|
||||
}
|
||||
|
||||
function midpoint(range: readonly [number, number] | undefined, fallback: number): number {
|
||||
if (!range) return fallback;
|
||||
return (Math.max(0, range[0]) + Math.max(0, range[1])) / 2;
|
||||
}
|
||||
|
||||
function expectedCasts(attack: EnemyAttackDefinition, windowMs: number): number {
|
||||
const initial = midpoint(attack.initialCooldownMs, 450);
|
||||
if (windowMs < initial) return 0;
|
||||
const repeat = Math.max(1, midpoint(attack.repeatCooldownMs, attack.cooldownMs));
|
||||
const casts = 1 + (windowMs - initial) / repeat;
|
||||
return Math.min(attack.maximumCasts ?? Number.POSITIVE_INFINITY, casts);
|
||||
}
|
||||
|
||||
function atomicDamageMultiplier(effect: EnemyAtomicAbilityEffect): number {
|
||||
if (effect.kind === "damage") return Math.max(0, effect.multiplier);
|
||||
if (effect.kind === "leech") return Math.max(0, effect.damageMultiplier);
|
||||
if (effect.kind === "control") return Math.max(0, effect.damageMultiplier ?? 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function damageMultiplier(attack: EnemyAttackDefinition, effect: EnemyAbilityEffect | undefined): number {
|
||||
if (!effect) return Math.max(0, attack.damageMultiplier);
|
||||
if (effect.kind === "composite") {
|
||||
return effect.effects.reduce((total, entry) => total + atomicDamageMultiplier(entry), 0);
|
||||
}
|
||||
return atomicDamageMultiplier(effect);
|
||||
}
|
||||
|
||||
function targetsPerCast(attack: EnemyAttackDefinition, partySize: number): number {
|
||||
if (attack.target === "nearby-party") return partySize;
|
||||
if (attack.target === "primary" || attack.target === "random-party") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparable direct-damage ceiling for one boss with a grouped party. It uses
|
||||
* live attack definitions and source creature stats, but deliberately excludes
|
||||
* armor/resistance, avoidable movement, summons, healing, and mutually exclusive
|
||||
* phase uptime. Those omissions keep the audit deterministic and make inflated
|
||||
* schedules or accidental party-wide targeting obvious.
|
||||
*/
|
||||
export function estimateBossPartyPressure(
|
||||
dungeon: DungeonDefinition,
|
||||
entityId: string,
|
||||
playerLevel = 80,
|
||||
windowMs = BOSS_PRESSURE_WINDOW_MS,
|
||||
partySize = BOSS_PRESSURE_PARTY_SIZE,
|
||||
): BossPartyPressureEstimate {
|
||||
const entity: PopulationEntityDefinition | undefined = dungeon.entities[entityId];
|
||||
if (!entity || entity.kind !== "boss") throw new Error(`${dungeon.id}:${entityId} is not a boss`);
|
||||
const enemyLevel = scaledDungeonEnemyLevel(playerLevel, true);
|
||||
const serverEntry = entity.combat?.serverEntry ?? null;
|
||||
const baseDamage = serverEntry
|
||||
? creatureRuntimeBaseStats(serverEntry, enemyLevel)?.averageAttackDamage
|
||||
: undefined;
|
||||
const attackDamage = (baseDamage ?? (12 + enemyLevel * 2)) * (entity.combat?.damageMultiplier ?? 1);
|
||||
const attacks = (entity.combat?.attacks ?? []).map((attack): BossAttackPressureEstimate => {
|
||||
const casts = expectedCasts(attack, windowMs);
|
||||
const targets = targetsPerCast(attack, partySize);
|
||||
const perTarget = attackDamage * damageMultiplier(attack, attack.effect);
|
||||
return {
|
||||
id: attack.id,
|
||||
name: attack.name,
|
||||
...(attack.spellId === undefined ? {} : { spellId: attack.spellId }),
|
||||
target: attack.target,
|
||||
school: attack.school,
|
||||
expectedCasts: casts,
|
||||
targetsPerCast: targets,
|
||||
damagePerTarget: perTarget,
|
||||
partyDamage: casts * targets * perTarget,
|
||||
};
|
||||
});
|
||||
const sum = (target: EnemyAttackDefinition["target"]): number => attacks
|
||||
.filter((attack) => attack.target === target)
|
||||
.reduce((total, attack) => total + attack.partyDamage, 0);
|
||||
const partyDamage = attacks.reduce((total, attack) => total + attack.partyDamage, 0);
|
||||
return {
|
||||
key: `${dungeon.id}/${entityId}`,
|
||||
dungeonId: dungeon.id,
|
||||
dungeonTitle: dungeon.title,
|
||||
boss: entity.name,
|
||||
serverEntry,
|
||||
playerLevel,
|
||||
enemyLevel,
|
||||
windowSeconds: windowMs / 1_000,
|
||||
attackDamage,
|
||||
partyDamage,
|
||||
partyDamagePerSecond: partyDamage / Math.max(1, windowMs / 1_000),
|
||||
tankDamage: sum("primary"),
|
||||
randomTargetDamage: sum("random-party"),
|
||||
groupwideDamage: sum("nearby-party"),
|
||||
attacks,
|
||||
};
|
||||
}
|
||||
|
||||
export function rankBossPartyPressure(
|
||||
definitions: readonly DungeonDefinition[],
|
||||
playerLevel = 80,
|
||||
windowMs = BOSS_PRESSURE_WINDOW_MS,
|
||||
partySize = BOSS_PRESSURE_PARTY_SIZE,
|
||||
): readonly BossPartyPressureEstimate[] {
|
||||
return definitions.flatMap((dungeon) => Object.entries(dungeon.entities)
|
||||
.filter(([, entity]) => entity.kind === "boss")
|
||||
.map(([entityId]) => estimateBossPartyPressure(dungeon, entityId, playerLevel, windowMs, partySize)))
|
||||
.sort((left, right) => right.partyDamage - left.partyDamage
|
||||
|| left.boss.localeCompare(right.boss)
|
||||
|| left.dungeonId.localeCompare(right.dungeonId));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
|
||||
import { anotherRandomTestingBoss, buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
|
||||
import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { useGameStore } from "./store";
|
||||
@@ -138,5 +138,7 @@ describe("Boss Testing", () => {
|
||||
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
|
||||
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
|
||||
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
|
||||
expect(anotherRandomTestingBoss(candidates, candidates[0].key, () => 0)).toBe(candidates[1]);
|
||||
expect(anotherRandomTestingBoss([candidates[0]], candidates[0].key)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,14 @@ export function randomTestingBoss(bosses: readonly TestableBoss[], random: () =>
|
||||
return bosses[index];
|
||||
}
|
||||
|
||||
export function anotherRandomTestingBoss(
|
||||
bosses: readonly TestableBoss[],
|
||||
currentKey: string,
|
||||
random: () => number = Math.random,
|
||||
): TestableBoss | null {
|
||||
return randomTestingBoss(bosses.filter((boss) => boss.key !== currentKey), random);
|
||||
}
|
||||
|
||||
function references(value: unknown, ids: ReadonlySet<string>): boolean {
|
||||
if (typeof value === "string") return ids.has(value);
|
||||
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
|
||||
|
||||
+10
-5
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { combatNow } from "./combatClock";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
@@ -2633,7 +2634,7 @@ function damageMobInWork(
|
||||
const health = mob.health - applied;
|
||||
const killed = health <= 0;
|
||||
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
|
||||
const testing = Boolean(useGameStore.getState().bossTest);
|
||||
const testing = useGameStore.getState().gmTestMode;
|
||||
const canAward = killed && !mob.experienceGranted && !testing;
|
||||
const beforeLevel = work.progression.level;
|
||||
const pendingLoot = testing ? [] : killed && mob.lootSourceId
|
||||
@@ -3365,7 +3366,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
|
||||
state.lastResourceSpentAt[id as ResourceType] ?? 0,
|
||||
)]),
|
||||
);
|
||||
actors[PLAYER_AURA_ENTITY_ID] = {
|
||||
if (useDungeonSimulationStore.getState().sessionMode === "play") actors[PLAYER_AURA_ENTITY_ID] = {
|
||||
id: PLAYER_AURA_ENTITY_ID,
|
||||
threatActorId: PLAYER_AURA_ENTITY_ID,
|
||||
kind: "player",
|
||||
@@ -4168,12 +4169,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
get().synchronizeActors();
|
||||
const state = get();
|
||||
const partyMembers = usePartyStore.getState().members;
|
||||
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
|
||||
const sources = [
|
||||
{
|
||||
...(includePlayer ? [{
|
||||
id: PLAYER_AURA_ENTITY_ID,
|
||||
level: state.level,
|
||||
abilities: state.abilities,
|
||||
},
|
||||
}] : []),
|
||||
...partyMembers.filter((member) => member.health > 0).map((member) => ({
|
||||
id: member.id,
|
||||
level: member.level,
|
||||
@@ -4200,7 +4202,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
selected.set(family, { sourceId: source.id, ability, effects, potency });
|
||||
}
|
||||
}
|
||||
const recipients = [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id)];
|
||||
const recipients = [
|
||||
...(includePlayer ? [PLAYER_AURA_ENTITY_ID] : []),
|
||||
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
|
||||
];
|
||||
for (const [family, buff] of selected) {
|
||||
for (const recipientId of recipients) {
|
||||
for (const effect of buff.effects) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { dispatchDungeonWorldObjectInteraction } from "./dungeonWorldInteraction";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { useDungeonRouteStore } from "./dungeonRouteStore";
|
||||
import {
|
||||
dungeonRouteDefinition,
|
||||
dungeonRouteLeg,
|
||||
} from "./dungeonRoutes";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import type { DungeonRouteDefinitionV1, DungeonRouteLeg } from "./dungeonSimulationTypes";
|
||||
import type { Vector3Tuple } from "./dungeonTypes";
|
||||
|
||||
export interface ActiveDungeonRouteTarget {
|
||||
readonly route: DungeonRouteDefinitionV1;
|
||||
readonly leg: DungeonRouteLeg;
|
||||
readonly checkpointIndex: number;
|
||||
readonly target: Vector3Tuple;
|
||||
readonly remaining: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
let activeDungeonId: string | null = null;
|
||||
let activeObjectiveId: string | null = null;
|
||||
let checkpointIndex = 0;
|
||||
const completedActions = new Set<string>();
|
||||
|
||||
function distance(left: Vector3Tuple, right: Vector3Tuple): number {
|
||||
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
|
||||
}
|
||||
|
||||
export function resetDungeonRouteRuntime(): void {
|
||||
activeDungeonId = null;
|
||||
activeObjectiveId = null;
|
||||
checkpointIndex = 0;
|
||||
completedActions.clear();
|
||||
}
|
||||
|
||||
export function routeForActiveDungeon(dungeonId: string): DungeonRouteDefinitionV1 | null {
|
||||
const route = dungeonRouteDefinition(dungeonId);
|
||||
if (!route) return null;
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
if (simulation.sessionMode === "spectate-live") return route;
|
||||
return useDungeonRouteStore.getState().isApproved(route) ? route : null;
|
||||
}
|
||||
|
||||
function executeReachedActions(
|
||||
dungeonId: string,
|
||||
leg: DungeonRouteLeg,
|
||||
reachedIndex: number,
|
||||
actorId: string | undefined,
|
||||
now: number,
|
||||
): void {
|
||||
const definition = requireDungeonDefinition(dungeonId);
|
||||
for (const action of leg.actions) {
|
||||
if (action.checkpointIndex > reachedIndex || completedActions.has(action.id)) continue;
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
|
||||
if (!object?.interaction) {
|
||||
useDungeonSimulationStore.getState().addFinding({
|
||||
id: `invalid-action:${leg.id}:${action.id}`,
|
||||
atMs: useDungeonSimulationStore.getState().elapsedMs,
|
||||
severity: "critical",
|
||||
code: "invalid-required-interaction",
|
||||
message: `Route action ${action.id} references unavailable object ${action.objectId}.`,
|
||||
objectiveId: leg.objectiveId,
|
||||
checkpointIndex: action.checkpointIndex,
|
||||
});
|
||||
completedActions.add(action.id);
|
||||
continue;
|
||||
}
|
||||
dispatchDungeonWorldObjectInteraction(object, actorId, now);
|
||||
if (useDungeonSimulationStore.getState().sessionMode === "spectate-live") {
|
||||
useDungeonSimulationStore.getState().addFinding({
|
||||
id: `route-action:${leg.id}:${action.id}`,
|
||||
atMs: useDungeonSimulationStore.getState().elapsedMs,
|
||||
severity: "info",
|
||||
code: "route-action",
|
||||
message: `Performed required route action ${action.id}.`,
|
||||
objectiveId: leg.objectiveId,
|
||||
checkpointIndex: action.checkpointIndex,
|
||||
actionId: action.id,
|
||||
});
|
||||
}
|
||||
completedActions.add(action.id);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceNavigationOnlyObjective(route: DungeonRouteDefinitionV1, leg: DungeonRouteLeg): void {
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
if (simulation.sessionMode !== "spectate-live" || simulation.mode !== "navigation-only") return;
|
||||
const index = route.legs.findIndex((candidate) => candidate.id === leg.id);
|
||||
const next = route.legs[index + 1] ?? null;
|
||||
simulation.setRouteProgress(next?.objectiveId ?? null, 0);
|
||||
activeObjectiveId = next?.objectiveId ?? null;
|
||||
checkpointIndex = 0;
|
||||
}
|
||||
|
||||
export function activeDungeonRouteTarget(
|
||||
dungeonId: string,
|
||||
objectiveId: string | null,
|
||||
position: Vector3Tuple,
|
||||
actorId: string | undefined,
|
||||
now: number,
|
||||
): ActiveDungeonRouteTarget | null {
|
||||
const route = routeForActiveDungeon(dungeonId);
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
const routedObjectiveId = simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only"
|
||||
? simulation.objectiveId
|
||||
: objectiveId;
|
||||
const leg = dungeonRouteLeg(route, routedObjectiveId);
|
||||
if (!route || !leg || !leg.checkpoints.length) return null;
|
||||
if (activeDungeonId !== dungeonId || activeObjectiveId !== leg.objectiveId) {
|
||||
activeDungeonId = dungeonId;
|
||||
activeObjectiveId = leg.objectiveId;
|
||||
checkpointIndex = 0;
|
||||
}
|
||||
while (
|
||||
checkpointIndex < leg.checkpoints.length
|
||||
&& distance(position, leg.checkpoints[checkpointIndex]) <= 1.25
|
||||
) {
|
||||
executeReachedActions(dungeonId, leg, checkpointIndex, actorId, now);
|
||||
checkpointIndex += 1;
|
||||
}
|
||||
if (checkpointIndex >= leg.checkpoints.length) {
|
||||
advanceNavigationOnlyObjective(route, leg);
|
||||
return null;
|
||||
}
|
||||
if (simulation.sessionMode === "spectate-live") {
|
||||
simulation.setRouteProgress(leg.objectiveId, checkpointIndex);
|
||||
}
|
||||
return {
|
||||
route,
|
||||
leg,
|
||||
checkpointIndex,
|
||||
target: leg.checkpoints[checkpointIndex],
|
||||
remaining: leg.checkpoints.slice(checkpointIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function activeDungeonRouteProgress(): {
|
||||
readonly dungeonId: string | null;
|
||||
readonly objectiveId: string | null;
|
||||
readonly checkpointIndex: number;
|
||||
} {
|
||||
return { dungeonId: activeDungeonId, objectiveId: activeObjectiveId, checkpointIndex };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from "zustand";
|
||||
import { fetchDungeonRouteApprovals } from "../app/dungeonSimulationClient";
|
||||
import type { DungeonRouteApproval, DungeonRouteDefinitionV1 } from "./dungeonSimulationTypes";
|
||||
import {
|
||||
cacheDungeonRouteApprovals,
|
||||
readCachedDungeonRouteApprovals,
|
||||
routeHasExactApproval,
|
||||
} from "./dungeonRoutes";
|
||||
|
||||
export interface DungeonRouteState {
|
||||
approvals: readonly DungeonRouteApproval[];
|
||||
loading: boolean;
|
||||
refreshedAt: number | null;
|
||||
refresh: () => Promise<void>;
|
||||
isApproved: (route: DungeonRouteDefinitionV1 | null) => boolean;
|
||||
}
|
||||
|
||||
export const useDungeonRouteStore = create<DungeonRouteState>((set, get) => ({
|
||||
approvals: readCachedDungeonRouteApprovals(),
|
||||
loading: false,
|
||||
refreshedAt: null,
|
||||
refresh: async () => {
|
||||
if (get().loading) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
const approvals = await fetchDungeonRouteApprovals();
|
||||
cacheDungeonRouteApprovals(approvals);
|
||||
set({ approvals, refreshedAt: Date.now(), loading: false });
|
||||
} catch {
|
||||
// An exact-hash cached approval is intentionally usable during outages.
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
isApproved: (route) => routeHasExactApproval(route, get().approvals),
|
||||
}));
|
||||
@@ -0,0 +1,108 @@
|
||||
import rawCatalog from "./generated/dungeonRouteCatalog.generated.json";
|
||||
import type {
|
||||
DungeonRouteApproval,
|
||||
DungeonRouteCatalogV1,
|
||||
DungeonRouteDefinitionV1,
|
||||
DungeonRouteLeg,
|
||||
} from "./dungeonSimulationTypes";
|
||||
import type { Vector3Tuple } from "./dungeonTypes";
|
||||
|
||||
export const DUNGEON_ROUTE_CATALOG = rawCatalog as unknown as DungeonRouteCatalogV1;
|
||||
export const DUNGEON_ROUTE_APPROVAL_CACHE_KEY = "healer-man.dungeon-route-approvals.v1";
|
||||
|
||||
const routesByDungeon = new Map(
|
||||
DUNGEON_ROUTE_CATALOG.routes.map((route) => [route.dungeonId, route] as const),
|
||||
);
|
||||
|
||||
export function dungeonRouteDefinition(dungeonId: string, routeId = "primary"): DungeonRouteDefinitionV1 | null {
|
||||
const route = routesByDungeon.get(dungeonId) ?? null;
|
||||
return route?.id === routeId ? route : null;
|
||||
}
|
||||
|
||||
export function dungeonRouteLeg(
|
||||
route: DungeonRouteDefinitionV1 | null,
|
||||
objectiveId: string | null,
|
||||
): DungeonRouteLeg | null {
|
||||
if (!route || !objectiveId) return null;
|
||||
return route.legs.find((leg) => leg.objectiveId === objectiveId) ?? null;
|
||||
}
|
||||
|
||||
export function routeDistance(points: readonly Vector3Tuple[]): number {
|
||||
let total = 0;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
total += Math.hypot(
|
||||
points[index][0] - points[index - 1][0],
|
||||
points[index][1] - points[index - 1][1],
|
||||
points[index][2] - points[index - 1][2],
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function routePointDistanceSquared(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): {
|
||||
readonly planarSquared: number;
|
||||
readonly vertical: number;
|
||||
readonly progress: number;
|
||||
} {
|
||||
const dx = end[0] - start[0];
|
||||
const dz = end[2] - start[2];
|
||||
const lengthSquared = dx * dx + dz * dz;
|
||||
const progress = lengthSquared > 1e-9
|
||||
? Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[2] - start[2]) * dz) / lengthSquared))
|
||||
: 0;
|
||||
const projectedX = start[0] + dx * progress;
|
||||
const projectedY = start[1] + (end[1] - start[1]) * progress;
|
||||
const projectedZ = start[2] + dz * progress;
|
||||
return {
|
||||
planarSquared: (point[0] - projectedX) ** 2 + (point[2] - projectedZ) ** 2,
|
||||
vertical: Math.abs(point[1] - projectedY),
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
export function positionAlongRouteCorridor(
|
||||
point: Vector3Tuple,
|
||||
checkpoints: readonly Vector3Tuple[],
|
||||
planarLimit = 10,
|
||||
verticalLimit = 4,
|
||||
): boolean {
|
||||
for (let index = 1; index < checkpoints.length; index += 1) {
|
||||
const distance = routePointDistanceSquared(point, checkpoints[index - 1], checkpoints[index]);
|
||||
if (distance.planarSquared <= planarLimit ** 2 && distance.vertical <= verticalLimit) return true;
|
||||
}
|
||||
return checkpoints.length === 1
|
||||
&& Math.hypot(point[0] - checkpoints[0][0], point[2] - checkpoints[0][2]) <= planarLimit
|
||||
&& Math.abs(point[1] - checkpoints[0][1]) <= verticalLimit;
|
||||
}
|
||||
|
||||
export function readCachedDungeonRouteApprovals(): readonly DungeonRouteApproval[] {
|
||||
if (typeof localStorage === "undefined") return [];
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY) ?? "null");
|
||||
if (!Array.isArray(parsed?.approvals)) return [];
|
||||
return parsed.approvals.filter((approval: unknown): approval is DungeonRouteApproval => {
|
||||
if (!approval || typeof approval !== "object") return false;
|
||||
const value = approval as Partial<DungeonRouteApproval>;
|
||||
return typeof value.dungeonId === "string" && typeof value.routeId === "string"
|
||||
&& /^[a-f0-9]{64}$/.test(value.routeHash ?? "") && Number.isFinite(value.reviewedAt);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function cacheDungeonRouteApprovals(approvals: readonly DungeonRouteApproval[]): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(DUNGEON_ROUTE_APPROVAL_CACHE_KEY, JSON.stringify({ approvals }));
|
||||
}
|
||||
|
||||
export function routeHasExactApproval(
|
||||
route: DungeonRouteDefinitionV1 | null,
|
||||
approvals: readonly DungeonRouteApproval[],
|
||||
): boolean {
|
||||
return Boolean(route?.generationStatus === "Candidate" && approvals.some((approval) => (
|
||||
approval.dungeonId === route.dungeonId
|
||||
&& approval.routeId === route.id
|
||||
&& approval.routeHash === route.hash
|
||||
)));
|
||||
}
|
||||
@@ -21,12 +21,16 @@ import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import type { DungeonRunProgress } from "./dungeonRunTypes";
|
||||
import { validBossDifficulty } from "./dungeonDifficulty";
|
||||
import { resetDungeonRouteRuntime } from "./dungeonRouteRuntime";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { dungeonRouteDefinition } from "./dungeonRoutes";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly bossDifficulty?: number;
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
readonly dungeonSimulation?: boolean;
|
||||
readonly bossTest?: BossTestSession | null;
|
||||
readonly gmReviewTarget?: GmReviewTarget | null;
|
||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||
@@ -50,7 +54,8 @@ export function activateDungeonSession(
|
||||
const definition = dungeonDefinitionById(dungeonId);
|
||||
const testingSource = bossTestingSourceDungeonId(dungeonId);
|
||||
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
|
||||
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
|
||||
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
|
||||
if (!definition || (!testing && !dungeonSimulation && !dungeonCanEnter(dungeonId))
|
||||
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
|
||||
return false;
|
||||
}
|
||||
@@ -72,6 +77,8 @@ export function activateDungeonSession(
|
||||
resetDungeonEncounters("dungeon", dungeonId);
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetDungeonRouteRuntime();
|
||||
useDungeonSimulationStore.getState().leave();
|
||||
clearDungeonRuntimeCaches();
|
||||
resetPartyRuntimeClock();
|
||||
if (options.preserveCharacter) {
|
||||
@@ -97,6 +104,7 @@ export function activateDungeonSession(
|
||||
});
|
||||
usePartyStore.setState({
|
||||
seed: null,
|
||||
spectatorParty: false,
|
||||
members: [],
|
||||
command: "defend",
|
||||
selectedMemberId: null,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DUNGEON_SIMULATION_FIXED_STEP_MS,
|
||||
fixedSimulationTickCount,
|
||||
useDungeonSimulationStore,
|
||||
} from "./dungeonSimulationStore";
|
||||
import { createSpectatorDungeonParty } from "./partyStore";
|
||||
import { routeHasExactApproval } from "./dungeonRoutes";
|
||||
import type { DungeonRouteDefinitionV1, DungeonSimulationSpeed } from "./dungeonSimulationTypes";
|
||||
|
||||
const route: DungeonRouteDefinitionV1 = {
|
||||
schemaVersion: 1,
|
||||
id: "primary",
|
||||
dungeonId: "test-dungeon",
|
||||
mapId: 1,
|
||||
hash: "a".repeat(64),
|
||||
generationStatus: "Candidate",
|
||||
generationIssues: [],
|
||||
legs: [{
|
||||
id: "leg-1",
|
||||
objectiveId: "boss-1",
|
||||
objectiveName: "Boss",
|
||||
checkpoints: [[0, 0, 0], [4, 0, 0]],
|
||||
expectedDistance: 4,
|
||||
corridorPlanarDistance: 10,
|
||||
corridorVerticalDistance: 4,
|
||||
actions: [],
|
||||
}],
|
||||
};
|
||||
|
||||
describe("dungeon simulation fixed clock", () => {
|
||||
beforeEach(() => useDungeonSimulationStore.getState().leave());
|
||||
|
||||
it("produces the same ordered ticks at every playback speed", () => {
|
||||
const speeds: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
|
||||
for (const speed of speeds) {
|
||||
const wallDuration = 500 / speed;
|
||||
const result = fixedSimulationTickCount(0, wallDuration, speed);
|
||||
expect(result.ticks).toBe(500 / DUNGEON_SIMULATION_FIXED_STEP_MS);
|
||||
expect(result.accumulatorMs).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("freezes while paused and advances exactly one tick per step", () => {
|
||||
const store = useDungeonSimulationStore.getState();
|
||||
store.startLive({ dungeonId: route.dungeonId, route, mode: "navigation-only", seed: "clock", startedAt: 10_000 });
|
||||
store.setRunning(false);
|
||||
expect(useDungeonSimulationStore.getState().consumeTicks(5_000)).toEqual([]);
|
||||
expect(useDungeonSimulationStore.getState().elapsedMs).toBe(0);
|
||||
useDungeonSimulationStore.getState().step();
|
||||
expect(useDungeonSimulationStore.getState().consumeTicks(0)).toEqual([10_050]);
|
||||
expect(useDungeonSimulationStore.getState().elapsedMs).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spectator roster and exact-hash approvals", () => {
|
||||
it("creates a stable tank, healer, and three damage dealers", () => {
|
||||
const first = createSpectatorDungeonParty("same-seed", 80, "wow");
|
||||
const second = createSpectatorDungeonParty("same-seed", 80, "wow");
|
||||
expect(second).toEqual(first);
|
||||
expect(first.map((member) => member.role)).toEqual(["tank", "healer", "damage", "damage", "damage"]);
|
||||
});
|
||||
|
||||
it("rejects stale hashes and generator-rejected routes", () => {
|
||||
const approval = { dungeonId: route.dungeonId, routeId: route.id, routeHash: route.hash, reviewedAt: 1 };
|
||||
expect(routeHasExactApproval(route, [approval])).toBe(true);
|
||||
expect(routeHasExactApproval({ ...route, hash: "b".repeat(64) }, [approval])).toBe(false);
|
||||
expect(routeHasExactApproval({ ...route, generationStatus: "Rejected" }, [approval])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
DungeonReplayFrame,
|
||||
DungeonReplayV1,
|
||||
DungeonRouteDefinitionV1,
|
||||
DungeonSessionMode,
|
||||
DungeonSimulationFinding,
|
||||
DungeonSimulationMode,
|
||||
DungeonSimulationPull,
|
||||
DungeonSimulationReportV1,
|
||||
DungeonSimulationResult,
|
||||
DungeonSimulationSpeed,
|
||||
DungeonSpectatorCameraMode,
|
||||
} from "./dungeonSimulationTypes";
|
||||
|
||||
export const DUNGEON_SIMULATION_FIXED_STEP_MS = 50;
|
||||
export const DUNGEON_REPLAY_SAMPLE_MS = 100;
|
||||
export const DUNGEON_REPLAY_KEYFRAME_MS = 5_000;
|
||||
export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000;
|
||||
const MAX_TICKS_PER_ADVANCE = 80;
|
||||
|
||||
export interface StartDungeonSimulationOptions {
|
||||
readonly dungeonId: string;
|
||||
readonly route: DungeonRouteDefinitionV1;
|
||||
readonly mode: DungeonSimulationMode;
|
||||
readonly seed: string;
|
||||
readonly startedAt?: number;
|
||||
}
|
||||
|
||||
export interface DungeonSimulationState {
|
||||
sessionMode: DungeonSessionMode;
|
||||
mode: DungeonSimulationMode;
|
||||
runState: "idle" | "running" | "paused" | "finished";
|
||||
speed: DungeonSimulationSpeed;
|
||||
cameraMode: DungeonSpectatorCameraMode;
|
||||
dungeonId: string | null;
|
||||
routeId: string | null;
|
||||
routeHash: string | null;
|
||||
seed: string;
|
||||
startedAt: number;
|
||||
elapsedMs: number;
|
||||
accumulatorMs: number;
|
||||
pendingSteps: number;
|
||||
objectiveId: string | null;
|
||||
checkpointIndex: number;
|
||||
result: DungeonSimulationResult | null;
|
||||
findings: readonly DungeonSimulationFinding[];
|
||||
pulls: readonly DungeonSimulationPull[];
|
||||
recoveries: number;
|
||||
frames: readonly DungeonReplayFrame[];
|
||||
replay: DungeonReplayV1 | null;
|
||||
startLive: (options: StartDungeonSimulationOptions) => void;
|
||||
startReplay: (replay: DungeonReplayV1) => void;
|
||||
leave: () => void;
|
||||
setRunning: (running: boolean) => void;
|
||||
toggleRunning: () => void;
|
||||
setSpeed: (speed: DungeonSimulationSpeed) => void;
|
||||
setCameraMode: (mode: DungeonSpectatorCameraMode) => void;
|
||||
step: () => void;
|
||||
seekReplay: (elapsedMs: number) => void;
|
||||
consumeTicks: (wallDeltaMs: number) => readonly number[];
|
||||
setRouteProgress: (objectiveId: string | null, checkpointIndex: number) => void;
|
||||
addFinding: (finding: DungeonSimulationFinding) => void;
|
||||
addPull: (pull: DungeonSimulationPull) => void;
|
||||
addRecovery: () => void;
|
||||
addFrame: (frame: DungeonReplayFrame) => void;
|
||||
finish: (result: DungeonSimulationResult, roster: DungeonSimulationReportV1["roster"]) => DungeonReplayV1 | null;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
sessionMode: "play" as DungeonSessionMode,
|
||||
mode: "full-combat" as DungeonSimulationMode,
|
||||
runState: "idle" as const,
|
||||
speed: 1 as DungeonSimulationSpeed,
|
||||
cameraMode: "follow-leader" as DungeonSpectatorCameraMode,
|
||||
dungeonId: null as string | null,
|
||||
routeId: null as string | null,
|
||||
routeHash: null as string | null,
|
||||
seed: "",
|
||||
startedAt: 0,
|
||||
elapsedMs: 0,
|
||||
accumulatorMs: 0,
|
||||
pendingSteps: 0,
|
||||
objectiveId: null as string | null,
|
||||
checkpointIndex: 0,
|
||||
result: null as DungeonSimulationResult | null,
|
||||
findings: [] as readonly DungeonSimulationFinding[],
|
||||
pulls: [] as readonly DungeonSimulationPull[],
|
||||
recoveries: 0,
|
||||
frames: [] as readonly DungeonReplayFrame[],
|
||||
replay: null as DungeonReplayV1 | null,
|
||||
};
|
||||
|
||||
function clampWallDelta(value: number): number {
|
||||
return Math.max(0, Math.min(1_000, Number.isFinite(value) ? value : 0));
|
||||
}
|
||||
|
||||
export function fixedSimulationTickCount(
|
||||
accumulatorMs: number,
|
||||
wallDeltaMs: number,
|
||||
speed: DungeonSimulationSpeed,
|
||||
): { readonly ticks: number; readonly accumulatorMs: number } {
|
||||
const total = Math.max(0, accumulatorMs) + clampWallDelta(wallDeltaMs) * speed;
|
||||
const ticks = Math.min(MAX_TICKS_PER_ADVANCE, Math.floor(total / DUNGEON_SIMULATION_FIXED_STEP_MS));
|
||||
return { ticks, accumulatorMs: total - ticks * DUNGEON_SIMULATION_FIXED_STEP_MS };
|
||||
}
|
||||
|
||||
export const useDungeonSimulationStore = create<DungeonSimulationState>((set, get) => ({
|
||||
...initialState,
|
||||
startLive: ({ dungeonId, route, mode, seed, startedAt = Date.now() }) => set({
|
||||
...initialState,
|
||||
sessionMode: "spectate-live",
|
||||
mode,
|
||||
runState: "running",
|
||||
dungeonId,
|
||||
routeId: route.id,
|
||||
routeHash: route.hash,
|
||||
seed,
|
||||
startedAt,
|
||||
objectiveId: route.legs[0]?.objectiveId ?? null,
|
||||
}),
|
||||
startReplay: (replay) => set({
|
||||
...initialState,
|
||||
sessionMode: "spectate-replay",
|
||||
mode: replay.mode,
|
||||
runState: "paused",
|
||||
dungeonId: replay.dungeonId,
|
||||
routeId: replay.routeId,
|
||||
routeHash: replay.routeHash,
|
||||
seed: replay.seed,
|
||||
startedAt: replay.startedAt,
|
||||
result: replay.result,
|
||||
findings: replay.report.findings,
|
||||
pulls: replay.report.pulls,
|
||||
recoveries: replay.report.recoveries,
|
||||
frames: replay.frames,
|
||||
replay,
|
||||
}),
|
||||
leave: () => set(initialState),
|
||||
setRunning: (running) => set((state) => state.sessionMode === "play" || state.runState === "finished"
|
||||
? state
|
||||
: { runState: running ? "running" : "paused", accumulatorMs: 0 }),
|
||||
toggleRunning: () => {
|
||||
const state = get();
|
||||
state.setRunning(state.runState !== "running");
|
||||
},
|
||||
setSpeed: (speed) => set({ speed }),
|
||||
setCameraMode: (cameraMode) => set({ cameraMode }),
|
||||
step: () => set((state) => {
|
||||
if (state.runState !== "paused") return state;
|
||||
if (state.sessionMode === "spectate-replay" && state.replay) {
|
||||
const next = state.replay.frames.find((frame) => frame.atMs > state.elapsedMs);
|
||||
return { elapsedMs: next?.atMs ?? state.elapsedMs };
|
||||
}
|
||||
return { pendingSteps: state.pendingSteps + 1 };
|
||||
}),
|
||||
seekReplay: (elapsedMs) => set((state) => state.sessionMode !== "spectate-replay" || !state.replay
|
||||
? state
|
||||
: { elapsedMs: Math.max(0, Math.min(state.replay.durationMs, elapsedMs)), accumulatorMs: 0 }),
|
||||
consumeTicks: (wallDeltaMs) => {
|
||||
const state = get();
|
||||
if (state.sessionMode === "play" || state.runState === "finished") return [];
|
||||
if (state.sessionMode === "spectate-replay") {
|
||||
if (state.runState !== "running" || !state.replay) return [];
|
||||
const advanced = Math.min(state.replay.durationMs, state.elapsedMs + clampWallDelta(wallDeltaMs) * state.speed);
|
||||
set({ elapsedMs: advanced, runState: advanced >= state.replay.durationMs ? "finished" : "running" });
|
||||
return [];
|
||||
}
|
||||
let ticks = 0;
|
||||
let accumulatorMs = state.accumulatorMs;
|
||||
let pendingSteps = state.pendingSteps;
|
||||
if (state.runState === "running") {
|
||||
const advance = fixedSimulationTickCount(accumulatorMs, wallDeltaMs, state.speed);
|
||||
ticks = advance.ticks;
|
||||
accumulatorMs = advance.accumulatorMs;
|
||||
} else if (state.runState === "paused" && pendingSteps > 0) {
|
||||
ticks = 1;
|
||||
pendingSteps -= 1;
|
||||
accumulatorMs = 0;
|
||||
}
|
||||
const availableTicks = Math.max(0, Math.floor((DUNGEON_SIMULATION_MAX_DURATION_MS - state.elapsedMs) / DUNGEON_SIMULATION_FIXED_STEP_MS));
|
||||
ticks = Math.min(ticks, availableTicks);
|
||||
const elapsedMs = state.elapsedMs + ticks * DUNGEON_SIMULATION_FIXED_STEP_MS;
|
||||
set({ elapsedMs, accumulatorMs, pendingSteps });
|
||||
return Array.from({ length: ticks }, (_, index) => state.startedAt + state.elapsedMs + (index + 1) * DUNGEON_SIMULATION_FIXED_STEP_MS);
|
||||
},
|
||||
setRouteProgress: (objectiveId, checkpointIndex) => set((state) => {
|
||||
const normalized = Math.max(0, Math.trunc(checkpointIndex));
|
||||
return state.objectiveId === objectiveId && state.checkpointIndex === normalized
|
||||
? state
|
||||
: { objectiveId, checkpointIndex: normalized };
|
||||
}),
|
||||
addFinding: (finding) => set((state) => state.findings.some((candidate) => candidate.id === finding.id)
|
||||
? state
|
||||
: { findings: [...state.findings, finding] }),
|
||||
addPull: (pull) => set((state) => ({ pulls: [...state.pulls, pull] })),
|
||||
addRecovery: () => set((state) => ({ recoveries: state.recoveries + 1 })),
|
||||
addFrame: (frame) => set((state) => {
|
||||
if (state.frames[state.frames.length - 1]?.atMs === frame.atMs) return state;
|
||||
return { frames: [...state.frames, frame] };
|
||||
}),
|
||||
finish: (result, roster) => {
|
||||
const state = get();
|
||||
if (state.sessionMode !== "spectate-live" || state.runState === "finished" || !state.dungeonId || !state.routeId || !state.routeHash) return null;
|
||||
const objectiveIds = [...new Set(state.frames.map((frame) => frame.objectiveId).filter((id): id is string => Boolean(id)))];
|
||||
const objectiveTimings = objectiveIds.map((objectiveId) => {
|
||||
const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId);
|
||||
const laterObjective = state.frames.find((frame) => frame.atMs > (frames.at(-1)?.atMs ?? 0) && frame.objectiveId !== objectiveId);
|
||||
return {
|
||||
objectiveId,
|
||||
startedAtMs: frames[0]?.atMs ?? 0,
|
||||
completedAtMs: laterObjective?.atMs ?? (state.objectiveId === objectiveId ? null : frames.at(-1)?.atMs ?? null),
|
||||
};
|
||||
});
|
||||
const pathDistances = objectiveIds.map((objectiveId) => {
|
||||
const frames = state.frames.filter((frame) => frame.objectiveId === objectiveId);
|
||||
let actualDistance = 0;
|
||||
for (let index = 1; index < frames.length; index += 1) {
|
||||
const previous = frames[index - 1].actors[0]?.position;
|
||||
const current = frames[index].actors[0]?.position;
|
||||
if (previous && current) actualDistance += Math.hypot(
|
||||
current[0] - previous[0], current[1] - previous[1], current[2] - previous[2],
|
||||
);
|
||||
}
|
||||
return { objectiveId, actualDistance };
|
||||
});
|
||||
const report: DungeonSimulationReportV1 = {
|
||||
schemaVersion: 1,
|
||||
dungeonId: state.dungeonId,
|
||||
routeId: state.routeId,
|
||||
routeHash: state.routeHash,
|
||||
seed: state.seed,
|
||||
mode: state.mode,
|
||||
startedAt: state.startedAt,
|
||||
durationMs: state.elapsedMs,
|
||||
result,
|
||||
roster,
|
||||
findings: state.findings,
|
||||
pulls: state.pulls,
|
||||
recoveries: state.recoveries,
|
||||
objectiveTimings,
|
||||
actionTimings: state.findings.filter((finding) => finding.code === "route-action" && finding.actionId).map((finding) => ({
|
||||
actionId: finding.actionId!,
|
||||
objectiveId: finding.objectiveId ?? null,
|
||||
atMs: finding.atMs,
|
||||
})),
|
||||
pathDistances,
|
||||
finalObjectiveId: state.objectiveId,
|
||||
finalCheckpointIndex: state.checkpointIndex,
|
||||
finalState: state.frames.at(-1)?.actors ?? [],
|
||||
};
|
||||
const replay: DungeonReplayV1 = {
|
||||
schemaVersion: 1,
|
||||
dungeonId: state.dungeonId,
|
||||
routeId: state.routeId,
|
||||
routeHash: state.routeHash,
|
||||
mode: state.mode,
|
||||
seed: state.seed,
|
||||
result,
|
||||
startedAt: state.startedAt,
|
||||
durationMs: state.elapsedMs,
|
||||
report,
|
||||
frames: state.frames,
|
||||
events: state.findings,
|
||||
};
|
||||
set({ runState: "finished", result, replay });
|
||||
return replay;
|
||||
},
|
||||
}));
|
||||
|
||||
export function dungeonSimulationNow(): number {
|
||||
const state = useDungeonSimulationStore.getState();
|
||||
return state.sessionMode === "spectate-live" ? state.startedAt + state.elapsedMs : Date.now();
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Vector3Tuple } from "./dungeonTypes";
|
||||
|
||||
export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay";
|
||||
export type DungeonSimulationMode = "full-combat" | "navigation-only";
|
||||
export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4;
|
||||
export type DungeonSpectatorCameraMode = "follow-leader" | "free-fly";
|
||||
export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork";
|
||||
export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit";
|
||||
export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical";
|
||||
|
||||
export interface DungeonRouteAction {
|
||||
readonly id: string;
|
||||
readonly checkpointIndex: number;
|
||||
readonly kind: "interact-world-object";
|
||||
readonly objectId: string;
|
||||
}
|
||||
|
||||
export interface DungeonRouteLeg {
|
||||
readonly id: string;
|
||||
readonly objectiveId: string;
|
||||
readonly objectiveName: string;
|
||||
readonly checkpoints: readonly Vector3Tuple[];
|
||||
readonly expectedDistance: number;
|
||||
readonly corridorPlanarDistance: number;
|
||||
readonly corridorVerticalDistance: number;
|
||||
readonly actions: readonly DungeonRouteAction[];
|
||||
}
|
||||
|
||||
export interface DungeonRouteDefinitionV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly mapId: number;
|
||||
readonly hash: string;
|
||||
readonly generationStatus: "Candidate" | "Rejected";
|
||||
readonly generationIssues: readonly string[];
|
||||
readonly legs: readonly DungeonRouteLeg[];
|
||||
}
|
||||
|
||||
export interface DungeonRouteCatalogV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generatedAt: string;
|
||||
readonly source: {
|
||||
readonly kind: "playerbots-landmark-reference";
|
||||
readonly auditReport: string;
|
||||
readonly note: string;
|
||||
};
|
||||
readonly routes: readonly DungeonRouteDefinitionV1[];
|
||||
}
|
||||
|
||||
export interface DungeonRouteApproval {
|
||||
readonly dungeonId: string;
|
||||
readonly routeId: string;
|
||||
readonly routeHash: string;
|
||||
readonly reviewedAt: number;
|
||||
}
|
||||
|
||||
export interface DungeonRouteReview {
|
||||
readonly dungeonId: string;
|
||||
readonly routeId: string;
|
||||
readonly routeHash: string;
|
||||
readonly status: DungeonRouteReviewStatus;
|
||||
readonly note: string | null;
|
||||
readonly criticalOverrideNote: string | null;
|
||||
readonly reviewedBy: string;
|
||||
readonly reviewedAt: number;
|
||||
readonly revision: number;
|
||||
}
|
||||
|
||||
export interface DungeonSimulationFinding {
|
||||
readonly id: string;
|
||||
readonly atMs: number;
|
||||
readonly severity: DungeonSimulationFindingSeverity;
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly position?: Vector3Tuple;
|
||||
readonly actorId?: string;
|
||||
readonly objectiveId?: string;
|
||||
readonly checkpointIndex?: number;
|
||||
readonly actionId?: string;
|
||||
}
|
||||
|
||||
export interface DungeonSimulationPull {
|
||||
readonly atMs: number;
|
||||
readonly mobIds: readonly string[];
|
||||
readonly routeLegId: string | null;
|
||||
readonly position: Vector3Tuple;
|
||||
readonly classification: "expected" | "off-route" | "theoretical";
|
||||
}
|
||||
|
||||
export interface DungeonSimulationActorSnapshot {
|
||||
readonly id: string;
|
||||
readonly position: Vector3Tuple;
|
||||
readonly yaw: number;
|
||||
readonly health: number;
|
||||
readonly maxHealth: number;
|
||||
readonly targetId: string | null;
|
||||
readonly status: string;
|
||||
}
|
||||
|
||||
export interface DungeonReplayFrame {
|
||||
readonly atMs: number;
|
||||
readonly keyframe: boolean;
|
||||
readonly actors: readonly DungeonSimulationActorSnapshot[];
|
||||
readonly objectiveId: string | null;
|
||||
readonly checkpointIndex: number;
|
||||
}
|
||||
|
||||
export interface DungeonSimulationReportV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly dungeonId: string;
|
||||
readonly routeId: string;
|
||||
readonly routeHash: string;
|
||||
readonly seed: string;
|
||||
readonly mode: DungeonSimulationMode;
|
||||
readonly startedAt: number;
|
||||
readonly durationMs: number;
|
||||
readonly result: DungeonSimulationResult;
|
||||
readonly roster: readonly { readonly id: string; readonly name: string; readonly role: string; readonly classId: string }[];
|
||||
readonly findings: readonly DungeonSimulationFinding[];
|
||||
readonly pulls: readonly DungeonSimulationPull[];
|
||||
readonly recoveries: number;
|
||||
readonly objectiveTimings: readonly {
|
||||
readonly objectiveId: string;
|
||||
readonly startedAtMs: number;
|
||||
readonly completedAtMs: number | null;
|
||||
}[];
|
||||
readonly actionTimings: readonly {
|
||||
readonly actionId: string;
|
||||
readonly objectiveId: string | null;
|
||||
readonly atMs: number;
|
||||
}[];
|
||||
readonly pathDistances: readonly {
|
||||
readonly objectiveId: string;
|
||||
readonly actualDistance: number;
|
||||
}[];
|
||||
readonly finalObjectiveId: string | null;
|
||||
readonly finalCheckpointIndex: number;
|
||||
readonly finalState: readonly DungeonSimulationActorSnapshot[];
|
||||
}
|
||||
|
||||
export interface DungeonReplayV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly dungeonId: string;
|
||||
readonly routeId: string;
|
||||
readonly routeHash: string;
|
||||
readonly mode: DungeonSimulationMode;
|
||||
readonly seed: string;
|
||||
readonly result: DungeonSimulationResult;
|
||||
readonly startedAt: number;
|
||||
readonly durationMs: number;
|
||||
readonly report: DungeonSimulationReportV1;
|
||||
readonly frames: readonly DungeonReplayFrame[];
|
||||
readonly events: readonly DungeonSimulationFinding[];
|
||||
}
|
||||
|
||||
export interface DungeonReplaySummary {
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly routeId: string;
|
||||
readonly routeHash: string;
|
||||
readonly mode: DungeonSimulationMode;
|
||||
readonly seed: string;
|
||||
readonly result: DungeonSimulationResult;
|
||||
readonly startedAt: number;
|
||||
readonly durationMs: number;
|
||||
readonly criticalFindings: number;
|
||||
readonly submittedBy: string;
|
||||
readonly createdAt: number;
|
||||
readonly expiresAt: number;
|
||||
readonly compressedBytes: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":1,"generatedAt":"2026-05-08T10:36:36-07:00","source":{"kind":"playerbots-landmark-reference","auditReport":"dungeon-pipeline/DUNGEON_ROUTE_GENERATION_AUDIT.json","note":"PlayerBots was used only as offline topology evidence. Shipped checkpoint geometry is computed from HealerMan navigation assets."},"routes":[{"schemaVersion":1,"id":"primary","dungeonId":"shadowfang-keep","mapId":33,"legs":[{"id":"leg-01-creature-16242","objectiveId":"creature-16242","objectiveName":"Rethilgore","checkpoints":[[2108.6162,76.9903,-228.5817],[2112.0329,76.9903,-228.419],[2111.7075,76.9903,-227.6055],[2109.4839,76.9903,-226.6293],[2111.816,76.9903,-225.4362],[2111.2736,76.9903,-223.9719],[2109.3755,76.9903,-223.1584],[2110.189,76.9903,-221.1518],[2108.7247,76.9903,-220.501],[2109.267,76.9903,-219.579],[2107.8569,76.9903,-218.4401],[2107.7485,76.9903,-216.4334],[2106.5553,76.9903,-215.1861],[2106.8265,76.9903,-213.3964],[2104.6572,76.9903,-213.5591],[2105.308,76.9903,-211.6067],[2103.2471,76.9903,-208.9492],[2100.5897,76.9903,-207.7019],[2100.21,76.9903,-204.1225],[2098.9627,76.9903,-203.4174],[2099.7762,76.9903,-202.2785],[2100.9693,76.9903,-202.4955],[2101.132,77.0445,-201.1939],[2101.7286,77.153,-201.7904],[2104.4945,78.7257,-201.682],[2105.308,78.6715,-200.2719],[2106.0672,78.6715,-200.6515],[2106.013,78.9969,-202.6582],[2108.0739,80.407,-205.0987],[2108.7789,80.5154,-205.0987],[2108.9958,81.0578,-206.4003],[2110.0805,81.112,-207.7561],[2111.0567,81.0578,-207.4307],[2112.5752,81.0578,-208.6781],[2115.3954,81.0578,-207.3222],[2117.2936,81.0578,-205.5868],[2114.365,81.0578,-205.4241],[2120.2764,81.2205,-204.2852],[2120.4391,81.3289,-203.4174],[2121.1441,81.4374,-203.2005],[2121.0899,81.4916,-202.1701],[2123.1508,81.5459,-201.9531],[2122.5542,81.5459,-199.9465],[2127.4352,81.5459,-198.8618],[2125.5913,81.5459,-196.5298],[2131.8823,81.5459,-196.0417],[2132.9128,81.5459,-194.957],[2127.7606,81.5459,-194.1977],[2132.5874,81.5459,-193.5469],[2127.7606,81.5459,-193.2215],[2127.8148,81.5459,-192.7334],[2134.2144,81.5459,-191.703],[2132.8043,81.5459,-191.1064],[2135.9499,81.6543,-190.9437],[2136.9803,82.034,-190.5641],[2137.0345,82.1967,-189.7506],[2137.6853,82.6848,-189.2083],[2138.7158,82.9559,-190.0218],[2139.4208,83.2271,-189.2083],[2141.2105,83.1729,-189.2625],[2140.8851,83.2271,-189.9133],[2141.5359,83.4983,-190.0218],[2141.102,83.6067,-190.7268],[2141.4274,84.6372,-192.625],[2143.3798,85.8303,-194.1435],[2143.0544,86.1557,-195.0112],[2143.651,86.5353,-196.1501],[2144.4645,86.5896,-196.0417],[2144.4645,86.6438,-198.8076],[2145.7661,86.8065,-199.8922],[2144.7899,87.0777,-200.3803],[2145.6034,87.4573,-201.1396],[2145.6034,87.8912,-202.0074],[2144.8441,88.3793,-202.6582],[2145.1153,89.0301,-203.8513],[2144.1933,89.7351,-204.7733],[2144.2476,90.0063,-205.8037],[2143.9764,90.2774,-206.346],[2143.3798,90.4401,-206.0749],[2143.1629,90.7655,-206.6714],[2142.6748,90.7113,-206.6172],[2143.4883,90.6571,-209.7627],[2142.6748,90.6571,-211.9863],[2144.356,90.7113,-215.5386],[2146.0373,90.7655,-219.0909],[2148.2066,90.9282,-219.0909],[2149.6167,91.0367,-221.6941],[2151.4064,90.9282,-226.087],[2149.1828,90.7113,-230.4257],[2151.4606,90.7113,-230.8053],[2150.5386,90.7113,-233.1373],[2148.3151,90.6028,-233.1373],[2149.0201,90.3859,-236.0659],[2148.0981,90.169,-234.8728],[2148.4235,90.0063,-236.554],[2147.2304,89.7893,-235.0897],[2145.9288,89.1928,-235.6863],[2144.9526,88.3793,-237.476],[2145.0611,88.1081,-239.2657],[2142.8375,87.2404,-239.7538],[2142.6206,87.2404,-240.5131],[2142.1325,87.2404,-240.3504],[2142.1325,87.0777,-243.7671],[2143.2714,87.1319,-245.8822],[2143.3798,87.1319,-247.5092],[2144.4645,87.1861,-248.9193],[2143.7052,87.1319,-248.4854],[2144.4645,87.1861,-249.4616],[2143.7052,87.2404,-250.0582],[2142.8917,87.1861,-250.2751],[2142.5663,87.0777,-249.4074],[2142.1325,86.915,-250.2751],[2141.4817,86.6438,-249.787],[2139.3123,85.3422,-251.1971],[2136.7634,84.1491,-251.5225],[2133.8348,82.5763,-252.8241],[2134.1059,82.5221,-253.3664],[2133.4009,82.0882,-253.6918],[2130.6892,81.2205,-253.6918],[2129.6588,81.2205,-254.5595],[2124.2897,81.2205,-254.1257],[2124.8862,81.2205,-253.041],[2121.3611,81.2205,-252.4444],[2126.3505,81.2205,-251.8479],[2124.3439,81.2205,-250.9259]],"expectedDistance":261.42,"corridorPlanarDistance":10,"corridorVerticalDistance":4,"actions":[]},{"id":"leg-02-creature-16254","objectiveId":"creature-16254","objectiveName":"Baron Silverlaine","checkpoints":[[2124.3439,81.2205,-250.9259],[2126.3505,81.2205,-251.8479],[2121.3611,81.2205,-252.4444],[2124.8862,81.2205,-253.041],[2124.2897,81.2205,-254.1257],[2129.6588,81.2205,-254.5595],[2130.6892,81.2205,-253.6918],[2133.4009,82.0882,-253.6918],[2134.1059,82.5221,-253.3664],[2133.8348,82.5763,-252.8241],[2136.7634,84.1491,-251.5225],[2139.3123,85.3422,-251.1971],[2141.4817,86.6438,-249.787],[2142.1325,86.915,-250.2751],[2142.5663,87.0777,-249.4074],[2142.8917,87.1861,-250.2751],[2143.7052,87.2404,-250.0582],[2144.4645,87.1861,-249.4616],[2143.7052,87.1319Line truncated
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MAX_MOUSE_DELTA_PER_FRAME,
|
||||
POINTER_LOCK_MOUSE_GRACE_MS,
|
||||
beginMouseLookDrag,
|
||||
clearBrowserSelection,
|
||||
clearJumpRequest,
|
||||
consumeJumpRequest,
|
||||
consumeMouseLook,
|
||||
@@ -31,9 +32,10 @@ import {
|
||||
import { normalizeInputPreferences, replaceInputBinding } from "../input/keybinds";
|
||||
import { setCapturingInput } from "../input/keybindRuntime";
|
||||
|
||||
function stubInputEventTargets(): void {
|
||||
function stubInputEventTargets(selection: Pick<Selection, "removeAllRanges"> | null = null): void {
|
||||
const windowTarget = new EventTarget();
|
||||
const documentTarget = new EventTarget();
|
||||
Object.defineProperty(windowTarget, "getSelection", { configurable: true, value: () => selection });
|
||||
Object.defineProperty(documentTarget, "pointerLockElement", { configurable: true, value: null });
|
||||
vi.stubGlobal("window", windowTarget);
|
||||
vi.stubGlobal("document", documentTarget);
|
||||
@@ -362,15 +364,19 @@ describe("gameplay input bindings", () => {
|
||||
});
|
||||
|
||||
it("turns the camera from a right-mouse drag without pointer lock", () => {
|
||||
stubInputEventTargets();
|
||||
const selection = { removeAllRanges: vi.fn() };
|
||||
stubInputEventTargets(selection);
|
||||
const nextActions = inputActionSpies();
|
||||
const dispose = installInput(nextActions);
|
||||
beginMouseLookDrag(100, 80);
|
||||
|
||||
const move = new Event("mousemove") as MouseEvent;
|
||||
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
|
||||
|
||||
const move = new Event("pointermove") as PointerEvent;
|
||||
Object.defineProperties(move, {
|
||||
clientX: { value: 114 },
|
||||
clientY: { value: 73 },
|
||||
pointerType: { value: "mouse" },
|
||||
movementX: { value: 9_999 },
|
||||
movementY: { value: 9_999 },
|
||||
});
|
||||
@@ -387,6 +393,33 @@ describe("gameplay input bindings", () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("prevents page selection only while right-mouse camera drag is active", () => {
|
||||
stubInputEventTargets();
|
||||
const dispose = installInput(inputActionSpies());
|
||||
|
||||
const idleSelection = new Event("selectstart", { cancelable: true });
|
||||
document.dispatchEvent(idleSelection);
|
||||
expect(idleSelection.defaultPrevented).toBe(false);
|
||||
|
||||
beginMouseLookDrag(20, 30);
|
||||
const cameraSelection = new Event("selectstart", { cancelable: true });
|
||||
document.dispatchEvent(cameraSelection);
|
||||
expect(cameraSelection.defaultPrevented).toBe(true);
|
||||
|
||||
endMouseLookDrag();
|
||||
const releasedSelection = new Event("selectstart", { cancelable: true });
|
||||
document.dispatchEvent(releasedSelection);
|
||||
expect(releasedSelection.defaultPrevented).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("can clear an existing browser selection without a camera drag", () => {
|
||||
const selection = { removeAllRanges: vi.fn() };
|
||||
stubInputEventTargets(selection);
|
||||
clearBrowserSelection();
|
||||
expect(selection.removeAllRanges).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("contains malformed and stale pointer-lock movement", () => {
|
||||
expect(sanitizeMouseDelta(Number.NaN)).toBe(0);
|
||||
expect(sanitizeMouseDelta(Number.POSITIVE_INFINITY)).toBe(0);
|
||||
|
||||
+27
-13
@@ -131,11 +131,18 @@ const mouseLookBuffer = createMouseLookBuffer();
|
||||
let mouseLookDragActive = false;
|
||||
let previousMousePosition: { x: number; y: number } | null = null;
|
||||
|
||||
/** Clears browser text selection before gameplay takes ownership of a mouse drag. */
|
||||
export function clearBrowserSelection(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts popup-free desktop camera look. Unlike Pointer Lock, this retains the
|
||||
* normal browser cursor and only consumes movement while right mouse is held.
|
||||
*/
|
||||
export function beginMouseLookDrag(clientX: number, clientY: number): void {
|
||||
clearBrowserSelection();
|
||||
mouseLookDragActive = true;
|
||||
previousMousePosition = Number.isFinite(clientX) && Number.isFinite(clientY)
|
||||
? { x: clientX, y: clientY }
|
||||
@@ -317,20 +324,20 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
}
|
||||
publishActionLayer();
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!mouseLookDragActive || event.pointerType !== "mouse") return;
|
||||
const current = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
|
||||
? { x: event.clientX, y: event.clientY }
|
||||
: null;
|
||||
if (!current) return;
|
||||
const previous = previousMousePosition;
|
||||
previousMousePosition = current;
|
||||
if (
|
||||
previous
|
||||
&& mouseLookBuffer.push(current.x - previous.x, current.y - previous.y)
|
||||
) actions?.setInputMode("keyboard");
|
||||
};
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (mouseLookDragActive) {
|
||||
const current = Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
|
||||
? { x: event.clientX, y: event.clientY }
|
||||
: null;
|
||||
if (!current) return;
|
||||
const previous = previousMousePosition;
|
||||
previousMousePosition = current;
|
||||
if (
|
||||
previous
|
||||
&& mouseLookBuffer.push(current.x - previous.x, current.y - previous.y)
|
||||
) actions?.setInputMode("keyboard");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
document.pointerLockElement
|
||||
&& mouseLookBuffer.push(event.movementX, event.movementY)
|
||||
@@ -341,6 +348,9 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
mouseLookBuffer.reset(locked ? POINTER_LOCK_MOUSE_GRACE_MS : 0);
|
||||
nextActions.setCameraLookActive(locked || mouseLookDragActive);
|
||||
};
|
||||
const onSelectStart = (event: Event) => {
|
||||
if (mouseLookDragActive) event.preventDefault();
|
||||
};
|
||||
const clear = () => {
|
||||
keys.clear();
|
||||
keyboardModifiers.clear();
|
||||
@@ -355,15 +365,19 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
window.addEventListener("blur", clear);
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("pointerlockchange", onPointerLockChange);
|
||||
document.addEventListener("selectstart", onSelectStart);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
window.removeEventListener("blur", clear);
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("pointerlockchange", onPointerLockChange);
|
||||
document.removeEventListener("selectstart", onSelectStart);
|
||||
unsubscribeController();
|
||||
clear();
|
||||
actions = null;
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
isPositionInFacingArc,
|
||||
isSourceBehindTarget,
|
||||
} from "./combatSpatial";
|
||||
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
|
||||
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
|
||||
const ATTACK_ACQUISITION_RANGE = 30;
|
||||
@@ -128,6 +129,16 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
||||
}] : [];
|
||||
}
|
||||
const game = useGameStore.getState();
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
if (
|
||||
simulation.sessionMode === "spectate-live"
|
||||
&& simulation.mode === "navigation-only"
|
||||
&& simulation.objectiveId
|
||||
) {
|
||||
const objective = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
|
||||
.find((candidate) => candidate.id === simulation.objectiveId);
|
||||
return objective ? [objective] : [];
|
||||
}
|
||||
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
|
||||
@@ -143,6 +154,10 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
||||
));
|
||||
}
|
||||
|
||||
function simulatedPartyExcludesPlayer(): boolean {
|
||||
return useDungeonSimulationStore.getState().sessionMode !== "play";
|
||||
}
|
||||
|
||||
export function resetPartyRuntimeClock(): void {
|
||||
lastAdvanceAt = 0;
|
||||
nextRegenAt = 0;
|
||||
@@ -799,7 +814,10 @@ function partyHealingTargets(
|
||||
): readonly string[] {
|
||||
const combat = useCombatStore.getState();
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
const living = [PLAYER_AGGRO_ID, ...members.map((member) => member.id)].filter((id) => combat.actors[id]?.alive);
|
||||
const living = [
|
||||
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
|
||||
...members.map((member) => member.id),
|
||||
].filter((id) => combat.actors[id]?.alive);
|
||||
const healthFraction = (id: string) => {
|
||||
const actor = combat.actors[id];
|
||||
return actor ? actor.health / Math.max(1, actor.maxHealth) : 1;
|
||||
@@ -850,7 +868,7 @@ function choosePartySupportIntent(
|
||||
): PartySupportIntent | null {
|
||||
const combat = useCombatStore.getState();
|
||||
const fallenTargetId = [
|
||||
...(combat.health <= 0 ? [PLAYER_AGGRO_ID] : []),
|
||||
...(!simulatedPartyExcludesPlayer() && combat.health <= 0 ? [PLAYER_AGGRO_ID] : []),
|
||||
...members
|
||||
.filter((candidate) => candidate.health <= 0)
|
||||
.sort((left, right) => (
|
||||
@@ -866,7 +884,7 @@ function choosePartySupportIntent(
|
||||
}
|
||||
if (member.role !== "healer") return null;
|
||||
const dispelTargetId = [
|
||||
...(combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
|
||||
...(!simulatedPartyExcludesPlayer() && combat.health > 0 ? [PLAYER_AGGRO_ID] : []),
|
||||
...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id),
|
||||
].find((id) => combat.auras.some((aura) => (
|
||||
aura.targetId === id
|
||||
@@ -880,7 +898,7 @@ function choosePartySupportIntent(
|
||||
}
|
||||
}
|
||||
const injured = [
|
||||
...(combat.health > 0 && combat.health < combat.maxHealth
|
||||
...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth
|
||||
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
|
||||
: []),
|
||||
...members.filter((candidate) => candidate.health > 0 && candidate.health < candidate.maxHealth),
|
||||
@@ -1024,7 +1042,10 @@ function executePartyAbility(
|
||||
}
|
||||
} else if (effect.kind === "apply-aura") {
|
||||
const recipients = effect.scope === "party"
|
||||
? [PLAYER_AGGRO_ID, ...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id)]
|
||||
? [
|
||||
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
|
||||
...members.filter((candidate) => candidate.health > 0).map((candidate) => candidate.id),
|
||||
]
|
||||
: effect.recipient === "caster"
|
||||
? [member.id]
|
||||
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
|
||||
@@ -1036,7 +1057,7 @@ function executePartyAbility(
|
||||
}
|
||||
} else if (effect.kind === "dispel") {
|
||||
const recipients = effect.scope === "party"
|
||||
? [PLAYER_AGGRO_ID, ...members.map((candidate) => candidate.id)]
|
||||
? [...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]), ...members.map((candidate) => candidate.id)]
|
||||
: [targetId];
|
||||
for (const recipientId of recipients) {
|
||||
effectiveAmount += useCombatStore.getState().dispelActor(recipientId, effect.categories, effect.maxCount);
|
||||
@@ -1134,6 +1155,24 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const authoritativeMembers: readonly PartyMember[] = party.members.map((member) => (
|
||||
memberFromCompanionActor(member, combat.actors[member.id])
|
||||
));
|
||||
const simulation = useDungeonSimulationStore.getState();
|
||||
if (simulation.sessionMode === "spectate-live" && simulation.mode === "navigation-only") {
|
||||
const members = authoritativeMembers.map((member) => ({
|
||||
...member,
|
||||
status: member.health > 0 ? "advancing" as const : "down" as const,
|
||||
activeCast: null,
|
||||
activeCastTargetId: null,
|
||||
nextActionAt: 0,
|
||||
}));
|
||||
party.commitRuntime({
|
||||
members,
|
||||
activeMobId: null,
|
||||
objectiveBossId: simulation.objectiveId,
|
||||
memberTargetIds: {},
|
||||
});
|
||||
useCombatStore.getState().commitCompanionActors(members);
|
||||
return;
|
||||
}
|
||||
let members: readonly PartyMember[] = authoritativeMembers;
|
||||
const partyOrigin = partyRuntimeCenter(
|
||||
members.filter((member) => !partyRuntimeIsRejoining(member.id)).map((member) => member.id),
|
||||
|
||||
+82
-4
@@ -89,6 +89,17 @@ interface PartyTemplate {
|
||||
readonly talentTreeId: string;
|
||||
}
|
||||
|
||||
export interface ConfiguredPartyMemberSeed {
|
||||
readonly name: string;
|
||||
readonly classId: ClassId;
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly specialization: string;
|
||||
readonly specializationId?: string | null;
|
||||
readonly talentTreeId?: string;
|
||||
readonly talentRanks?: TalentRanks;
|
||||
readonly role: PartyRole;
|
||||
}
|
||||
|
||||
const TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
|
||||
{ name: "Garrick", classId: "warrior", specialization: "Protection", talentTreeId: "warrior-protection" },
|
||||
{ name: "Morvane", classId: "death-knight", specialization: "Blood", talentTreeId: "death-knight-blood" },
|
||||
@@ -241,6 +252,7 @@ function createMember(
|
||||
level: number,
|
||||
index: number,
|
||||
seed: string,
|
||||
configuredTalentRanks?: TalentRanks,
|
||||
): PartyMember {
|
||||
// Appearance has its own keyed stream so adding customization fields later
|
||||
// cannot reshuffle which class/spec templates make up this character's party.
|
||||
@@ -282,9 +294,9 @@ function createMember(
|
||||
specialization: template.specialization,
|
||||
specializationId,
|
||||
talentTreeId: template.talentTreeId,
|
||||
talentRanks: isRomClassId(template.classId)
|
||||
talentRanks: configuredTalentRanks ?? (isRomClassId(template.classId)
|
||||
? Object.freeze({})
|
||||
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level),
|
||||
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level)),
|
||||
role,
|
||||
level: Math.max(1, Math.trunc(level)),
|
||||
health: maxHealth,
|
||||
@@ -310,6 +322,27 @@ function createMember(
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates one exact companion while retaining the live party stat/equipment path. */
|
||||
export function createConfiguredPartyMember(
|
||||
configuration: ConfiguredPartyMemberSeed,
|
||||
level: number,
|
||||
index: number,
|
||||
seed: string,
|
||||
): PartyMember {
|
||||
const member = createMember({
|
||||
name: configuration.name,
|
||||
classId: configuration.classId,
|
||||
specialization: configuration.specialization,
|
||||
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
|
||||
}, configuration.role, level, index, seed, configuration.talentRanks);
|
||||
return Object.freeze({
|
||||
...member,
|
||||
secondaryClassId: configuration.secondaryClassId ?? null,
|
||||
specializationId: configuration.specializationId ?? member.specializationId,
|
||||
talentRanks: configuration.talentRanks ?? member.talentRanks,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the four AI slots around the player's selected role. The resulting
|
||||
* group is always a classic five-player composition including the player.
|
||||
@@ -352,6 +385,29 @@ export function createDungeonParty(
|
||||
].slice(0, Math.max(0, partySize - 1));
|
||||
}
|
||||
|
||||
/** Builds a complete deterministic NPC group for GM spectator simulations. */
|
||||
export function createSpectatorDungeonParty(
|
||||
seed: string,
|
||||
level: number,
|
||||
categoryId: ContentCategoryId = "wow",
|
||||
): readonly PartyMember[] {
|
||||
const random = { value: hashSeed(seed || "healer-man-spectator") };
|
||||
const tankTemplates = categoryId === "rom" ? ROM_TANK_TEMPLATES : TANK_TEMPLATES;
|
||||
const healerTemplates = categoryId === "rom" ? ROM_HEALER_TEMPLATES : HEALER_TEMPLATES;
|
||||
const damagePool = [...(categoryId === "rom" ? ROM_DAMAGE_TEMPLATES : DAMAGE_TEMPLATES)];
|
||||
for (let index = damagePool.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(nextRandom(random) * (index + 1));
|
||||
[damagePool[index], damagePool[swapIndex]] = [damagePool[swapIndex], damagePool[index]];
|
||||
}
|
||||
const tank = tankTemplates[Math.floor(nextRandom(random) * tankTemplates.length)] ?? tankTemplates[0];
|
||||
const healer = healerTemplates[Math.floor(nextRandom(random) * healerTemplates.length)] ?? healerTemplates[0];
|
||||
return [
|
||||
createMember(tank, "tank", level, 0, seed),
|
||||
createMember(healer, "healer", level, 1, seed),
|
||||
...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 2, seed)),
|
||||
];
|
||||
}
|
||||
|
||||
export interface PartyRuntimeCommit {
|
||||
readonly members: readonly PartyMember[];
|
||||
readonly activeMobId: string | null;
|
||||
@@ -361,6 +417,7 @@ export interface PartyRuntimeCommit {
|
||||
|
||||
export interface PartyState {
|
||||
seed: string | null;
|
||||
spectatorParty: boolean;
|
||||
playerRole: PartyRole;
|
||||
partySize: PartySize;
|
||||
categoryId: ContentCategoryId;
|
||||
@@ -381,6 +438,7 @@ export interface PartyState {
|
||||
partySize?: PartySize,
|
||||
categoryId?: ContentCategoryId,
|
||||
) => void;
|
||||
initializeSpectatorParty: (seed: string, level: number, categoryId?: ContentCategoryId) => void;
|
||||
resetCombat: (level: number, playerHealth: number) => void;
|
||||
syncLevel: (level: number) => void;
|
||||
syncEquipment: (equipment: EquipmentAssignments, inventory: readonly InventoryItem[]) => void;
|
||||
@@ -424,6 +482,7 @@ export interface PartyState {
|
||||
|
||||
export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
seed: null,
|
||||
spectatorParty: false,
|
||||
playerRole: "healer",
|
||||
partySize: 5,
|
||||
categoryId: "wow",
|
||||
@@ -439,6 +498,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
|
||||
initializeParty: (seed, level, playerHealth, playerRole = "healer", partySize = 5, categoryId = "wow") => set({
|
||||
seed,
|
||||
spectatorParty: false,
|
||||
playerRole,
|
||||
partySize,
|
||||
categoryId,
|
||||
@@ -452,10 +512,28 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
memberTargetIds: {},
|
||||
}),
|
||||
|
||||
initializeSpectatorParty: (seed, level, categoryId = "wow") => set({
|
||||
seed,
|
||||
spectatorParty: true,
|
||||
playerRole: "healer",
|
||||
partySize: 5,
|
||||
categoryId,
|
||||
members: createSpectatorDungeonParty(seed, level, categoryId),
|
||||
command: "attack",
|
||||
selectedMemberId: null,
|
||||
selfSelected: false,
|
||||
routeBlocked: false,
|
||||
activeMobId: null,
|
||||
objectiveBossId: null,
|
||||
memberTargetIds: {},
|
||||
}),
|
||||
|
||||
resetCombat: (level, playerHealth) => set((state) => {
|
||||
return {
|
||||
members: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
|
||||
command: "defend",
|
||||
members: state.spectatorParty
|
||||
? createSpectatorDungeonParty(state.seed ?? "healer-man-spectator", level, state.categoryId)
|
||||
: createDungeonParty(state.seed ?? "healer-man", level, state.playerRole, state.partySize, state.categoryId),
|
||||
command: state.spectatorParty ? "attack" : "defend",
|
||||
selectedMemberId: null,
|
||||
selfSelected: false,
|
||||
routeBlocked: false,
|
||||
|
||||
@@ -525,6 +525,32 @@ describe("source-reviewed Wrath encounter batch two", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("keeps Eck's source cadence without turning setup and Berserk spells into repeat damage", () => {
|
||||
const gundrak = adaptedDefinition("gundrak");
|
||||
const eck = Object.values(gundrak.entities).find((entity) => entity.combat?.serverEntry === 29_932);
|
||||
expect(eck?.name).toBe("Eck the Ferocious");
|
||||
expect(eck?.visual.model).toMatchObject({ groundOffset: 0, labelHeight: 7 });
|
||||
const attacks = new Map(eck?.combat?.attacks.map((attack) => [attack.spellId ?? 0, attack]));
|
||||
expect(attacks.get(55_813)).toMatchObject({
|
||||
name: "Eck Bite", target: "primary", initialCooldownMs: [5_000, 5_000],
|
||||
repeatCooldownMs: [8_000, 12_000], damageMultiplier: 1.35,
|
||||
});
|
||||
expect(attacks.get(55_814)).toMatchObject({
|
||||
name: "Eck Spit", target: "primary", initialCooldownMs: [10_000, 37_000],
|
||||
repeatCooldownMs: [8_000, 12_000], damageMultiplier: 1.35,
|
||||
});
|
||||
expect(attacks.get(55_815)).toMatchObject({
|
||||
name: "Eck Spring", target: "random-party", initialCooldownMs: [10_000, 24_000],
|
||||
repeatCooldownMs: [10_000, 24_000], damageMultiplier: 1.35,
|
||||
telegraph: { shape: "circle", anchor: "target", warningMs: 750 },
|
||||
});
|
||||
expect(attacks.get(55_837)).toMatchObject({ target: "self", maximumCasts: 1, damageMultiplier: 0 });
|
||||
expect(attacks.get(55_816)).toMatchObject({
|
||||
target: "self", initialCooldownMs: [90_000, 90_000], maximumCasts: 1,
|
||||
damageMultiplier: 0, effect: { kind: "aura", aura: "enrage" },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs every required lifecycle through its reviewed gates and completes without renderer state", () => {
|
||||
const halls = adaptedDefinition("halls-of-stone");
|
||||
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
PopulationEntityDefinition,
|
||||
StaticMobSpawnDefinition,
|
||||
} from "./mobPopulation";
|
||||
import type { EnemyAttackDefinition } from "./enemyAttacks";
|
||||
|
||||
export interface WowWrathEncounterBatchTwoSourceFile {
|
||||
readonly path: string;
|
||||
@@ -71,6 +72,43 @@ const UTGARDE_PINNACLE_FILES = Object.freeze([
|
||||
const GUNDRAK_FILES = Object.freeze([
|
||||
`${SOURCE_ROOT}/Northrend/Gundrak/instance_gundrak.cpp`,
|
||||
`${SOURCE_ROOT}/Northrend/Gundrak/gundrak.h`,
|
||||
`${SOURCE_ROOT}/Northrend/Gundrak/boss_eck.cpp`,
|
||||
]);
|
||||
|
||||
const ECK_ATTACKS: readonly EnemyAttackDefinition[] = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "basic-attack", name: "Basic Attack", delivery: "melee", target: "primary",
|
||||
school: "physical", animation: "attack", range: 4.5, cooldownMs: 2_000,
|
||||
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [2_000, 2_000] as const, damageMultiplier: 1,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "spell-55837", name: "Eck Spring Init", spellId: 55_837, delivery: "area", target: "self",
|
||||
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 1,
|
||||
initialCooldownMs: [450, 450] as const, repeatCooldownMs: [1, 1] as const, maximumCasts: 1, damageMultiplier: 0,
|
||||
effect: { kind: "aura", aura: "eck-spring-init", durationMs: 1_000 } as const,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "spell-55813", name: "Eck Bite", spellId: 55_813, delivery: "melee", target: "primary",
|
||||
school: "physical", animation: "attack", range: 5.5, cooldownMs: 10_000,
|
||||
initialCooldownMs: [5_000, 5_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "spell-55814", name: "Eck Spit", spellId: 55_814, delivery: "projectile", target: "primary",
|
||||
school: "nature", animation: "cast", castTimeMs: 0, range: 28, cooldownMs: 10_000,
|
||||
initialCooldownMs: [10_000, 37_000] as const, repeatCooldownMs: [8_000, 12_000] as const, damageMultiplier: 1.35,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "spell-55815", name: "Eck Spring", spellId: 55_815, delivery: "projectile", target: "random-party",
|
||||
school: "physical", animation: "cast", castTimeMs: 750, range: 30, cooldownMs: 17_000,
|
||||
initialCooldownMs: [10_000, 24_000] as const, repeatCooldownMs: [10_000, 24_000] as const, damageMultiplier: 1.35,
|
||||
telegraph: { shape: "circle", anchor: "target", warningMs: 750, radius: 3 } as const,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "spell-55816", name: "Eck Berserk", spellId: 55_816, delivery: "area", target: "self",
|
||||
school: "physical", animation: "cast", castTimeMs: 0, range: 0, cooldownMs: 90_000,
|
||||
initialCooldownMs: [90_000, 90_000] as const, repeatCooldownMs: [90_000, 90_000] as const, maximumCasts: 1,
|
||||
damageMultiplier: 0, effect: { kind: "aura", aura: "enrage", durationMs: 0, magnitude: 1 } as const,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowWrathEncounterBatchTwoReview[] = Object.freeze([
|
||||
@@ -239,11 +277,20 @@ export const WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS: readonly WowWrathEncounterBa
|
||||
sha256: "ec0477724b5343879ecfd713060c408796373d3f6fbeaf16dcef84f1393c6356",
|
||||
anchors: ["boss, altar, statue, bridge, collision, Eck, and Gal'darah door ids (lines 27-73)"],
|
||||
},
|
||||
{
|
||||
path: GUNDRAK_FILES[2],
|
||||
sha256: "ef6f4736b921c5f654260cb0c61f2590e77270c8e5a4c4f0935db4c5f0478e07",
|
||||
anchors: [
|
||||
"Eck Spring initialization and threat handoff (lines 51-72)",
|
||||
"Bite, Spit, Spring, and 90-second Berserk schedules (lines 75-90)",
|
||||
],
|
||||
},
|
||||
],
|
||||
reviewedBehavior: "Slad'ran, Moorabi, and the Drakkari Colossus independently enable their exact altars; using all three rotates the exact statues and bridge collision after five seconds, exposing Gal'darah; Moorabi releases the heroic Eck route.",
|
||||
adaptations: [
|
||||
"The bridge and statues change discrete procedural states without native rotation animation; the source five-second delay is retained.",
|
||||
"The optional Eck actor is activated after Moorabi for deterministic recovery rather than after the source Ruins Dweller formation; its actor spawn remains heroic-mask-only, so normal rendering filters it out.",
|
||||
"Eck keeps the source's independent Bite, Spit, Spring, and 90-second Berserk timings; Spring uses a short target warning because the runtime does not yet execute creature jump motion.",
|
||||
"Gal'darah's room door closes on engagement and all three source Gal'darah doors open on death; no native door animation is claimed.",
|
||||
"Exact imported altar, statue, bridge, collision, Eck-door, underwater-gate, and Gal'darah-door rows are replaced by procedural stateful objects at their source transforms.",
|
||||
],
|
||||
@@ -1529,9 +1576,33 @@ function buildGundrak(definition: DungeonDefinition): DungeonDefinition {
|
||||
const galDarah = objectiveForEntry(definition, 29_306);
|
||||
const eckBase = objectiveForEntry(definition, 29_932);
|
||||
const eck = eventObjective(definition, eckBase.id);
|
||||
const entities = ensureEntities(definition, []);
|
||||
const eckEntityId = entityIdForEntry(entities, 29_932);
|
||||
const baseEntities = ensureEntities(definition, []);
|
||||
const eckEntityId = entityIdForEntry(baseEntities, 29_932);
|
||||
if (!eckEntityId) throw new Error("gundrak is missing Eck the Ferocious entry 29932");
|
||||
const sourceEck = baseEntities[eckEntityId];
|
||||
if (!sourceEck.combat) throw new Error("gundrak Eck the Ferocious is missing combat data");
|
||||
if (!sourceEck.visual.model) throw new Error("gundrak Eck the Ferocious is missing its source model");
|
||||
const entities = Object.freeze({
|
||||
...baseEntities,
|
||||
[eckEntityId]: Object.freeze({
|
||||
...sourceEck,
|
||||
visual: Object.freeze({
|
||||
...sourceEck.visual,
|
||||
model: Object.freeze({
|
||||
...sourceEck.visual.model,
|
||||
// The skinned Oracle bind pose extends far below its animated root.
|
||||
// Treating that minimum as the floor lifted Eck out of the camera.
|
||||
groundOffset: 0,
|
||||
labelHeight: 7,
|
||||
}),
|
||||
}),
|
||||
combat: Object.freeze({
|
||||
...sourceEck.combat,
|
||||
mechanicIds: Object.freeze(ECK_ATTACKS.slice(1).map((attack) => attack.id)),
|
||||
attacks: ECK_ATTACKS,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const eckGroup: DungeonEncounterSpawnGroupDefinition = Object.freeze({
|
||||
id: "gundrak:eck",
|
||||
spawns: Object.freeze([spawnAt("gundrak:eck-actor", eckEntityId, eck.position, [0, 0], 2)]),
|
||||
|
||||
Reference in new issue
Block a user