Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

+70 -36
View File
@@ -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);
};
}, []);
+3 -2
View File
@@ -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);
+16 -9
View File
@@ -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");
});
});
+206
View File
@@ -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
+32
View File
@@ -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,
});
});
});
+151
View File
@@ -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));
}
+3 -1
View File
@@ -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();
});
});
+8
View File
@@ -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
View File
@@ -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) {
+143
View File
@@ -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 };
}
+35
View File
@@ -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),
}));
+108
View File
@@ -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
)));
}
+9 -1
View File
@@ -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,
+70
View File
@@ -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);
});
});
+274
View File
@@ -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();
}
+172
View File
@@ -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
+36 -3
View File
@@ -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
View File
@@ -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;
+45 -6
View File
@@ -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
View File
@@ -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));
+73 -2
View File
@@ -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)]),