Release Healer Man 0.1.46
This commit is contained in:
1 parent
f82daf1c13
commit
9ff574ac0b
73 files changed
+33486
-174
No files matched your search
@@ -0,0 +1,635 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
CLASSES,
|
||||
classById,
|
||||
contentCategoryForClass,
|
||||
type ClassId,
|
||||
} from "../../src/app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../src/app/contentCategories";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
} from "../../src/game/abilityCatalog";
|
||||
import { aiHintsForAbility } from "../../src/game/abilityRules";
|
||||
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
|
||||
import { installCombatClock } from "../../src/game/combatClock";
|
||||
import { subscribeCombatEvents } from "../../src/game/combatEvents";
|
||||
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
|
||||
import { useCombatStore } from "../../src/game/combatStore";
|
||||
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
|
||||
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
|
||||
import {
|
||||
BOSS_TESTING_CATALOG,
|
||||
createBossTestingRoom,
|
||||
type TestableBoss,
|
||||
} from "../../src/game/bossTesting";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "../../src/game/mobRuntimeRegistry";
|
||||
import {
|
||||
advanceDungeonPartyCombat,
|
||||
partyCombatRangeBand,
|
||||
resetPartyRuntimeClock,
|
||||
} from "../../src/game/partyRuntime";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
registerPartyRuntimePosition,
|
||||
updatePartyRuntimeTargetVisibility,
|
||||
} from "../../src/game/partyRuntimeRegistry";
|
||||
import {
|
||||
createConfiguredPartyMember,
|
||||
usePartyStore,
|
||||
type PartyMember,
|
||||
} from "../../src/game/partyStore";
|
||||
import { specializationsForClass } from "../../src/game/specializationCatalog";
|
||||
import { BenchmarkController } from "../../src/game/benchmark/benchmarkController";
|
||||
import {
|
||||
loadSelectableBenchmarkBuilds,
|
||||
selectableBenchmarkBuildCatalog,
|
||||
} from "../../src/game/benchmark/benchmarkBuilds";
|
||||
import type {
|
||||
BenchmarkBuildDefinition,
|
||||
BenchmarkScenarioDefinition,
|
||||
} from "../../src/game/benchmark/benchmarkTypes";
|
||||
|
||||
type BossCategory = "all" | "wow" | "rom";
|
||||
type PartyCatalogSource = ContentCategoryId | "all";
|
||||
|
||||
interface BalanceLabConfig {
|
||||
readonly schemaVersion: 1;
|
||||
readonly source: ContentCategoryId;
|
||||
readonly level: number;
|
||||
readonly durationSeconds: number;
|
||||
readonly seeds: readonly number[];
|
||||
readonly partyBuildIds: readonly string[];
|
||||
readonly activeSlot: number;
|
||||
readonly bossCategory: BossCategory;
|
||||
readonly bossKeys?: readonly string[];
|
||||
}
|
||||
|
||||
interface MutableAbilityMetrics {
|
||||
casts: number;
|
||||
rawDamage: number;
|
||||
effectiveDamage: number;
|
||||
rawHealing: number;
|
||||
effectiveHealing: number;
|
||||
overheal: number;
|
||||
absorbs: number;
|
||||
hits: number;
|
||||
}
|
||||
|
||||
interface MutableMemberMetrics {
|
||||
readonly slot: number;
|
||||
readonly actorId: string;
|
||||
readonly build: BenchmarkBuildDefinition;
|
||||
damage: number;
|
||||
rawDamage: number;
|
||||
bossDamage: number;
|
||||
healing: number;
|
||||
rawHealing: number;
|
||||
overheal: number;
|
||||
absorbs: number;
|
||||
damageTaken: number;
|
||||
rawDamageTaken: number;
|
||||
absorbedDamageTaken: number;
|
||||
deathEvents: number;
|
||||
casts: number;
|
||||
minimumHealthPercent: number;
|
||||
readonly abilities: Map<string, MutableAbilityMetrics>;
|
||||
}
|
||||
|
||||
const scenario = (durationMs: number): BenchmarkScenarioDefinition => ({
|
||||
id: "balance-lab-boss",
|
||||
version: 1,
|
||||
name: "Balance lab boss",
|
||||
description: "A selected five-member party against one isolated authored boss.",
|
||||
family: "mechanics",
|
||||
roles: ["damage", "tank", "healer"],
|
||||
durationMs,
|
||||
tickMs: 100,
|
||||
hostileTargets: 1,
|
||||
partyLayout: "melee",
|
||||
attacks: [],
|
||||
});
|
||||
|
||||
function argument(name: string): string | null {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] ?? null : null;
|
||||
}
|
||||
|
||||
function requiredArgument(name: string): string {
|
||||
const value = argument(name);
|
||||
if (!value) throw new Error(`Missing required ${name} argument.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeNumber(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function classIdsForSource(source: PartyCatalogSource): readonly ClassId[] {
|
||||
return CLASSES
|
||||
.map((definition) => definition.id)
|
||||
.filter((classId) => source === "all" || contentCategoryForClass(classId) === source);
|
||||
}
|
||||
|
||||
function bossCatalogRows() {
|
||||
return BOSS_TESTING_CATALOG.map((boss) => ({
|
||||
key: boss.key,
|
||||
name: boss.name,
|
||||
dungeonId: boss.dungeonId,
|
||||
dungeonTitle: boss.dungeonTitle,
|
||||
category: boss.category,
|
||||
}));
|
||||
}
|
||||
|
||||
async function writeJson(path: string, value: unknown): Promise<void> {
|
||||
const absolute = resolve(path);
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function catalogPayload(source: PartyCatalogSource, level: number) {
|
||||
if (!["wow", "coa", "rom", "all"].includes(source)) throw new Error(`Unknown party source: ${source}`);
|
||||
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
level: builds[0]?.level ?? level,
|
||||
source,
|
||||
builds: builds.map((build) => ({
|
||||
id: build.id,
|
||||
source: build.source,
|
||||
classId: build.classId,
|
||||
className: classById(build.classId).name,
|
||||
role: build.role,
|
||||
specialization: build.orientation,
|
||||
label: `${classById(build.classId).name} — ${build.orientation} (${build.role})`,
|
||||
})),
|
||||
bosses: bossCatalogRows(),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeCatalog(): Promise<void> {
|
||||
const level = Math.trunc(safeNumber(argument("--level"), 60));
|
||||
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
|
||||
await writeJson(requiredArgument("--output"), catalogPayload(source, level));
|
||||
}
|
||||
|
||||
function selectedBosses(config: BalanceLabConfig): readonly TestableBoss[] {
|
||||
const requested = new Set(config.bossKeys ?? []);
|
||||
const rows = BOSS_TESTING_CATALOG.filter((boss) => (
|
||||
(config.bossCategory === "all" || boss.category === config.bossCategory)
|
||||
&& (!requested.size || requested.has(boss.key))
|
||||
));
|
||||
if (!rows.length) throw new Error("No bosses matched the requested boss filters.");
|
||||
if (requested.size) {
|
||||
const found = new Set(rows.map((boss) => boss.key));
|
||||
const missing = [...requested].filter((key) => !found.has(key));
|
||||
if (missing.length) throw new Error(`Unknown or category-excluded boss keys: ${missing.join(", ")}`);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function configuredCompanion(
|
||||
build: BenchmarkBuildDefinition,
|
||||
slot: number,
|
||||
level: number,
|
||||
seed: string,
|
||||
): PartyMember {
|
||||
const specialization = specializationsForClass(build.classId)
|
||||
.find((definition) => definition.displayName === build.orientation);
|
||||
return createConfiguredPartyMember({
|
||||
name: `${classById(build.classId).name} ${build.orientation}`,
|
||||
classId: build.classId,
|
||||
secondaryClassId: build.secondaryClassId,
|
||||
specialization: build.orientation,
|
||||
specializationId: specialization?.id ?? null,
|
||||
talentTreeId: specialization?.id ?? "",
|
||||
talentRanks: build.talentRanks,
|
||||
role: build.role,
|
||||
}, level, slot, seed);
|
||||
}
|
||||
|
||||
function blankAbility(): MutableAbilityMetrics {
|
||||
return {
|
||||
casts: 0,
|
||||
rawDamage: 0,
|
||||
effectiveDamage: 0,
|
||||
rawHealing: 0,
|
||||
effectiveHealing: 0,
|
||||
overheal: 0,
|
||||
absorbs: 0,
|
||||
hits: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefined): MutableAbilityMetrics {
|
||||
const key = abilityId ?? "unknown";
|
||||
const existing = member.abilities.get(key);
|
||||
if (existing) return existing;
|
||||
const created = blankAbility();
|
||||
member.abilities.set(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function currentHealth(actorId: string): readonly [number, number] {
|
||||
const combat = useCombatStore.getState();
|
||||
if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth];
|
||||
const member = usePartyStore.getState().members.find((candidate) => candidate.id === actorId);
|
||||
return [member?.health ?? 0, member?.maxHealth ?? 1];
|
||||
}
|
||||
|
||||
function availableAbilities(build: BenchmarkBuildDefinition) {
|
||||
return abilitiesForClass(build.classId)
|
||||
.filter((ability) => !ability.passive && isAbilityUnlocked(ability, build.level))
|
||||
.map((ability) => {
|
||||
const learnedRank = ability.talentEntryId === undefined
|
||||
? undefined
|
||||
: build.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
|
||||
if (ability.talentEntryId !== undefined && !learnedRank) return null;
|
||||
const ranked = abilityAtLevel(ability, build.level, learnedRank);
|
||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||
return {
|
||||
abilityId: ranked.id,
|
||||
abilityName: ranked.name,
|
||||
icon: ranked.icon,
|
||||
categories: aiHintsForAbility(ranked).categories,
|
||||
cooldownSeconds: ranked.cooldownMs / 1_000,
|
||||
};
|
||||
})
|
||||
.filter((ability) => ability !== null)
|
||||
.sort((left, right) => left.abilityName.localeCompare(right.abilityName));
|
||||
}
|
||||
|
||||
function serializedMember(
|
||||
metrics: MutableMemberMetrics,
|
||||
activeSeconds: number,
|
||||
) {
|
||||
const [health, maxHealth] = currentHealth(metrics.actorId);
|
||||
return {
|
||||
slot: metrics.slot,
|
||||
activePlayer: metrics.actorId === PLAYER_AGGRO_ID,
|
||||
actorId: metrics.actorId,
|
||||
buildId: metrics.build.id,
|
||||
classId: metrics.build.classId,
|
||||
className: classById(metrics.build.classId).name,
|
||||
specialization: metrics.build.orientation,
|
||||
role: metrics.build.role,
|
||||
damage: metrics.damage,
|
||||
rawDamage: metrics.rawDamage,
|
||||
bossDamage: metrics.bossDamage,
|
||||
dps: metrics.damage / Math.max(0.1, activeSeconds),
|
||||
bossDps: metrics.bossDamage / Math.max(0.1, activeSeconds),
|
||||
healing: metrics.healing,
|
||||
rawHealing: metrics.rawHealing,
|
||||
overheal: metrics.overheal,
|
||||
hps: metrics.healing / Math.max(0.1, activeSeconds),
|
||||
absorbs: metrics.absorbs,
|
||||
damageTaken: metrics.damageTaken,
|
||||
rawDamageTaken: metrics.rawDamageTaken,
|
||||
absorbedDamageTaken: metrics.absorbedDamageTaken,
|
||||
dtps: metrics.damageTaken / Math.max(0.1, activeSeconds),
|
||||
deathEvents: metrics.deathEvents,
|
||||
alive: health > 0,
|
||||
finalHealth: health,
|
||||
maxHealth,
|
||||
minimumHealthPercent: metrics.minimumHealthPercent,
|
||||
casts: metrics.casts,
|
||||
availableAbilities: availableAbilities(metrics.build),
|
||||
abilities: [...metrics.abilities.entries()]
|
||||
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
|
||||
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|
||||
|| right.effectiveHealing - left.effectiveHealing
|
||||
|| left.abilityId.localeCompare(right.abilityId)),
|
||||
};
|
||||
}
|
||||
|
||||
async function runBoss(
|
||||
config: BalanceLabConfig,
|
||||
builds: readonly BenchmarkBuildDefinition[],
|
||||
boss: TestableBoss,
|
||||
seed: number,
|
||||
) {
|
||||
const sourceDungeon = requireDungeonDefinition(boss.dungeonId);
|
||||
const testingRoom = createBossTestingRoom(sourceDungeon, boss).definition;
|
||||
const entity = testingRoom.entities[boss.entityId];
|
||||
if (!entity) throw new Error(`Boss entity ${boss.key} is missing from its testing room.`);
|
||||
const bossActorId = `${boss.spawn.id}:balance-${seed}`;
|
||||
const activeBuild = builds[config.activeSlot];
|
||||
const durationMs = Math.max(1_000, Math.min(600_000, Math.round(config.durationSeconds * 1_000)));
|
||||
const specialization = specializationsForClass(activeBuild.classId)
|
||||
.find((definition) => definition.displayName === activeBuild.orientation);
|
||||
const scaling = dungeonEnemyScaling(
|
||||
testingRoom,
|
||||
entity,
|
||||
bossActorId,
|
||||
activeBuild.level,
|
||||
testingRoom.defaultDifficultyId,
|
||||
1,
|
||||
activeBuild.source,
|
||||
);
|
||||
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
let now = 1_000_000;
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
const companions = builds
|
||||
.map((build, slot) => ({ build, slot }))
|
||||
.filter(({ slot }) => slot !== config.activeSlot)
|
||||
.map(({ build, slot }) => configuredCompanion(build, slot, activeBuild.level, `${seed}:${boss.key}`));
|
||||
const actorIds = builds.map((_, slot) => (
|
||||
slot === config.activeSlot
|
||||
? PLAYER_AGGRO_ID
|
||||
: companions.find((member) => member.id.includes(`-${slot}-`))?.id ?? ""
|
||||
));
|
||||
const memberMetrics = builds.map((build, slot): MutableMemberMetrics => ({
|
||||
slot,
|
||||
actorId: actorIds[slot],
|
||||
build,
|
||||
damage: 0,
|
||||
rawDamage: 0,
|
||||
bossDamage: 0,
|
||||
healing: 0,
|
||||
rawHealing: 0,
|
||||
overheal: 0,
|
||||
absorbs: 0,
|
||||
damageTaken: 0,
|
||||
rawDamageTaken: 0,
|
||||
absorbedDamageTaken: 0,
|
||||
deathEvents: 0,
|
||||
casts: 0,
|
||||
minimumHealthPercent: 1,
|
||||
abilities: new Map(),
|
||||
}));
|
||||
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
|
||||
const bossAbilities = new Map<string, MutableAbilityMetrics>();
|
||||
const stopEvents = subscribeCombatEvents((event) => {
|
||||
const source = byActorId.get(event.sourceActorId);
|
||||
const target = byActorId.get(event.targetActorId);
|
||||
if (event.kind === "cast" && source) {
|
||||
source.casts += 1;
|
||||
abilityMetrics(source, event.abilityId).casts += 1;
|
||||
} else if (event.kind === "damage") {
|
||||
if (source && !target) {
|
||||
source.rawDamage += event.rawAmount;
|
||||
source.damage += event.effectiveAmount;
|
||||
if (event.targetActorId === bossActorId) source.bossDamage += event.effectiveAmount;
|
||||
const ability = abilityMetrics(source, event.abilityId);
|
||||
ability.rawDamage += event.rawAmount;
|
||||
ability.effectiveDamage += event.effectiveAmount;
|
||||
ability.hits += 1;
|
||||
}
|
||||
if (target && !source) {
|
||||
target.rawDamageTaken += event.rawAmount;
|
||||
target.damageTaken += event.effectiveAmount;
|
||||
target.absorbedDamageTaken += event.absorbed;
|
||||
const key = event.abilityId ?? "unknown";
|
||||
const ability = bossAbilities.get(key) ?? blankAbility();
|
||||
ability.rawDamage += event.rawAmount;
|
||||
ability.effectiveDamage += event.effectiveAmount;
|
||||
ability.absorbs += event.absorbed;
|
||||
ability.hits += 1;
|
||||
bossAbilities.set(key, ability);
|
||||
}
|
||||
} else if (event.kind === "healing" && source) {
|
||||
source.rawHealing += event.rawAmount;
|
||||
source.healing += event.effectiveAmount;
|
||||
source.overheal += event.overheal;
|
||||
const ability = abilityMetrics(source, event.abilityId);
|
||||
ability.rawHealing += event.rawAmount;
|
||||
ability.effectiveHealing += event.effectiveAmount;
|
||||
ability.overheal += event.overheal;
|
||||
ability.hits += 1;
|
||||
} else if (event.kind === "absorb" && source) {
|
||||
source.absorbs += event.effectiveAmount;
|
||||
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
||||
} else if (event.kind === "death" && target) {
|
||||
target.deathEvents += 1;
|
||||
}
|
||||
});
|
||||
|
||||
let elapsedMs = 0;
|
||||
let outcome: "defeated" | "wipe" | "timeout" = "timeout";
|
||||
try {
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: activeBuild.classId,
|
||||
secondaryClassId: activeBuild.secondaryClassId,
|
||||
specializationId: specialization?.id,
|
||||
role: activeBuild.role,
|
||||
level: activeBuild.level,
|
||||
talentRanks: activeBuild.talentRanks,
|
||||
inventory: activeBuild.inventory,
|
||||
equipment: activeBuild.equipment,
|
||||
});
|
||||
combat.setPlayerPosition([-2, 0, 0]);
|
||||
usePartyStore.setState({
|
||||
seed: `${seed}:${boss.key}`,
|
||||
playerRole: activeBuild.role,
|
||||
partySize: 5,
|
||||
categoryId: config.source,
|
||||
members: companions,
|
||||
command: "defend",
|
||||
selectedMemberId: null,
|
||||
selfSelected: false,
|
||||
routeBlocked: false,
|
||||
activeMobId: null,
|
||||
objectiveBossId: null,
|
||||
memberTargetIds: {},
|
||||
});
|
||||
combat.synchronizeActors();
|
||||
combat.registerMob(bossActorId, {
|
||||
name: entity.name,
|
||||
boss: true,
|
||||
serverEntry: entity.combat?.serverEntry,
|
||||
...scaling,
|
||||
attacks: entity.combat?.attacks,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(bossActorId, [0, 0, 0]);
|
||||
for (const member of companions) {
|
||||
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, bossActorId, true);
|
||||
}
|
||||
combat.selectMob(bossActorId);
|
||||
const tank = memberMetrics.find((member) => member.build.role === "tank") ?? memberMetrics[0];
|
||||
combat.damageMob(bossActorId, 1, now, {
|
||||
actorId: tank.actorId,
|
||||
role: "tank",
|
||||
abilityId: "balance-lab-pull",
|
||||
}, "physical");
|
||||
const controller = new BenchmarkController();
|
||||
const runScenario = scenario(durationMs);
|
||||
for (elapsedMs = 100; elapsedMs <= durationMs; elapsedMs += 100) {
|
||||
now = 1_000_000 + elapsedMs;
|
||||
combat.tick(0.1, now);
|
||||
combat.advanceMobCombat(now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
controller.act(now, activeBuild.role, runScenario, false);
|
||||
for (const metrics of memberMetrics) {
|
||||
const [health, maxHealth] = currentHealth(metrics.actorId);
|
||||
metrics.minimumHealthPercent = Math.min(
|
||||
metrics.minimumHealthPercent,
|
||||
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
|
||||
);
|
||||
}
|
||||
const state = useCombatStore.getState();
|
||||
if (state.mobs[bossActorId]?.dead) {
|
||||
outcome = "defeated";
|
||||
break;
|
||||
}
|
||||
if (memberMetrics.every((metrics) => currentHealth(metrics.actorId)[0] <= 0)) {
|
||||
outcome = "wipe";
|
||||
break;
|
||||
}
|
||||
}
|
||||
const activeSeconds = Math.min(durationMs, elapsedMs) / 1_000;
|
||||
const mob = useCombatStore.getState().mobs[bossActorId];
|
||||
const members = memberMetrics.map((metrics) => serializedMember(metrics, activeSeconds));
|
||||
const incomingDamage = members.reduce((total, member) => total + member.damageTaken, 0);
|
||||
const rawIncomingDamage = members.reduce((total, member) => total + member.rawDamageTaken, 0);
|
||||
const partyDamage = members.reduce((total, member) => total + member.damage, 0);
|
||||
const bossDamage = members.reduce((total, member) => total + member.bossDamage, 0);
|
||||
return {
|
||||
bossKey: boss.key,
|
||||
bossName: boss.name,
|
||||
bossCategory: boss.category,
|
||||
dungeonId: boss.dungeonId,
|
||||
dungeonTitle: boss.dungeonTitle,
|
||||
seed,
|
||||
outcome,
|
||||
elapsedSeconds: activeSeconds,
|
||||
timeToDefeatSeconds: outcome === "defeated" ? activeSeconds : null,
|
||||
bossLevel: scaling.level,
|
||||
bossMaxHealth: scaling.maxHealth,
|
||||
bossRemainingHealth: mob?.health ?? 0,
|
||||
partyDamage,
|
||||
bossDamage,
|
||||
partyDps: partyDamage / Math.max(0.1, activeSeconds),
|
||||
bossDpsTaken: bossDamage / Math.max(0.1, activeSeconds),
|
||||
incomingDamage,
|
||||
rawIncomingDamage,
|
||||
incomingDtps: incomingDamage / Math.max(0.1, activeSeconds),
|
||||
deaths: members.filter((member) => !member.alive).length,
|
||||
deathEvents: members.reduce((total, member) => total + member.deathEvents, 0),
|
||||
bossAttacks: mob?.attackRevision ?? 0,
|
||||
members,
|
||||
bossAbilities: [...bossAbilities.entries()]
|
||||
.map(([abilityId, ability]) => ({ abilityId, ...ability }))
|
||||
.sort((left, right) => right.effectiveDamage - left.effectiveDamage
|
||||
|| left.abilityId.localeCompare(right.abilityId)),
|
||||
};
|
||||
} finally {
|
||||
stopEvents();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
resetCombatSpatialProvider();
|
||||
}
|
||||
}
|
||||
|
||||
async function simulateConfig(config: BalanceLabConfig, output: string): Promise<void> {
|
||||
if (config.schemaVersion !== 1) throw new Error(`Unsupported config schema: ${config.schemaVersion}`);
|
||||
if (config.partyBuildIds.length !== 5) throw new Error("A balance-lab party must contain exactly five builds.");
|
||||
if (!Number.isInteger(config.activeSlot) || config.activeSlot < 0 || config.activeSlot >= 5) {
|
||||
throw new Error("activeSlot must be an integer from 0 through 4.");
|
||||
}
|
||||
if (!config.seeds.length) throw new Error("At least one simulation seed is required.");
|
||||
const catalog = selectableBenchmarkBuildCatalog(config.level, classIdsForSource(config.source));
|
||||
const catalogById = new Map(catalog.map((build) => [build.id, build]));
|
||||
const selectedClassIds = [...new Set(config.partyBuildIds.map((id) => {
|
||||
const entry = catalogById.get(id);
|
||||
if (!entry) throw new Error(`Unknown ${config.source} party build: ${id}`);
|
||||
return entry.classId;
|
||||
}))];
|
||||
const availableBuilds = await loadSelectableBenchmarkBuilds(config.level, selectedClassIds);
|
||||
const byId = new Map(availableBuilds.map((build) => [build.id, build]));
|
||||
const builds = config.partyBuildIds.map((id) => {
|
||||
const build = byId.get(id);
|
||||
if (!build) throw new Error(`Unknown ${config.source} party build: ${id}`);
|
||||
return build;
|
||||
});
|
||||
await preloadAbilityCatalogsForClasses(builds.map((build) => build.classId));
|
||||
const bosses = selectedBosses(config);
|
||||
const runs = [];
|
||||
let completed = 0;
|
||||
const total = bosses.length * config.seeds.length;
|
||||
for (const boss of bosses) {
|
||||
for (const seed of config.seeds) {
|
||||
runs.push(await runBoss(config, builds, boss, seed));
|
||||
completed += 1;
|
||||
if (completed === 1 || completed === total || completed % 10 === 0) {
|
||||
process.stderr.write(`[balance-lab] ${completed}/${total} ${boss.name} seed ${seed}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await writeJson(output, {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
engine: "live-typescript-runtime",
|
||||
config: {
|
||||
...config,
|
||||
level: builds[0]?.level ?? config.level,
|
||||
},
|
||||
party: builds.map((build, slot) => ({
|
||||
slot,
|
||||
activePlayer: slot === config.activeSlot,
|
||||
buildId: build.id,
|
||||
classId: build.classId,
|
||||
className: classById(build.classId).name,
|
||||
specialization: build.orientation,
|
||||
role: build.role,
|
||||
})),
|
||||
bossCount: bosses.length,
|
||||
runCount: runs.length,
|
||||
runs,
|
||||
});
|
||||
}
|
||||
|
||||
async function simulate(): Promise<void> {
|
||||
const config = JSON.parse(await readFile(resolve(requiredArgument("--config")), "utf8")) as BalanceLabConfig;
|
||||
await simulateConfig(config, requiredArgument("--output"));
|
||||
}
|
||||
|
||||
async function serveSession(): Promise<void> {
|
||||
const level = Math.trunc(safeNumber(argument("--level"), 60));
|
||||
const source = (argument("--source") ?? "wow") as PartyCatalogSource;
|
||||
process.stdout.write(`${JSON.stringify(catalogPayload(source, level))}\n`);
|
||||
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const request = JSON.parse(line) as {
|
||||
readonly close?: boolean;
|
||||
readonly config?: string;
|
||||
readonly output?: string;
|
||||
};
|
||||
if (request.close) {
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, closed: true })}\n`);
|
||||
lines.close();
|
||||
return;
|
||||
}
|
||||
if (!request.config || !request.output) throw new Error("A session simulation requires config and output paths.");
|
||||
const config = JSON.parse(await readFile(resolve(request.config), "utf8")) as BalanceLabConfig;
|
||||
await simulateConfig(config, request.output);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true })}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const command = process.argv[2];
|
||||
if (command === "catalog") await writeCatalog();
|
||||
else if (command === "simulate") await simulate();
|
||||
else if (command === "session") await serveSession();
|
||||
else throw new Error("Usage: vite-node scripts/balance-lab/runtime.ts <catalog|simulate|session> [options]");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
BOSS_PRESSURE_PARTY_SIZE,
|
||||
BOSS_PRESSURE_WINDOW_MS,
|
||||
rankBossPartyPressure,
|
||||
} from "../../src/game/bossPressure";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const playerLevel = Math.max(1, Math.min(80, Number(process.env.BOSS_PRESSURE_LEVEL) || 80));
|
||||
const { DUNGEON_DEFINITIONS } = await import("../../src/game/dungeonRegistry");
|
||||
const wowDefinitions = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
|
||||
const ranking = rankBossPartyPressure(wowDefinitions, playerLevel);
|
||||
const median = ranking.length ? ranking[Math.floor(ranking.length / 2)].partyDamagePerSecond : 0;
|
||||
const entries = ranking.map((entry, index) => ({
|
||||
rank: index + 1,
|
||||
pressureVsMedian: median > 0 ? entry.partyDamagePerSecond / median : 0,
|
||||
...entry,
|
||||
}));
|
||||
const eck = entries.find((entry) => entry.serverEntry === 29_932) ?? null;
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
assumptions: {
|
||||
playerLevel,
|
||||
enemyLevelOffset: 2,
|
||||
durationSeconds: BOSS_PRESSURE_WINDOW_MS / 1_000,
|
||||
partySize: BOSS_PRESSURE_PARTY_SIZE,
|
||||
partyLayout: "all five actors inside nearby-party effects",
|
||||
metric: "unmitigated direct damage from the live boss attack catalog",
|
||||
excludes: [
|
||||
"armor and resistance",
|
||||
"avoidable movement and interrupts",
|
||||
"summoned creature damage",
|
||||
"damage-amplifying debuffs",
|
||||
"healing and absorbs",
|
||||
"mutually exclusive phase uptime",
|
||||
],
|
||||
},
|
||||
bosses: entries.length,
|
||||
medianPartyDamagePerSecond: median,
|
||||
eck,
|
||||
entries,
|
||||
};
|
||||
|
||||
const number = (value: number): string => Math.round(value).toLocaleString("en-US");
|
||||
const decimal = (value: number): string => value.toFixed(1);
|
||||
const top = entries.slice(0, 30);
|
||||
const markdown = `# WoW boss party-pressure audit
|
||||
|
||||
Generated ${report.generatedAt}. This is a deterministic comparison at player level ${playerLevel}: ${BOSS_PRESSURE_PARTY_SIZE} grouped party members, ${BOSS_PRESSURE_WINDOW_MS / 1_000} seconds, and live boss attack definitions. It is a pressure ceiling, not a combat-log prediction: mitigation, movement, interrupts, summons, debuff amplification, and phase uptime are excluded.
|
||||
|
||||
| Rank | Boss | Dungeon | Party DPS | Tank DPS | Random DPS | Groupwide DPS | vs median |
|
||||
| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |
|
||||
${top.map((entry) => `| ${entry.rank} | ${entry.boss} | ${entry.dungeonTitle} | ${number(entry.partyDamagePerSecond)} | ${number(entry.tankDamage / entry.windowSeconds)} | ${number(entry.randomTargetDamage / entry.windowSeconds)} | ${number(entry.groupwideDamage / entry.windowSeconds)} | ${decimal(entry.pressureVsMedian)}x |`).join("\n")}
|
||||
|
||||
## Eck the Ferocious
|
||||
|
||||
${eck ? `Eck ranks **${eck.rank} of ${entries.length}** at **${number(eck.partyDamagePerSecond)} raw party DPS** (**${decimal(eck.pressureVsMedian)}x** the median). His corrected schedule contains one 2-second melee swing, Bite every 8–12 seconds, Spit every 8–12 seconds after its delayed opener, Spring every 10–24 seconds against a random party member, and a non-damaging Berserk at 90 seconds.` : "Eck was not found in the runtime catalog."}
|
||||
|
||||
The complete machine-readable ranking and per-attack breakdown are in \`dungeon-pipeline/WOW_BOSS_PRESSURE_REPORT.json\`.
|
||||
`;
|
||||
|
||||
const jsonPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.json");
|
||||
const markdownPath = path.join(projectRoot, "dungeon-pipeline", "WOW_BOSS_PRESSURE_REPORT.md");
|
||||
await Promise.all([
|
||||
writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"),
|
||||
writeFile(markdownPath, markdown, "utf8"),
|
||||
]);
|
||||
console.log(JSON.stringify({
|
||||
status: "complete",
|
||||
bosses: entries.length,
|
||||
playerLevel,
|
||||
medianPartyDamagePerSecond: Number(median.toFixed(2)),
|
||||
eck: eck ? { rank: eck.rank, partyDamagePerSecond: Number(eck.partyDamagePerSecond.toFixed(2)), pressureVsMedian: Number(eck.pressureVsMedian.toFixed(2)) } : null,
|
||||
outputs: [path.relative(projectRoot, jsonPath), path.relative(projectRoot, markdownPath)],
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,426 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const defaultPlayerbotsRoot = path.resolve(projectRoot, "..", "wow-pvt-server-backups-2", "azerothcore-wotlk", "modules", "mod-playerbots");
|
||||
const argumentIndex = process.argv.indexOf("--playerbots-root");
|
||||
const playerbotsRoot = path.resolve(argumentIndex >= 0 ? process.argv[argumentIndex + 1] : defaultPlayerbotsRoot);
|
||||
const sqlRoot = path.join(playerbotsRoot, "data", "sql", "playerbots", "base");
|
||||
const catalogPath = path.join(projectRoot, "src", "game", "generated", "dungeonCampaignCatalog.json");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "dungeonRouteCatalog.generated.json");
|
||||
const auditPath = path.join(projectRoot, "dungeon-pipeline", "DUNGEON_ROUTE_GENERATION_AUDIT.json");
|
||||
const gltfIo = new NodeIO()
|
||||
.registerExtensions(ALL_EXTENSIONS)
|
||||
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
|
||||
function tuples(fileName, pattern, convert) {
|
||||
return readFileSync(path.join(sqlRoot, fileName), "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => pattern.exec(line.trim()))
|
||||
.filter(Boolean)
|
||||
.map(convert);
|
||||
}
|
||||
|
||||
const number = (value) => Number(value);
|
||||
const nodes = tuples(
|
||||
"playerbots_travelnode.sql",
|
||||
/^\((\d+), '((?:[^'\\]|\\.)*)', (\d+), (-?[\d.eE+]+), (-?[\d.eE+]+), (-?[\d.eE+]+), (?:NULL|\d+)\)[,;]?$/,
|
||||
(match) => ({ id: number(match[1]), mapId: number(match[3]), wow: [number(match[4]), number(match[5]), number(match[6])] }),
|
||||
);
|
||||
const links = tuples(
|
||||
"playerbots_travelnode_link.sql",
|
||||
/^\((\d+), (\d+), \d+, \d+, (-?[\d.eE+]+),/,
|
||||
(match) => ({ from: number(match[1]), to: number(match[2]), distance: Math.max(0.001, number(match[3])) }),
|
||||
);
|
||||
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
||||
|
||||
function transformPoint(dungeon, wow) {
|
||||
const base = dungeon.transform.fromWow === "three(x,y,z)=(wow.y,wow.z,wow.x)"
|
||||
? [wow[1], wow[2], wow[0]]
|
||||
: [-wow[0], wow[2], wow[1]];
|
||||
const scale = dungeon.transform.scale ?? 1;
|
||||
return [
|
||||
base[0] * scale + (dungeon.transform.position?.[0] ?? 0),
|
||||
base[1] * scale + (dungeon.transform.position?.[1] ?? 0),
|
||||
base[2] * scale + (dungeon.transform.position?.[2] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function distance(left, right) {
|
||||
return Math.hypot(right[0] - left[0], right[1] - left[1], right[2] - left[2]);
|
||||
}
|
||||
|
||||
function nearestNode(candidates, point) {
|
||||
return candidates.reduce((best, candidate) => {
|
||||
const candidateDistance = distance(candidate.three, point);
|
||||
return !best || candidateDistance < best.distance || (candidateDistance === best.distance && candidate.id < best.node.id)
|
||||
? { node: candidate, distance: candidateDistance }
|
||||
: best;
|
||||
}, null)?.node ?? null;
|
||||
}
|
||||
|
||||
function shortestNodePath(mapNodes, mapLinks, startId, endId) {
|
||||
if (startId === endId) return [startId];
|
||||
const allowed = new Set(mapNodes.map((node) => node.id));
|
||||
const adjacency = new Map(mapNodes.map((node) => [node.id, []]));
|
||||
for (const link of mapLinks) {
|
||||
if (allowed.has(link.from) && allowed.has(link.to)) adjacency.get(link.from).push(link);
|
||||
}
|
||||
for (const values of adjacency.values()) values.sort((left, right) => left.to - right.to);
|
||||
const costs = new Map([[startId, 0]]);
|
||||
const previous = new Map();
|
||||
const pending = new Set(allowed);
|
||||
while (pending.size) {
|
||||
let current = null;
|
||||
let currentCost = Number.POSITIVE_INFINITY;
|
||||
for (const id of pending) {
|
||||
const cost = costs.get(id) ?? Number.POSITIVE_INFINITY;
|
||||
if (cost < currentCost || (cost === currentCost && (current === null || id < current))) {
|
||||
current = id;
|
||||
currentCost = cost;
|
||||
}
|
||||
}
|
||||
if (current === null || !Number.isFinite(currentCost)) break;
|
||||
pending.delete(current);
|
||||
if (current === endId) break;
|
||||
for (const edge of adjacency.get(current) ?? []) {
|
||||
if (!pending.has(edge.to)) continue;
|
||||
const nextCost = currentCost + edge.distance;
|
||||
if (nextCost < (costs.get(edge.to) ?? Number.POSITIVE_INFINITY)) {
|
||||
costs.set(edge.to, nextCost);
|
||||
previous.set(edge.to, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!previous.has(endId)) return null;
|
||||
const result = [endId];
|
||||
while (result[0] !== startId) result.unshift(previous.get(result[0]));
|
||||
return result;
|
||||
}
|
||||
|
||||
function transformByMatrix(point, matrix) {
|
||||
return [
|
||||
matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12],
|
||||
matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13],
|
||||
matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14],
|
||||
];
|
||||
}
|
||||
|
||||
async function loadNavigationGraph(dungeon) {
|
||||
const navigationUrl = dungeon.assets?.navigation?.url;
|
||||
if (!navigationUrl) throw new Error(`${dungeon.id}: missing navigation asset.`);
|
||||
const navigationFile = path.join(projectRoot, "public", navigationUrl.replace(/^\//, ""));
|
||||
const document = await gltfIo.read(navigationFile);
|
||||
const welded = new Map();
|
||||
const triangles = [];
|
||||
const edgeOwners = new Map();
|
||||
const vertexId = (point) => {
|
||||
const key = point.map((value) => Math.round(value * 1_000)).join(":");
|
||||
if (!welded.has(key)) welded.set(key, welded.size);
|
||||
return welded.get(key);
|
||||
};
|
||||
for (const node of document.getRoot().listNodes()) {
|
||||
const mesh = node.getMesh();
|
||||
if (!mesh) continue;
|
||||
const matrix = node.getWorldMatrix();
|
||||
for (const primitive of mesh.listPrimitives()) {
|
||||
const positionAccessor = primitive.getAttribute("POSITION");
|
||||
const positions = positionAccessor?.getArray();
|
||||
const sourceIndices = primitive.getIndices()?.getArray();
|
||||
if (!positions) continue;
|
||||
const indices = sourceIndices ? Array.from(sourceIndices) : Array.from({ length: positions.length / 3 }, (_, index) => index);
|
||||
for (let offset = 0; offset + 2 < indices.length; offset += 3) {
|
||||
const points = [indices[offset], indices[offset + 1], indices[offset + 2]].map((index) => transformByMatrix(
|
||||
positionAccessor.getElement(index, []),
|
||||
matrix,
|
||||
));
|
||||
const ids = points.map(vertexId);
|
||||
const id = triangles.length;
|
||||
triangles.push({
|
||||
centroid: points.reduce((sum, point) => [sum[0] + point[0] / 3, sum[1] + point[1] / 3, sum[2] + point[2] / 3], [0, 0, 0]),
|
||||
neighbors: new Set(),
|
||||
});
|
||||
for (const edge of [[ids[0], ids[1]], [ids[1], ids[2]], [ids[2], ids[0]]]) {
|
||||
const key = [...edge].sort((left, right) => left - right).join(":");
|
||||
const previous = edgeOwners.get(key);
|
||||
if (previous !== undefined) {
|
||||
triangles[id].neighbors.add(previous);
|
||||
triangles[previous].neighbors.add(id);
|
||||
} else edgeOwners.set(key, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!triangles.length) throw new Error(`${dungeon.id}: navigation asset contains no triangles.`);
|
||||
const nearest = (point) => {
|
||||
let id = -1;
|
||||
let snapDistance = Number.POSITIVE_INFINITY;
|
||||
triangles.forEach((triangle, index) => {
|
||||
const candidate = distance(point, triangle.centroid);
|
||||
if (candidate < snapDistance) {
|
||||
id = index;
|
||||
snapDistance = candidate;
|
||||
}
|
||||
});
|
||||
return { id, distance: snapDistance, point: triangles[id].centroid };
|
||||
};
|
||||
for (const link of dungeon.navigationLinks ?? []) {
|
||||
const start = nearest(link.start);
|
||||
const end = nearest(link.end);
|
||||
if (start.distance <= 5 && end.distance <= 5) {
|
||||
triangles[start.id].neighbors.add(end.id);
|
||||
if (link.bidirectional !== false) triangles[end.id].neighbors.add(start.id);
|
||||
}
|
||||
}
|
||||
return { triangles, nearest };
|
||||
}
|
||||
|
||||
class MinimumHeap {
|
||||
values = [];
|
||||
push(value) {
|
||||
this.values.push(value);
|
||||
let index = this.values.length - 1;
|
||||
while (index > 0) {
|
||||
const parent = (index - 1) >> 1;
|
||||
if (this.values[parent].cost <= value.cost) break;
|
||||
this.values[index] = this.values[parent];
|
||||
index = parent;
|
||||
}
|
||||
this.values[index] = value;
|
||||
}
|
||||
pop() {
|
||||
if (!this.values.length) return null;
|
||||
const first = this.values[0];
|
||||
const last = this.values.pop();
|
||||
if (this.values.length) {
|
||||
let index = 0;
|
||||
while (true) {
|
||||
const left = index * 2 + 1;
|
||||
const right = left + 1;
|
||||
if (left >= this.values.length) break;
|
||||
const child = right < this.values.length && this.values[right].cost < this.values[left].cost ? right : left;
|
||||
if (this.values[child].cost >= last.cost) break;
|
||||
this.values[index] = this.values[child];
|
||||
index = child;
|
||||
}
|
||||
this.values[index] = last;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
function navigationTrianglePath(graph, startId, endId) {
|
||||
if (startId === endId) return [startId];
|
||||
const costs = new Map([[startId, 0]]);
|
||||
const previous = new Map();
|
||||
const pending = new MinimumHeap();
|
||||
pending.push({ id: startId, cost: 0 });
|
||||
while (pending.values.length) {
|
||||
const current = pending.pop();
|
||||
const known = costs.get(current.id);
|
||||
if (current.cost > known + 1e-6) continue;
|
||||
if (current.id === endId) break;
|
||||
const origin = graph.triangles[current.id].centroid;
|
||||
for (const neighborId of graph.triangles[current.id].neighbors) {
|
||||
const nextCost = known + distance(origin, graph.triangles[neighborId].centroid);
|
||||
if (nextCost >= (costs.get(neighborId) ?? Number.POSITIVE_INFINITY)) continue;
|
||||
costs.set(neighborId, nextCost);
|
||||
previous.set(neighborId, current.id);
|
||||
pending.push({ id: neighborId, cost: nextCost });
|
||||
}
|
||||
}
|
||||
if (!previous.has(endId)) return null;
|
||||
const result = [endId];
|
||||
while (result[0] !== startId) result.unshift(previous.get(result[0]));
|
||||
return result;
|
||||
}
|
||||
|
||||
function segmentDistanceSquared(point, start, end) {
|
||||
const delta = [end[0] - start[0], end[1] - start[1], end[2] - start[2]];
|
||||
const lengthSquared = delta[0] ** 2 + delta[1] ** 2 + delta[2] ** 2;
|
||||
if (lengthSquared <= 1e-9) return distance(point, start) ** 2;
|
||||
const t = Math.max(0, Math.min(1, (
|
||||
(point[0] - start[0]) * delta[0]
|
||||
+ (point[1] - start[1]) * delta[1]
|
||||
+ (point[2] - start[2]) * delta[2]
|
||||
) / lengthSquared));
|
||||
return distance(point, [start[0] + delta[0] * t, start[1] + delta[1] * t, start[2] + delta[2] * t]) ** 2;
|
||||
}
|
||||
|
||||
function simplify(points, tolerance = 0.75) {
|
||||
if (points.length <= 2) return points;
|
||||
let furthest = -1;
|
||||
let furthestDistance = tolerance ** 2;
|
||||
for (let index = 1; index < points.length - 1; index += 1) {
|
||||
const candidate = segmentDistanceSquared(points[index], points[0], points[points.length - 1]);
|
||||
if (candidate > furthestDistance) {
|
||||
furthest = index;
|
||||
furthestDistance = candidate;
|
||||
}
|
||||
}
|
||||
if (furthest < 0) return [points[0], points[points.length - 1]];
|
||||
return [...simplify(points.slice(0, furthest + 1), tolerance).slice(0, -1), ...simplify(points.slice(furthest), tolerance)];
|
||||
}
|
||||
|
||||
function boundSegments(points, maximum = 7.5) {
|
||||
const result = [points[0]];
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const start = result[result.length - 1];
|
||||
const end = points[index];
|
||||
const length = distance(start, end);
|
||||
const steps = Math.max(1, Math.ceil(length / maximum));
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
const t = step / steps;
|
||||
result.push([
|
||||
start[0] + (end[0] - start[0]) * t,
|
||||
start[1] + (end[1] - start[1]) * t,
|
||||
start[2] + (end[2] - start[2]) * t,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rounded(point) {
|
||||
return point.map((coordinate) => Number(coordinate.toFixed(4)));
|
||||
}
|
||||
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function playerbotsTopologyEvidence(start, end, mapNodes, mapLinks) {
|
||||
const startNode = nearestNode(mapNodes, start);
|
||||
const endNode = nearestNode(mapNodes, end);
|
||||
if (!startNode || !endNode) return { landmarksAvailable: false, connected: false };
|
||||
return {
|
||||
landmarksAvailable: true,
|
||||
connected: Boolean(shortestNodePath(mapNodes, mapLinks, startNode.id, endNode.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function routeGeometry(graph, start, end) {
|
||||
const startSnap = graph.nearest(start);
|
||||
const endSnap = graph.nearest(end);
|
||||
const trianglePath = navigationTrianglePath(graph, startSnap.id, endSnap.id);
|
||||
const points = trianglePath
|
||||
? trianglePath.map((id) => graph.triangles[id].centroid)
|
||||
: [startSnap.point, endSnap.point];
|
||||
const checkpoints = boundSegments(simplify(points, 0.35)).map(rounded);
|
||||
return {
|
||||
checkpoints,
|
||||
connected: Boolean(trianglePath),
|
||||
startSnapDistance: startSnap.distance,
|
||||
endSnapDistance: endSnap.distance,
|
||||
};
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||
const stock = campaign.definitions.filter((dungeon) => String(dungeon.provenance?.databaseAdapter ?? "").startsWith("azerothcore-"));
|
||||
const routes = [];
|
||||
const routeAudits = [];
|
||||
for (const dungeon of stock) {
|
||||
const mapNodes = nodes.filter((node) => node.mapId === dungeon.mapId).map((node) => ({
|
||||
...node,
|
||||
three: transformPoint(dungeon, node.wow),
|
||||
}));
|
||||
const mapLinks = links.filter((link) => nodesById.get(link.from)?.mapId === dungeon.mapId && nodesById.get(link.to)?.mapId === dungeon.mapId);
|
||||
const navigationGraph = await loadNavigationGraph(dungeon);
|
||||
let start = dungeon.entrance.footPosition;
|
||||
const legAudits = [];
|
||||
const legs = dungeon.objectiveOrder.map((objective, index) => {
|
||||
const topology = playerbotsTopologyEvidence(start, objective.position, mapNodes, mapLinks);
|
||||
const geometry = routeGeometry(navigationGraph, start, objective.position);
|
||||
const { checkpoints } = geometry;
|
||||
if (!checkpoints.length) throw new Error(`${dungeon.id}: empty navigation leg to ${objective.id}.`);
|
||||
const expectedDistance = checkpoints.slice(1).reduce((total, point, pointIndex) => total + distance(checkpoints[pointIndex], point), 0);
|
||||
legAudits.push({
|
||||
objectiveId: objective.id,
|
||||
playerbotsLandmarksAvailable: topology.landmarksAvailable,
|
||||
playerbotsTopologyConnected: topology.connected,
|
||||
navigationStartSnapDistance: Number(geometry.startSnapDistance.toFixed(3)),
|
||||
navigationEndSnapDistance: Number(geometry.endSnapDistance.toFixed(3)),
|
||||
checkpointCount: checkpoints.length,
|
||||
valid: geometry.connected && geometry.startSnapDistance <= 15 && geometry.endSnapDistance <= 15,
|
||||
issues: [
|
||||
...(!geometry.connected ? ["disconnected-navigation-leg"] : []),
|
||||
...(geometry.startSnapDistance > 15 ? ["origin-snap-distance-exceeded"] : []),
|
||||
...(geometry.endSnapDistance > 15 ? ["endpoint-snap-distance-exceeded"] : []),
|
||||
],
|
||||
});
|
||||
start = objective.position;
|
||||
return {
|
||||
id: `leg-${String(index + 1).padStart(2, "0")}-${objective.id}`,
|
||||
objectiveId: objective.id,
|
||||
objectiveName: objective.name,
|
||||
checkpoints,
|
||||
expectedDistance: Number(expectedDistance.toFixed(2)),
|
||||
corridorPlanarDistance: 10,
|
||||
corridorVerticalDistance: 4,
|
||||
actions: [],
|
||||
};
|
||||
});
|
||||
const unhashed = { schemaVersion: 1, id: "primary", dungeonId: dungeon.id, mapId: dungeon.mapId, legs };
|
||||
const valid = legAudits.every((leg) => leg.valid);
|
||||
routes.push({
|
||||
...unhashed,
|
||||
hash: createHash("sha256").update(canonical(unhashed)).digest("hex"),
|
||||
generationStatus: valid ? "Candidate" : "Rejected",
|
||||
generationIssues: [...new Set(legAudits.flatMap((leg) => leg.issues))],
|
||||
});
|
||||
routeAudits.push({
|
||||
dungeonId: dungeon.id,
|
||||
mapId: dungeon.mapId,
|
||||
navigationTriangles: navigationGraph.triangles.length,
|
||||
valid,
|
||||
disposition: valid ? "candidate" : "rejected-needs-work",
|
||||
legs: legAudits,
|
||||
});
|
||||
}
|
||||
|
||||
if (routes.length !== 51) throw new Error(`Expected 51 stock dungeon routes, generated ${routes.length}.`);
|
||||
const safePlayerbotsRoot = playerbotsRoot.replaceAll("\\", "/");
|
||||
const playerbotsGit = (...arguments_) => execFileSync(
|
||||
"git",
|
||||
["-c", `safe.directory=${safePlayerbotsRoot}`, "-C", playerbotsRoot, ...arguments_],
|
||||
{ encoding: "utf8" },
|
||||
).trim();
|
||||
const playerbotsCommit = playerbotsGit("rev-parse", "HEAD");
|
||||
const generatedAt = playerbotsGit("show", "-s", "--format=%cI", "HEAD");
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
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,
|
||||
};
|
||||
writeFileSync(outputPath, `${JSON.stringify(output)}\n`);
|
||||
writeFileSync(auditPath, `${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
playerbots: {
|
||||
repository: "azerothcore/mod-playerbots",
|
||||
commit: playerbotsCommit,
|
||||
attribution: "AzerothCore PlayerBots travel data was consulted offline as landmark and connectivity evidence; no source code, node IDs, raw node positions, or dense path rows are shipped.",
|
||||
},
|
||||
policy: {
|
||||
anchors: "HealerMan entrance and required objective positions",
|
||||
geometry: "HealerMan Recast navigation GLBs",
|
||||
maximumCheckpointSegment: 7.5,
|
||||
},
|
||||
routes: routeAudits,
|
||||
}, null, 2)}\n`);
|
||||
console.log(`Generated ${routes.length} candidate routes at ${path.relative(projectRoot, outputPath)}.`);
|
||||
@@ -0,0 +1,129 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const directory = path.resolve(process.env.BALANCE_LAB_PLAYTEST_DIR || 'playtest-artifacts/balance-lab');
|
||||
const port = Number(process.env.BALANCE_LAB_PLAYTEST_PORT || 4191);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui-'));
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({
|
||||
dataDir: runtimeData,
|
||||
enableShelvedModes: false,
|
||||
pythonExecutable: process.env.BALANCE_LAB_PYTHON,
|
||||
});
|
||||
await new Promise((resolve) => runtime.server.listen(port, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(90_000);
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
const capture = async (name) => {
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
|
||||
console.log(`Captured ${name}`);
|
||||
};
|
||||
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByLabel('Username').fill('Phenom');
|
||||
await page.getByLabel('Password').fill('balance-lab-test');
|
||||
await page.getByRole('button', { name: /Create account/ }).click();
|
||||
await page.locator('.character-select-screen').waitFor();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('Balancer');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue/ }).first().click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
const gmTools = page.getByRole('button', { name: /GM Tools/ });
|
||||
const balanceLab = page.getByRole('button', { name: /Balance Lab/ });
|
||||
await gmTools.waitFor();
|
||||
await balanceLab.waitFor();
|
||||
const gmBox = await gmTools.boundingBox();
|
||||
const labBox = await balanceLab.boundingBox();
|
||||
assert(gmBox && labBox && labBox.y > gmBox.y, 'Balance Lab is placed underneath GM Tools');
|
||||
await capture('01-gm-menu');
|
||||
|
||||
await balanceLab.click();
|
||||
await page.locator('.balance-lab-screen').waitFor();
|
||||
await page.getByLabel('Party source').selectOption('wow');
|
||||
await page.getByLabel('Level').fill('20');
|
||||
await page.getByLabel('Parties per source').fill('1');
|
||||
await page.getByLabel('Runs per party/boss').fill('1');
|
||||
await page.getByLabel('Fight limit (seconds)').fill('5');
|
||||
await page.getByLabel('Bosses').selectOption('specific');
|
||||
await page.getByLabel('Search Balance Lab bosses').fill('Eck the Ferocious');
|
||||
await page.getByRole('checkbox', { name: /Eck the Ferocious/ }).check();
|
||||
const totalSimulations = page.locator('.balance-lab-estimate span').filter({ hasText: 'Total simulations' }).locator('b');
|
||||
await totalSimulations.waitFor();
|
||||
assert.equal(await totalSimulations.innerText(), '1');
|
||||
await capture('02-server-job-config');
|
||||
|
||||
await page.getByRole('button', { name: 'Run on server' }).click();
|
||||
await page.locator('.balance-lab-job-list em.is-complete').waitFor({ timeout: 240_000 });
|
||||
await page.getByRole('button', { name: 'Open full report' }).waitFor();
|
||||
assert.equal(await page.locator('.balance-lab-artifacts > button').count(), 12);
|
||||
await capture('03-server-job-complete');
|
||||
|
||||
await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
|
||||
await page.locator('.balance-results').waitFor();
|
||||
assert.equal(await page.locator('.balance-results-chart').count(), 5);
|
||||
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
|
||||
await capture('04-run-analytics');
|
||||
|
||||
await page.getByRole('button', { name: 'bosses', exact: true }).click();
|
||||
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
|
||||
assert.match(await page.locator('.balance-results-table-panel tbody').innerText(), /Eck the Ferocious/);
|
||||
await page.getByRole('button', { name: 'Class / spec', exact: true }).click();
|
||||
const allClassRows = await page.locator('.balance-results-table-panel tbody tr').count();
|
||||
assert(allClassRows >= 5, 'the complete five-member party should be present in the class/spec table');
|
||||
await page.getByLabel('Sort class specs').selectOption('totalDamageTaken');
|
||||
assert.equal(await page.getByLabel('Sort class specs').inputValue(), 'totalDamageTaken');
|
||||
await page.getByLabel('Class role').selectOption('damage');
|
||||
const damageRows = await page.locator('.balance-results-table-panel tbody tr').count();
|
||||
assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table');
|
||||
await page.getByLabel('Class role').selectOption('all');
|
||||
await page.getByRole('button', { name: 'abilities', exact: true }).click();
|
||||
const abilityRows = page.locator('.balance-results-abilities tbody tr');
|
||||
assert(await abilityRows.count() > 0, 'ability breakdown should retain observed and available abilities');
|
||||
await page.getByLabel('Filter ability usage').selectOption('unused');
|
||||
assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible');
|
||||
await page.getByLabel('Filter ability usage').selectOption('all');
|
||||
await page.getByLabel('Sort abilities').selectOption('effectiveDamage');
|
||||
await capture('04b-ability-breakdown');
|
||||
await page.getByRole('button', { name: 'parties', exact: true }).click();
|
||||
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
|
||||
await page.getByLabel('Search Balance Lab run history').fill('does-not-match');
|
||||
await page.getByText('No saved runs match these filters.').waitFor();
|
||||
await page.getByLabel('Search Balance Lab run history').fill('wow');
|
||||
assert.equal(await page.locator('.balance-lab-job-list > button').count(), 1);
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
await page.waitForTimeout(250);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
|
||||
await page.locator('#balance-lab-analysis').scrollIntoViewIfNeeded();
|
||||
await page.getByRole('button', { name: 'overview', exact: true }).click();
|
||||
await page.getByRole('heading', { name: 'Boss damage to party' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Main menu' }).waitFor();
|
||||
await capture('05-mobile-analytics');
|
||||
await page.getByRole('button', { name: 'abilities', exact: true }).click();
|
||||
await page.getByText('Every class/spec ability').scrollIntoViewIfNeeded();
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
|
||||
await capture('06-mobile-abilities');
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log('Balance Lab GM playtest passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
fs.rmSync(runtimeData, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -118,6 +118,25 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
||||
const reports = runtime.database.listDungeonReports().reports;
|
||||
assert.equal(reports[0].bossKey, before.boss.key);
|
||||
assert.equal(runtime.database.listBossTestingReviews().reviews[0].status, 'ready');
|
||||
await page.evaluate(async (previousKey) => {
|
||||
window.previousBossTestingKey = previousKey;
|
||||
window.liveBossTestingStores = await window.bossTestingStores();
|
||||
}, before.boss.key);
|
||||
await page.getByRole('button', { name: 'Fight another random boss', exact: true }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const { useGameStore, useCombatStore } = window.liveBossTestingStores;
|
||||
const game = useGameStore.getState();
|
||||
return game.assetStatus === 'ready' && game.bossTest?.key !== window.previousBossTestingKey
|
||||
&& game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
|
||||
}, null, { timeout: 120000 });
|
||||
const nextBoss = await page.evaluate(() => {
|
||||
const { useGameStore } = window.liveBossTestingStores;
|
||||
return useGameStore.getState().bossTest;
|
||||
});
|
||||
assert.notEqual(nextBoss.key, before.boss.key, 'Post-fight random selection must choose another boss');
|
||||
await page.waitForTimeout(1000);
|
||||
await capture('06b-another-random-boss');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Return to main menu', exact: true }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||
@@ -168,7 +187,8 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
||||
assert.deepEqual(errors, [], 'No browser runtime errors');
|
||||
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, classId: before.classId,
|
||||
characterLevel: before.level, bossLevel: before.bossLevel, bossMaxHealth: before.bossMaxHealth,
|
||||
initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
|
||||
initialMobCount: before.count, nextRandomBoss: nextBoss, xpUnchanged: true, profileUnchanged: true,
|
||||
automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
|
||||
console.log('Boss Testing live flow passed');
|
||||
} catch (error) {
|
||||
await capture('failure');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const directory = path.resolve('playtest-artifacts', 'dungeon-simulation');
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const databaseDirectory = path.join(directory, 'test-database');
|
||||
fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({
|
||||
dataDir: databaseDirectory,
|
||||
enableShelvedModes: false,
|
||||
});
|
||||
await new Promise((resolve) => runtime.server.listen(4189, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(90000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
|
||||
|
||||
try {
|
||||
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
|
||||
await page.getByLabel('Username').fill('Phenom');
|
||||
await page.getByLabel('Password').fill('dungeon-route-admin');
|
||||
await page.getByRole('button', { name: /Create account/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('Routewatcher');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue/ }).first().click();
|
||||
await page.getByRole('button', { name: /GM Tools/ }).click();
|
||||
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
|
||||
await capture('01-gm-route-launch');
|
||||
|
||||
const routeSelects = page.locator('.dungeon-sim-admin__catalog select');
|
||||
await routeSelects.nth(0).selectOption('wailing-caverns');
|
||||
await routeSelects.nth(1).selectOption('navigation-only');
|
||||
await page.locator('.dungeon-sim-admin__catalog input').fill('browser-smoke-fixed-seed');
|
||||
await page.getByRole('button', { name: 'Launch spectate mode' }).click();
|
||||
await page.locator('canvas').waitFor();
|
||||
await page.getByLabel('Dungeon simulation controls').waitFor();
|
||||
await page.waitForTimeout(6000);
|
||||
await capture('02-live-navigation-simulation');
|
||||
|
||||
await page.getByRole('button', { name: 'Pause' }).click();
|
||||
const pausedTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
|
||||
await page.waitForTimeout(700);
|
||||
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'pause freezes simulation time');
|
||||
await page.getByRole('button', { name: 'Step' }).click();
|
||||
await page.getByRole('button', { name: 'Step' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time');
|
||||
await page.getByRole('button', { name: 'Follow camera' }).click();
|
||||
await page.getByRole('button', { name: 'Free-fly camera' }).waitFor();
|
||||
await capture('03-paused-free-fly');
|
||||
|
||||
await page.getByRole('button', { name: '4×' }).click();
|
||||
await page.getByRole('button', { name: 'Play' }).click();
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: 'Stop & save' }).click();
|
||||
await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor();
|
||||
await capture('04-finished-report');
|
||||
await page.getByRole('button', { name: 'Return to GM Tools' }).click();
|
||||
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
|
||||
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns');
|
||||
await page.getByText(/1 runs for this route/).waitFor();
|
||||
await capture('05-saved-replay');
|
||||
|
||||
await page.getByRole('button', { name: 'Watch' }).first().click();
|
||||
await page.getByText('Recorded replay').waitFor();
|
||||
await page.getByLabel('Replay position').waitFor();
|
||||
await page.waitForTimeout(6000);
|
||||
await page.getByRole('button', { name: 'Play' }).click();
|
||||
await page.waitForTimeout(800);
|
||||
await capture('06-replay-controls');
|
||||
await page.getByRole('button', { name: 'Pause' }).click();
|
||||
const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedReplayTime, 'replay pause freezes playback time');
|
||||
await page.getByRole('button', { name: '4×' }).click();
|
||||
await page.getByRole('button', { name: 'Play' }).click();
|
||||
await page.waitForTimeout(600);
|
||||
await capture('07-replay-4x');
|
||||
|
||||
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
|
||||
console.log('Dungeon simulation browser smoke test passed.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright");
|
||||
|
||||
const base = process.env.MOUSE_CAMERA_TEST_URL || "http://127.0.0.1:5176";
|
||||
const directory = path.resolve("playtest-artifacts/mouse-camera");
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: "msedge", headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.setDefaultTimeout(120_000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
|
||||
const gameState = () => page.evaluate(async () => {
|
||||
const storeEntry = performance.getEntriesByType("resource")
|
||||
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
|
||||
if (!storeEntry) throw new Error("game store module was not loaded");
|
||||
const { useGameStore } = await import(storeEntry.name);
|
||||
const state = useGameStore.getState();
|
||||
return {
|
||||
cameraLookActive: state.cameraLookActive,
|
||||
cameraYaw: state.cameraYaw,
|
||||
pointerLocked: document.pointerLockElement !== null,
|
||||
selection: window.getSelection()?.toString() ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(base, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: /Play offline/ }).click();
|
||||
await page.getByRole("button", { name: /Create character/ }).click();
|
||||
await page.getByRole("button", { name: /Customize & name/ }).click();
|
||||
await page.getByLabel("Character name").fill("Mouselooker");
|
||||
await page.getByRole("button", { name: /Create character/ }).click();
|
||||
await page.getByRole("button", { name: /Continue|Enter world/ }).first().click();
|
||||
await page.locator(".main-menu-screen").waitFor();
|
||||
await page.getByRole("button", { name: /^Dungeons/ }).click();
|
||||
await page.getByRole("button", { name: /Choose role/ }).click();
|
||||
await page.locator(".dungeon-role-option").first().click();
|
||||
await page.locator(".game-shell").waitFor();
|
||||
await page.waitForFunction(async () => {
|
||||
const storeEntry = performance.getEntriesByType("resource")
|
||||
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
|
||||
if (!storeEntry) return false;
|
||||
const { useGameStore } = await import(storeEntry.name);
|
||||
return ["ready", "proxy", "error"].includes(useGameStore.getState().assetStatus);
|
||||
});
|
||||
await page.locator(".loading-screen").waitFor({ state: "hidden" });
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const selectionBefore = await page.evaluate(() => {
|
||||
const fixture = document.createElement("p");
|
||||
fixture.textContent = "Existing browser selection";
|
||||
fixture.style.userSelect = "text";
|
||||
fixture.style.position = "fixed";
|
||||
fixture.style.left = "-10000px";
|
||||
document.body.append(fixture);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(fixture);
|
||||
const selection = window.getSelection();
|
||||
if (!selection) throw new Error("browser selection API is unavailable");
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return selection.toString();
|
||||
});
|
||||
assert.equal(selectionBefore, "Existing browser selection");
|
||||
|
||||
const scene = page.locator(".scene-shell");
|
||||
const bounds = await scene.boundingBox();
|
||||
assert(bounds, "3D scene is visible");
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 };
|
||||
const before = await gameState();
|
||||
assert.equal(before.cameraLookActive, false);
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down({ button: "right" });
|
||||
await page.waitForFunction(async () => {
|
||||
const storeEntry = performance.getEntriesByType("resource")
|
||||
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
|
||||
if (!storeEntry) return false;
|
||||
const { useGameStore } = await import(storeEntry.name);
|
||||
return useGameStore.getState().cameraLookActive;
|
||||
});
|
||||
const captured = await gameState();
|
||||
assert.equal(captured.pointerLocked, false);
|
||||
assert.equal(captured.selection, "");
|
||||
|
||||
await cdp.send("Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: start.x + 160,
|
||||
y: start.y - 55,
|
||||
button: "right",
|
||||
buttons: 2,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
await page.waitForTimeout(350);
|
||||
const moved = await gameState();
|
||||
assert.notEqual(moved.cameraYaw, before.cameraYaw);
|
||||
await page.screenshot({ path: path.join(directory, "right-drag-camera.png") });
|
||||
|
||||
await page.mouse.up({ button: "right" });
|
||||
await page.waitForFunction(async () => {
|
||||
const storeEntry = performance.getEntriesByType("resource")
|
||||
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
|
||||
if (!storeEntry) return false;
|
||||
const { useGameStore } = await import(storeEntry.name);
|
||||
return !useGameStore.getState().cameraLookActive;
|
||||
});
|
||||
const released = await gameState();
|
||||
await page.mouse.move(start.x - 120, start.y + 70, { steps: 8 });
|
||||
await page.waitForTimeout(350);
|
||||
const afterReleaseMove = await gameState();
|
||||
assert.equal(afterReleaseMove.cameraYaw, released.cameraYaw);
|
||||
assert.equal(afterReleaseMove.pointerLocked, false);
|
||||
assert.equal(
|
||||
await page.locator(".game-shell").evaluate((element) => getComputedStyle(element).userSelect),
|
||||
"none",
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
const result = {
|
||||
passed: true,
|
||||
yawBefore: before.cameraYaw,
|
||||
yawDuringDrag: moved.cameraYaw,
|
||||
yawAfterRelease: afterReleaseMove.cameraYaw,
|
||||
pointerLocked: afterReleaseMove.pointerLocked,
|
||||
selectionCleared: captured.selection === "",
|
||||
errors,
|
||||
};
|
||||
fs.writeFileSync(path.join(directory, "results.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
console.log("Mouse camera playtest passed");
|
||||
} catch (error) {
|
||||
await page.mouse.up({ button: "right" }).catch(() => {});
|
||||
await page.screenshot({ path: path.join(directory, "failure.png") });
|
||||
console.log((await page.locator("body").innerText()).slice(-2_000));
|
||||
console.log("Browser errors:", errors);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import unittest
|
||||
|
||||
from scripts.balance_lab import (
|
||||
aggregate_class_abilities,
|
||||
aggregate_class_specs,
|
||||
automatic_parties,
|
||||
random_party,
|
||||
search_bosses,
|
||||
tuning_label,
|
||||
)
|
||||
|
||||
|
||||
class BalanceLabControllerTests(unittest.TestCase):
|
||||
def test_random_party_is_repeatable_and_complete(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "role": "tank"},
|
||||
{"id": "tank-b", "role": "tank"},
|
||||
{"id": "healer-a", "role": "healer"},
|
||||
{"id": "healer-b", "role": "healer"},
|
||||
*({"id": f"damage-{index}", "role": "damage"} for index in range(6)),
|
||||
]
|
||||
first = random_party(builds, 17)
|
||||
second = random_party(builds, 17)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual([member["role"] for member in first], [
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
self.assertEqual(len({member["id"] for member in first[2:]}), 3)
|
||||
|
||||
def test_tuning_flags_keep_both_sides_of_the_median(self) -> None:
|
||||
self.assertEqual(tuning_label(1.51), "very high")
|
||||
self.assertEqual(tuning_label(1.25), "high")
|
||||
self.assertEqual(tuning_label(1.0), "baseline")
|
||||
self.assertEqual(tuning_label(0.75), "low")
|
||||
self.assertEqual(tuning_label(0.49), "very low")
|
||||
self.assertEqual(tuning_label(None), "no baseline")
|
||||
|
||||
def test_automatic_parties_are_unique_repeatable_and_source_isolated(self) -> None:
|
||||
builds = []
|
||||
for source in ("wow", "coa"):
|
||||
builds.extend([
|
||||
{"id": f"{source}-tank-a", "source": source, "role": "tank"},
|
||||
{"id": f"{source}-tank-b", "source": source, "role": "tank"},
|
||||
{"id": f"{source}-healer-a", "source": source, "role": "healer"},
|
||||
{"id": f"{source}-healer-b", "source": source, "role": "healer"},
|
||||
*({"id": f"{source}-damage-{index}", "source": source, "role": "damage"}
|
||||
for index in range(6)),
|
||||
])
|
||||
first = automatic_parties(builds, ("wow", "coa"), 4, 29)
|
||||
second = automatic_parties(builds, ("wow", "coa"), 4, 29)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first), 8)
|
||||
self.assertEqual(len({configuration["id"] for configuration in first}), 8)
|
||||
for configuration in first:
|
||||
self.assertTrue(all(member["source"] == configuration["source"] for member in configuration["party"]))
|
||||
self.assertEqual([member["role"] for member in configuration["party"]], [
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
|
||||
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
|
||||
bosses = [
|
||||
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
|
||||
{"key": "abbey/duke", "name": "Duke", "dungeonTitle": "Forsaken Abbey", "category": "rom"},
|
||||
]
|
||||
self.assertEqual([boss["key"] for boss in search_bosses("ferocious", bosses)], ["gundrak/eck"])
|
||||
self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"])
|
||||
self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"])
|
||||
|
||||
def test_class_spec_aggregate_keeps_rate_and_absolute_damage_taken(self) -> None:
|
||||
key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer")
|
||||
rows = aggregate_class_specs({key: [
|
||||
{
|
||||
"partyConfigurationId": "wow-party-001", "elapsedSeconds": 10,
|
||||
"damage": 200, "bossDamage": 180, "healing": 500,
|
||||
"damageTaken": 120, "absorbs": 25, "deathEvents": 0,
|
||||
},
|
||||
{
|
||||
"partyConfigurationId": "wow-party-002", "elapsedSeconds": 20,
|
||||
"damage": 400, "bossDamage": 360, "healing": 900,
|
||||
"damageTaken": 330, "absorbs": 50, "deathEvents": 1,
|
||||
},
|
||||
]})
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["dtps"], 15)
|
||||
self.assertEqual(rows[0]["totalDamageTaken"], 450)
|
||||
|
||||
def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None:
|
||||
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
|
||||
class_runs = [
|
||||
{"partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10, "damage": 100},
|
||||
{"partyConfigurationId": "party-2", "bossKey": "boss-2", "elapsedSeconds": 10, "damage": 100},
|
||||
]
|
||||
base = {
|
||||
"abilityName": "Fireball", "icon": "/fireball.png", "categories": ["damage"],
|
||||
"cooldownSeconds": 0, "available": True, "casts": 0, "hits": 0,
|
||||
"rawDamage": 0, "effectiveDamage": 0, "rawHealing": 0,
|
||||
"effectiveHealing": 0, "overheal": 0, "absorbs": 0,
|
||||
}
|
||||
groups = {
|
||||
(*class_key, "mage-fireball"): [
|
||||
{**base, "partyConfigurationId": "party-1", "bossKey": "boss-1", "elapsedSeconds": 10,
|
||||
"used": True, "casts": 1, "hits": 1, "rawDamage": 120, "effectiveDamage": 100},
|
||||
{**base, "partyConfigurationId": "party-2", "bossKey": "boss-2", "elapsedSeconds": 10,
|
||||
"used": False},
|
||||
],
|
||||
(*class_key, "mage-unused-blast"): [
|
||||
{**base, "abilityName": "Unused Blast", "partyConfigurationId": "party-1",
|
||||
"bossKey": "boss-1", "elapsedSeconds": 10, "used": False},
|
||||
{**base, "abilityName": "Unused Blast", "partyConfigurationId": "party-2",
|
||||
"bossKey": "boss-2", "elapsedSeconds": 10, "used": False},
|
||||
],
|
||||
}
|
||||
rows = aggregate_class_abilities(groups, {class_key: class_runs})
|
||||
by_id = {row["abilityId"]: row for row in rows}
|
||||
self.assertEqual(by_id["mage-fireball"]["activeRuns"], 1)
|
||||
self.assertEqual(by_id["mage-fireball"]["usageRate"], 0.5)
|
||||
self.assertEqual(by_id["mage-fireball"]["damageShare"], 0.5)
|
||||
self.assertTrue(by_id["mage-unused-blast"]["unusedAvailable"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user