Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
@@ -5,7 +5,7 @@ import { subscribeCombatEvents } from "../combatEvents";
|
||||
import { resetCombatSpatialProvider } from "../combatSpatial";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { dungeonEnemyScaling } from "../dungeonBossScaling";
|
||||
import { requireDungeonDefinition } from "../dungeonRegistry";
|
||||
import { prepareDungeonSessionDefinition, requireDungeonDefinition } from "../dungeonRegistry";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "../mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "../partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "../partyRuntimeRegistry";
|
||||
@@ -23,10 +23,16 @@ const scenario: BenchmarkScenarioDefinition = {
|
||||
/** Uses the live boss stats, companion AI and baseline player policy with a controlled clock. */
|
||||
export async function runDungeonBossBenchmark(
|
||||
build: BenchmarkBuildDefinition, bossId: string, seed: string, mechanics = false,
|
||||
options: { readonly dungeonId?: string; readonly durationMs?: number; readonly partySeed?: string } = {},
|
||||
) {
|
||||
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
||||
const dungeon = options.dungeonId
|
||||
? prepareDungeonSessionDefinition(options.dungeonId, seed)
|
||||
: requireDungeonDefinition("ragefire-chasm");
|
||||
if (!dungeon) throw new Error(`Unknown dungeon: ${options.dungeonId}`);
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)
|
||||
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((entry) => entry.id === bossId);
|
||||
if (!spawn) throw new Error(`Unknown boss ${bossId} in ${dungeon.id}.`);
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
|
||||
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
||||
@@ -38,12 +44,14 @@ export async function runDungeonBossBenchmark(
|
||||
let selfHealing = 0; let allyHealing = 0;
|
||||
const healingByAbility: Record<string, number> = {};
|
||||
const damageByAbility: Record<string, number> = {};
|
||||
const damageByActor: Record<string, number> = {};
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.kind === "damage" && event.targetActorId === bossId) {
|
||||
if (event.sourceActorId === PLAYER_AGGRO_ID) playerDamage += event.effectiveAmount;
|
||||
else partyDamage += event.effectiveAmount;
|
||||
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}`;
|
||||
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
|
||||
damageByActor[event.sourceActorId] = (damageByActor[event.sourceActorId] ?? 0) + event.effectiveAmount;
|
||||
}
|
||||
if (event.kind === "damage" && event.sourceActorId === bossId) incomingDamage += event.effectiveAmount;
|
||||
if (event.kind === "healing") {
|
||||
@@ -60,7 +68,8 @@ export async function runDungeonBossBenchmark(
|
||||
specializationId: specialization?.id, role: build.role, level: build.level,
|
||||
talentRanks: build.talentRanks, inventory: build.inventory, equipment: build.equipment });
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||
usePartyStore.getState().initializeParty(options.partySeed ?? seed, build.level,
|
||||
useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs(now);
|
||||
combat.applyPrecombatPreparation(now);
|
||||
@@ -77,31 +86,132 @@ export async function runDungeonBossBenchmark(
|
||||
actorId: build.role === "tank" ? PLAYER_AGGRO_ID : tank!.id, role: "tank", abilityId: "benchmark-pull",
|
||||
}, "physical");
|
||||
const controller = new BenchmarkController();
|
||||
for (let elapsed = 100; elapsed <= scenario.durationMs; elapsed += 100) {
|
||||
const durationMs = options.durationMs ?? scenario.durationMs;
|
||||
const activeScenario = durationMs === scenario.durationMs ? scenario : { ...scenario, durationMs };
|
||||
for (let elapsed = 100; elapsed <= durationMs; elapsed += 100) {
|
||||
now = 1_000_000 + elapsed;
|
||||
combat.tick(.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
if (mechanics) combat.advanceMobCombat(now);
|
||||
if (controller.act(now, build.role, scenario, false).cast) casts += 1;
|
||||
if (controller.act(now, build.role, activeScenario, false).cast) casts += 1;
|
||||
const state = useCombatStore.getState();
|
||||
const mob = state.mobs[bossId];
|
||||
if (mob.dead || (mechanics && state.health <= 0)) {
|
||||
return { seconds: mob.dead ? ((mob.diedAt ?? now) - 1_000_000) / 1000 : null,
|
||||
elapsedSeconds: elapsed / 1000, outcome: mob.dead ? "defeated" : "player-death",
|
||||
maxHealth: scaling.maxHealth, playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
maxHealth: scaling.maxHealth, playerDamage, partyDamage, damageByActor, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerAlive: state.health > 0, deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
||||
attacks: mob.attackRevision, specialization: specialization?.displayName,
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: usePartyStore.getState().members.map((member) => member.id) };
|
||||
}
|
||||
}
|
||||
return { seconds: null, elapsedSeconds: scenario.durationMs / 1000, outcome: "timeout",
|
||||
return { seconds: null, elapsedSeconds: durationMs / 1000, outcome: "timeout",
|
||||
maxHealth: scaling.maxHealth, remainingHealth: useCombatStore.getState().mobs[bossId].health,
|
||||
playerDamage, partyDamage, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerDamage, partyDamage, damageByActor, incomingDamage, healing, selfHealing, allyHealing, healingByAbility, casts, damageByAbility,
|
||||
playerAlive: useCombatStore.getState().health > 0,
|
||||
deaths: usePartyStore.getState().members.filter((member) => member.health <= 0).length,
|
||||
attacks: useCombatStore.getState().mobs[bossId].attackRevision,
|
||||
specialization: specialization?.displayName,
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`) };
|
||||
party: usePartyStore.getState().members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: usePartyStore.getState().members.map((member) => member.id) };
|
||||
} finally {
|
||||
stop(); restoreClock(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
/** Measures a compact pull using the route's real RoM trash stats and the live five-seat combat runtime. */
|
||||
export async function runBiteSizedTrashBenchmark(
|
||||
build: BenchmarkBuildDefinition, dungeonId: string, seed: string,
|
||||
options: { readonly durationMs?: number; readonly partySeed?: string; readonly maxMobs?: number } = {},
|
||||
) {
|
||||
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
|
||||
const dungeon = prepareDungeonSessionDefinition(dungeonId, seed);
|
||||
if (!dungeon) throw new Error(`Unknown dungeon: ${dungeonId}`);
|
||||
const spawns = dungeon.staticSpawns.filter((spawn) => dungeon.entities[spawn.entityId]?.kind !== "boss")
|
||||
.slice(0, options.maxMobs ?? 6);
|
||||
if (spawns.length < 2) throw new Error(`${dungeonId} has fewer than two trash mobs.`);
|
||||
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
||||
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
let now = 1_000_000;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
const damageByActor: Record<string, number> = {};
|
||||
const damageByAbility: Record<string, number> = {};
|
||||
const mobIds = new Set(spawns.map((spawn) => spawn.id));
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.kind !== "damage" || !mobIds.has(event.targetActorId)) return;
|
||||
damageByActor[event.sourceActorId] = (damageByActor[event.sourceActorId] ?? 0) + event.effectiveAmount;
|
||||
const key = `${event.sourceActorId}:${event.abilityId ?? "unknown"}`;
|
||||
damageByAbility[key] = (damageByAbility[key] ?? 0) + event.effectiveAmount;
|
||||
});
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({ classId: build.classId, raceId: build.raceId,
|
||||
secondaryClassId: build.secondaryClassId, specializationId: specialization?.id,
|
||||
role: build.role, level: build.level, talentRanks: build.talentRanks,
|
||||
inventory: build.inventory, equipment: build.equipment });
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(options.partySeed ?? seed, build.level,
|
||||
useCombatStore.getState().maxHealth, build.role, 5, build.source);
|
||||
combat.synchronizeActors();
|
||||
combat.applyDefaultPartyBuffs(now);
|
||||
combat.applyPrecombatPreparation(now);
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
let totalHealth = 0;
|
||||
for (const [index, spawn] of spawns.entries()) {
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, spawn.id, build.level, "normal", 1, build.source);
|
||||
totalHealth += scaling.maxHealth;
|
||||
combat.registerMob(spawn.id, { name: entity.name, serverEntry: entity.combat?.serverEntry,
|
||||
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
||||
const angle = index / spawns.length * Math.PI * 2;
|
||||
setMobPosition(spawn.id, [Math.cos(angle) * 1.5, 0, Math.sin(angle) * 1.5]);
|
||||
}
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
const preferred = partyCombatRangeBand(member).preferred;
|
||||
registerPartyRuntimePosition(member.id, [preferred <= 6 ? -1 : -preferred, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, spawns[0].id, true);
|
||||
}
|
||||
combat.selectMob(spawns[0].id);
|
||||
for (const spawn of spawns) combat.damageMob(spawn.id, 1, now, {
|
||||
actorId: build.role === "tank" ? PLAYER_AGGRO_ID : tank!.id,
|
||||
role: "tank", abilityId: "benchmark-pull",
|
||||
}, "physical");
|
||||
const controller = new BenchmarkController();
|
||||
const durationMs = options.durationMs ?? 120_000;
|
||||
const areaScenario: BenchmarkScenarioDefinition = { ...scenario, id: "bite-sized-trash",
|
||||
hostileTargets: spawns.length, durationMs };
|
||||
for (let elapsed = 100; elapsed <= durationMs; elapsed += 100) {
|
||||
now = 1_000_000 + elapsed;
|
||||
combat.tick(.1, now);
|
||||
// The scene normally publishes one current line-of-sight target per
|
||||
// companion. Mirror that update when a benchmark has several mobs.
|
||||
const currentParty = usePartyStore.getState();
|
||||
for (const member of currentParty.members) {
|
||||
const targetId = currentParty.memberTargetIds[member.id]
|
||||
?? useCombatStore.getState().selectedTargetId;
|
||||
if (targetId) updatePartyRuntimeTargetVisibility(member.id, targetId, true);
|
||||
}
|
||||
advanceDungeonPartyCombat(now);
|
||||
const active = spawns.find((spawn) => !useCombatStore.getState().mobs[spawn.id]?.dead);
|
||||
if (!active) {
|
||||
const members = usePartyStore.getState().members;
|
||||
return { seconds: elapsed / 1_000, outcome: "defeated", totalHealth,
|
||||
mobCount: spawns.length, damageByActor, damageByAbility,
|
||||
party: members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: members.map((member) => member.id) };
|
||||
}
|
||||
if (useCombatStore.getState().selectedTargetId !== active.id) combat.selectMob(active.id);
|
||||
const livingCount = spawns.filter((spawn) => !useCombatStore.getState().mobs[spawn.id]?.dead).length;
|
||||
controller.act(now, build.role, { ...areaScenario, hostileTargets: livingCount }, false);
|
||||
}
|
||||
const members = usePartyStore.getState().members;
|
||||
return { seconds: durationMs / 1_000, outcome: "timeout", totalHealth,
|
||||
mobCount: spawns.length, damageByActor, damageByAbility,
|
||||
party: members.map((member) => `${member.classId}:${member.specialization}`),
|
||||
partyActorIds: members.map((member) => member.id) };
|
||||
} finally {
|
||||
stop(); restoreClock(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
|
||||
Reference in new issue
Block a user