Release Healer Man 0.1.49
This commit is contained in:
1 parent
39425f2494
commit
612170afd0
91 files changed
+2278
-263
No files matched your search
@@ -5,7 +5,7 @@ import { registerCombatSpatialProvider } from "../../src/game/combatSpatial";
|
||||
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
|
||||
import { targetingRulesForAbility } from "../../src/game/abilityRules";
|
||||
import { resourceProfileForClass, abilityAtLevel, isAbilityUnlocked } from "../../src/game/abilityCatalog";
|
||||
import { resourceProfileForClass, learnedAbilityForBuild } from "../../src/game/abilityCatalog";
|
||||
import { deriveCharacterStats } from "../../src/game/wotlkStats";
|
||||
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
|
||||
import { arenaDampening } from "../../server/arena-rules.mjs";
|
||||
@@ -36,9 +36,10 @@ export function initializeArenaCharacter(actor: any, now: number) {
|
||||
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
|
||||
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
|
||||
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
|
||||
const abilities = state.abilities.filter((ability: any) => !ability.passive && isAbilityUnlocked(ability, state.level)
|
||||
&& (ability.talentEntryId === undefined || (state.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
|
||||
.map((ability: any) => { const resolved = abilityAtLevel(ability, state.level, ability.talentEntryId === undefined ? undefined : state.talentRanks[`coa-entry-${ability.talentEntryId}`]); return { ...resolved, targeting: targetingRulesForAbility(resolved) }; });
|
||||
const abilities = state.abilities.filter((ability: any) => !ability.passive)
|
||||
.map((ability: any) => learnedAbilityForBuild(ability, state.level, state.talentRanks))
|
||||
.filter((ability: any) => ability !== null)
|
||||
.map((ability: any) => ({ ...ability, targeting: targetingRulesForAbility(ability) }));
|
||||
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
|
||||
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
|
||||
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
|
||||
|
||||
@@ -9,9 +9,8 @@ import {
|
||||
} from "../../src/app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../src/app/contentCategories";
|
||||
import {
|
||||
abilitiesForClass,
|
||||
abilityAtLevel,
|
||||
isAbilityUnlocked,
|
||||
abilitiesForCharacter,
|
||||
learnedAbilityForBuild,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
} from "../../src/game/abilityCatalog";
|
||||
import { aiHintsForAbility } from "../../src/game/abilityRules";
|
||||
@@ -44,7 +43,8 @@ import {
|
||||
type PartyMember,
|
||||
} from "../../src/game/partyStore";
|
||||
import { specializationsForClass } from "../../src/game/specializationCatalog";
|
||||
import { BenchmarkController } from "../../src/game/benchmark/benchmarkController";
|
||||
import { BENCHMARK_POLICY_ID, BenchmarkController } from "../../src/game/benchmark/benchmarkController";
|
||||
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
|
||||
import {
|
||||
loadSelectableBenchmarkBuilds,
|
||||
selectableBenchmarkBuildCatalog,
|
||||
@@ -157,6 +157,8 @@ function catalogPayload(source: PartyCatalogSource, level: number) {
|
||||
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||
level: builds[0]?.level ?? level,
|
||||
source,
|
||||
builds: builds.map((build) => ({
|
||||
@@ -243,14 +245,11 @@ function currentHealth(actorId: string): readonly [number, number] {
|
||||
}
|
||||
|
||||
function availableAbilities(build: BenchmarkBuildDefinition) {
|
||||
return abilitiesForClass(build.classId)
|
||||
.filter((ability) => !ability.passive && isAbilityUnlocked(ability, build.level))
|
||||
return abilitiesForCharacter(build.classId, build.secondaryClassId)
|
||||
.filter((ability) => !ability.passive)
|
||||
.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);
|
||||
const ranked = learnedAbilityForBuild(ability, build.level, build.talentRanks);
|
||||
if (!ranked) return null;
|
||||
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
|
||||
return {
|
||||
abilityId: ranked.id,
|
||||
@@ -573,6 +572,8 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
engine: "live-typescript-runtime",
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||
config: {
|
||||
...config,
|
||||
level: builds[0]?.level ?? config.level,
|
||||
|
||||
+27
-5
@@ -443,6 +443,8 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": raw["generatedAt"],
|
||||
"engine": raw["engine"],
|
||||
"policyId": raw["policyId"],
|
||||
"rotationVersion": raw["rotationVersion"],
|
||||
"config": raw["config"],
|
||||
"party": raw["party"],
|
||||
"summary": {
|
||||
@@ -508,6 +510,8 @@ def markdown_report(report: dict[str, Any]) -> str:
|
||||
"",
|
||||
f"Generated: {report['generatedAt']}",
|
||||
"",
|
||||
f"Policy: `{report['policyId']}` · rotation: `{report['rotationVersion']}`",
|
||||
"",
|
||||
f"Party source: **{config['source']}** · level **{config['level']}** · "
|
||||
f"{report['summary']['bosses']} bosses · {report['summary']['runs']} simulations",
|
||||
"",
|
||||
@@ -614,7 +618,7 @@ input {{ width:min(420px,90%); padding:9px 12px; margin:0 0 12px; color:inherit;
|
||||
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
|
||||
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
|
||||
</style></head><body><h1>Healer Man Balance Lab</h1>
|
||||
<p class="summary">{html.escape(report['generatedAt'])} · level {report['config']['level']} {html.escape(report['config']['source'])} party · {report['summary']['bosses']} bosses · {report['summary']['runs']} simulations. Click a heading to sort; filter boxes keep every row accessible.</p>
|
||||
<p class="summary">{html.escape(report['generatedAt'])} · policy {html.escape(report['policyId'])} · rotation {html.escape(report['rotationVersion'])} · level {report['config']['level']} {html.escape(report['config']['source'])} party · {report['summary']['bosses']} bosses · {report['summary']['runs']} simulations. Click a heading to sort; filter boxes keep every row accessible.</p>
|
||||
{member_table}{boss_table}
|
||||
<script>
|
||||
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
|
||||
@@ -642,9 +646,13 @@ def create_reports(raw_path: Path, output_dir: Path) -> dict[str, Any]:
|
||||
"bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role",
|
||||
"dps", "bossDps", "hps", "dtps", "deathEvents",
|
||||
))
|
||||
run_rows = [{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}} for run in raw["runs"]]
|
||||
run_rows = [{
|
||||
"policyId": raw["policyId"],
|
||||
"rotationVersion": raw["rotationVersion"],
|
||||
**{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}},
|
||||
} for run in raw["runs"]]
|
||||
write_csv(output_dir / "runs.csv", run_rows, (
|
||||
"bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome",
|
||||
"policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome",
|
||||
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
||||
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
||||
"incomingDtps", "deaths", "deathEvents", "bossAttacks",
|
||||
@@ -787,6 +795,8 @@ def suite_markdown(report: dict[str, Any]) -> str:
|
||||
"",
|
||||
f"Generated: {report['generatedAt']}",
|
||||
"",
|
||||
f"Policy: `{report['policyId']}` · rotation: `{report['rotationVersion']}`",
|
||||
"",
|
||||
f"**{summary['partyConfigurations']} party configurations**, **{summary['bosses']} bosses**, "
|
||||
f"and **{summary['runs']} simulations** at level **{report['level']}**.",
|
||||
"",
|
||||
@@ -938,7 +948,7 @@ input {{ width:min(420px,90%); padding:9px 12px; margin:0 0 12px; color:inherit;
|
||||
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
|
||||
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
|
||||
</style></head><body><h1>Healer Man Automatic Balance Suite</h1>
|
||||
<p class="summary">{html.escape(report['generatedAt'])} · level {report['level']} · {summary['partyConfigurations']} parties · {summary['bosses']} bosses · {summary['runs']} simulations. Every row is retained; click headings to sort or filter each table.</p>
|
||||
<p class="summary">{html.escape(report['generatedAt'])} · policy {html.escape(report['policyId'])} · rotation {html.escape(report['rotationVersion'])} · level {report['level']} · {summary['partyConfigurations']} parties · {summary['bosses']} bosses · {summary['runs']} simulations. Every row is retained; click headings to sort or filter each table.</p>
|
||||
{class_table}{ability_table}{party_table}{boss_table}
|
||||
<script>
|
||||
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
|
||||
@@ -953,13 +963,19 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
class_boss_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
policy_ids: set[str] = set()
|
||||
rotation_versions: set[str] = set()
|
||||
for entry in entries:
|
||||
raw = json.loads(Path(entry["rawPath"]).read_text(encoding="utf-8"))
|
||||
policy_ids.add(raw["policyId"])
|
||||
rotation_versions.add(raw["rotationVersion"])
|
||||
report = entry["report"]
|
||||
runs = raw["runs"]
|
||||
compact_runs.extend({
|
||||
"partyConfigurationId": entry["id"],
|
||||
"partySource": entry["source"],
|
||||
"policyId": raw["policyId"],
|
||||
"rotationVersion": raw["rotationVersion"],
|
||||
**{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}},
|
||||
} for run in runs)
|
||||
defeated = [run for run in runs if run["outcome"] == "defeated"]
|
||||
@@ -1057,10 +1073,14 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
))
|
||||
party_rows.sort(key=lambda row: (row["source"], row["partyConfigurationId"]))
|
||||
boss_by_party.sort(key=lambda row: (row["partyConfigurationId"], -row["incomingDtps"], row["bossName"]))
|
||||
if len(policy_ids) != 1 or len(rotation_versions) != 1:
|
||||
raise RuntimeError("Balance Lab suite mixed combat policy or rotation versions")
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": datetime.now().astimezone().isoformat(),
|
||||
"engine": "live-typescript-runtime",
|
||||
"policyId": next(iter(policy_ids)),
|
||||
"rotationVersion": next(iter(rotation_versions)),
|
||||
"level": level,
|
||||
"summary": {
|
||||
"partyConfigurations": len(entries),
|
||||
@@ -1113,7 +1133,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
|
||||
"totalDamageTaken", "deathEvents",
|
||||
))
|
||||
write_csv(output_dir / "suite_runs.csv", compact_runs, (
|
||||
"partyConfigurationId", "partySource", "bossKey", "bossName", "bossCategory", "dungeonId",
|
||||
"partyConfigurationId", "partySource", "policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId",
|
||||
"dungeonTitle", "seed", "outcome",
|
||||
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
|
||||
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
|
||||
@@ -1223,6 +1243,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if automatic:
|
||||
write_json(output_dir / "suite-config.json", {
|
||||
"schemaVersion": 1,
|
||||
"policyId": catalog["policyId"],
|
||||
"rotationVersion": catalog["rotationVersion"],
|
||||
"mode": "automatic",
|
||||
"source": args.source,
|
||||
"level": args.level,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
loadCanonicalBenchmarkBuilds,
|
||||
} from "../../src/game/benchmark/benchmarkBuilds";
|
||||
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
|
||||
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
|
||||
import {
|
||||
renderBenchmarkMatrixMarkdown,
|
||||
serializeBenchmarkMatrixCsv,
|
||||
@@ -154,6 +155,7 @@ async function main(): Promise<void> {
|
||||
seeds: options.seeds,
|
||||
durationScale: options.durationScale,
|
||||
policyId: BENCHMARK_POLICY_ID,
|
||||
rotationVersion: COMBAT_ROTATION_VERSION,
|
||||
},
|
||||
scenarios,
|
||||
builds,
|
||||
|
||||
@@ -25,6 +25,19 @@ const CLASS_SOURCES = Object.freeze({
|
||||
druid: { classMask: 1024, skillLines: [574, 134, 573] },
|
||||
});
|
||||
|
||||
const CLASS_BY_TALENT_TAB = new Map([
|
||||
[161, "warrior"], [164, "warrior"], [163, "warrior"],
|
||||
[382, "paladin"], [383, "paladin"], [381, "paladin"],
|
||||
[361, "hunter"], [363, "hunter"], [362, "hunter"],
|
||||
[182, "rogue"], [181, "rogue"], [183, "rogue"],
|
||||
[201, "priest"], [202, "priest"], [203, "priest"],
|
||||
[398, "death-knight"], [399, "death-knight"], [400, "death-knight"],
|
||||
[261, "shaman"], [263, "shaman"], [262, "shaman"],
|
||||
[81, "mage"], [41, "mage"], [61, "mage"],
|
||||
[302, "warlock"], [303, "warlock"], [301, "warlock"],
|
||||
[283, "druid"], [281, "druid"], [282, "druid"],
|
||||
]);
|
||||
|
||||
// Triggered subspells can appear on a class skill line even though the player
|
||||
// never learns them directly. Their owning active spell/talent remains present.
|
||||
const TRIGGERED_ONLY_NAMES = new Set([
|
||||
@@ -202,16 +215,19 @@ function parseSpells(buffer, castTimes, durations, radii, ranges, icons) {
|
||||
return spells;
|
||||
}
|
||||
|
||||
function talentSpellIds(buffer) {
|
||||
function talentNodesBySpellId(buffer) {
|
||||
assertDbc(buffer, "Talent.dbc", 23);
|
||||
const count = buffer.readUInt32LE(4);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
const result = new Set();
|
||||
const result = new Map();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const base = 20 + index * recordSize;
|
||||
const talentNodeId = `talent-${buffer.readUInt32LE(base)}`;
|
||||
const classId = CLASS_BY_TALENT_TAB.get(buffer.readUInt32LE(base + 4));
|
||||
if (!classId) continue;
|
||||
for (let rank = 0; rank < 9; rank += 1) {
|
||||
const spellId = buffer.readUInt32LE(base + (4 + rank) * 4);
|
||||
if (spellId) result.add(spellId);
|
||||
if (spellId) result.set(spellId, { talentNodeId, classId });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -271,7 +287,10 @@ function slimRank(spell) {
|
||||
castTimeMs: spell.castTimeMs,
|
||||
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
|
||||
gcdMs: spell.startRecoveryTimeMs || 1500,
|
||||
durationMs: Math.abs(spell.durationMs),
|
||||
// SpellDuration.dbc uses negative durations for permanent/toggled effects.
|
||||
// Preserve those as zero so the runtime converts them to a non-expiring aura
|
||||
// instead of a one-millisecond aura that the AI immediately recasts.
|
||||
durationMs: Math.max(0, spell.durationMs),
|
||||
channelDurationMs: spell.channelInterruptFlags ? Math.max(0, spell.durationMs) : 0,
|
||||
powerType: spell.powerType,
|
||||
powerCost: spell.powerCost,
|
||||
@@ -285,6 +304,15 @@ function slimRank(spell) {
|
||||
};
|
||||
}
|
||||
|
||||
function iconPath(iconBasename) {
|
||||
const candidates = [
|
||||
`/assets/ui/spells/${iconBasename}.png`,
|
||||
`/assets/ui/talents/icons/${iconBasename}.png`,
|
||||
];
|
||||
return candidates.find((candidate) => fs.existsSync(path.join(projectRoot, "public", candidate.slice(1))))
|
||||
?? "/assets/ui/spells/inv_misc_questionmark.png";
|
||||
}
|
||||
|
||||
const archive = MpqArchive.open(archivePath);
|
||||
let result;
|
||||
try {
|
||||
@@ -300,7 +328,8 @@ try {
|
||||
rangeTable(extract("SpellRange")),
|
||||
stringTable(extract("SpellIcon")),
|
||||
);
|
||||
const talentIds = talentSpellIds(talentBuffer);
|
||||
const talentNodeBySpellId = talentNodesBySpellId(talentBuffer);
|
||||
const talentIds = new Set(talentNodeBySpellId.keys());
|
||||
const passiveTalentNames = new Set(
|
||||
[...talentIds]
|
||||
.map((spellId) => spells.get(spellId))
|
||||
@@ -309,19 +338,24 @@ try {
|
||||
);
|
||||
const classes = {};
|
||||
for (const [classId, source] of Object.entries(CLASS_SOURCES)) {
|
||||
const selected = [...classSpellIds(skillLineBuffer, source)]
|
||||
.map((spellId) => spells.get(spellId))
|
||||
const classSpellSet = classSpellIds(skillLineBuffer, source);
|
||||
const activeTalentSpellIds = new Set(
|
||||
[...talentNodeBySpellId.entries()]
|
||||
.filter(([, owner]) => owner.classId === classId)
|
||||
.map(([spellId]) => spellId),
|
||||
);
|
||||
const selected = [...spells.values()]
|
||||
.filter((spell) => (
|
||||
spell
|
||||
&& spell.name
|
||||
&& spell.spellLevel > 0
|
||||
&& (classSpellSet.has(spell.id) || activeTalentSpellIds.has(spell.id))
|
||||
&& (spell.spellLevel > 0 || talentIds.has(spell.id))
|
||||
&& spell.spellLevel <= 80
|
||||
&& (spell.attributes[0] & 0x40) === 0
|
||||
));
|
||||
const talentNames = new Set(selected.filter((spell) => talentIds.has(spell.id)).map((spell) => spell.name));
|
||||
const chains = new Map();
|
||||
for (const spell of selected) {
|
||||
if (talentNames.has(spell.name) || TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
|
||||
if (TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
|
||||
const ranks = chains.get(spell.name) ?? [];
|
||||
ranks.push(spell);
|
||||
chains.set(spell.name, ranks);
|
||||
@@ -334,6 +368,9 @@ try {
|
||||
|| left.id - right.id
|
||||
));
|
||||
const first = ranks[0];
|
||||
const talentNodeId = chainSpells
|
||||
.map((spell) => talentNodeBySpellId.get(spell.id)?.talentNodeId)
|
||||
.find(Boolean);
|
||||
// Passive talents sometimes publish a separate, non-passive proc spell
|
||||
// with the same name on the class skill line. That proc has no player
|
||||
// activation mechanics, so retain it for the spellbook but classify it
|
||||
@@ -349,8 +386,10 @@ try {
|
||||
id: `wow335-${classId}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
|
||||
name,
|
||||
iconBasename: first.iconBasename,
|
||||
sourceUnlockLevel: Math.min(...ranks.map((spell) => spell.spellLevel)),
|
||||
iconPath: iconPath(first.iconBasename),
|
||||
sourceUnlockLevel: Math.max(1, Math.min(...ranks.map((spell) => spell.spellLevel))),
|
||||
...(passive ? { passive: true } : {}),
|
||||
...(talentNodeId ? { talentNodeId } : {}),
|
||||
ranks: ranks.map(slimRank),
|
||||
};
|
||||
}).sort((left, right) => (
|
||||
|
||||
@@ -100,6 +100,12 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
||||
assert.equal(await memberButtons.nth(2).getAttribute('aria-pressed'), 'true');
|
||||
await page.waitForTimeout(300);
|
||||
await capture('03-follow-selected-member');
|
||||
const beforeFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||
await page.keyboard.down('ArrowLeft');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.up('ArrowLeft');
|
||||
const afterFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||
assert.notEqual(afterFollowArrowLook.yaw, beforeFollowArrowLook.yaw, 'ArrowLeft orbits the follow camera');
|
||||
|
||||
await page.getByRole('button', { name: 'Free camera' }).click();
|
||||
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
|
||||
@@ -110,6 +116,12 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
||||
await page.waitForTimeout(150);
|
||||
const afterFlight = await captureCanvas();
|
||||
assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays');
|
||||
const beforeArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||
await page.keyboard.down('ArrowRight');
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.up('ArrowRight');
|
||||
const afterArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||
assert.notEqual(afterArrowLook.yaw, beforeArrowLook.yaw, 'ArrowRight turns the free camera');
|
||||
const canvasBounds = await page.locator('canvas').boundingBox();
|
||||
assert(canvasBounds, 'simulation canvas is visible');
|
||||
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
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('playtest-artifacts', 'rotation-overhaul');
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const databaseDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rotation-playtest-'));
|
||||
const partySeed = 'rotation-boss-test-0';
|
||||
const bossName = 'Lady Anacondra';
|
||||
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
(async () => {
|
||||
const { createGameServer } = await import('../server/server.mjs');
|
||||
const runtime = createGameServer({ dataDir: databaseDirectory, enableShelvedModes: false });
|
||||
await new Promise((resolve) => runtime.server.listen(4191, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(90_000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
const moduleUrls = fs.readdirSync('dist/assets')
|
||||
.filter((file) => /^(?:index|GameRuntime|combatStore)-.*\.js$/.test(file))
|
||||
.map((file) => `/assets/${file}`);
|
||||
await page.addInitScript((urls) => {
|
||||
const runtimeStores = async () => {
|
||||
const values = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
|
||||
const stores = values.filter((value) => value && typeof value.getState === 'function');
|
||||
const partyStore = stores.find((store) => {
|
||||
const state = store.getState();
|
||||
return Array.isArray(state?.members) && typeof state?.initializeParty === 'function'
|
||||
&& typeof state?.setCommand === 'function';
|
||||
});
|
||||
const combatStore = stores.find((store) => {
|
||||
const state = store.getState();
|
||||
return state?.mobs && state?.actors && Array.isArray(state?.combatEvents)
|
||||
&& typeof state?.synchronizeActors === 'function';
|
||||
});
|
||||
const gameStore = stores.find((store) => {
|
||||
const state = store.getState();
|
||||
return 'bossTest' in (state ?? {}) && 'activeDungeonId' in (state ?? {});
|
||||
});
|
||||
if (!partyStore || !combatStore || !gameStore) throw new Error('Required runtime stores were not found.');
|
||||
return { partyStore, combatStore, gameStore };
|
||||
};
|
||||
|
||||
window.configureRotationBossParty = async (seed) => {
|
||||
const { partyStore, combatStore } = await runtimeStores();
|
||||
const combat = combatStore.getState();
|
||||
partyStore.getState().initializeParty(seed, combat.level, combat.health, 'damage', 5, 'wow');
|
||||
partyStore.getState().setCommand('stop');
|
||||
combatStore.getState().synchronizeActors();
|
||||
combatStore.getState().applyDefaultPartyBuffs();
|
||||
combatStore.getState().applyPrecombatPreparation();
|
||||
};
|
||||
|
||||
window.startRotationBossParty = async () => {
|
||||
const { partyStore } = await runtimeStores();
|
||||
partyStore.getState().setCommand('attack');
|
||||
};
|
||||
|
||||
window.readRotationPlaytestState = async () => {
|
||||
const { partyStore, combatStore, gameStore } = await runtimeStores();
|
||||
const party = partyStore.getState();
|
||||
const combat = combatStore.getState();
|
||||
const game = gameStore.getState();
|
||||
const runtimeIds = game.bossTest?.runtimeIds ?? [];
|
||||
const boss = runtimeIds.map((id) => combat.mobs[id]).find(Boolean)
|
||||
?? Object.values(combat.mobs).find((mob) => mob.boss);
|
||||
const currentActorIds = new Set(['__player__', ...party.members.map((member) => member.id)]);
|
||||
return {
|
||||
activeDungeonId: game.activeDungeonId,
|
||||
boss: boss ? {
|
||||
id: boss.id,
|
||||
name: boss.name,
|
||||
health: boss.health,
|
||||
maxHealth: boss.maxHealth,
|
||||
engaged: boss.engaged,
|
||||
dead: boss.dead,
|
||||
} : null,
|
||||
party: party.members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
classId: member.classId,
|
||||
specialization: member.specialization,
|
||||
role: member.role,
|
||||
health: member.health,
|
||||
maxHealth: member.maxHealth,
|
||||
status: member.status,
|
||||
})),
|
||||
auras: combat.auras.filter((aura) => currentActorIds.has(aura.targetId)).map((aura) => ({
|
||||
name: aura.definition.name,
|
||||
exclusiveGroup: aura.definition.exclusiveGroup ?? null,
|
||||
sourceId: aura.sourceId,
|
||||
targetId: aura.targetId,
|
||||
expiresAt: aura.expiresAt,
|
||||
})),
|
||||
summons: combat.summons.filter((summon) => currentActorIds.has(summon.ownerActorId)).map((summon) => ({
|
||||
name: summon.name,
|
||||
kind: summon.kind,
|
||||
ownerActorId: summon.ownerActorId,
|
||||
sourceAbilityId: summon.sourceAbilityId,
|
||||
expiresAt: summon.expiresAt,
|
||||
})),
|
||||
events: combat.combatEvents.map((event) => ({
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
sourceActorId: event.sourceActorId,
|
||||
targetActorId: event.targetActorId,
|
||||
abilityId: event.abilityId,
|
||||
effectiveAmount: event.effectiveAmount,
|
||||
})),
|
||||
};
|
||||
};
|
||||
}, moduleUrls);
|
||||
|
||||
try {
|
||||
await page.goto('http://127.0.0.1:4191', { waitUntil: 'domcontentloaded' });
|
||||
await page.getByLabel('Username').fill('Phenom');
|
||||
await page.getByLabel('Password').fill('rotation-overhaul-boss-run');
|
||||
await page.getByRole('button', { name: /Create account/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByLabel('Specialization').getByRole('button', { name: /Shadow/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('Rotationwatcher');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue/ }).first().click();
|
||||
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||
await page.getByRole('heading', { name: 'Boss Testing' }).waitFor();
|
||||
await page.getByLabel('Search bosses').fill(bossName);
|
||||
const bossPicker = page.locator('.boss-testing-picker select');
|
||||
await bossPicker.selectOption({ index: 1 });
|
||||
await page.getByRole('button', { name: 'Test selected boss' }).click();
|
||||
await page.locator('canvas').waitFor();
|
||||
await page.waitForTimeout(3_000);
|
||||
await page.evaluate((seed) => window.configureRotationBossParty(seed), partySeed);
|
||||
|
||||
const initial = await page.evaluate(() => window.readRotationPlaytestState());
|
||||
assert.equal(initial.boss?.name, bossName, 'the requested boss is active in Boss Testing');
|
||||
assert.equal(initial.party.length, 4, 'the player has a complete four-companion party');
|
||||
assert(initial.party.some((member) => member.role === 'tank'), 'party includes a tank');
|
||||
assert(initial.party.some((member) => member.role === 'healer'), 'party includes a healer');
|
||||
const damage = initial.party.filter((member) => member.role === 'damage');
|
||||
const ranged = damage.filter((member) => (
|
||||
['hunter', 'mage', 'priest', 'warlock'].includes(member.classId)
|
||||
|| (member.classId === 'druid' && member.specialization === 'Balance')
|
||||
|| (member.classId === 'shaman' && member.specialization === 'Elemental')
|
||||
));
|
||||
const melee = damage.filter((member) => !ranged.some((candidate) => candidate.id === member.id));
|
||||
assert(ranged.length > 0, `party includes ranged damage: ${JSON.stringify(damage)}`);
|
||||
assert(melee.length > 0, `party includes melee damage: ${JSON.stringify(damage)}`);
|
||||
const paladin = initial.party.find((member) => member.classId === 'paladin');
|
||||
assert(paladin, `party includes the expected paladin setup check: ${JSON.stringify(initial.party)}`);
|
||||
assert(initial.auras.some((aura) => (
|
||||
aura.targetId === paladin.id && aura.exclusiveGroup === 'paladin:seal'
|
||||
)), `paladin seal is active before the attack command: ${JSON.stringify(initial.auras)}`);
|
||||
|
||||
await page.evaluate(() => window.startRotationBossParty());
|
||||
|
||||
const eventKeys = new Set(initial.events.map((event) => `${event.id}:${event.sourceActorId}:${event.abilityId}`));
|
||||
const observedEvents = [];
|
||||
const collect = (state) => {
|
||||
for (const event of state.events) {
|
||||
const key = `${event.id}:${event.sourceActorId}:${event.abilityId}`;
|
||||
if (eventKeys.has(key)) continue;
|
||||
eventKeys.add(key);
|
||||
observedEvents.push(event);
|
||||
}
|
||||
};
|
||||
|
||||
let engaged = null;
|
||||
const engagementDeadline = Date.now() + 30_000;
|
||||
while (Date.now() < engagementDeadline) {
|
||||
const state = await page.evaluate(() => window.readRotationPlaytestState());
|
||||
collect(state);
|
||||
if (state.boss?.engaged && !state.boss.dead && state.boss.health < state.boss.maxHealth) {
|
||||
engaged = state;
|
||||
break;
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
assert(engaged, 'the companion party engages Lady Anacondra');
|
||||
await page.screenshot({ path: path.join(directory, 'boss-engaged.png'), fullPage: true });
|
||||
const canvasImage = await page.locator('canvas').screenshot({ path: path.join(directory, 'boss-engaged-canvas.png') });
|
||||
assert(canvasImage.length > 10_000, 'boss screenshot contains a rendered WebGL scene');
|
||||
|
||||
let completed = null;
|
||||
const completionDeadline = Date.now() + 90_000;
|
||||
while (Date.now() < completionDeadline) {
|
||||
const state = await page.evaluate(() => window.readRotationPlaytestState());
|
||||
collect(state);
|
||||
if (state.boss?.dead || state.boss?.health === 0) {
|
||||
completed = state;
|
||||
break;
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
assert(completed, 'the companion party defeats Lady Anacondra');
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: path.join(directory, 'boss-defeated.png'), fullPage: true });
|
||||
|
||||
const partyIds = new Set(initial.party.map((member) => member.id));
|
||||
const partyEvents = observedEvents.filter((event) => partyIds.has(event.sourceActorId));
|
||||
for (const member of [...initial.party.filter((candidate) => candidate.role === 'tank'), ...melee, ...ranged]) {
|
||||
assert(partyEvents.some((event) => event.sourceActorId === member.id && event.kind === 'damage'
|
||||
&& event.abilityId && event.abilityId !== 'party-auto-attack'), `${member.name} uses a damaging ability`);
|
||||
}
|
||||
for (const healer of initial.party.filter((member) => member.role === 'healer')) {
|
||||
assert(partyEvents.some((event) => event.sourceActorId === healer.id
|
||||
&& ['damage', 'healing'].includes(event.kind) && event.abilityId), `${healer.name} takes an active combat action`);
|
||||
}
|
||||
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
|
||||
|
||||
const results = {
|
||||
dungeon: 'Wailing Caverns — Boss Testing',
|
||||
boss: bossName,
|
||||
partySeed,
|
||||
engagedBoss: engaged.boss,
|
||||
defeatedBoss: completed.boss,
|
||||
party: initial.party,
|
||||
precombatAuras: initial.auras,
|
||||
precombatSummons: initial.summons,
|
||||
observedAbilityEvents: partyEvents.filter((event) => event.abilityId !== 'party-auto-attack'),
|
||||
browserErrors: errors,
|
||||
};
|
||||
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
console.log('Rotation-overhaul dungeon boss playtest passed.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user