Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 22:51:21 -04:00
1 parent 6cc1b9217f
commit 6114a2295f
67 files changed
+6241 -347

No files matched your search

+111 -45
View File
@@ -1,79 +1,145 @@
import { NodeIO } from "@gltf-transform/core";
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
import { requireDungeonDefinition } from "../src/game/dungeonRegistry";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { createRequire } from "node:module";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { MeshoptDecoder } from "meshoptimizer";
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
import {
buildPartyNavigationGraph,
findNearestPartyNavigationSurface,
findPartyNavigationPath,
} from "../src/game/partyNavigation";
import { preparePartyNavigationGraph } from "../src/game/partyNavigationAssetCache";
import type { DungeonDefinition } from "../src/game/dungeonTypes";
import { withWowDungeonEnvironment } from "../src/game/wowDungeonEnvironment";
const dungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--"));
if (!dungeonIds.length) throw new Error("Pass at least one dungeon id.");
const require = createRequire(import.meta.url);
const { DEFAULT_TEST_DUNGEON_ID } = require("./playtest-defaults.cjs") as {
DEFAULT_TEST_DUNGEON_ID: string;
};
type Position = readonly [number, number, number];
type ProbeDungeon = {
id: string;
title: string;
entrance: { footPosition: Position };
assets: { navigation?: { fileName: string; url: string } | null };
navigationLinks?: readonly { start: Position; end: Position; bidirectional: boolean }[];
traversalRamps?: DungeonDefinition["traversalRamps"];
bosses: readonly { id: string; name: string; position: Position; eventOnly?: boolean }[];
};
const campaignCatalogPath = resolve(process.cwd(), "src", "game", "generated", "dungeonCampaignCatalog.json");
const campaignCatalog = JSON.parse(readFileSync(campaignCatalogPath, "utf8")) as { definitions: ProbeDungeon[] };
function requireGeneratedDungeonDefinition(dungeonId: string): ProbeDungeon {
const dungeon = campaignCatalog.definitions.find((candidate) => candidate.id === dungeonId);
if (!dungeon) throw new Error(`Generated campaign catalog has no dungeon ${dungeonId}.`);
return dungeon;
}
const navigationIo = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const requestedDungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--"));
const dungeonIds = requestedDungeonIds.length ? requestedDungeonIds : [DEFAULT_TEST_DUNGEON_ID];
async function navigationGraph(dungeonId: string) {
const dungeon = requireDungeonDefinition(dungeonId);
const dungeon = requireGeneratedDungeonDefinition(dungeonId);
const navigation = dungeon.assets.navigation;
if (!navigation) throw new Error(`${dungeonId} has no navigation asset.`);
const file = `src/assets/game/dungeons/${dungeonId}/${navigation.fileName}`;
const document = await new NodeIO().read(file);
const positions: number[] = [];
const vertex = new Vector3();
const publicAssetPath = navigation.url.startsWith("/")
? resolve(process.cwd(), "public", navigation.url.slice(1))
: resolve(process.cwd(), "public", "assets", "game", "dungeons", dungeonId, navigation.fileName);
const sourceAssetPath = resolve(process.cwd(), "src", "assets", "game", "dungeons", dungeonId, navigation.fileName);
const file = [publicAssetPath, sourceAssetPath].find(existsSync);
if (!file) throw new Error(`${dungeonId} navigation asset is missing at ${publicAssetPath} and ${sourceAssetPath}.`);
await MeshoptDecoder.ready;
const document = await navigationIo.read(file);
const scene = new Group();
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const world = new Matrix4().fromArray(node.getWorldMatrix());
for (const primitive of mesh.listPrimitives()) {
const source = primitive.getAttribute("POSITION")?.getArray();
if (!source) continue;
const indices = primitive.getIndices()?.getArray()
?? Array.from({ length: source.length / 3 }, (_, index) => index);
for (const index of indices) {
vertex.fromArray(source, Number(index) * 3).applyMatrix4(world);
positions.push(vertex.x, vertex.y, vertex.z);
}
const accessor = primitive.getAttribute("POSITION");
const source = accessor?.getArray();
if (!source || !accessor) continue;
const geometry = new BufferGeometry();
geometry.setAttribute("position", new BufferAttribute(source, 3, accessor.getNormalized()));
const sourceIndices = primitive.getIndices()?.getArray();
if (sourceIndices) geometry.setIndex(Array.from(sourceIndices));
const navigationMesh = new Mesh(geometry);
navigationMesh.matrixAutoUpdate = false;
navigationMesh.matrix.fromArray(node.getWorldMatrix());
scene.add(navigationMesh);
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const definition = withWowDungeonEnvironment(dungeon as unknown as DungeonDefinition);
try {
return buildPartyNavigationGraph(geometry);
return preparePartyNavigationGraph(navigation.url, scene, definition.navigationLinks ?? [], definition.traversalRamps ?? []);
} finally {
geometry.dispose();
scene.traverse((node) => {
if ((node as Mesh).isMesh) (node as Mesh).geometry.dispose();
});
}
}
for (const dungeonId of dungeonIds) {
const dungeon = requireDungeonDefinition(dungeonId);
const boss = dungeon.bosses.find((candidate) => !candidate.eventOnly);
if (!boss) throw new Error(`${dungeonId} has no normal campaign boss.`);
const dungeon = requireGeneratedDungeonDefinition(dungeonId);
const eligibleBosses = dungeon.bosses.filter((candidate) => !candidate.eventOnly);
const bosses = dungeonId === DEFAULT_TEST_DUNGEON_ID ? eligibleBosses : eligibleBosses.slice(0, 1);
if (!bosses.length) throw new Error(`${dungeonId} has no normal campaign bosses.`);
const graph = await navigationGraph(dungeonId);
const start = findNearestPartyNavigationSurface(graph, dungeon.entrance.footPosition, 3);
const end = findNearestPartyNavigationSurface(graph, boss.position, 6);
if (!start || !end) throw new Error(`${dungeonId} entrance or ${boss.name} does not snap to navigation.`);
const path = findPartyNavigationPath(graph, start.position, end.position, {
maxStartSnapDistance: 3,
maxEndSnapDistance: 6,
simplifyTolerance: 0.01,
});
if (!path?.length) throw new Error(`${dungeonId} has no navigation path to ${boss.name}.`);
if (!start) throw new Error(`${dungeonId} entrance does not snap to navigation.`);
const waypoints: number[][] = [[...start.position]];
const objectives: Array<{ id: string; name: string; position: readonly number[]; navigationPosition: readonly number[];
waypointIndex: number; expectedNavigationDistance: number; snapDistance: number }> = [];
let segmentStart = start.position;
let expectedDistance = 0;
for (let index = 1; index < path.length; index += 1) {
expectedDistance += Math.hypot(
path[index][0] - path[index - 1][0],
path[index][1] - path[index - 1][1],
path[index][2] - path[index - 1][2],
);
for (const boss of bosses) {
const end = findNearestPartyNavigationSurface(graph, boss.position, 6);
if (!end) throw new Error(`${dungeonId} ${boss.name} does not snap to navigation.`);
const path = findPartyNavigationPath(graph, segmentStart, end.position, {
maxStartSnapDistance: 3,
maxEndSnapDistance: 6,
simplifyTolerance: 0.01,
});
if (!path?.length) throw new Error(`${dungeonId} has no navigation path to ${boss.name}.`);
const startIndex = waypoints.length - 1;
let segmentDistance = 0;
for (let index = 1; index < path.length; index += 1) {
const segmentLength = Math.hypot(
path[index][0] - path[index - 1][0],
path[index][1] - path[index - 1][1],
path[index][2] - path[index - 1][2],
);
segmentDistance += segmentLength;
expectedDistance += segmentLength;
waypoints.push([...path[index]]);
}
objectives.push({
id: boss.id,
name: boss.name,
position: boss.position,
navigationPosition: end.position,
waypointIndex: waypoints.length - 1,
expectedNavigationDistance: Number(segmentDistance.toFixed(2)),
snapDistance: end.distance,
});
if (waypoints.length - 1 <= startIndex) {
throw new Error(`${dungeonId} navigation path to ${boss.name} has no traversable segment.`);
}
segmentStart = end.position;
}
process.stdout.write(`${JSON.stringify({
dungeonId,
status: "navigation-guidance-only; live UI traversal required",
objective: boss.name,
objective: bosses.at(-1)!.name,
objectiveId: bosses.at(-1)!.id,
objectives,
entrance: start.position,
objectivePosition: end.position,
objectivePosition: bosses.at(-1)!.position,
startSnapDistance: start.distance,
objectiveSnapDistance: end.distance,
waypointCount: path.length,
objectiveSnapDistance: objectives.at(-1)!.snapDistance,
waypointCount: waypoints.length,
expectedNavigationDistance: Number(expectedDistance.toFixed(2)),
waypoints: path,
waypoints,
})}\n`);
}
@@ -973,7 +973,7 @@ async function spellSupportTables(spellFile, fallbackRoot) {
};
}
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()) {
function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map(), sourceProfile) {
if (!buffer || buffer.toString("ascii", 0, 4) !== "WDBC") return new Map();
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
@@ -1002,11 +1002,13 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
}
spells.set(id, {
id,
sourceProfile,
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
recoveryTimeMs: unsigned(29),
school: schoolFor(unsigned(225)),
schoolMask: unsigned(225),
damageClass: unsigned(213),
rangeIndex: unsigned(46),
durationMs: durations.get(unsigned(40)) ?? 0,
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
effectType: unsigned(71 + effectIndex),
@@ -1017,6 +1019,7 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
implicitTargetB: unsigned(shifted(89 + effectIndex)),
radius: radii.get(unsigned(shifted(92 + effectIndex))) ?? 0,
miscValue: signed(shifted(110 + effectIndex)),
periodMs: unsigned(98 + effectIndex),
})).filter((effect) => effect.effectType || effect.auraType),
});
}
@@ -1037,6 +1040,191 @@ const CONTROL_AURA_MECHANICS = Object.freeze(new Map([
[193, "slow"],
]));
const DIRECT_DAMAGE_EFFECT_TYPES = Object.freeze([2, 31, 58, 121]);
const pinnedEffect = (effectType, auraType, basePoints, targetA, targetB, options = {}) => ({
effectType,
auraType,
basePoints,
mechanic: options.mechanic ?? 0,
implicitTargetA: targetA,
implicitTargetB: targetB,
radius: options.radius ?? 0,
miscValue: options.miscValue ?? 0,
periodMs: options.periodMs ?? 0,
});
const pinnedSourceSpell = (rangeIndex, schoolMask, damageClass, durationMs, effects, contract = {}) => {
const { epochSignature, ...attackContract } = contract;
const signature = { rangeIndex, schoolMask, damageClass, durationMs, effects };
return {
signatures: {
ascension: signature,
epoch: epochSignature ?? signature,
},
...attackContract,
};
};
const PINNED_SOURCE_SPELLS = Object.freeze(new Map([
[7164, pinnedSourceSpell(1, 1, 0, 180_000, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 18 })], { unsupportedUtility: true })],
[7165, pinnedSourceSpell(1, 1, 2, -1, [pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 })], { unsupportedUtility: true })],
[16244, pinnedSourceSpell(1, 1, 1, 15_000, [pinnedEffect(6, 79, -10, 22, 15, { radius: 10, miscValue: 1 })], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
effects: [pinnedEffect(6, 99, -20, 22, 15, { radius: 10 })],
},
})],
[13730, pinnedSourceSpell(1, 1, 1, 30_000, [pinnedEffect(6, 166, -15, 22, 15, { radius: 10 })], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 30_000,
effects: [pinnedEffect(6, 99, -10, 22, 15, { radius: 10 })],
},
})],
[3419, pinnedSourceSpell(1, 1, 0, 6_000, [pinnedEffect(6, 51, 75, 1, 0)], { unsupportedUtility: true })],
[9128, pinnedSourceSpell(1, 1, 1, 120_000, [pinnedEffect(6, 79, 15, 20, 0, { radius: 20, miscValue: 1 })], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 1, durationMs: 120_000,
effects: [pinnedEffect(6, 99, 4, 20, 0, { radius: 20 })],
},
})],
[674, pinnedSourceSpell(1, 1, 1, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
[29651, pinnedSourceSpell(1, 1, 0, 0, [pinnedEffect(40, 0, 1, 1, 0)], { unsupportedUtility: true })],
[2457, pinnedSourceSpell(1, 1, 0, -1, [
pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 }),
pinnedEffect(6, 280, 10, 1, 0),
pinnedEffect(6, 286, 100, 1, 0),
], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 1, schoolMask: 1, damageClass: 2, durationMs: -1,
effects: [
pinnedEffect(6, 36, 0, 1, 0, { miscValue: 17 }),
pinnedEffect(6, 280, 10, 1, 0),
],
},
})],
[3391, pinnedSourceSpell(1, 1, 2, 0, [pinnedEffect(19, 0, 2, 1, 0)], { unsupportedUtility: true })],
[6742, pinnedSourceSpell(4, 8, 1, 30_000, [pinnedEffect(6, 138, 30, 21, 0)], { unsupportedUtility: true })],
[6713, pinnedSourceSpell(2, 1, 2, 5_000, [
pinnedEffect(6, 67, 0, 6, 0),
pinnedEffect(6, 278, 1, 6, 0),
], {
unsupportedUtility: true,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 5_000,
effects: [pinnedEffect(6, 67, 0, 6, 0)],
},
})],
[17230, pinnedSourceSpell(2, 8, 0, 300_000, [pinnedEffect(6, 14, 6, 6, 0, { miscValue: 1 })], { unsupportedUtility: true })],
[6253, pinnedSourceSpell(2, 1, 2, 2_000, [pinnedEffect(6, 12, 1, 6, 0)], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
})],
[8242, pinnedSourceSpell(2, 1, 2, 2_000, [
pinnedEffect(6, 12, 1, 6, 0),
pinnedEffect(2, 0, 53, 6, 0),
], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 2_000,
effects: [
pinnedEffect(6, 12, 1, 6, 0),
pinnedEffect(2, 0, 20, 6, 0),
],
},
})],
[11976, pinnedSourceSpell(2, 1, 2, 0, [pinnedEffect(58, 0, 5, 6, 0)], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
})],
[15657, pinnedSourceSpell(2, 1, 2, 0, [pinnedEffect(2, 0, 100, 6, 0)], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 0,
effects: [pinnedEffect(58, 0, 20, 6, 0)],
},
})],
[11978, pinnedSourceSpell(2, 1, 2, 4_000, [pinnedEffect(68, 0, 1, 6, 0, { mechanic: 26 })], {
target: "primary",
delivery: "melee",
animation: "attack",
range: 5,
interruptControl: true,
preserveSourceDamage: true,
epochSignature: {
rangeIndex: 2, schoolMask: 1, damageClass: 2, durationMs: 6_000,
effects: [
pinnedEffect(2, 0, 2, 6, 0),
pinnedEffect(68, 0, 1, 6, 0, { mechanic: 26 }),
],
},
})],
[421, pinnedSourceSpell(4, 8, 1, 0, [pinnedEffect(2, 0, 287, 6, 0)], {
range: 30,
epochSignature: {
rangeIndex: 4, schoolMask: 8, damageClass: 1, durationMs: 0,
effects: [pinnedEffect(2, 0, 191, 6, 0)],
},
})],
[5246, pinnedSourceSpell(137, 1, 1, 5_000, [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
], {
target: "nearby-party",
delivery: "area",
range: 8,
radius: 8,
epochSignature: {
rangeIndex: 137, schoolMask: 1, damageClass: 1, durationMs: 8_000,
effects: [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
],
},
})],
[19134, pinnedSourceSpell(7, 1, 1, 4_000, [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
], {
target: "nearby-party",
delivery: "area",
range: 10,
radius: 8,
epochSignature: {
rangeIndex: 7, schoolMask: 1, damageClass: 1, durationMs: 6_000,
effects: [
pinnedEffect(64, 0, 1, 6, 0),
pinnedEffect(6, 7, 1, 22, 15, { radius: 8 }),
pinnedEffect(6, 31, 25, 22, 15, { radius: 8 }),
],
},
})],
[7964, pinnedSourceSpell(1, 1, 1, 4_000, [
pinnedEffect(6, 12, 1, 22, 15, { mechanic: 12, radius: 5 }),
pinnedEffect(3, 0, 1, 18, 0),
], {
target: "nearby-party",
delivery: "area",
range: 5,
radius: 5,
})],
]));
const AUXILIARY_NON_BOSS_CASTS = Object.freeze(new Set([
"26687:47669", // The stasis orb awakens Gortok; Gortok does not cast this spell.
"29307:54888", // The Colossus does not own the elemental's spawn/reset visual.
@@ -1049,7 +1237,7 @@ function spellSemantic(spell, boss, area) {
? 0
: spell.durationMs || (boss ? 8_000 : 5_000);
const damageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
const hasDamage = spell.effects.some((effect) => [2, 31, 58, 121].includes(effect.effectType));
const hasDamage = spell.effects.some((effect) => DIRECT_DAMAGE_EFFECT_TYPES.includes(effect.effectType));
const controlEffect = spell.effects.find((effect) => CONTROL_AURA_MECHANICS.has(effect.auraType));
const controlMechanic = /\b(?:sleep|slumber)\b/i.test(spell.name)
? "sleep"
@@ -1164,6 +1352,50 @@ function spellSemantic(spell, boss, area) {
return undefined;
}
function pinnedSourceSpellFor(spell) {
const contract = PINNED_SOURCE_SPELLS.get(spell.id);
if (!contract) return undefined;
const signature = contract.signatures[spell.sourceProfile];
if (!signature) {
throw new Error(
`Pinned Spell.dbc contract for spell ${spell.id} has no ${spell.sourceProfile} source profile.`,
);
}
const scalarFieldsMatch = ["rangeIndex", "schoolMask", "damageClass", "durationMs"]
.every((field) => spell[field] === signature[field]);
const effectsMatch = spell.effects.length === signature.effects.length
&& signature.effects.every((expected, index) => {
const actual = spell.effects[index];
return [
"effectType",
"auraType",
"basePoints",
"mechanic",
"implicitTargetA",
"implicitTargetB",
"radius",
"miscValue",
"periodMs",
].every((field) => actual[field] === expected[field]);
});
if (!scalarFieldsMatch || !effectsMatch) {
const actualSignature = {
rangeIndex: spell.rangeIndex,
schoolMask: spell.schoolMask,
damageClass: spell.damageClass,
durationMs: spell.durationMs,
effects: spell.effects,
};
throw new Error(
`Pinned ${spell.sourceProfile} Spell.dbc contract drift for spell ${spell.id}; `
+ `review its source effects before changing the import. `
+ `Expected ${JSON.stringify(signature)}; found ${JSON.stringify(actualSignature)}.`,
);
}
return contract;
}
function basicAttack(cooldownMs = 1_900) {
return {
id: "basic-attack",
@@ -1179,8 +1411,10 @@ function basicAttack(cooldownMs = 1_900) {
}
function spellAttack(spell, hash, boss) {
const pinnedSource = pinnedSourceSpellFor(spell);
const pinnedHostileMelee = pinnedSource?.delivery === "melee";
const area = /nova|roar|stomp|whirl|rain|volley|blizzard|explosion|quake|storm|breath|cleave|fear|horror|terror/i
.test(spell.name) || spell.id === 53030;
.test(spell.name) || spell.id === 53030 || pinnedSource?.delivery === "area";
const melee = spell.school === "physical"
&& /strike|slash|cleave|bash|smash|rend|bite|claw|pummel/i.test(spell.name);
const healing = /\b(?:heal|healing|mend|renew|rejuvenat\w*|restor\w*|holy light|lay on hands)\b/i
@@ -1202,14 +1436,28 @@ function spellAttack(spell, hash, boss) {
: null;
const aura = /\b(?:enrage|frenzy|shield|reflection|reflect|defensive aura|battle aura|berserker aura|haste|hasten)\b/i
.test(spell.name);
const semanticEffect = spellSemantic(spell, boss, area);
const hasDamage = spell.effects.some((entry) => DIRECT_DAMAGE_EFFECT_TYPES.includes(entry.effectType));
const defaultDamageMultiplier = area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12;
const semanticEffect = pinnedSource?.interruptControl
? {
kind: "control",
mechanic: "interrupt",
durationMs: spell.durationMs,
...(pinnedSource.preserveSourceDamage && hasDamage
? { damageMultiplier: defaultDamageMultiplier }
: {}),
}
: pinnedSource?.effect ?? spellSemantic(spell, boss, area);
if (pinnedSource?.unsupportedUtility && !semanticEffect && !hasDamage) return null;
const semanticEffects = semanticEffect?.kind === "composite"
? semanticEffect.effects
: semanticEffect ? [semanticEffect] : [];
const semanticHas = (kind) => semanticEffects.some((entry) => entry.kind === kind);
const target = healing
? "lowest-health-friendly"
: semanticHas("aura") || aura
const target = pinnedSource?.target
? pinnedSource.target
: healing
? "lowest-health-friendly"
: semanticHas("aura") || aura
? "self"
: semanticEffects.length > 0 && semanticEffects.every((entry) => entry.kind === "summon")
? "self"
@@ -1245,7 +1493,7 @@ function spellAttack(spell, hash, boss) {
id: `spell-${spell.id}`,
name: spell.name,
spellId: spell.id,
delivery: target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile",
delivery: pinnedSource?.delivery ?? (target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile"),
target,
school: spell.school,
schoolMask: spell.schoolMask,
@@ -1254,11 +1502,11 @@ function spellAttack(spell, hash, boss) {
...(spell.damageClass === 1 ? { attackKind: "spell" }
: spell.damageClass === 2 ? { attackKind: "melee" }
: spell.damageClass === 3 ? { attackKind: "ranged" } : {}),
animation: melee ? "attack" : "cast",
range: target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28,
animation: pinnedSource?.animation ?? (pinnedHostileMelee || melee ? "attack" : "cast"),
range: pinnedSource?.range ?? (target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28),
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
damageMultiplier: effect ? 0 : area ? (boss ? 0.82 : 0.62) : boss ? 1.35 : 1.12,
...(area ? { radius: dbcRadius || (boss ? 11 : 8) } : {}),
...(area ? { radius: pinnedSource?.radius ?? (dbcRadius || (boss ? 11 : 8)) } : {}),
...(effect ? { effect } : {}),
...(healing
? {
@@ -1405,15 +1653,20 @@ function smartAiMechanicsFor(entity, boss, spells, worldAi) {
if (!spell) continue;
const hash = stableHash(`${entry}:${spellId}:${action.id}`);
const attack = spellAttack(spell, hash, true);
const target = [5, 6].includes(Number(action.target_type))
? "random-party"
: attack.target;
if (!attack) continue;
const targetType = Number(action.target_type);
const pinnedSource = PINNED_SOURCE_SPELLS.get(spellId);
const sourceTarget = pinnedSource?.target;
const target = sourceTarget
?? ([5, 6].includes(targetType) ? "random-party" : attack.target);
mechanics.push({
...attack,
...event,
id: `smart-${entry}-${action.id}-${slugPart(spell.name)}`,
target,
range: target === "self" ? attack.range : Math.max(attack.range, 28),
range: pinnedSource?.range !== undefined || target === "self"
? attack.range
: Math.max(attack.range, 28),
source,
});
} else if (actionType === 12) {
@@ -1526,7 +1779,8 @@ function combatFor(
: null;
})
.filter(Boolean)
.map((spell) => spellAttack(spell, hash, boss));
.map((spell) => spellAttack(spell, hash, boss))
.filter(Boolean);
const importedMechanics = smartAiMechanicsFor(entity, boss, spells, worldAi);
const authored = boss ? bossMechanics?.bosses?.[String(entry)] : undefined;
let mechanics = authored?.mode === "replace"
@@ -1814,6 +2068,7 @@ async function compile() {
wantedSpellIds,
ascensionSpellSupport.durations,
ascensionSpellSupport.radii,
"ascension",
)
: new Map();
const epochSpellSupport = await spellSupportTables(epochSpellDbc, defaultSpellSupportRoot);
@@ -1823,6 +2078,7 @@ async function compile() {
wantedSpellIds,
epochSpellSupport.durations,
epochSpellSupport.radii,
"epoch",
)
: ascensionSpells;
const standardGameObjectDisplays = await exists(standardGameObjectDisplayDbc)
+1 -1
View File
@@ -6,7 +6,7 @@ const path = require('node:path');
const out = path.resolve(process.env.BITE_SIZED_PLAYTEST_DIR || 'playtest-artifacts/bite-sized-spawns');
fs.mkdirSync(out, { recursive: true });
const defaultLocations = [
{ id: 'bite-sized-wailing-caverns', name: 'wailing' },
{ id: 'bite-sized-stormwind-stockade', name: 'stockade' },
{ id: 'bite-sized-utgarde-pinnacle-skadi', name: 'utgarde' },
];
const locations = process.env.BITE_SIZED_PLAYTEST_IDS
+9 -1
View File
@@ -1,9 +1,17 @@
const { DEFAULT_TEST_DUNGEON_BOSS } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
const selectedBossName = process.env.BOSS_TEST_BOSS || DEFAULT_TEST_DUNGEON_BOSS;
const historicalWailingBosses = [
'Lady Anacondra', 'Lord Cobrahn', 'Kresh', 'Lord Pythas', 'Skum',
'Lord Serpentis', 'Verdan the Everliving', 'Mutanus the Devourer',
];
if (historicalWailingBosses.includes(selectedBossName) && !process.argv.includes('--historical-wailing')) {
throw new Error('A Wailing boss override requires an explicit owner request and --historical-wailing. Routine Boss Testing defaults to Hamhock.');
}
const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join(
'playtest-artifacts',
selectedBossName === 'Celebras the Cursed' ? 'boss-testing-scaling' : 'boss-testing',
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -72,11 +73,11 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-readiness-
assert.equal(await beta.isDisabled(), false);
assert.equal(await beta.isChecked(), false);
assert.match(await page.locator('.companion-pool-controls').innerText(), /1 beta spec available/);
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText('Wailing Caverns', { exact: true }).waitFor();
await selectDefaultTestDungeon(page);
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
await capture('03-dungeon-ready-pool');
await page.getByRole('button', { name: /Choose role/ }).click();
await openSelectedDungeonRoles(page);
const roleButton = page.locator('.dungeon-role-option').first();
await roleButton.click();
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
+31
View File
@@ -0,0 +1,31 @@
// Routine test fixture selected by the project owner; see AGENTS.md.
const DEFAULT_TEST_DUNGEON_ID = 'stormwind-stockade';
const DEFAULT_TEST_DUNGEON_TITLE = 'Stormwind Stockade';
const DEFAULT_TEST_DUNGEON_BOSS = 'Hamhock';
async function selectDefaultTestDungeon(page) {
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({
has: page.getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }),
}).click();
}
async function openSelectedDungeonRoles(page) {
const download = page.getByRole('button', { name: 'Download and continue', exact: true });
const role = page.locator('.dungeon-role-option').first();
// Selecting an uninstalled pack may open its confirmation immediately.
// Installed selections expose Choose Role without that modal.
if (await download.isVisible()) await download.click();
else if (!await role.isVisible()) await page.getByRole('button', { name: /choose role/i }).click();
await role.or(download).first().waitFor({ timeout: 180_000 });
if (await download.isVisible()) await download.click();
await role.waitFor({ timeout: 180_000 });
}
module.exports = Object.freeze({
DEFAULT_TEST_DUNGEON_ID,
DEFAULT_TEST_DUNGEON_TITLE,
DEFAULT_TEST_DUNGEON_BOSS,
selectDefaultTestDungeon,
openSelectedDungeonRoles,
});
+3 -2
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_ID } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
@@ -75,7 +76,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
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(0).selectOption(DEFAULT_TEST_DUNGEON_ID);
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();
@@ -160,7 +161,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await capture('06-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.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption(DEFAULT_TEST_DUNGEON_ID);
await page.getByText(/1 runs for this route/).waitFor();
await capture('07-saved-replay');
+3 -4
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -14,9 +15,7 @@ async function enterDungeon(page, category, name) {
await page.getByRole('button', { name: /^Dungeons/ }).click();
if (category) await page.locator('.dungeon-category-tab').filter({ hasText: category }).click();
await page.locator('.dungeon-select-row').filter({ hasText: name }).first().click();
const download = page.getByRole('button', { name: 'Download and continue' });
if (await download.isVisible()) await download.click();
else await page.getByRole('button', { name: 'Choose role' }).click();
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'visible', timeout: 180_000 });
@@ -49,7 +48,7 @@ async function canvasScale(page) {
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
await page.locator('.main-menu-screen').waitFor();
await enterDungeon(page, 'WoW', 'Wailing Caverns');
await enterDungeon(page, 'WoW', DEFAULT_TEST_DUNGEON_TITLE);
const defaultScale = await canvasScale(page);
await page.screenshot({ path: path.join(output, 'wow-default.png') });
await page.keyboard.press('Escape');
+15 -14
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -159,13 +160,13 @@ async function createHunter(page, name) {
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
async function enterDefaultTestDungeon(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor();
await page.getByRole('button', { name: /Choose role/ }).click();
await selectDefaultTestDungeon(page);
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
@@ -257,7 +258,7 @@ async function capture(page, name, fullPage = false) {
await page.locator('.roster-character').filter({ hasText: 'Catguard' }).click();
await page.getByRole('button', { name: /Continue as Catguard/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await enterWailingCaverns(page);
await enterDefaultTestDungeon(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
@@ -278,10 +279,10 @@ async function capture(page, name, fullPage = false) {
};
assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.');
if (entranceLock.disabled) {
await capture(page, '01-wailing-entry-combat-locked-desktop');
await capture(page, '01-stockade-entry-combat-locked-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
await capture(page, '02-wailing-entry-combat-locked-narrow');
await capture(page, '02-stockade-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
@@ -508,18 +509,18 @@ async function capture(page, name, fullPage = false) {
passed: true,
server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context',
characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so the level-10 family controls and Cat skill ranks could be inspected. This is eligibility setup, not earned progression.',
scope: 'Manual Hunter Cat family UI diagnostic only in disposable Wailing Caverns. Not a Hunter balance certification or Ready-party certification.',
scope: 'Manual Hunter Cat family UI diagnostic only in disposable Stormwind Stockade. Not a Hunter balance certification or Ready-party certification.',
familySelectionProof: 'Cat was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.',
combatLockProof: entranceLock.disabled
? 'At entry, live Wailing Caverns mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
? 'At entry, live Stormwind Stockade mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
: 'The Cat run’s first observation came after the entrance pack had ended; this run did not capture an in-combat Cat-family lock state. Earlier Hunter family QA covered the lock with Wolf/Turtle.',
callAndRevive: spellRoute,
commandReactProof,
catAbilityProof: 'The active player-owned pet runtime profile reports Claw 16828 rank 2, Rake 59881 rank 1, and Prowl 24450 rank 1 at level 10. It has neither Bite, Furious Howl, nor Shell Shield. This profile/readiness evidence does not prove live Rake or Prowl casts.',
visual: { renderReadyProof, bossIntroObserved, screenshots: [
...(entranceLock.disabled ? [
'01-wailing-entry-combat-locked-desktop.png',
'02-wailing-entry-combat-locked-narrow.png',
'01-stockade-entry-combat-locked-desktop.png',
'02-stockade-entry-combat-locked-narrow.png',
] : []),
'03-cat-family-desktop.png',
'04-cat-family-narrow.png',
@@ -572,7 +573,7 @@ async function capture(page, name, fullPage = false) {
}).catch((diagnosticError) => ({ error: diagnosticError.message }));
let narrowEvidence = null;
if (page) {
await page.screenshot({ path: path.join(directory, '01-wailing-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.screenshot({ path: path.join(directory, '01-stockade-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined);
await page.waitForTimeout(350).catch(() => undefined);
narrowEvidence = await page.evaluate(() => {
@@ -598,7 +599,7 @@ async function capture(page, name, fullPage = false) {
partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null,
};
}).catch(() => null);
await page.screenshot({ path: path.join(directory, '02-wailing-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.screenshot({ path: path.join(directory, '02-stockade-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
@@ -606,7 +607,7 @@ async function capture(page, name, fullPage = false) {
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
screenshots: ['failure.png', '01-stockade-entry-desktop-diagnostic.png', '02-stockade-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
+15 -14
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -95,13 +96,13 @@ async function createHunter(page, name) {
await page.locator('.main-menu-screen').waitFor();
}
async function enterWailingCaverns(page) {
async function enterDefaultTestDungeon(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor();
await page.getByRole('button', { name: /Choose role/ }).click();
await selectDefaultTestDungeon(page);
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click();
await waitForGameReady(page);
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
@@ -223,7 +224,7 @@ async function capture(page, name, fullPage = false) {
await page.locator('.roster-character').filter({ hasText: 'Shellguard' }).click();
await page.getByRole('button', { name: /Continue as Shellguard/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await enterWailingCaverns(page);
await enterDefaultTestDungeon(page);
let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]');
await selector.waitFor();
@@ -241,10 +242,10 @@ async function capture(page, name, fullPage = false) {
};
assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.');
if (entranceLock.disabled) {
await capture(page, '01-wailing-entry-combat-locked-desktop');
await capture(page, '01-stockade-entry-combat-locked-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
await capture(page, '02-wailing-entry-combat-locked-narrow');
await capture(page, '02-stockade-entry-combat-locked-narrow');
await page.setViewportSize({ width: 1440, height: 1000 });
}
// Entrance mobs must die through the ordinary party simulation before the
@@ -341,15 +342,15 @@ async function capture(page, name, fullPage = false) {
passed: true,
server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context',
characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so sourced Call Pet (level 10) and Revive Pet (level 10) are available.',
scope: 'Manual Hunter family UI mechanics diagnostic only in disposable Wailing Caverns. Not a Hunter balance certification or Ready-party certification.',
scope: 'Manual Hunter family UI mechanics diagnostic only in disposable Stormwind Stockade. Not a Hunter balance certification or Ready-party certification.',
familySelectionProof: 'Turtle was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.',
combatLockProof: entranceLock.disabled
? 'At entry, live Wailing Caverns mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
? 'At entry, live Stormwind Stockade mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.'
: 'The rendered family selector was enabled at the first post-intro observation with no engaged mobs. A separate earlier diagnostic screenshot and failure.json record the selector disabled while the entrance mobs were engaged; there is no narrow combat-lock screenshot.',
callAndRevive: 'Spellbook Call Pet / Revive Pet activation was not covered; the Escape shortcut reached the focused scene but did not open the pause menu in this route. Neither spell was activated by state injection.',
visual: { canvasSize, screenshots: [
'01-wailing-entry-combat-locked-desktop.png',
'02-wailing-entry-combat-locked-narrow.png',
'01-stockade-entry-combat-locked-desktop.png',
'02-stockade-entry-combat-locked-narrow.png',
'03-turtle-family-desktop.png',
'04-turtle-family-narrow.png',
] },
@@ -400,7 +401,7 @@ async function capture(page, name, fullPage = false) {
}).catch((diagnosticError) => ({ error: diagnosticError.message }));
let narrowEvidence = null;
if (page) {
await page.screenshot({ path: path.join(directory, '01-wailing-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.screenshot({ path: path.join(directory, '01-stockade-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined);
await page.waitForTimeout(350).catch(() => undefined);
narrowEvidence = await page.evaluate(() => {
@@ -426,7 +427,7 @@ async function capture(page, name, fullPage = false) {
partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null,
};
}).catch(() => null);
await page.screenshot({ path: path.join(directory, '02-wailing-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
await page.screenshot({ path: path.join(directory, '02-stockade-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined);
}
const failure = {
passed: false,
@@ -434,7 +435,7 @@ async function capture(page, name, fullPage = false) {
error: { name: error.name, message: error.message },
diagnostic,
narrowEvidence,
screenshots: ['failure.png', '01-wailing-entry-desktop-diagnostic.png', '02-wailing-entry-narrow-diagnostic.png'],
screenshots: ['failure.png', '01-stockade-entry-desktop-diagnostic.png', '02-stockade-entry-narrow-diagnostic.png'],
consoleErrors,
httpErrors,
recentRequests: recentRequests.slice(-30),
+4
View File
@@ -1,3 +1,7 @@
// Historical Barren/Lady evidence harness. Routine live QA uses Stockade.
if (!process.argv.includes('--historical-wailing') && !process.argv.includes('--fixtures-only')) {
throw new Error('Historical Lady live checks require an explicit owner request and --historical-wailing. Use the Stockade campaign/mechanics harness for new routine live QA.');
}
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');
+7 -2
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_ID, DEFAULT_TEST_DUNGEON_TITLE } = require('./playtest-defaults.cjs');
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');
@@ -20,9 +21,13 @@ const moduleFiles = fs.readdirSync('dist/assets')
const moduleUrls = moduleFiles.map((file) => `/assets/${file}`);
const routes = [
{ id: 'barren-caves', title: 'Barren Caves', category: 'RoM' },
{ id: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' },
{ id: DEFAULT_TEST_DUNGEON_ID, title: DEFAULT_TEST_DUNGEON_TITLE, category: 'WoW' },
...(process.argv.includes('--historical-wailing') ? [{ id: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' }] : []),
];
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes;
if (routeSelection === 'wailing-caverns' && !process.argv.includes('--historical-wailing')) {
throw new Error('Historical Wailing reproduction requires an explicit owner request and --historical-wailing.');
}
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes.filter((route) => route.id === DEFAULT_TEST_DUNGEON_ID);
const actionSlots = [
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
+146 -22
View File
@@ -1,10 +1,11 @@
const { DEFAULT_TEST_DUNGEON_TITLE, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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 output = path.resolve('playtest-artifacts/party-equipment');
const output = path.resolve('playtest-artifacts/party-equipment', process.argv.includes('--basic-only') ? 'basic' : '.');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-equipment-'));
fs.mkdirSync(output, { recursive: true });
@@ -23,8 +24,19 @@ async function capture(page, name) {
for (const category of process.argv.includes('--rom-only') ? ['RoM'] : ['WoW', 'RoM']) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await context.newPage();
// Test-only observation of the existing canvas; no alternate character renderer.
await page.route(/\/assets\/SkeletonUtils-.*\.js$/, async (route) => {
const response = await route.fetch();
const source = await response.text();
const instrumented = source.replace(/onCreated:([\w$]+)=>\{(?=\1\.events\.connect)/,
'onCreated:$1=>{(window.__equipmentQaScenes??=[]).push($1);');
assert.notEqual(instrumented, source, 'The built canvas creation hook must be observable.');
await route.fulfill({ response, body: instrumented });
});
page.setDefaultTimeout(60000);
const errors = [];
let inspectedParty = [];
let bowFacing = null;
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) errors.push(message.text());
@@ -43,27 +55,33 @@ async function capture(page, name) {
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(4000);
await capture(page, `${category}-roster`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Downloads/ }).click();
const pack = page.locator('.content-pack-row').filter({ hasText: category === 'WoW'
? 'Extra Wrath Character Models & Skins' : 'RuneWaker Character Models & Skins' });
await pack.getByRole('button', { name: 'Download', exact: true }).click();
await pack.getByText('Installed — offline ready', { exact: true }).waitFor({ timeout: 180000 });
await page.getByRole('button', { name: 'Back', exact: true }).click();
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(5000);
await capture(page, `${category}-roster`);
await capture(page, `${category}-roster-basic`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
if (!process.argv.includes('--basic-only')) {
await page.getByRole('button', { name: /Downloads/ }).click();
const pack = page.locator('.content-pack-row').filter({ hasText: category === 'WoW'
? 'Extra Wrath Character Models & Skins' : 'RuneWaker Character Models & Skins' });
await pack.getByRole('button', { name: 'Download', exact: true }).click();
await pack.getByText('Installed — offline ready', { exact: true }).waitFor({ timeout: 180000 });
await page.getByRole('button', { name: 'Back', exact: true }).click();
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(5000);
await capture(page, `${category}-roster`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
}
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: category }).click();
await page.locator('.dungeon-select-row').filter({ hasText: category === 'WoW' ? 'Wailing Caverns' : 'Forsaken Abbey' }).first().click();
const download = page.getByRole('button', { name: 'Download and continue' });
if (await download.isVisible()) await download.click();
else await page.getByRole('button', { name: 'Choose role' }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: DEFAULT_TEST_DUNGEON_TITLE }).first().click();
const download = page.getByRole('button', { name: 'Download and continue', exact: true });
if (await download.isVisible()) {
await download.click();
await page.locator('.dungeon-role-option').first().waitFor({ timeout: 180000 });
} else {
await openSelectedDungeonRoles(page);
}
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden' });
@@ -71,6 +89,107 @@ async function capture(page, name) {
await page.waitForTimeout(12000);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden' });
await capture(page, `${category}-dungeon`);
if (category === 'RoM') {
inspectedParty = await page.evaluate(() => {
const canvas = document.querySelector('.scene-shell canvas');
const state = window.__equipmentQaScenes?.find((entry) => entry.gl.domElement === canvas);
if (!state) throw new Error('Could not inspect the live shared scene.');
window.equipmentQaScene = state;
const actors = state.scene.getObjectByName('party-population').children;
return actors.map((actor) => {
const weapons = [];
const sourceMeshes = [];
actor.traverse((node) => {
if (node.name.startsWith('equipped-')) weapons.push(node.name);
if (/^(blade01|hammer01|shield01|staff00|bow03|dagger01)_/.test(node.name)) sourceMeshes.push(node.name);
});
if (!sourceMeshes.length) throw new Error(`${actor.name} must carry original RoM weapon meshes.`);
return { name: actor.name, position: actor.position.toArray(), weapons, sourceMeshes };
});
});
console.log(JSON.stringify({ inspection: inspectedParty }, null, 2));
await page.evaluate(() => {
const scene = window.equipmentQaScene.scene;
const actors = scene.getObjectByName('party-population').children;
const scout = actors.find((actor) => {
let found = false;
actor.traverse((node) => { if (node.name.startsWith('bow03_')) found = true; });
return found;
});
if (!scout) throw new Error('Caelan must carry the original bow03 RoM mesh.');
const state = scout.__r3f.root.getState();
window.equipmentQaScene = state;
let bow;
scout.traverse((node) => { if (node.name.startsWith('bow03_')) bow = node; });
let prop = bow;
while (prop && !prop.name.startsWith('equipped-')) prop = prop.parent;
window.equipmentQaBow = { bow, prop };
const vector = scout.position.clone();
const eye = state.camera.position.clone();
window.equipmentQaOriginalFov = state.camera.fov;
const view = {
ref: { current: () => {
const center = scout.getWorldPosition(vector.clone());
center.y += 0.95;
// Keep the normal camera's collision-safe location in the corridor.
state.camera.position.copy(eye);
state.camera.fov = 40;
state.camera.updateProjectionMatrix();
state.camera.lookAt(center);
} }, priority: 0, store: scout.__r3f.root,
};
state.internal.subscribers.push(view);
state.internal.subscribers.sort((left, right) => left.priority - right.priority);
window.equipmentQaCameraView = view;
});
await page.waitForTimeout(1200);
bowFacing = await page.evaluate(() => {
window.equipmentQaBowFacing = () => {
const { bow, prop } = window.equipmentQaBow;
bow.updateWorldMatrix(true, true);
const center = bow.position.clone().set(0, 0, 0);
const tips = center.clone();
const upper = center.clone();
const lower = center.clone();
let upperCount = 0;
let lowerCount = 0;
let centerCount = 0;
let tipCount = 0;
const positions = bow.geometry.attributes.position;
for (let index = 0; index < positions.count; index++) {
const y = Math.abs(positions.getY(index));
const point = bow.localToWorld(bow.getVertexPosition(index, center.clone().set(0, 0, 0)));
if (y < 0.2) { center.add(point); centerCount++; }
if (y > 0.8) { tips.add(point); tipCount++; }
if (positions.getY(index) > 0.8) { upper.add(point); upperCount++; }
if (positions.getY(index) < -0.8) { lower.add(point); lowerCount++; }
}
center.divideScalar(centerCount);
tips.divideScalar(tipCount);
upper.divideScalar(upperCount);
lower.divideScalar(lowerCount);
const actor = window.equipmentQaScene.scene.getObjectByName('party-population').children.find((node) => {
let found = false;
node.traverse((child) => { if (child === bow) found = true; });
return found;
});
const chest = actor.localToWorld(center.clone().set(0, 1.1, 0));
const facing = tips.sub(center).normalize().dot(chest.sub(prop.getWorldPosition(center.clone())).normalize());
return { stringSideTowardTorso: facing, longAxisUp: Math.abs(upper.sub(lower).normalize().y), grip: prop.getWorldPosition(center.clone()).toArray() };
};
return window.equipmentQaBowFacing();
});
console.log(JSON.stringify({ bowFacing }));
assert.ok(bowFacing.stringSideTowardTorso > 0.1, 'The original bow string side must face Caelan’s torso.');
await capture(page, process.argv.includes('--basic-only') ? 'RoM-Caelan-basic' : 'RoM-Caelan-native');
await page.evaluate(() => {
const subscribers = window.equipmentQaScene.internal.subscribers;
const index = subscribers.indexOf(window.equipmentQaCameraView);
if (index >= 0) subscribers.splice(index, 1);
window.equipmentQaScene.camera.fov = window.equipmentQaOriginalFov;
window.equipmentQaScene.camera.updateProjectionMatrix();
});
}
// Rotate the camera with the normal held-right-mouse gesture to show the party fronts.
const bounds = await page.locator('.scene-shell canvas').boundingBox();
await page.mouse.move(bounds.x + bounds.width * 0.5, bounds.y + bounds.height * 0.55);
@@ -90,9 +209,14 @@ async function capture(page, name) {
}
assert.deepEqual(errors, []);
results[category] = {
installedNativeCharacterPack: true,
screenshots: [`${category}-roster.png`, `${category}-dungeon.png`, `${category}-party-front.png`,
...(category === 'RoM' ? [`${category}-party-open.png`] : [])],
dungeon: DEFAULT_TEST_DUNGEON_TITLE,
installedNativeCharacterPack: !process.argv.includes('--basic-only'),
screenshots: [`${category}-roster-basic.png`,
...(!process.argv.includes('--basic-only') ? [`${category}-roster.png`] : []),
`${category}-dungeon.png`, `${category}-party-front.png`,
...(category === 'RoM' ? [`${category}-party-open.png`, process.argv.includes('--basic-only') ? 'RoM-Caelan-basic.png' : 'RoM-Caelan-native.png'] : [])],
inspectedParty,
bowFacing,
errors,
};
} catch (error) {
+4 -7
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -37,11 +38,6 @@ fs.mkdirSync(directory, { recursive: true });
const { shell } = await stores();
shell.getState().openMainMenu();
};
window.enterDungeonForPartyHealPlaytest = async () => {
const { shell } = await stores();
const ok = shell.getState().enterDungeon('wailing-caverns');
return { ok, notice: shell.getState().notice, phase: shell.getState().phase };
};
window.openDungeonsForPartyHealPlaytest = async () => {
const { shell } = await stores();
shell.getState().openDungeons();
@@ -86,8 +82,9 @@ fs.mkdirSync(directory, { recursive: true });
await page.locator('.main-menu-screen').waitFor();
await page.evaluate(() => window.openDungeonsForPartyHealPlaytest());
await page.locator('.dungeon-select-screen').waitFor();
const entry = await page.evaluate(() => window.enterDungeonForPartyHealPlaytest());
assert.equal(entry.ok, true, JSON.stringify(entry));
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.party-frames').waitFor();
await page.locator('.party-frame').nth(4).waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
+4 -3
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
@@ -42,10 +43,10 @@ async function sample(page, cdp, label) {
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
console.log('Entering adventure menu');
await page.locator('.main-menu-screen').waitFor();
console.log('Choosing bundled Wailing Caverns');
console.log('Choosing Stormwind Stockade');
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Wailing Caverns/i }).first().click();
await page.getByRole('button', { name: 'Choose role' }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
console.log('Waiting for scene');
await page.locator('.scene-shell').waitFor();
+3 -2
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
@@ -9,8 +10,8 @@ fs.mkdirSync(output, { recursive: true });
async function enterDungeon(page) {
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Wailing Caverns/i }).first().click();
await page.getByRole('button', { name: 'Choose role' }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 });
+3 -2
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -61,8 +62,8 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-five-u
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.getByRole('button', { name: /Choose role/ }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--damage').click();
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 });
+4 -7
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -60,13 +61,9 @@ fs.mkdirSync(directory, { recursive: true });
shell.getState().openDungeons();
});
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-select-row').first().waitFor();
const entry = await page.evaluate(async () => {
const { shell } = await window.romStatusStores();
const ok = shell.getState().enterDungeon('wailing-caverns');
return { ok, notice: shell.getState().notice };
});
assert.equal(entry.ok, true, JSON.stringify(entry));
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.party-frames').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
await page.evaluate(async () => {
+5 -4
View File
@@ -1,3 +1,4 @@
const { DEFAULT_TEST_DUNGEON_TITLE, DEFAULT_TEST_DUNGEON_BOSS } = require('./playtest-defaults.cjs');
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');
@@ -8,7 +9,7 @@ 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 bossName = DEFAULT_TEST_DUNGEON_BOSS;
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
@@ -183,7 +184,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
}
await sleep(50);
}
assert(engaged, 'the companion party engages Lady Anacondra');
assert(engaged, `the companion party engages ${bossName}`);
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');
@@ -199,7 +200,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
}
await sleep(50);
}
assert(completed, 'the companion party defeats Lady Anacondra');
assert(completed, `the companion party defeats ${bossName}`);
await page.waitForTimeout(250);
await page.screenshot({ path: path.join(directory, 'boss-defeated.png'), fullPage: true });
@@ -216,7 +217,7 @@ const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, mil
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
const results = {
dungeon: 'Wailing Caverns — Boss Testing',
dungeon: `${DEFAULT_TEST_DUNGEON_TITLE} — Boss Testing`,
boss: bossName,
partySeed,
engagedBoss: engaged.boss,
+6 -5
View File
@@ -1,3 +1,4 @@
const { selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
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');
@@ -232,12 +233,12 @@ async function chooseMode(page, ruleset) {
assert.equal(await button.getAttribute('aria-pressed'), 'true');
}
async function selectWailingCaverns(page) {
async function selectDefaultDungeon(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await waitForScreen(page, '.dungeon-select-screen');
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.getByRole('button', { name: /Choose role/ }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
const damageRole = page.locator('.dungeon-role-option--damage');
if (await damageRole.count()) await damageRole.first().click();
else await page.locator('.dungeon-role-option').first().click();
@@ -475,7 +476,7 @@ async function readStatsPanelPage(page) {
results.screenshots.menuSelectorMobile = await capture(page, '03-menu-selector-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await selectWailingCaverns(page);
await selectDefaultDungeon(page);
results.screenshots.nativeDungeonGameplay = await capture(page, '04-native-dungeon-gameplay-overlays-closed');
const nativeRuntime = await page.evaluate(async () => {
const { shell, combat, party } = await window.statRulesetStores();
@@ -682,7 +683,7 @@ async function readStatsPanelPage(page) {
results.screenshots.menuWowAfterDungeon = await capture(page, '06-menu-wow-after-dungeon');
// A second actual run verifies the saved compatibility selection reaches runtime.
await selectWailingCaverns(page);
await selectDefaultDungeon(page);
results.screenshots.wowDungeonGameplay = await capture(page, '08-wow-compatibility-dungeon-gameplay-overlays-closed');
const wowRuntime = await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1,3 +1,7 @@
// Historical Lady Anacondra reproduction only. New routine Vow QA uses Stockade.
if (!process.argv.includes('--historical-wailing')) {
throw new Error('This historical Wailing harness requires an explicit owner request and --historical-wailing. Use the Stockade campaign/mechanics harness for routine QA.');
}
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');