110 lines
6.9 KiB
TypeScript
110 lines
6.9 KiB
TypeScript
import { preloadAbilityCatalogsForClasses } from "../abilityCatalog";
|
|
import { PLAYER_AGGRO_ID } from "../aggro";
|
|
import { installCombatClock } from "../combatClock";
|
|
import { subscribeCombatEvents } from "../combatEvents";
|
|
import { resetCombatSpatialProvider } from "../combatSpatial";
|
|
import { useCombatStore } from "../combatStore";
|
|
import { dungeonEnemyScaling } from "../dungeonBossScaling";
|
|
import { requireDungeonDefinition } from "../dungeonRegistry";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "../mobRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "../partyRuntime";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "../partyRuntimeRegistry";
|
|
import { usePartyStore } from "../partyStore";
|
|
import { specializationsForClass } from "../specializationCatalog";
|
|
import { BenchmarkController } from "./benchmarkController";
|
|
import type { BenchmarkBuildDefinition, BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
|
|
|
const scenario: BenchmarkScenarioDefinition = {
|
|
id: "dungeon-boss", version: 1, name: "Dungeon boss", description: "Full party against an authored dungeon boss",
|
|
family: "damage", roles: ["damage", "tank", "healer"], durationMs: 60_000, tickMs: 100,
|
|
hostileTargets: 1, partyLayout: "melee", attacks: [],
|
|
};
|
|
|
|
/** Uses the live boss stats, companion AI and baseline player policy with a controlled clock. */
|
|
export async function runDungeonBossBenchmark(
|
|
build: BenchmarkBuildDefinition, bossId: string, seed: string, mechanics = false,
|
|
) {
|
|
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
|
|
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
|
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
|
const entity = dungeon.entities[spawn.entityId];
|
|
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
|
|
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
|
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
|
|
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
|
let now = 1_000_000;
|
|
const restoreClock = installCombatClock(() => now);
|
|
let playerDamage = 0; let partyDamage = 0; let incomingDamage = 0; let healing = 0; let casts = 0;
|
|
let selfHealing = 0; let allyHealing = 0;
|
|
const healingByAbility: Record<string, number> = {};
|
|
const damageByAbility: Record<string, number> = {};
|
|
const stop = subscribeCombatEvents((event) => {
|
|
if (event.kind === "damage" && event.targetActorId === bossId) {
|
|
if (event.sourceActorId === PLAYER_AGGRO_ID) playerDamage += event.effectiveAmount;
|
|
else partyDamage += event.effectiveAmount;
|
|
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}`;
|
|
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
|
|
}
|
|
if (event.kind === "damage" && event.sourceActorId === bossId) incomingDamage += event.effectiveAmount;
|
|
if (event.kind === "healing") {
|
|
healing += event.effectiveAmount;
|
|
if (event.targetActorId === PLAYER_AGGRO_ID) selfHealing += event.effectiveAmount;
|
|
else allyHealing += event.effectiveAmount;
|
|
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}:${event.targetActorId}`;
|
|
healingByAbility[key] = (healingByAbility[key] ?? 0) + event.effectiveAmount;
|
|
}
|
|
});
|
|
try {
|
|
const combat = useCombatStore.getState();
|
|
combat.initializeCharacter({ classId: build.classId, raceId: build.raceId, secondaryClassId: build.secondaryClassId,
|
|
specializationId: specialization?.id, role: build.role, level: build.level,
|
|
talentRanks: build.talentRanks, inventory: build.inventory, equipment: build.equipment });
|
|
combat.setPlayerPosition([-2, 0, 0]);
|
|
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
|
combat.synchronizeActors();
|
|
combat.applyDefaultPartyBuffs(now);
|
|
combat.applyPrecombatPreparation(now);
|
|
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
|
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
|
setMobPosition(bossId, [0, 0, 0]);
|
|
for (const member of usePartyStore.getState().members) {
|
|
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
|
updatePartyRuntimeTargetVisibility(member.id, bossId, true);
|
|
}
|
|
combat.selectMob(bossId);
|
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
|
combat.damageMob(bossId, 1, now, {
|
|
actorId: build.role === "tank" ? PLAYER_AGGRO_ID : tank!.id, role: "tank", abilityId: "benchmark-pull",
|
|
}, "physical");
|
|
const controller = new BenchmarkController();
|
|
for (let elapsed = 100; elapsed <= scenario.durationMs; elapsed += 100) {
|
|
now = 1_000_000 + elapsed;
|
|
combat.tick(.1, now);
|
|
advanceDungeonPartyCombat(now);
|
|
if (mechanics) combat.advanceMobCombat(now);
|
|
if (controller.act(now, build.role, scenario, false).cast) casts += 1;
|
|
const state = useCombatStore.getState();
|
|
const mob = state.mobs[bossId];
|
|
if (mob.dead || (mechanics && state.health <= 0)) {
|
|
return { seconds: mob.dead ? ((mob.diedAt ?? now) - 1_000_000) / 1000 : null,
|
|
elapsedSeconds: elapsed / 1000, outcome: mob.dead ? "defeated" : "player-death",
|
|
maxHealth: scaling.maxHealth, playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
|
playerAlive: state.health > 0, deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
|
attacks: mob.attackRevision, specialization: specialization?.displayName,
|
|
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
|
|
}
|
|
}
|
|
return { seconds: null, elapsedSeconds: scenario.durationMs / 1000, outcome: "timeout",
|
|
maxHealth: scaling.maxHealth, remainingHealth: useCombatStore.getState().mobs[bossId].health,
|
|
playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
|
playerAlive: useCombatStore.getState().health > 0,
|
|
deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
|
attacks: useCombatStore.getState().mobs[bossId].attackRevision,
|
|
specialization: specialization?.displayName,
|
|
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
|
|
} finally {
|
|
stop(); restoreClock(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
|
}
|
|
}
|