Update Healer Man server
This commit is contained in:
1 parent
6cc1b9217f
commit
6114a2295f
67 files changed
+6241
-347
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2493
-129
File diff suppressed because it is too large.
Load diff
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user